fix(ui): refine shortcut and recent session interactions

This commit is contained in:
ChangeHow
2026-08-06 14:18:15 +08:00
parent aba10476c6
commit 6420460dfc
25 changed files with 545 additions and 133 deletions
@@ -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<Record<'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey', boolean>> = {}) {
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']);
});
});
@@ -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<CustomizableShortcutAction>;
@@ -36,7 +53,15 @@ interface ShortcutRecordingDialogProps {
onOpenChange: (open: boolean) => void;
}
function keyboardEventToCombo(event: React.KeyboardEvent<HTMLDivElement>): 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<HTMLDivElement>): 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<ShortcutRecordingDialogProps> = ({
action,
actions,
@@ -70,15 +126,16 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
}) => {
const { t } = useI18n();
const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey);
const [chords, setChords] = React.useState<ShortcutCombo[]>([]);
const [recording, setRecording] = React.useState<ShortcutRecordingState>({ chords: [], livePreview: null });
const recordingRef = React.useRef<HTMLDivElement>(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<ShortcutRecordingDialogProps> = (
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<HTMLDivElement>, 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 (
<Dialog open={action !== null} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-md"
initialFocus={recordingRef}
>
<DialogContent className="max-w-md" initialFocus={recordingRef}>
<DialogHeader>
<DialogTitle>
{action
? t('settings.openchamber.keyboardShortcuts.dialog.title', {
action: actionLabel(action),
})
: ''}
{action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''}
</DialogTitle>
<DialogDescription>{t('settings.openchamber.keyboardShortcuts.dialog.instructions')}</DialogDescription>
</DialogHeader>
<div
className="space-y-3 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex min-h-28 items-center justify-center rounded-lg border border-border bg-[var(--surface-elevated)] px-4 py-5 text-center outline-none focus-visible:ring-2 focus-visible:ring-ring"
tabIndex={0}
ref={recordingRef}
onKeyDown={(event) => {
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) => (
<div key={index} className="flex items-center justify-between gap-3">
<span className="typography-ui-label text-foreground">
{t(index === 0
? 'settings.openchamber.keyboardShortcuts.dialog.firstChord'
: 'settings.openchamber.keyboardShortcuts.dialog.secondChord')}
</span>
<kbd
className="min-w-32 rounded-md border border-border bg-muted px-3 py-2 text-center typography-meta font-mono text-foreground"
>
{chords[index]
? formatShortcutForDisplay(chords[index])
: t('settings.openchamber.keyboardShortcuts.dialog.recording')}
<div className="flex flex-wrap items-center justify-center gap-2">
{recording.chords.map((chord, index) => (
<kbd key={`${chord}-${index}`} className="rounded-md border border-border bg-muted px-3 py-2 typography-ui-label font-mono text-foreground">
{formatShortcutForDisplay(chord)}
</kbd>
</div>
))}
{prefixConflict ? (
<p className="typography-meta text-[var(--status-error)]">
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
</p>
) : null}
{exactConflict && !prefixConflict ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
</p>
) : null}
{combo && isRiskyBrowserShortcut(combo) ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')}
</p>
) : null}
))}
{recording.livePreview ? (
<kbd className="rounded-md border border-dashed border-border bg-muted px-3 py-2 typography-ui-label font-mono text-muted-foreground">
{formatShortcutForDisplay(recording.livePreview)}
</kbd>
) : null}
{recording.chords.length === 0 && !recording.livePreview ? (
<span className="typography-ui-label text-muted-foreground">
{t('settings.openchamber.keyboardShortcuts.dialog.recording')}
</span>
) : null}
</div>
</div>
<DialogFooter>
<Button type="button" variant="ghost" size="sm" onClick={close}>
{t('settings.common.actions.cancel')}
</Button>
{exactConflict && !prefixConflict ? (
{prefixConflict ? (
<p className="typography-meta text-[var(--status-error)]">
{t('settings.openchamber.keyboardShortcuts.error.prefixConflict', { action: actionLabel(prefixConflict.action) })}
</p>
) : null}
{exactConflict && !prefixConflict ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.error.exactConflict', { action: actionLabel(exactConflict.action) })}
</p>
) : null}
{combo && isRiskyBrowserShortcut(combo) ? (
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut')}
</p>
) : null}
{exactConflict && !prefixConflict ? (
<DialogFooter>
<Button type="button" size="sm" onClick={() => {
if (!action) return;
onSave(action.id, combo, exactConflict.action.id);
@@ -192,16 +246,8 @@ export const ShortcutRecordingDialog: React.FC<ShortcutRecordingDialogProps> = (
}}>
{t('settings.openchamber.keyboardShortcuts.actions.replaceAndSave')}
</Button>
) : (
<Button type="button" size="sm" disabled={!combo || Boolean(prefixConflict)} onClick={() => {
if (!action) return;
onSave(action.id, combo);
close();
}}>
{t('settings.common.actions.saveChanges')}
</Button>
)}
</DialogFooter>
</DialogFooter>
) : null}
</DialogContent>
</Dialog>
);
@@ -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 (
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false}>
<DropdownMenu open={isOpen} onOpenChange={setOpen} modal={false} disableGlobalShortcuts>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
@@ -69,7 +74,9 @@ type SwitcherContentProps = {
};
function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentProps): React.ReactElement {
const items = useSwitcherItems(true, { scopeProjectId });
const currentSessionId = useSessionUIStore((state) => 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<Set<string>>(new Set());
const contentRef = React.useRef<HTMLDivElement>(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<HTMLElement>('[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 (
<div className="max-h-[60vh] overflow-y-auto">
<div ref={contentRef} className="max-h-[60vh] overflow-y-auto">
<div className="space-y-0.5">
<BaseMenu.Item
data-switcher-item-id={NEW_SESSION_SWITCHER_TARGET}
onClick={handleNewSession}
className={cn(
'group relative flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
@@ -229,6 +265,7 @@ function SwitcherRow({ session, depth, variant, secondaryMeta, hasChildren, isEx
handleSelect();
}}
data-slot="session-switcher-item"
data-switcher-item-id={session.id}
className={cn(
'group relative flex w-full cursor-pointer items-start gap-2 rounded-lg px-2 py-1.5 outline-hidden select-none',
'data-[highlighted]:bg-interactive-hover hover:bg-interactive-hover',
@@ -0,0 +1,44 @@
import { describe, expect, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import {
findSwitcherItemAncestorIds,
selectSwitcherParents,
type SwitcherItem,
} from './useSwitcherItems';
const session = (id: string, options: { parentID?: string; archived?: boolean; projectId?: string } = {}): Session => ({
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));
});
});
@@ -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<string>,
sessionOrderRanks: Map<string, number>,
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<string>();
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;
};
@@ -0,0 +1,6 @@
export function getDropdownMenuNavigationKey(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): '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;
}
@@ -0,0 +1,23 @@
import { expect, test } from 'bun:test';
import { getDropdownMenuNavigationKey } from './dropdown-menu-keyboard';
function keyEvent(key: string, modifiers: Partial<Pick<KeyboardEvent, 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>> = {}) {
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);
});
@@ -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<typeof BaseMenu.Root> & {
disableGlobalShortcuts?: boolean;
};
function DropdownMenu({
disableGlobalShortcuts = false,
open,
defaultOpen,
onOpenChange,
...props
}: React.ComponentProps<typeof BaseMenu.Root>) {
}: DropdownMenuProps) {
const [portalContainer, setPortalContainer] = React.useState<HTMLElement | null>(null);
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false);
const isOpen = open ?? uncontrolledOpen;
const portalContextValue = React.useMemo<DropdownPortalContextValue>(() => ({
portalContainer,
setPortalContainer,
}), [portalContainer]);
React.useLayoutEffect(() => {
if (!disableGlobalShortcuts || !isOpen) return;
return shortcutRegistry.suspend();
}, [disableGlobalShortcuts, isOpen]);
const handleOpenChange: NonNullable<React.ComponentProps<typeof BaseMenu.Root>['onOpenChange']> = (nextOpen, eventDetails) => {
if (open === undefined) setUncontrolledOpen(nextOpen);
onOpenChange?.(nextOpen, eventDetails);
};
return (
<DropdownPortalContext.Provider value={portalContextValue}>
<BaseMenu.Root {...props} />
<BaseMenu.Root
{...props}
defaultOpen={defaultOpen}
open={open}
onOpenChange={handleOpenChange}
/>
</DropdownPortalContext.Provider>
)
}
@@ -106,11 +133,27 @@ function DropdownMenuContent({
style,
children,
onCloseAutoFocus,
onKeyDown,
...props
}: ContentProps) {
const portalContext = React.useContext(DropdownPortalContext);
void onCloseAutoFocus
const handleKeyDown: NonNullable<React.ComponentProps<typeof BaseMenu.Popup>['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 (
<BaseMenu.Portal container={portalToBody ? undefined : portalContext?.portalContainer || undefined}>
<BaseMenu.Positioner
@@ -132,6 +175,7 @@ function DropdownMenuContent({
className
)}
{...props}
onKeyDown={handleKeyDown}
>
{children}
</BaseMenu.Popup>
@@ -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);
};
@@ -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);
};
@@ -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',
@@ -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",
@@ -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',
@@ -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': 'プロジェクトを追加',
@@ -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': '프로젝트 추가',
@@ -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}',
@@ -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",
@@ -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": "Додати проєкт",
@@ -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': '添加项目',
@@ -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': '新增專案',
@@ -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.
@@ -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[] = [];
+30 -5
View File
@@ -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<KeyboardEvent>();
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) {
@@ -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);
});
+22
View File
@@ -9,6 +9,8 @@ interface RegisteredHandler {
/** Active application command handlers, keyed by shortcut action ID. */
export class ShortcutRegistry {
private readonly handlers = new Map<ShortcutActionId, RegisteredHandler[]>();
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<ShortcutActionId> {
return this.handlers.keys();
}