diff --git a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts new file mode 100644 index 00000000..75fba52f --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test'; +import { updateShortcutRecordingState } from './ShortcutRecordingDialog'; + +const emptyState = { chords: [], livePreview: null }; + +function keyEvent(key: string, modifiers: Partial> = {}) { + return { key, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers }; +} + +describe('ShortcutRecordingDialog recording state', () => { + test('previews modifiers and clears the preview when they are released', () => { + const pressed = updateShortcutRecordingState(emptyState, keyEvent('Control', { ctrlKey: true, shiftKey: true }), 'keydown'); + expect(pressed.state.livePreview).toBe('mod+shift'); + expect(updateShortcutRecordingState(pressed.state, keyEvent('Control'), 'keyup').state.livePreview).toBeNull(); + }); + + test('records up to two chords', () => { + const first = updateShortcutRecordingState(emptyState, keyEvent('k', { ctrlKey: true }), 'keydown'); + const second = updateShortcutRecordingState(first.state, keyEvent('p', { ctrlKey: true }), 'keydown'); + const third = updateShortcutRecordingState(second.state, keyEvent('x', { ctrlKey: true }), 'keydown'); + expect(first.state.chords).toEqual(['mod+k']); + expect(second.state.chords).toEqual(['mod+k', 'mod+p']); + expect(third.state.chords).toEqual(['mod+k', 'mod+p']); + }); + + test('ignores repeat and IME events', () => { + expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), repeat: true }, 'keydown').state).toEqual(emptyState); + expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), isComposing: true }, 'keydown').state).toEqual(emptyState); + }); + + test('uses Enter and Escape for dialog actions and Backspace to remove the final chord', () => { + const state = { chords: ['mod+k', 'mod+p'], livePreview: null }; + expect(updateShortcutRecordingState(state, keyEvent('Enter'), 'keydown').action).toBe('save'); + expect(updateShortcutRecordingState(state, keyEvent('Escape'), 'keydown').action).toBe('cancel'); + expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').state.chords).toEqual(['mod+k']); + }); +}); diff --git a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx index 08f40047..57c83458 100644 --- a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx @@ -24,6 +24,23 @@ import { useI18n } from '@/lib/i18n'; const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']); +interface RecordingKeyboardEvent { + altKey: boolean; + ctrlKey: boolean; + isComposing: boolean; + key: string; + metaKey: boolean; + repeat: boolean; + shiftKey: boolean; +} + +interface ShortcutRecordingState { + chords: ShortcutCombo[]; + livePreview: ShortcutCombo | null; +} + +type ShortcutRecordingAction = 'cancel' | 'none' | 'save'; + interface ShortcutRecordingDialogProps { action: CustomizableShortcutAction | null; actions: ReadonlyArray; @@ -36,7 +53,15 @@ interface ShortcutRecordingDialogProps { onOpenChange: (open: boolean) => void; } -function keyboardEventToCombo(event: React.KeyboardEvent): ShortcutCombo | null { +function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null { + const parts: string[] = []; + if (event.metaKey || event.ctrlKey) parts.push('mod'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; +} + +function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null { if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null; const key = keyToShortcutToken(event.key); @@ -61,6 +86,37 @@ function modifierKeyUpToCombo(event: React.KeyboardEvent): Short return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; } +// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition +export function updateShortcutRecordingState( + state: ShortcutRecordingState, + event: RecordingKeyboardEvent, + phase: 'keydown' | 'keyup', +): { action: ShortcutRecordingAction; state: ShortcutRecordingState } { + if (event.repeat || event.isComposing) return { action: 'none', state }; + if (phase === 'keyup') { + return { action: 'none', state: { ...state, livePreview: getModifierPreview(event) } }; + } + + if (event.key === 'Escape') return { action: 'cancel', state }; + if (event.key === 'Enter') return { action: 'save', state }; + if (event.key === 'Backspace') { + return { action: 'none', state: { chords: state.chords.slice(0, -1), livePreview: null } }; + } + + const chord = keyboardEventToCombo(event); + if (chord) { + return { + action: 'none', + state: { + chords: state.chords.length < 2 ? [...state.chords, chord] : state.chords, + livePreview: null, + }, + }; + } + + return { action: 'none', state: { ...state, livePreview: getModifierPreview(event) } }; +} + export const ShortcutRecordingDialog: React.FC = ({ action, actions, @@ -70,15 +126,16 @@ export const ShortcutRecordingDialog: React.FC = ( }) => { const { t } = useI18n(); const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey); - const [chords, setChords] = React.useState([]); + const [recording, setRecording] = React.useState({ chords: [], livePreview: null }); const recordingRef = React.useRef(null); React.useEffect(() => { if (!action) return; - setChords([]); + setRecording({ chords: [], livePreview: null }); + recordingRef.current?.focus(); }, [action]); - const combo = normalizeCombo(chords.join(' ')); + const combo = normalizeCombo(recording.chords.join(' ')); const conflicts = React.useMemo(() => { if (!action || !combo) return []; const result: Array<{ action: CustomizableShortcutAction; kind: 'exact' | 'prefix' }> = []; @@ -96,95 +153,92 @@ export const ShortcutRecordingDialog: React.FC = ( const exactConflict = conflicts.find((conflict) => conflict.kind === 'exact'); const close = () => onOpenChange(false); + const save = () => { + if (!action || !combo || prefixConflict || exactConflict) return; + onSave(action.id, combo); + close(); + }; + const handleRecordingEvent = (event: React.KeyboardEvent, phase: 'keydown' | 'keyup') => { + event.preventDefault(); + event.stopPropagation(); + if (phase === 'keyup' && action?.id === 'switch_context_surface' && recording.chords.length === 0) { + const modifierCombo = modifierKeyUpToCombo(event); + if (modifierCombo) { + setRecording({ chords: [modifierCombo], livePreview: null }); + return; + } + } + const result = updateShortcutRecordingState(recording, { + altKey: event.altKey, + ctrlKey: event.ctrlKey, + isComposing: event.nativeEvent.isComposing, + key: event.key, + metaKey: event.metaKey, + repeat: event.repeat, + shiftKey: event.shiftKey, + }, phase); + setRecording(action?.id === 'switch_context_surface' && result.state.chords.length > 1 + ? { ...result.state, chords: result.state.chords.slice(0, 1) } + : result.state); + if (result.action === 'cancel') close(); + if (result.action === 'save') save(); + }; return ( - + - {action - ? t('settings.openchamber.keyboardShortcuts.dialog.title', { - action: actionLabel(action), - }) - : ''} + {action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''} {t('settings.openchamber.keyboardShortcuts.dialog.instructions')}
{ - event.preventDefault(); - event.stopPropagation(); - - if (event.key === 'Escape') { - close(); - return; - } - if (event.key === 'Backspace') { - setChords((current) => current.slice(0, -1)); - return; - } - - const chord = keyboardEventToCombo(event); - if (chord) { - setChords((current) => action?.id === 'switch_context_surface' - ? [chord] - : current.length < 2 ? [...current, chord] : current); - } - }} - onKeyUp={(event) => { - if (action?.id !== 'switch_context_surface' || chords.length > 0) return; - const combo = modifierKeyUpToCombo(event); - if (!combo) return; - event.preventDefault(); - event.stopPropagation(); - setChords([combo]); - }} + onKeyDown={(event) => handleRecordingEvent(event, 'keydown')} + onKeyUp={(event) => handleRecordingEvent(event, 'keyup')} + onBlur={() => setRecording((current) => ({ ...current, livePreview: null }))} > - {[0, 1].map((index) => ( -
- - {t(index === 0 - ? 'settings.openchamber.keyboardShortcuts.dialog.firstChord' - : 'settings.openchamber.keyboardShortcuts.dialog.secondChord')} - - - {chords[index] - ? formatShortcutForDisplay(chords[index]) - : t('settings.openchamber.keyboardShortcuts.dialog.recording')} +
+ {recording.chords.map((chord, index) => ( + + {formatShortcutForDisplay(chord)} -
- ))} - {prefixConflict ? ( -

- {t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })} -

- ) : null} - {exactConflict && !prefixConflict ? ( -

- {t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })} -

- ) : null} - {combo && isRiskyBrowserShortcut(combo) ? ( -

- {t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')} -

- ) : null} + ))} + {recording.livePreview ? ( + + {formatShortcutForDisplay(recording.livePreview)} + + ) : null} + {recording.chords.length === 0 && !recording.livePreview ? ( + + {t('settings.openchamber.keyboardShortcuts.dialog.recording')} + + ) : null} +
- - - {exactConflict && !prefixConflict ? ( + {prefixConflict ? ( +

+ {t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })} +

+ ) : null} + {exactConflict && !prefixConflict ? ( +

+ {t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })} +

+ ) : null} + {combo && isRiskyBrowserShortcut(combo) ? ( +

+ {t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')} +

+ ) : null} + + {exactConflict && !prefixConflict ? ( + - ) : ( - - )} - +
+ ) : null}
); diff --git a/packages/ui/src/components/session/SessionSwitcherDropdown.tsx b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx index c07208b6..f7a6bdd9 100644 --- a/packages/ui/src/components/session/SessionSwitcherDropdown.tsx +++ b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx @@ -11,7 +11,11 @@ import { Icon } from '@/components/icon/Icon'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useGlobalSessionStatus } from '@/sync/sync-context'; import { useSessionUnseenCount } from '@/sync/notification-store'; -import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/hooks/useSwitcherItems'; +import { + findSwitcherItemAncestorIds, + useSwitcherItems, + type SwitcherItem, +} from '@/components/session/sidebar/hooks/useSwitcherItems'; import { useUIStore } from '@/stores/useUIStore'; import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; import { formatSessionCompactDateLabel } from './sidebar/utils'; @@ -22,6 +26,7 @@ import { cn } from '@/lib/utils'; type SecondaryMeta = SwitcherItem['secondaryMeta']; type SwitcherVariant = 'default' | 'compact'; +const NEW_SESSION_SWITCHER_TARGET = 'new-session'; type SessionSwitcherDropdownProps = { children: React.ReactNode; @@ -40,7 +45,7 @@ export function SessionSwitcherDropdown({ const setOpen = useUIStore((state) => state.setSessionDropdownOpen); return ( - + {children} state.currentSessionId); + const isNewSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft.open === true); + const items = useSwitcherItems(true, { scopeProjectId, currentSessionId }); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); const setActiveMainTab = useUIStore((state) => state.setActiveMainTab); const { t } = useI18n(); @@ -81,6 +88,9 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP }, [onSelect, openNewSessionDraft, setActiveMainTab]); const [expandedParents, setExpandedParents] = React.useState>(new Set()); + const contentRef = React.useRef(null); + const initialFocusCompleteRef = React.useRef(false); + const initialTarget = isNewSessionDraftOpen ? NEW_SESSION_SWITCHER_TARGET : currentSessionId; const toggleParent = React.useCallback((sessionId: string) => { setExpandedParents((prev) => { const next = new Set(prev); @@ -93,10 +103,36 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP }); }, []); + React.useLayoutEffect(() => { + if (initialFocusCompleteRef.current || !initialTarget) return; + + const ancestorIds = initialTarget === NEW_SESSION_SWITCHER_TARGET + ? [] + : findSwitcherItemAncestorIds(items, initialTarget); + if (!ancestorIds) return; + + if (ancestorIds.some((id) => !expandedParents.has(id))) { + setExpandedParents((previous) => new Set([...previous, ...ancestorIds])); + return; + } + + const animationFrame = requestAnimationFrame(() => { + const item = Array.from( + contentRef.current?.querySelectorAll('[data-switcher-item-id]') ?? [], + ).find((element) => element.dataset.switcherItemId === initialTarget); + if (!item) return; + item.focus(); + item.scrollIntoView({ block: 'nearest' }); + initialFocusCompleteRef.current = true; + }); + return () => cancelAnimationFrame(animationFrame); + }, [expandedParents, initialTarget, items]); + return ( -
+
({ + id, + parentID: options.parentID, + time: options.archived ? { archived: Date.now() } : undefined, + projectId: options.projectId ?? 'project-a', +} as unknown as Session); + +const selectParents = (sessions: Session[], currentSessionId: string | null, scopeProjectId: string | null = null): Session[] => ( + selectSwitcherParents(sessions, new Set(), new Map(), scopeProjectId, currentSessionId, (item) => (item as Session & { projectId: string }).projectId) +); + +describe('session switcher initial selection', () => { + test('finds all local ancestors for a current child session', () => { + const items: SwitcherItem[] = [{ + node: { session: session('root'), worktree: null, children: [{ session: session('parent', { parentID: 'root' }), worktree: null, children: [{ session: session('child', { parentID: 'parent' }), worktree: null, children: [] }] }] }, + projectId: 'project-a', groupDirectory: null, secondaryMeta: null, + }]; + + expect(findSwitcherItemAncestorIds(items, 'child')).toEqual(['root', 'parent']); + expect(findSwitcherItemAncestorIds(items, 'missing')).toBeNull(); + }); + + test('replaces the final recent slot with the current root and excludes invalid current sessions', () => { + const roots = Array.from({ length: 8 }, (_, index) => session(`root-${index}`)); + const child = session('child', { parentID: 'root-7' }); + + expect(selectParents([...roots, child], 'child').map((item) => item.id)).toEqual([ + 'root-0', 'root-1', 'root-2', 'root-3', 'root-4', 'root-5', 'root-7', + ]); + expect(selectParents([...roots, child], 'missing').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id)); + expect(selectParents([...roots, child], 'child', 'project-b').map((item) => item.id)).toEqual([]); + expect(selectParents([...roots.slice(0, 7), session('archived', { archived: true })], 'archived').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id)); + expect(selectParents([...roots, session('archived-child', { archived: true, parentID: 'root-7' })], 'archived-child').map((item) => item.id)).toEqual(roots.slice(0, 7).map((item) => item.id)); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts index 1329daab..f858d0bc 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts @@ -24,6 +24,7 @@ const MAX_PARENT_SESSIONS = 7; type SwitcherItemsOptions = { scopeProjectId?: string | null; + currentSessionId?: string | null; /** How many parent sessions to return (default 7 — the desktop dropdown). */ maxParents?: number; }; @@ -43,8 +44,65 @@ const formatProjectLabel = (project: { label?: string | null; path: string } | n return segments[segments.length - 1] ?? null; }; +export const findSwitcherItemAncestorIds = (items: SwitcherItem[], sessionId: string): string[] | null => { + const visit = (node: SessionNode, ancestors: string[]): string[] | null => { + if (node.session.id === sessionId) return ancestors; + for (const child of node.children) { + const result = visit(child, [...ancestors, node.session.id]); + if (result) return result; + } + return null; + }; + + for (const item of items) { + const result = visit(item.node, []); + if (result) return result; + } + return null; +}; + +export const selectSwitcherParents = ( + activeSessions: Session[], + pinnedSessionIds: Set, + sessionOrderRanks: Map, + scopeProjectId: string | null, + currentSessionId: string | null, + getProjectId: (session: Session) => string | null, + maxParents = MAX_PARENT_SESSIONS, +): Session[] => { + const sessionsById = new Map(activeSessions.map((session) => [session.id, session])); + const isEligibleParent = (session: Session): boolean => { + if (session.time?.archived) return false; + if ((session as Session & { parentID?: string | null }).parentID) return false; + return !scopeProjectId || getProjectId(session) === scopeProjectId; + }; + const parents = activeSessions + .filter(isEligibleParent) + .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); + + const currentSession = currentSessionId ? sessionsById.get(currentSessionId) ?? null : null; + let currentRoot: Session | null = currentSession?.time?.archived ? null : currentSession; + const visited = new Set(); + while (currentRoot) { + const parentId = (currentRoot as Session & { parentID?: string | null }).parentID; + if (!parentId) break; + if (visited.has(parentId)) { + currentRoot = null; + break; + } + visited.add(parentId); + currentRoot = sessionsById.get(parentId) ?? null; + } + + const currentRootIndex = currentRoot && isEligibleParent(currentRoot) ? parents.indexOf(currentRoot) : -1; + if (currentRootIndex >= maxParents) { + return [...parents.slice(0, Math.max(0, maxParents - 1)), currentRoot!]; + } + return parents.slice(0, maxParents); +}; + export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions = {}): SwitcherItem[] => { - const { scopeProjectId = null, maxParents = MAX_PARENT_SESSIONS } = options; + const { scopeProjectId = null, currentSessionId = null, maxParents = MAX_PARENT_SESSIONS } = options; const activeSessions = useGlobalSessionsStore((state) => state.activeSessions); const projects = useProjectsStore((state) => state.projects); const pinnedSessionIds = useSessionPinnedStore((state) => state.ids); @@ -112,16 +170,15 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); }); - const parents = activeSessions - .filter((session) => !session.time?.archived) - .filter((session) => !(session as Session & { parentID?: string | null }).parentID) - .filter((session) => { - if (!scopeProjectId) return true; - const directory = resolveGlobalSessionDirectory(session); - return findProjectForDirectory(directory)?.id === scopeProjectId; - }) - .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)) - .slice(0, maxParents); + const parents = selectSwitcherParents( + activeSessions, + pinnedSessionIds, + sessionOrderRanks, + scopeProjectId, + currentSessionId, + (session) => findProjectForDirectory(resolveGlobalSessionDirectory(session))?.id ?? null, + maxParents, + ); const buildNode = (session: Session): SessionNode => { const childSessions = childrenByParent.get(session.id) ?? []; @@ -151,7 +208,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions }, }; }); - }, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); + }, [activeSessions, branchesByDirectory, currentSessionId, enabled, findProjectForDirectory, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); return items; }; diff --git a/packages/ui/src/components/ui/dropdown-menu-keyboard.ts b/packages/ui/src/components/ui/dropdown-menu-keyboard.ts new file mode 100644 index 00000000..66f32699 --- /dev/null +++ b/packages/ui/src/components/ui/dropdown-menu-keyboard.ts @@ -0,0 +1,6 @@ +export function getDropdownMenuNavigationKey(event: Pick): 'ArrowDown' | 'ArrowUp' | null { + if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return null; + if (event.key.toLowerCase() === 'n') return 'ArrowDown'; + if (event.key.toLowerCase() === 'p') return 'ArrowUp'; + return null; +} diff --git a/packages/ui/src/components/ui/dropdown-menu.test.ts b/packages/ui/src/components/ui/dropdown-menu.test.ts new file mode 100644 index 00000000..a02fe028 --- /dev/null +++ b/packages/ui/src/components/ui/dropdown-menu.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from 'bun:test'; +import { getDropdownMenuNavigationKey } from './dropdown-menu-keyboard'; + +function keyEvent(key: string, modifiers: Partial> = {}) { + return { + key, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + ...modifiers, + } as KeyboardEvent; +} + +test('maps only exact Ctrl+N and Ctrl+P to menu navigation keys', () => { + expect(getDropdownMenuNavigationKey(keyEvent('n', { ctrlKey: true }))).toBe('ArrowDown'); + expect(getDropdownMenuNavigationKey(keyEvent('p', { ctrlKey: true }))).toBe('ArrowUp'); + expect(getDropdownMenuNavigationKey(keyEvent('N', { ctrlKey: true }))).toBe('ArrowDown'); + expect(getDropdownMenuNavigationKey(keyEvent('n'))).toBe(null); + expect(getDropdownMenuNavigationKey(keyEvent('n', { ctrlKey: true, shiftKey: true }))).toBe(null); + expect(getDropdownMenuNavigationKey(keyEvent('p', { ctrlKey: true, altKey: true }))).toBe(null); + expect(getDropdownMenuNavigationKey(keyEvent('p', { ctrlKey: true, metaKey: true }))).toBe(null); +}); diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index 16215d81..81e91380 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -3,6 +3,8 @@ import { Menu as BaseMenu } from "@base-ui/react/menu" import { cn } from "@/lib/utils" import { Icon } from "@/components/icon/Icon"; +import { shortcutRegistry } from "@/lib/shortcuts"; +import { getDropdownMenuNavigationKey } from "./dropdown-menu-keyboard"; import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles"; type AsChildProps = { asChild?: boolean }; @@ -32,18 +34,43 @@ function renderFromAsChild(asChild: boolean | undefined, children: React.ReactNo return { children }; } +type DropdownMenuProps = React.ComponentProps & { + disableGlobalShortcuts?: boolean; +}; + function DropdownMenu({ + disableGlobalShortcuts = false, + open, + defaultOpen, + onOpenChange, ...props -}: React.ComponentProps) { +}: DropdownMenuProps) { const [portalContainer, setPortalContainer] = React.useState(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false); + const isOpen = open ?? uncontrolledOpen; const portalContextValue = React.useMemo(() => ({ portalContainer, setPortalContainer, }), [portalContainer]); + React.useLayoutEffect(() => { + if (!disableGlobalShortcuts || !isOpen) return; + return shortcutRegistry.suspend(); + }, [disableGlobalShortcuts, isOpen]); + + const handleOpenChange: NonNullable['onOpenChange']> = (nextOpen, eventDetails) => { + if (open === undefined) setUncontrolledOpen(nextOpen); + onOpenChange?.(nextOpen, eventDetails); + }; + return ( - + ) } @@ -106,11 +133,27 @@ function DropdownMenuContent({ style, children, onCloseAutoFocus, + onKeyDown, ...props }: ContentProps) { const portalContext = React.useContext(DropdownPortalContext); void onCloseAutoFocus + const handleKeyDown: NonNullable['onKeyDown']> = (event) => { + onKeyDown?.(event); + if (event.defaultPrevented || event.isPropagationStopped() || event.nativeEvent.isComposing) return; + const navigationKey = getDropdownMenuNavigationKey(event); + if (!navigationKey) return; + + event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { + key: navigationKey, + bubbles: true, + cancelable: true, + })); + event.preventDefault(); + event.stopPropagation(); + }; + return ( {children} diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 08f87108..89f5bd69 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -399,7 +399,16 @@ export const useKeyboardShortcuts = () => { } }, Math.max(expiresAt - now, 0)); }; + const handleActivePrefixKeyDownCapture = (event: KeyboardEvent) => { + if (isTerminalEventTarget(event.target)) return; + if (!dispatcher.hasActivePrefix()) return; + if (dispatcher.dispatchActivePrefix(event)) { + event.preventDefault(); + event.stopPropagation(); + } + }; const handleKeyDown = (event: KeyboardEvent) => { + if (dispatcher.consumeCapturedPrefixEvent(event)) return; if (event.key === 'Escape' || isTerminalEventTarget(event.target)) return; const combo = getEffectiveShortcutCombo('cycle_agent', useUIStore.getState().shortcutOverrides); const backward = combo && !combo.includes('shift') ? normalizeCombo(`shift+${combo}`) : ''; @@ -456,6 +465,7 @@ export const useKeyboardShortcuts = () => { window.addEventListener('keyup', handleKeyUp, true); window.addEventListener('keydown', handleTerminalShortcutCapture, true); window.addEventListener('keydown', handleEscapeKeyDownCapture, true); + window.addEventListener('keydown', handleActivePrefixKeyDownCapture, true); window.addEventListener('keydown', handleKeyDown); window.addEventListener('blur', handleBlur); return () => { @@ -463,6 +473,7 @@ export const useKeyboardShortcuts = () => { window.removeEventListener('keyup', handleKeyUp, true); window.removeEventListener('keydown', handleTerminalShortcutCapture, true); window.removeEventListener('keydown', handleEscapeKeyDownCapture, true); + window.removeEventListener('keydown', handleActivePrefixKeyDownCapture, true); window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('blur', handleBlur); }; diff --git a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts index 3b135aa5..d655ac95 100644 --- a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts @@ -98,14 +98,24 @@ export const useMiniChatKeyboardShortcuts = () => { }); React.useEffect(() => { + const handleActivePrefixKeyDownCapture = (event: KeyboardEvent) => { + if (!dispatcher.hasActivePrefix()) return; + if (dispatcher.dispatchActivePrefix(event)) { + event.preventDefault(); + event.stopPropagation(); + } + }; const handleKeyDown = (event: KeyboardEvent) => { + if (dispatcher.consumeCapturedPrefixEvent(event)) return; if (dispatcher.dispatch(event)) event.preventDefault(); }; const handleBlur = () => dispatcher.handleBlur(); + window.addEventListener('keydown', handleActivePrefixKeyDownCapture, true); window.addEventListener('keydown', handleKeyDown); window.addEventListener('blur', handleBlur); return () => { + window.removeEventListener('keydown', handleActivePrefixKeyDownCapture, true); window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('blur', handleBlur); }; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 3e095b78..9b6b31b6 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1131,9 +1131,9 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation', 'settings.openchamber.keyboardShortcuts.category.application': 'Application', 'settings.openchamber.keyboardShortcuts.actions.edit': 'Edit', - 'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Replace and Save', + 'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Replace and save', 'settings.openchamber.keyboardShortcuts.dialog.title': 'Edit {action}', - 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Press up to two key combinations. Press Backspace to remove the last one.', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Press up to two key combinations. Enter to finish, Esc to cancel, or Backspace to remove the last one.', 'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'First combination', 'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Second combination', 'settings.openchamber.keyboardShortcuts.dialog.recording': 'Press keys…', @@ -1142,7 +1142,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.error.exactConflict': 'This combination is already used by {action}.', 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Open draft project picker', 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Open draft worktree picker', - 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Open session list', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Open recent sessions', 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Voice input', 'settings.projects.sidebar.total': 'Total {count}', 'settings.projects.sidebar.actions.addProject': 'Add project', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index c2fdd90f..08b37e07 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1100,7 +1100,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.actions.edit": "Editar", "settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Reemplazar y guardar", "settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}", - "settings.openchamber.keyboardShortcuts.dialog.instructions": "Pulse hasta dos combinaciones de teclas. Pulse Retroceso para quitar la última.", + "settings.openchamber.keyboardShortcuts.dialog.instructions": "Pulse hasta dos combinaciones de teclas. Pulse Intro para terminar, Esc para cancelar o Retroceso para quitar la última.", "settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primera combinación", "settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinación", "settings.openchamber.keyboardShortcuts.dialog.recording": "Pulse las teclas…", @@ -1109,7 +1109,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinación ya la usa {action}.", "settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Abrir selector de proyecto de borrador", "settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Abrir selector de árbol de trabajo de borrador", - "settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir lista de sesiones", + "settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sesiones recientes", "settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada de voz", "settings.projects.sidebar.total": "Total {count}", "settings.projects.sidebar.actions.addProject": "Añadir proyecto", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index f2957df1..88318503 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1021,7 +1021,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.actions.edit': 'Modifier', 'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Remplacer et enregistrer', 'settings.openchamber.keyboardShortcuts.dialog.title': 'Modifier {action}', - 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Appuyez sur deux combinaisons de touches au maximum. Appuyez sur Retour arrière pour supprimer la dernière.', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Appuyez sur deux combinaisons de touches au maximum. Appuyez sur Entrée pour terminer, Échap pour annuler ou Retour arrière pour supprimer la dernière.', 'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Première combinaison', 'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Deuxième combinaison', 'settings.openchamber.keyboardShortcuts.dialog.recording': 'Appuyez sur les touches…', @@ -1030,7 +1030,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Cette combinaison est déjà utilisée par {action}.', 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Ouvrir le sélecteur de projet de brouillon', 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Ouvrir le sélecteur de worktree de brouillon', - 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Ouvrir la liste des sessions', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Ouvrir les sessions récentes', 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Saisie vocale', 'settings.projects.sidebar.total': 'Total {count}', 'settings.projects.sidebar.actions.addProject': 'Ajouter un projet', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index e26df24d..9bf7ef5d 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1133,7 +1133,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.actions.edit': '編集', 'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '置き換えて保存', 'settings.openchamber.keyboardShortcuts.dialog.title': '{action} を編集', - 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力できます。Backspace で最後の組み合わせを削除します。', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力できます。Enter で完了、Esc でキャンセル、Backspace で最後の組み合わせを削除します。', 'settings.openchamber.keyboardShortcuts.dialog.firstChord': '最初の組み合わせ', 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '2番目の組み合わせ', 'settings.openchamber.keyboardShortcuts.dialog.recording': 'キーを押してください…', @@ -1142,7 +1142,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.error.exactConflict': 'この組み合わせは {action} で使用されています。', 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '下書きプロジェクト選択を開く', 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '下書きワークツリー選択を開く', - 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'セッション一覧を開く', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '最近のセッションを開く', 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '音声入力', 'settings.projects.sidebar.total': '合計 {count}', 'settings.projects.sidebar.actions.addProject': 'プロジェクトを追加', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 23bc6322..f0978532 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1100,7 +1100,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.actions.edit': '편집', 'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '바꾸고 저장', 'settings.openchamber.keyboardShortcuts.dialog.title': '{action} 편집', - 'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. Backspace를 누르면 마지막 조합이 삭제됩니다.', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. Enter로 완료하고 Esc로 취소하거나 Backspace로 마지막 조합을 삭제하세요.', 'settings.openchamber.keyboardShortcuts.dialog.firstChord': '첫 번째 조합', 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '두 번째 조합', 'settings.openchamber.keyboardShortcuts.dialog.recording': '키를 누르세요…', @@ -1109,7 +1109,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.error.exactConflict': '이 조합은 이미 {action}에서 사용합니다.', 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '초안 프로젝트 선택기 열기', 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '초안 워크트리 선택기 열기', - 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '세션 목록 열기', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '최근 세션 열기', 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '음성 입력', 'settings.projects.sidebar.total': '총 {count}개', 'settings.projects.sidebar.actions.addProject': '프로젝트 추가', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 45453328..fca82afd 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1368,7 +1368,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.actions.edit': 'Edytuj', 'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': 'Zastąp i zapisz', 'settings.openchamber.keyboardShortcuts.dialog.title': 'Edytuj: {action}', - 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Naciśnij maksymalnie dwie kombinacje klawiszy. Naciśnij Backspace, aby usunąć ostatnią.', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Naciśnij maksymalnie dwie kombinacje klawiszy. Naciśnij Enter, aby zakończyć, Esc, aby anulować, lub Backspace, aby usunąć ostatnią.', 'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Pierwsza kombinacja', 'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Druga kombinacja', 'settings.openchamber.keyboardShortcuts.dialog.recording': 'Naciśnij klawisze…', @@ -1377,7 +1377,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Ta kombinacja jest już używana przez {action}.', 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Otwórz wybór projektu szkicu', 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Otwórz wybór worktree szkicu', - 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Otwórz listę sesji', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Otwórz ostatnie sesje', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Otwórz oś czasu rozmowy', 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Wprowadzanie głosowe', 'settings.projects.sidebar.total': 'Suma: {count}', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 86b58337..d09ab415 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1100,7 +1100,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.actions.edit": "Editar", "settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Substituir e salvar", "settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}", - "settings.openchamber.keyboardShortcuts.dialog.instructions": "Pressione até duas combinações de teclas. Pressione Backspace para remover a última.", + "settings.openchamber.keyboardShortcuts.dialog.instructions": "Pressione até duas combinações de teclas. Pressione Enter para concluir, Esc para cancelar ou Backspace para remover a última.", "settings.openchamber.keyboardShortcuts.dialog.firstChord": "Primeira combinação", "settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinação", "settings.openchamber.keyboardShortcuts.dialog.recording": "Pressione as teclas…", @@ -1109,7 +1109,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.error.exactConflict": "Esta combinação já é usada por {action}.", "settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Abrir seletor de projeto do rascunho", "settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Abrir seletor de worktree do rascunho", - "settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir lista de sessões", + "settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Abrir sessões recentes", "settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Entrada por voz", "settings.projects.sidebar.total": "Total {count}", "settings.projects.sidebar.actions.addProject": "Adicionar projeto", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 3eb4cf88..67f9cf31 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1100,7 +1100,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.actions.edit": "Редагувати", "settings.openchamber.keyboardShortcuts.actions.replaceAndSave": "Замінити й зберегти", "settings.openchamber.keyboardShortcuts.dialog.title": "Редагувати {action}", - "settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш. Натисніть Backspace, щоб видалити останню.", + "settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш. Натисніть Enter, щоб завершити, Esc, щоб скасувати, або Backspace, щоб видалити останню.", "settings.openchamber.keyboardShortcuts.dialog.firstChord": "Перша комбінація", "settings.openchamber.keyboardShortcuts.dialog.secondChord": "Друга комбінація", "settings.openchamber.keyboardShortcuts.dialog.recording": "Натисніть клавіші…", @@ -1109,7 +1109,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.error.exactConflict": "Цю комбінацію вже використовує {action}.", "settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label": "Відкрити вибір проєкту чернетки", "settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label": "Відкрити вибір worktree чернетки", - "settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Відкрити список сесій", + "settings.openchamber.keyboardShortcuts.action.open_session_list.label": "Відкрити останні сесії", "settings.openchamber.keyboardShortcuts.action.toggle_dictation.label": "Голосове введення", "settings.projects.sidebar.total": "Усього {count}", "settings.projects.sidebar.actions.addProject": "Додати проєкт", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 5048c4a9..22843f4e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1100,7 +1100,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.actions.edit': '编辑', 'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '替换并保存', 'settings.openchamber.keyboardShortcuts.dialog.title': '编辑{action}', - 'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下两个按键组合。按 Backspace 删除最后一个。', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下两个按键组合。按 Enter 完成,按 Esc 取消,按 Backspace 删除最后一个。', 'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一个组合', 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二个组合', 'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按键…', @@ -1109,7 +1109,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.error.exactConflict': '此组合已被 {action} 使用。', 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '打开草稿项目选择器', 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '打开草稿工作树选择器', - 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '打开会话列表', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '打开最近会话', 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '语音输入', 'settings.projects.sidebar.total': '总计 {count}', 'settings.projects.sidebar.actions.addProject': '添加项目', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 90e9e345..318b76f0 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1007,7 +1007,7 @@ 'settings.openchamber.keyboardShortcuts.actions.edit': '編輯', 'settings.openchamber.keyboardShortcuts.actions.replaceAndSave': '取代並儲存', 'settings.openchamber.keyboardShortcuts.dialog.title': '編輯 {action}', - 'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下兩個按鍵組合。按 Backspace 可刪除最後一個。', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多按下兩個按鍵組合。按 Enter 完成,按 Esc 取消,按 Backspace 可刪除最後一個。', 'settings.openchamber.keyboardShortcuts.dialog.firstChord': '第一個組合', 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二個組合', 'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按鍵…', @@ -1016,7 +1016,7 @@ 'settings.openchamber.keyboardShortcuts.error.exactConflict': '此組合已由 {action} 使用。', 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': '開啟草稿專案選擇器', 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': '開啟草稿 worktree 選擇器', - 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '開啟工作階段列表', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': '開啟最近工作階段', 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': '語音輸入', 'settings.projects.sidebar.total': '總計 {count}', 'settings.projects.sidebar.actions.addProject': '新增專案', diff --git a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md index 3bb74106..750f010b 100644 --- a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -18,7 +18,7 @@ Component interaction keys that are not application commands, such as list navig - `config.ts` owns grouped declarations and the final `SHORTCUT_SCHEMA`. - `schema.ts` derives action and category types and provides schema lookup and effective binding resolution. - `bindings.ts` owns chord parsing, normalization, display, browser-risk checks, and conflict rules. -- `registry.ts` owns the active handler for each action ID. +- `registry.ts` owns the active handler for each action ID and stack-safe temporary suspension of all application handlers. - `dispatcher.ts` resolves current bindings and turns keyboard events into registered command calls. - `useKeybind.ts` ties registrations to React component lifetimes while keeping handlers current without re-registering after every render. - Runtime hooks install one dispatcher listener for their window. The main application and Mini Chat have separate windows but use the same contracts. @@ -35,7 +35,11 @@ The settings recorder also stops at two chords. It keeps the recording local unt # Dispatching -`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 1500ms. The application keydown route clears that prefix on window blur and consumes Escape only when it cancels a prefix. A handler returns `false` to leave the completed binding unconsumed. +`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 1500ms. The application keydown route clears that prefix on window blur and consumes Escape only when it cancels a prefix. A handler returns `false` to leave the completed binding unconsumed. When a sequence prefix is active, only its second key is dispatched during window capture so local input handlers cannot block it; unconsumed keys retain local input behavior, while consumed keys are prevented and stopped. Normal application shortcuts remain window-bubble listeners. + +`shortcutRegistry.suspend()` disables all application handlers and returns an idempotent cleanup. Suspensions nest; handlers resume only after the final cleanup. Starting or ending a suspension invalidates every pending dispatcher prefix, so stale second keys and Escape cannot consume it. + +Shared `DropdownMenu` can opt into this boundary with `disableGlobalShortcuts`; it suspends while open for both controlled and uncontrolled menus and resumes on close or unmount. Terminal capture, Escape abort priming, and the shifted reverse-agent chord are input-boundary exceptions. They preserve their target-specific semantics and invoke the registered application handler rather than duplicating command behavior. diff --git a/packages/ui/src/lib/shortcuts/dispatcher.test.ts b/packages/ui/src/lib/shortcuts/dispatcher.test.ts index 1ba04344..714d9a64 100644 --- a/packages/ui/src/lib/shortcuts/dispatcher.test.ts +++ b/packages/ui/src/lib/shortcuts/dispatcher.test.ts @@ -134,6 +134,35 @@ describe('ShortcutDispatcher', () => { expect(calls).toEqual(['x', 'y']); }); + test('invalidates a prefix when shortcut suspension changes', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_command_palette', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' }); + + expect(dispatcher.dispatch(key('g'))).toBe(true); + const resume = registry.suspend(); + expect(dispatcher.hasActivePrefix()).toBe(false); + expect(dispatcher.handleEscape()).toBe(false); + resume(); + expect(dispatcher.dispatch(key('h'))).toBe(false); + expect(calls).toEqual([]); + }); + + test('marks a second key dispatched from capture so bubble does not dispatch it again', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_command_palette', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' }); + const secondKey = key('h'); + + dispatcher.dispatch(key('g')); + expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true); + expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true); + expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(false); + expect(calls).toEqual(['sequence']); + }); + test('stops after the first handler that accepts a conflicting binding', () => { const registry = new ShortcutRegistry(); const calls: string[] = []; diff --git a/packages/ui/src/lib/shortcuts/dispatcher.ts b/packages/ui/src/lib/shortcuts/dispatcher.ts index a4bd8cbb..deb9f8ab 100644 --- a/packages/ui/src/lib/shortcuts/dispatcher.ts +++ b/packages/ui/src/lib/shortcuts/dispatcher.ts @@ -29,6 +29,8 @@ export class ShortcutDispatcher { private readonly timeoutMs: number; private prefix: string | undefined; private expiresAt = 0; + private prefixSuspensionVersion = 0; + private readonly capturedPrefixEvents = new WeakSet(); constructor(private readonly options: ShortcutDispatcherOptions) { this.now = options.now ?? Date.now; @@ -39,12 +41,10 @@ export class ShortcutDispatcher { if (event.repeat || event.isComposing || MODIFIER_KEYS.has(event.key.toLowerCase())) { return false; } - if (event.key === 'Escape' && this.prefix) { + if (event.key === 'Escape' && this.hasActivePrefix()) { return this.handleEscape(); } - if (this.prefix && this.now() >= this.expiresAt) { - this.clear(); - } + this.hasActivePrefix(); const matches = this.getMatches(); if (this.prefix) { @@ -73,6 +73,7 @@ export class ShortcutDispatcher { if (leader) { this.prefix = leader.chords[0]; this.expiresAt = this.now() + this.timeoutMs; + this.prefixSuspensionVersion = this.options.registry.getSuspensionVersion(); return true; } return false; @@ -81,6 +82,7 @@ export class ShortcutDispatcher { clear(): void { this.prefix = undefined; this.expiresAt = 0; + this.prefixSuspensionVersion = 0; } handleBlur(): void { @@ -88,11 +90,34 @@ export class ShortcutDispatcher { } handleEscape(): boolean { - const hadPrefix = Boolean(this.prefix); + const hadPrefix = this.hasActivePrefix(); this.clear(); return hadPrefix; } + hasActivePrefix(): boolean { + if (!this.prefix) return false; + if ( + this.now() >= this.expiresAt + || this.prefixSuspensionVersion !== this.options.registry.getSuspensionVersion() + ) { + this.clear(); + return false; + } + return true; + } + + dispatchActivePrefix(event: KeyboardEvent): boolean { + this.capturedPrefixEvents.add(event); + return this.dispatch(event); + } + + consumeCapturedPrefixEvent(event: KeyboardEvent): boolean { + if (!this.capturedPrefixEvents.has(event)) return false; + this.capturedPrefixEvents.delete(event); + return true; + } + private invoke(matches: BindingMatch[], event: KeyboardEvent): boolean { for (const match of matches) { if (match.handler(event) !== false) { diff --git a/packages/ui/src/lib/shortcuts/registry.test.ts b/packages/ui/src/lib/shortcuts/registry.test.ts index ecf90cd1..b32af8ae 100644 --- a/packages/ui/src/lib/shortcuts/registry.test.ts +++ b/packages/ui/src/lib/shortcuts/registry.test.ts @@ -25,3 +25,20 @@ test('a later registration takes over after the first unregisters', () => { first(); expect(registry.get('open_settings')).toBe(secondHandler); }); + +test('suspends all handlers until every idempotent cleanup completes', () => { + const registry = new ShortcutRegistry(); + const handler = () => undefined; + registry.register('open_settings', handler); + + const resumeFirst = registry.suspend(); + const resumeSecond = registry.suspend(); + expect(registry.get('open_settings')).toBe(undefined); + + resumeFirst(); + resumeFirst(); + expect(registry.get('open_settings')).toBe(undefined); + resumeSecond(); + resumeSecond(); + expect(registry.get('open_settings')).toBe(handler); +}); diff --git a/packages/ui/src/lib/shortcuts/registry.ts b/packages/ui/src/lib/shortcuts/registry.ts index 4b47ab3d..e7cfd5aa 100644 --- a/packages/ui/src/lib/shortcuts/registry.ts +++ b/packages/ui/src/lib/shortcuts/registry.ts @@ -9,6 +9,8 @@ interface RegisteredHandler { /** Active application command handlers, keyed by shortcut action ID. */ export class ShortcutRegistry { private readonly handlers = new Map(); + private suspensionCount = 0; + private suspensionVersion = 0; register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void { const registration = { handler }; @@ -28,9 +30,29 @@ export class ShortcutRegistry { } get(actionId: ShortcutActionId): ShortcutHandler | undefined { + if (this.suspensionCount > 0) return undefined; return this.handlers.get(actionId)?.[0]?.handler; } + /** Temporarily disables every registered application shortcut. */ + suspend(): () => void { + this.suspensionCount += 1; + this.suspensionVersion += 1; + let active = true; + return () => { + if (!active) return; + active = false; + this.suspensionCount -= 1; + if (this.suspensionCount === 0) { + this.suspensionVersion += 1; + } + }; + } + + getSuspensionVersion(): number { + return this.suspensionVersion; + } + actionIds(): IterableIterator { return this.handlers.keys(); }