diff --git a/frontend/src/app/AppShell.test.tsx b/frontend/src/app/AppShell.test.tsx index 56f32b0a..d753ab19 100644 --- a/frontend/src/app/AppShell.test.tsx +++ b/frontend/src/app/AppShell.test.tsx @@ -137,7 +137,9 @@ describe('index route renders the default view without changing the URL', () => const { router } = renderShellAt('/') // The real ChatPage renders in place (its full-bleed thread region), not the // Overview view — proving the mobile index default resolves to chat. - await waitFor(() => expect(document.querySelector('.chat-thread')).not.toBeNull()) + await waitFor(() => expect(document.querySelector('.chat-thread')).not.toBeNull(), { + timeout: 5000, + }) expect(router.state.location.pathname).toBe('/') // On mobile the closed drawer is aria-hidden/inert, so the nav link is not // in the accessibility tree; assert the highlight via the DOM node instead. diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index 8ba446d5..7764bf82 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -6,6 +6,7 @@ import { useConnection } from '@/stores/connection' import { initTheme } from '@/stores/theme' import { approvalMonitor } from '@/services/approval-monitor' import type { RpcState } from '@/lib/ws-rpc' +import { KeyboardShortcutProvider } from '@/components/KeyboardShortcuts' const WS_URL_KEY = 'agentos.wsUrl' const WS_TOKEN_KEY = 'agentos.wsToken' @@ -89,7 +90,9 @@ export function AppProviders({ children }: { children: ReactNode }) { return ( - {children} + + {children} + ) diff --git a/frontend/src/components/KeyboardShortcuts.css b/frontend/src/components/KeyboardShortcuts.css new file mode 100644 index 00000000..13aef69d --- /dev/null +++ b/frontend/src/components/KeyboardShortcuts.css @@ -0,0 +1,128 @@ +@layer components { + .help-modal__overlay { + position: fixed; + inset: 0; + z-index: var(--z-critical-approval); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + background: rgba(3, 6, 10, 0.75); + backdrop-filter: blur(12px); + } + + .help-modal { + width: 100%; + max-width: 520px; + max-height: calc(100dvh - 48px); + display: flex; + flex-direction: column; + background: var(--surface, #0e0e13); + border: 1px solid var(--hairline, rgba(204, 255, 0, 0.1)); + border-radius: var(--radius-dialog, 18px); + box-shadow: 0 32px 80px -20px rgba(0, 0, 0, 0.8); + overflow: hidden; + } + + .help-modal__head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--hairline, rgba(204, 255, 0, 0.1)); + } + + .help-modal__title { + font-size: 1.125rem; + font-weight: 600; + color: var(--foreground, #ececef); + letter-spacing: -0.01em; + } + + .help-modal__close { + background: transparent; + border: none; + color: var(--dim, #93939e); + cursor: pointer; + padding: 4px; + border-radius: var(--radius-compact, 6px); + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; + } + + .help-modal__close:hover { + color: var(--foreground, #ececef); + background: var(--elevated, #17171d); + } + + .help-modal__body { + padding: 24px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 24px; + } + + .help-modal__section { + display: flex; + flex-direction: column; + gap: 12px; + } + + .help-modal__section-title { + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--dim, #93939e); + margin-bottom: 4px; + } + + .help-modal__shortcut-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.02); + } + + .help-modal__shortcut-row:last-child { + border-bottom: none; + } + + .help-modal__shortcut-desc { + font-size: 0.875rem; + color: var(--foreground, #ececef); + } + + .help-modal__kbd-list { + display: flex; + align-items: center; + gap: 4px; + } + + .help-modal__kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 24px; + padding: 0 6px; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.75rem; + font-weight: 600; + background: var(--elevated, #17171d); + border: 1px solid var(--hairline, rgba(204, 255, 0, 0.1)); + border-radius: 4px; + color: var(--foreground, #ececef); + box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); + } + + .help-modal__plus { + font-size: 0.75rem; + color: var(--dim, #93939e); + font-weight: 500; + } +} diff --git a/frontend/src/components/KeyboardShortcuts.test.tsx b/frontend/src/components/KeyboardShortcuts.test.tsx new file mode 100644 index 00000000..1b49ac9d --- /dev/null +++ b/frontend/src/components/KeyboardShortcuts.test.tsx @@ -0,0 +1,146 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { useState } from 'react' +import { describe, expect, it } from 'vitest' +import { + formatShortcutKey, + getEventCombo, + KeyboardShortcutProvider, + useKeyboardShortcut, +} from './KeyboardShortcuts' + +describe('KeyboardShortcuts Utilities', () => { + it('formats shortcut keys platform-awarely', () => { + // Mock userAgent for non-mac + const originalUserAgent = navigator.userAgent + Object.defineProperty(navigator, 'userAgent', { + value: 'Windows NT 10.0', + configurable: true, + }) + + expect(formatShortcutKey('mod+shift+o')).toBe('Ctrl+Shift+O') + expect(formatShortcutKey('alt+arrowup')).toBe('Alt+↑') + expect(formatShortcutKey('escape')).toBe('Esc') + + // Mock userAgent for mac + Object.defineProperty(navigator, 'userAgent', { + value: 'Macintosh; Intel Mac OS X 10_15_7', + configurable: true, + }) + + expect(formatShortcutKey('mod+shift+o')).toBe('⌘⇧O') + expect(formatShortcutKey('alt+arrowup')).toBe('⌥↑') + expect(formatShortcutKey('escape')).toBe('Esc') + + // Restore + Object.defineProperty(navigator, 'userAgent', { + value: originalUserAgent, + configurable: true, + }) + }) + + it('determines the correct event combo key', () => { + const e1 = { + key: 'o', + code: 'KeyO', + ctrlKey: true, + shiftKey: true, + metaKey: false, + altKey: false, + } as KeyboardEvent + expect(getEventCombo(e1)).toBe('mod+shift+o') + + const e2 = { + key: 'ArrowUp', + ctrlKey: false, + shiftKey: false, + metaKey: false, + altKey: true, + } as KeyboardEvent + expect(getEventCombo(e2)).toBe('alt+arrowup') + + const e3 = { + key: '?', + ctrlKey: false, + shiftKey: true, + metaKey: false, + altKey: false, + } as KeyboardEvent + expect(getEventCombo(e3)).toBe('?') + }) +}) + +function TestComponent() { + const [pressed, setPressed] = useState(false) + + useKeyboardShortcut( + { + key: 'mod+k', + description: 'Test shortcut description', + category: 'Test Category', + }, + (e) => { + e.preventDefault() + setPressed(true) + }, + ) + + return
{pressed ? 'Pressed' : 'Not Pressed'}
+} + +describe('KeyboardShortcutProvider', () => { + it('registers and triggers a global shortcut', async () => { + render( + + + , + ) + + expect(screen.getByText('Not Pressed')).toBeInTheDocument() + + // Trigger Mod+K (Ctrl+K or Cmd+K) + const event = new KeyboardEvent('keydown', { + key: 'k', + ctrlKey: true, + bubbles: true, + }) + document.dispatchEvent(event) + + await waitFor(() => { + expect(screen.getByText('Pressed')).toBeInTheDocument() + }) + }) + + it('toggles the help modal on "?" keydown', async () => { + render( + + + , + ) + + // Initially modal is not open + expect(screen.queryByText('Keyboard Shortcuts')).not.toBeInTheDocument() + + // Dispatch "?" keydown + const event = new KeyboardEvent('keydown', { + key: '?', + bubbles: true, + }) + document.dispatchEvent(event) + + // Modal should be open and display the title and test description + await waitFor(() => { + expect(screen.getByText('Keyboard Shortcuts')).toBeInTheDocument() + }) + expect(screen.getByText('Test shortcut description')).toBeInTheDocument() + expect(screen.getByText('Test Category')).toBeInTheDocument() + + // Close the modal + const closeBtn = screen.getByRole('button', { name: 'Close dialog' }) + fireEvent.click(closeBtn) + + // Modal should be closed + await waitFor(() => { + expect(screen.queryByText('Keyboard Shortcuts')).not.toBeInTheDocument() + }) + }) +}) diff --git a/frontend/src/components/KeyboardShortcuts.tsx b/frontend/src/components/KeyboardShortcuts.tsx new file mode 100644 index 00000000..7e1c7ff2 --- /dev/null +++ b/frontend/src/components/KeyboardShortcuts.tsx @@ -0,0 +1,363 @@ +import React, { + createContext, + useContext, + useEffect, + useRef, + useState, + useCallback, + useId, + useMemo, +} from 'react' +import { AnimatePresence } from 'motion/react' +import { X } from 'lucide-react' +import { ModalShell } from '@/components/ModalShell' +import './KeyboardShortcuts.css' + +export interface KeyboardShortcut { + key: string // e.g. "mod+shift+o" + description: string + category: string // e.g. "Global", "Composer" + allowInInputs?: boolean + allowWithOverlays?: boolean + documentationOnly?: boolean +} + +interface ShortcutRegistryItem { + id: string + config: KeyboardShortcut + handler: (e: KeyboardEvent) => void +} + +interface KeyboardShortcutContextType { + register: ( + id: string, + config: KeyboardShortcut, + handler: (e: KeyboardEvent) => void, + ) => () => void + shortcuts: ShortcutRegistryItem[] + isHelpOpen: boolean + setIsHelpOpen: (open: boolean) => void +} + +const KeyboardShortcutContext = createContext(null) + +export function isMac(): boolean { + if (typeof navigator === 'undefined') return false + return /mac/i.test(navigator.userAgent) +} + +export function formatShortcutKey(keyCombo: string): string { + const parts = keyCombo.toLowerCase().split('+') + const mac = isMac() + + return parts + .map((part) => { + if (part === 'mod') { + return mac ? '⌘' : 'Ctrl' + } + if (part === 'shift') { + return mac ? '⇧' : 'Shift' + } + if (part === 'alt') { + return mac ? '⌥' : 'Alt' + } + if (part === 'arrowup' || part === 'up') { + return '↑' + } + if (part === 'arrowdown' || part === 'down') { + return '↓' + } + if (part === 'enter') { + return 'Enter' + } + if (part === 'escape' || part === 'esc') { + return 'Esc' + } + if (part.length === 1) { + return part.toUpperCase() + } + return part.charAt(0).toUpperCase() + part.slice(1) + }) + .join(mac ? '' : '+') +} + +export function getEventCombo(e: KeyboardEvent | React.KeyboardEvent): string { + const parts: string[] = [] + + let key = (e.key || '').toLowerCase() + if (!key || key === 'unidentified') { + if (e.code) { + if (e.code.startsWith('Key') && e.code.length === 4) { + key = e.code.charAt(3).toLowerCase() + } else if (e.code.startsWith('Digit') && e.code.length === 6) { + key = e.code.charAt(5) + } else { + key = e.code.toLowerCase() + } + } + } + + if (e.metaKey || e.ctrlKey) { + parts.push('mod') + } + if (e.altKey) { + parts.push('alt') + } + + // Only add 'shift' modifier if key itself isn't a symbol produced by shift (like '?') + // and key itself isn't 'shift'. + if (e.shiftKey && key !== '?' && key !== 'shift') { + parts.push('shift') + } + + if (key !== 'control' && key !== 'meta' && key !== 'alt' && key !== 'shift') { + parts.push(key) + } + + return parts.join('+') +} + +export function KeyboardShortcutProvider({ children }: { children: React.ReactNode }) { + const [shortcuts, setShortcuts] = useState([]) + const [isHelpOpen, setIsHelpOpen] = useState(false) + + const shortcutsRef = useRef([]) + + useEffect(() => { + shortcutsRef.current = shortcuts + }, [shortcuts]) + + const register = useCallback( + (id: string, config: KeyboardShortcut, handler: (e: KeyboardEvent) => void) => { + setShortcuts((prev) => [...prev, { id, config, handler }]) + return () => { + setShortcuts((prev) => prev.filter((item) => item.id !== id)) + } + }, + [], + ) + + // Handle global shortcuts dispatching + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented) return + + const combo = getEventCombo(e) + if (!combo) return + + const hasOverlay = !!document.querySelector( + '.modal-backdrop, .chat-session-popover, .chat-session-actions-menu, .help-modal__overlay, .ag-modal__overlay, .sess-modal__overlay, .sk-modal__overlay, .cron-modal__overlay', + ) + + const target = e.target as HTMLElement | null + const isEditable = + !!target && + (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) + + // Handle the helper shortcut "?" statically + if (combo === '?' && !isEditable && !hasOverlay) { + e.preventDefault() + setIsHelpOpen((prev) => !prev) + return + } + + const matches = shortcutsRef.current.filter((item) => { + return ( + !item.config.documentationOnly && item.config.key.toLowerCase() === combo.toLowerCase() + ) + }) + + if (matches.length === 0) return + + // Evaluate matches in reverse order of registration (latest first - stack behavior) + for (let i = matches.length - 1; i >= 0; i--) { + const match = matches[i]! + + if (hasOverlay && !match.config.allowWithOverlays) { + continue + } + + if (isEditable && !match.config.allowInInputs) { + continue + } + + match.handler(e) + + if (e.defaultPrevented) { + break + } + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, []) + + const contextValue = useMemo( + () => ({ + register, + shortcuts, + isHelpOpen, + setIsHelpOpen, + }), + [register, shortcuts, isHelpOpen], + ) + + return ( + + {children} + + {isHelpOpen && ( + setIsHelpOpen(false)} shortcuts={shortcuts} /> + )} + + + ) +} + +export function useKeyboardShortcut(config: KeyboardShortcut, handler: (e: KeyboardEvent) => void) { + const context = useContext(KeyboardShortcutContext) + + const id = useId() + const handlerRef = useRef(handler) + + useEffect(() => { + handlerRef.current = handler + }, [handler]) + + const { key, description, category, allowInInputs, allowWithOverlays, documentationOnly } = config + const register = context?.register + + useEffect(() => { + if (!register) return + + const stableHandler = (e: KeyboardEvent) => { + handlerRef.current(e) + } + + return register( + id, + { key, description, category, allowInInputs, allowWithOverlays, documentationOnly }, + stableHandler, + ) + }, [ + register, + id, + key, + description, + category, + allowInInputs, + allowWithOverlays, + documentationOnly, + ]) +} + +function KeyboardShortcutHelpModal({ + onClose, + shortcuts, +}: { + onClose: () => void + shortcuts: ShortcutRegistryItem[] +}) { + const categories: Record = {} + + // Initialize with helper shortcut + categories['Global'] = [ + { + id: 'global-help-shortcut', + config: { + key: '?', + description: 'Show keyboard shortcuts', + category: 'Global', + }, + handler: () => {}, + }, + ] + + shortcuts.forEach((item) => { + const cat = item.config.category || 'Other' + if (!categories[cat]) { + categories[cat] = [] + } + const exists = categories[cat].some( + (existing) => + existing.config.key.toLowerCase() === item.config.key.toLowerCase() && + existing.config.description === item.config.description, + ) + if (!exists) { + categories[cat].push(item) + } + }) + + const renderKbdParts = (keyCombo: string) => { + const parts = keyCombo.toLowerCase().split('+') + const mac = isMac() + + return parts.map((part, idx) => { + let label = part + if (part === 'mod') { + label = mac ? '⌘' : 'Ctrl' + } else if (part === 'shift') { + label = mac ? '⇧' : 'Shift' + } else if (part === 'alt') { + label = mac ? '⌥' : 'Alt' + } else if (part === 'arrowup' || part === 'up') { + label = '↑' + } else if (part === 'arrowdown' || part === 'down') { + label = '↓' + } else if (part === 'enter') { + label = 'Enter' + } else if (part === 'escape' || part === 'esc') { + label = 'Esc' + } else if (part.length === 1) { + label = part.toUpperCase() + } else { + label = part.charAt(0).toUpperCase() + part.slice(1) + } + + return ( + + {idx > 0 && !mac && +} + {label} + + ) + }) + } + + return ( + +
+

+ Keyboard Shortcuts +

+ +
+
+ {Object.entries(categories).map(([category, items]) => ( +
+

{category}

+ {items.map((item) => ( +
+ {item.config.description} +
{renderKbdParts(item.config.key)}
+
+ ))} +
+ ))} +
+
+ ) +} diff --git a/frontend/src/views/chat/ChatPage.test.tsx b/frontend/src/views/chat/ChatPage.test.tsx index 6d1d730f..a551b827 100644 --- a/frontend/src/views/chat/ChatPage.test.tsx +++ b/frontend/src/views/chat/ChatPage.test.tsx @@ -5,6 +5,7 @@ import { focusManager, QueryClient, QueryClientProvider } from '@tanstack/react- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { toast } from 'sonner' import { ChatPage } from './ChatPage' +import { KeyboardShortcutProvider } from '@/components/KeyboardShortcuts' import * as logicModule from './logic' vi.mock('sonner', () => ({ @@ -94,8 +95,10 @@ function renderPage(initialEntry = '/chat') { - - + + + + , ) diff --git a/frontend/src/views/chat/ChatPage.tsx b/frontend/src/views/chat/ChatPage.tsx index b613f028..55b140d9 100644 --- a/frontend/src/views/chat/ChatPage.tsx +++ b/frontend/src/views/chat/ChatPage.tsx @@ -8,6 +8,7 @@ import { AnimatePresence } from 'motion/react' import { useRpc } from '@/app/providers' import { ShellHeaderPortal, ShellPrimaryActionPortal } from '@/app/ShellHeaderSlot' import { ModalShell } from '@/components/ModalShell' +import { useKeyboardShortcut, formatShortcutKey } from '@/components/KeyboardShortcuts' import { Attachments, useAttachments } from './Attachments' import { Composer, type ComposerHandle } from './Composer' import { @@ -524,37 +525,31 @@ export function ChatPage() { toast.info('Exported as Markdown') }, [containerRef, sessionKey]) - const shortcutHint = /mac/i.test(navigator.userAgent) ? '⌘⇧O' : 'Ctrl+Shift+O' - - // chat.js:2518-2539 `_onDocKeydown` — document-level keyboard shortcuts. - // Cmd/Ctrl+Shift+O mirrors the New chat button from anywhere in the app, - // while Escape keeps the legacy priority chain: - // 1. streaming → abort the turn (which recovers pending). - // 2. pending non-empty → recover the whole queue into the composer. - // Visible overlays own their shortcuts, and Escape inside other editable - // targets remains handled by those elements. Unlike Escape, the New chat - // shortcut intentionally works even when focus is inside the composer or - // another editable field. - useEffect(() => { - const onDocKeydown = (e: KeyboardEvent) => { - if (e.defaultPrevented) return - const isNewChatShortcut = (e.metaKey || e.ctrlKey) && e.shiftKey && e.code === 'KeyO' - const isEscape = e.key === 'Escape' - if (!isNewChatShortcut && !isEscape) return - const hasOverlay = !!document.querySelector( - '.modal-backdrop, .chat-session-popover, .chat-session-actions-menu', - ) - if (hasOverlay) return - if (isNewChatShortcut) { - e.preventDefault() - startNewChat() - return - } - const target = e.target as HTMLElement | null - const isEditable = - !!target && - (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) - if (isEditable) return + const shortcutHint = formatShortcutKey('mod+shift+o') + + // Register New Chat shortcut with registry + useKeyboardShortcut( + { + key: 'mod+shift+o', + description: 'New chat', + category: 'Global', + allowInInputs: true, + }, + (e) => { + e.preventDefault() + startNewChat() + }, + ) + + // Register Escape shortcut with registry (global priority chain: abort streaming or recover pending queue) + useKeyboardShortcut( + { + key: 'escape', + description: 'Abort streaming turn / recover pending queue', + category: 'Global', + allowInInputs: false, + }, + (e) => { if (busy) { e.preventDefault() abortAndRecover('webui_escape') @@ -564,10 +559,8 @@ export function ChatPage() { e.preventDefault() pending.popAllIntoComposer() } - } - document.addEventListener('keydown', onDocKeydown) - return () => document.removeEventListener('keydown', onDocKeydown) - }, [busy, abortAndRecover, pending, startNewChat]) + }, + ) return (
diff --git a/frontend/src/views/chat/Composer.tsx b/frontend/src/views/chat/Composer.tsx index e210cb59..33ce23aa 100644 --- a/frontend/src/views/chat/Composer.tsx +++ b/frontend/src/views/chat/Composer.tsx @@ -3,6 +3,7 @@ import { ArrowUpIcon, PaperclipIcon, SlidersHorizontalIcon, SquareIcon, XIcon } import { AnimatePresence, motion, useReducedMotion } from 'motion/react' import { toast } from 'sonner' import { MAX_PENDING, sendButtonState, shouldAutofocusComposer } from './logic' +import { useKeyboardShortcut } from '@/components/KeyboardShortcuts' /** * The chat command line (React). @@ -168,6 +169,78 @@ export function Composer({ const toolbarCloseRef = useRef(null) const reduceMotion = useReducedMotion() + // Register composer shortcuts for documentation + useKeyboardShortcut( + { + key: 'enter', + description: 'Send message', + category: 'Composer', + allowInInputs: true, + documentationOnly: true, + }, + () => {}, + ) + useKeyboardShortcut( + { + key: 'shift+enter', + description: 'Insert newline', + category: 'Composer', + allowInInputs: true, + documentationOnly: true, + }, + () => {}, + ) + useKeyboardShortcut( + { + key: 'escape', + description: 'Clear input / recover queue / abort turn', + category: 'Composer', + allowInInputs: true, + documentationOnly: true, + }, + () => {}, + ) + useKeyboardShortcut( + { + key: 'alt+arrowup', + description: 'Pop the most-recent pending item into the composer for editing', + category: 'Composer', + allowInInputs: true, + documentationOnly: true, + }, + () => {}, + ) + useKeyboardShortcut( + { + key: 'alt+arrowdown', + description: 'Enqueue the current composer text', + category: 'Composer', + allowInInputs: true, + documentationOnly: true, + }, + () => {}, + ) + useKeyboardShortcut( + { + key: 'arrowup', + description: 'Walk backwards through sent history', + category: 'Composer', + allowInInputs: true, + documentationOnly: true, + }, + () => {}, + ) + useKeyboardShortcut( + { + key: 'arrowdown', + description: 'Walk forward through sent history', + category: 'Composer', + allowInInputs: true, + documentationOnly: true, + }, + () => {}, + ) + // Close the composer-settings popover on an outside click / Escape (it // previously stayed open until the toolbar trigger was clicked again). Bound only // while open; a mousedown outside the toolbar wrap or an Escape key closes it.