From 82f099be0ac26307757c035e4c6d3cd5017ecf88 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Thu, 30 Jul 2026 11:10:59 +0800 Subject: [PATCH 001/192] feat(ui): add shortcut registry and sequence dispatcher --- packages/ui/src/hooks/useKeybind.ts | 27 ++ .../ui/src/lib/shortcutDispatcher.test.ts | 148 ++++++++++ packages/ui/src/lib/shortcutDispatcher.ts | 119 ++++++++ packages/ui/src/lib/shortcutRegistry.test.ts | 27 ++ packages/ui/src/lib/shortcutRegistry.ts | 41 +++ packages/ui/src/lib/shortcuts.test.ts | 38 +++ packages/ui/src/lib/shortcuts.ts | 277 +++++++++++++++--- .../ui/src/lib/shortcuts/DOCUMENTATION.md | 29 ++ 8 files changed, 660 insertions(+), 46 deletions(-) create mode 100644 packages/ui/src/hooks/useKeybind.ts create mode 100644 packages/ui/src/lib/shortcutDispatcher.test.ts create mode 100644 packages/ui/src/lib/shortcutDispatcher.ts create mode 100644 packages/ui/src/lib/shortcutRegistry.test.ts create mode 100644 packages/ui/src/lib/shortcutRegistry.ts create mode 100644 packages/ui/src/lib/shortcuts/DOCUMENTATION.md diff --git a/packages/ui/src/hooks/useKeybind.ts b/packages/ui/src/hooks/useKeybind.ts new file mode 100644 index 00000000..f97fa57f --- /dev/null +++ b/packages/ui/src/hooks/useKeybind.ts @@ -0,0 +1,27 @@ +import React from 'react'; +import { shortcutRegistry, type ShortcutHandler } from '@/lib/shortcutRegistry'; +import type { ShortcutActionId } from '@/lib/shortcuts'; + +export function useKeybind(actionId: ShortcutActionId, handler: ShortcutHandler): void { + const handlerRef = React.useRef(handler); + handlerRef.current = handler; + + React.useEffect(() => shortcutRegistry.register(actionId, (event) => handlerRef.current(event)), [actionId]); +} + +export function useKeybinds( + bindings: Partial>, +): void { + const handlersRef = React.useRef(bindings); + handlersRef.current = bindings; + const actionIdsKey = Object.keys(bindings).sort().join('\0'); + + React.useEffect(() => { + const actionIds = (actionIdsKey ? actionIdsKey.split('\0') : []) as ShortcutActionId[]; + const unregister = actionIds.map((actionId) => shortcutRegistry.register(actionId, (event) => { + const handler = handlersRef.current[actionId]; + return handler ? handler(event) : false; + })); + return () => unregister.forEach((remove) => remove()); + }, [actionIdsKey]); +} diff --git a/packages/ui/src/lib/shortcutDispatcher.test.ts b/packages/ui/src/lib/shortcutDispatcher.test.ts new file mode 100644 index 00000000..9df23ef3 --- /dev/null +++ b/packages/ui/src/lib/shortcutDispatcher.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from 'bun:test'; +import { ShortcutDispatcher } from './shortcutDispatcher'; +import { ShortcutRegistry } from './shortcutRegistry'; + +function key(key: string, options: Partial = {}): KeyboardEvent { + return { + key, + code: `Key${key.toUpperCase()}`, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + repeat: false, + isComposing: false, + ...options, + } as KeyboardEvent; +} + +describe('ShortcutDispatcher', () => { + test('dispatches a sequence and consumes only leaders with active handlers', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + const unregister = registry.register('open_command_palette', (event) => { + calls.push(event.key); + }); + const dispatcher = new ShortcutDispatcher({ + registry, + getBinding: (id) => id === 'open_command_palette' ? 'g h' : '', + }); + + expect(dispatcher.dispatch(key('g'))).toBe(true); + expect(dispatcher.dispatch(key('h'))).toBe(true); + expect(calls).toEqual(['h']); + + unregister(); + expect(dispatcher.dispatch(key('g'))).toBe(false); + }); + + test('re-matches a prefix mismatch and clears on escape or blur', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_command_palette', () => { calls.push('sequence'); }); + registry.register('open_help', () => { calls.push('single'); }); + const dispatcher = new ShortcutDispatcher({ + registry, + getBinding: (id) => id === 'open_command_palette' ? 'g h' : 'x', + }); + + dispatcher.dispatch(key('g')); + expect(dispatcher.dispatch(key('x'))).toBe(true); + expect(calls).toEqual(['single']); + dispatcher.dispatch(key('g')); + expect(dispatcher.dispatch(key('Escape'))).toBe(true); + expect(dispatcher.handleEscape()).toBe(false); + dispatcher.dispatch(key('g')); + dispatcher.handleBlur(); + expect(dispatcher.dispatch(key('h'))).toBe(false); + }); + + test('expires prefixes and ignores repeats, composition, and modifier keys', () => { + let now = 0; + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_command_palette', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h', now: () => now }); + + expect(dispatcher.dispatch(key('g'))).toBe(true); + now = 1500; + expect(dispatcher.dispatch(key('h'))).toBe(false); + expect(dispatcher.dispatch(key('g', { repeat: true }))).toBe(false); + expect(dispatcher.dispatch(key('g', { isComposing: true }))).toBe(false); + expect(dispatcher.dispatch(key('Shift'))).toBe(false); + expect(calls).toEqual([]); + }); + + test('does not consume a completed binding when every handler declines it', () => { + const registry = new ShortcutRegistry(); + registry.register('open_command_palette', () => false); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'g h' }); + + expect(dispatcher.dispatch(key('g'))).toBe(true); + expect(dispatcher.dispatch(key('h'))).toBe(false); + }); + + test('does not consume a single chord when its handler declines it', () => { + const registry = new ShortcutRegistry(); + registry.register('open_command_palette', () => false); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'x' }); + + expect(dispatcher.dispatch(key('x'))).toBe(false); + }); + + test('starts a sequence when a single-chord handler with the same leader declines', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('save_file', () => false); + registry.register('open_draft_project_picker', () => { calls.push('project'); }); + const dispatcher = new ShortcutDispatcher({ + registry, + getBinding: (id) => id === 'save_file' ? 'mod+s' : 'mod+s p', + }); + + expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true); + expect(dispatcher.dispatch(key('p'))).toBe(true); + expect(calls).toEqual(['project']); + }); + + test('does not start a sequence when a single-chord handler accepts the leader', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('save_file', () => { calls.push('save'); }); + registry.register('open_draft_project_picker', () => { calls.push('project'); }); + const dispatcher = new ShortcutDispatcher({ + registry, + getBinding: (id) => id === 'save_file' ? 'mod+s' : 'mod+s p', + }); + + expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true); + expect(dispatcher.dispatch(key('p'))).toBe(false); + expect(calls).toEqual(['save']); + }); + + test('resolves bindings at dispatch time', () => { + const registry = new ShortcutRegistry(); + let binding = 'x'; + const calls: string[] = []; + registry.register('open_command_palette', (event) => { calls.push(event.key); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => binding }); + + expect(dispatcher.dispatch(key('x'))).toBe(true); + binding = 'y'; + expect(dispatcher.dispatch(key('x'))).toBe(false); + expect(dispatcher.dispatch(key('y'))).toBe(true); + expect(calls).toEqual(['x', 'y']); + }); + + test('stops after the first handler that accepts a conflicting binding', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_command_palette', () => { calls.push('declined'); return false; }); + registry.register('open_help', () => { calls.push('first'); }); + registry.register('open_settings', () => { calls.push('second'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'x' }); + + expect(dispatcher.dispatch(key('x'))).toBe(true); + expect(calls).toEqual(['declined', 'first']); + }); +}); diff --git a/packages/ui/src/lib/shortcutDispatcher.ts b/packages/ui/src/lib/shortcutDispatcher.ts new file mode 100644 index 00000000..775bc0b9 --- /dev/null +++ b/packages/ui/src/lib/shortcutDispatcher.ts @@ -0,0 +1,119 @@ +import { + eventMatchesShortcut, + normalizeCombo, + parseShortcut, + UNASSIGNED_SHORTCUT, + type ShortcutActionId, + type ShortcutCombo, +} from './shortcuts'; +import { type ShortcutHandler, ShortcutRegistry } from './shortcutRegistry'; + +const SEQUENCE_TIMEOUT_MS = 1500; +const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']); + +export interface ShortcutDispatcherOptions { + registry: ShortcutRegistry; + getBinding: (actionId: ShortcutActionId) => ShortcutCombo; + now?: () => number; + timeoutMs?: number; +} + +interface BindingMatch { + chords: string[]; + handler: ShortcutHandler; +} + +/** Stateless with respect to the DOM; callers decide whether a consumed event is prevented. */ +export class ShortcutDispatcher { + private readonly now: () => number; + private readonly timeoutMs: number; + private prefix: string | undefined; + private expiresAt = 0; + + constructor(private readonly options: ShortcutDispatcherOptions) { + this.now = options.now ?? Date.now; + this.timeoutMs = options.timeoutMs ?? SEQUENCE_TIMEOUT_MS; + } + + dispatch(event: KeyboardEvent): boolean { + if (event.repeat || event.isComposing || MODIFIER_KEYS.has(event.key.toLowerCase())) { + return false; + } + if (event.key === 'Escape' && this.prefix) { + return this.handleEscape(); + } + if (this.prefix && this.now() >= this.expiresAt) { + this.clear(); + } + + const matches = this.getMatches(); + if (this.prefix) { + const pending = matches.filter((match) => ( + match.chords.length === 2 + && match.chords[0] === this.prefix + && eventMatchesShortcut(event, match.chords[1]) + )); + if (pending.length > 0) { + this.clear(); + return this.invoke(pending, event); + } + this.clear(); + } + + const singles = matches.filter((match) => ( + match.chords.length === 1 && eventMatchesShortcut(event, match.chords[0]) + )); + if (singles.length > 0 && this.invoke(singles, event)) { + return true; + } + + const leader = matches.find((match) => ( + match.chords.length === 2 && eventMatchesShortcut(event, match.chords[0]) + )); + if (leader) { + this.prefix = leader.chords[0]; + this.expiresAt = this.now() + this.timeoutMs; + return true; + } + return false; + } + + clear(): void { + this.prefix = undefined; + this.expiresAt = 0; + } + + handleBlur(): void { + this.clear(); + } + + handleEscape(): boolean { + const hadPrefix = Boolean(this.prefix); + this.clear(); + return hadPrefix; + } + + private invoke(matches: BindingMatch[], event: KeyboardEvent): boolean { + for (const match of matches) { + if (match.handler(event) !== false) { + return true; + } + } + return false; + } + + private getMatches(): BindingMatch[] { + const matches: BindingMatch[] = []; + for (const actionId of this.options.registry.actionIds()) { + const handler = this.options.registry.get(actionId); + const binding = normalizeCombo(this.options.getBinding(actionId)); + const parsed = parseShortcut(binding); + const isDispatchable = parsed + && parsed.chords.every((chord) => chord.key && chord.key !== UNASSIGNED_SHORTCUT); + if (handler && isDispatchable) { + matches.push({ chords: binding.split(' '), handler }); + } + } + return matches; + } +} diff --git a/packages/ui/src/lib/shortcutRegistry.test.ts b/packages/ui/src/lib/shortcutRegistry.test.ts new file mode 100644 index 00000000..658b46d2 --- /dev/null +++ b/packages/ui/src/lib/shortcutRegistry.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from 'bun:test'; +import { ShortcutRegistry } from './shortcutRegistry'; + +test('the first registration wins and a later unregister cannot remove it', () => { + const registry = new ShortcutRegistry(); + const firstHandler = () => undefined; + const first = registry.register('open_settings', firstHandler); + const replacement = registry.register('open_settings', () => false); + + replacement(); + + expect(registry.get('open_settings')).toBe(firstHandler); + first(); + expect(registry.get('open_settings')).toBe(undefined); +}); + +test('a later registration takes over after the first unregisters', () => { + const registry = new ShortcutRegistry(); + const firstHandler = () => undefined; + const secondHandler = () => false; + const first = registry.register('open_settings', firstHandler); + registry.register('open_settings', secondHandler); + + expect(registry.get('open_settings')).toBe(firstHandler); + first(); + expect(registry.get('open_settings')).toBe(secondHandler); +}); diff --git a/packages/ui/src/lib/shortcutRegistry.ts b/packages/ui/src/lib/shortcutRegistry.ts new file mode 100644 index 00000000..06c05527 --- /dev/null +++ b/packages/ui/src/lib/shortcutRegistry.ts @@ -0,0 +1,41 @@ +import type { ShortcutActionId } from './shortcuts'; + +export type ShortcutHandler = (event: KeyboardEvent) => boolean | void; + +interface RegisteredHandler { + handler: ShortcutHandler; + token: symbol; +} + +/** Active application command handlers, keyed by shortcut action ID. */ +export class ShortcutRegistry { + private readonly handlers = new Map(); + + register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void { + const token = Symbol(actionId); + const registered = this.handlers.get(actionId) ?? []; + registered.push({ handler, token }); + this.handlers.set(actionId, registered); + return () => { + const current = this.handlers.get(actionId); + if (!current) return; + const index = current.findIndex((entry) => entry.token === token); + if (index === -1) return; + current.splice(index, 1); + if (current.length === 0) { + this.handlers.delete(actionId); + } + }; + } + + get(actionId: ShortcutActionId): ShortcutHandler | undefined { + return this.handlers.get(actionId)?.[0]?.handler; + } + + actionIds(): IterableIterator { + return this.handlers.keys(); + } +} + +/** Shared registry for application commands registered by React surfaces. */ +export const shortcutRegistry = new ShortcutRegistry(); diff --git a/packages/ui/src/lib/shortcuts.test.ts b/packages/ui/src/lib/shortcuts.test.ts index 593ea714..a97abd37 100644 --- a/packages/ui/src/lib/shortcuts.test.ts +++ b/packages/ui/src/lib/shortcuts.test.ts @@ -2,8 +2,16 @@ import { describe, expect, test } from 'bun:test'; import { eventMatchesShortcutPrefix, + formatShortcutForDisplay, getEffectiveShortcutPrefix, + getCustomizableShortcutActions, + getShortcutAction, + getShortcutCategory, + getShortcutConflict, + isRiskyBrowserShortcut, isShortcutPrefixHeld, + normalizeCombo, + parseShortcut, UNASSIGNED_SHORTCUT, } from './shortcuts'; @@ -78,3 +86,33 @@ describe('eventMatchesShortcutPrefix', () => { expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), UNASSIGNED_SHORTCUT)).toBe(false); }); }); + +describe('shortcut sequences', () => { + test('normalizes, parses, and formats up to two chords', () => { + expect(normalizeCombo(' command + S P ')).toBe('mod+s p'); + expect(parseShortcut('mod+s p')?.chords).toHaveLength(2); + expect(formatShortcutForDisplay('mod+s p')).toBe('Ctrl + S, P'); + }); + + test('rejects bindings with more than two chords', () => { + expect(normalizeCombo('mod+s p q')).toBe(''); + expect(parseShortcut('mod+s p q')).toBe(undefined); + }); + + test('reports exact and prefix conflicts but allows sibling sequences', () => { + expect(getShortcutConflict('mod+s', 'mod+s')).toBe('exact'); + expect(getShortcutConflict('mod+s', 'mod+s p')).toBe('prefix'); + expect(getShortcutConflict('mod+s p', 'mod+s q')).toBe(undefined); + }); + + test('warns when a sequence leader conflicts with a browser shortcut', () => { + expect(isRiskyBrowserShortcut('mod+s p')).toBe(true); + }); + + test('categorizes customizable actions and includes draft picker sequences', () => { + expect(getCustomizableShortcutActions().every((action) => action.category !== undefined)).toBe(true); + expect(getShortcutAction('open_draft_project_picker')?.defaultCombo).toBe('mod+s p'); + expect(getShortcutAction('open_draft_worktree_picker')?.defaultCombo).toBe('mod+s g'); + expect(getShortcutCategory(getShortcutAction('focus_input')!)).toBe('session'); + }); +}); diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 4398aca5..18b1679a 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -4,22 +4,33 @@ import { isDesktopShell } from '@/lib/desktop'; type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl'; type ShortcutKey = string; export type ShortcutCombo = string; +export type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application'; export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__'; -export interface ShortcutAction { +interface ShortcutActionDefinition { id: string; defaultCombo: ShortcutCombo; label: string; description?: string; customizable?: boolean; + /** Metadata for shortcut browsers; omitted actions use the application category. */ + category?: ShortcutCategory; } -interface ParsedShortcut { +interface ParsedShortcutChord { modifiers: Set; key: ShortcutKey; } +export interface ParsedShortcut { + chords: ReadonlyArray; +} + +export type ShortcutConflict = 'exact' | 'prefix'; + +const DEFAULT_SHORTCUT_CATEGORY: ShortcutCategory = 'application'; + const MODIFIER_KEY_MAP: Record = { 'mod': 'mod', 'shift': 'shift', @@ -70,6 +81,7 @@ const KEY_LABEL_MAP: Record = { }; const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt']; +const RISKY_BROWSER_SHORTCUT_KEYS = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']); const SHIFTED_KEY_BASE_MAP: Record = { '{': '[', @@ -114,13 +126,26 @@ export function keyToShortcutToken(key: string): string { return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered; } -const SHORTCUT_ACTIONS: ReadonlyArray = [ +const SHORTCUT_ACTIONS = [ + { + id: 'save_file', + defaultCombo: 'mod+s', + label: 'Save file', + description: 'Save the active file editor', + }, + { + id: 'find_in_file', + defaultCombo: 'mod+f', + label: 'Find in file', + description: 'Search in the active file editor', + }, { id: 'open_go_to_line', defaultCombo: 'alt+g', label: 'Go to line (files editor)', description: 'Open go to line in the files editor', customizable: true, + category: 'navigation', }, { id: 'open_command_palette', @@ -128,6 +153,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open command palette', description: 'Open the command palette', customizable: true, + category: 'application', }, { id: 'focus_input', @@ -135,6 +161,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Focus input', description: 'Focus the chat input field', customizable: true, + category: 'session', }, { id: 'open_status', @@ -148,6 +175,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open settings', description: 'Open the settings panel', customizable: true, + category: 'application', }, { id: 'toggle_terminal', @@ -155,6 +183,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle terminal dock', description: 'Toggle the bottom terminal dock', customizable: true, + category: 'panels', }, { id: 'toggle_terminal_expanded', @@ -162,6 +191,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle terminal expanded', description: 'Toggle terminal expanded or collapsed', customizable: true, + category: 'panels', }, { id: 'toggle_files', @@ -175,6 +205,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Add selection to chat', description: 'Add the selected text to the chat input', customizable: true, + category: 'session', }, { id: 'toggle_sidebar', @@ -182,6 +213,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle sidebar', description: 'Toggle the session sidebar', customizable: true, + category: 'panels', }, { id: 'open_timeline_dialog', @@ -189,6 +221,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open conversation timeline', description: 'Search and navigate within current conversation', customizable: true, + category: 'session', }, { id: 'toggle_prompt_navigator', @@ -196,6 +229,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle prompt navigator', description: 'Show or hide the prompt navigator panel in chat', customizable: true, + category: 'panels', }, { id: 'toggle_right_sidebar', @@ -203,6 +237,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle right sidebar', description: 'Toggle the right sidebar', customizable: true, + category: 'panels', }, { id: 'open_right_sidebar_git', @@ -210,6 +245,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open right sidebar Git tab', description: 'Open right sidebar and select Git', customizable: true, + category: 'panels', }, { id: 'open_right_sidebar_files', @@ -217,6 +253,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open right sidebar Files tab', description: 'Open right sidebar and select Files', customizable: true, + category: 'panels', }, { id: 'switch_context_surface', @@ -224,6 +261,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Switch context panel surface', description: 'Hold the modifier and press a number to open or close the matching rail icon', customizable: true, + category: 'panels', }, { id: 'new_chat', @@ -231,6 +269,23 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'New session', description: 'Start a new session', customizable: true, + category: 'session', + }, + { + id: 'open_draft_project_picker', + defaultCombo: 'mod+s p', + label: 'Open draft project picker', + description: 'Choose a project for a new draft', + customizable: true, + category: 'session', + }, + { + id: 'open_draft_worktree_picker', + defaultCombo: 'mod+s g', + label: 'Open draft worktree picker', + description: 'Choose a worktree for a new draft', + customizable: true, + category: 'session', }, { id: 'new_chat_worktree', @@ -238,6 +293,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'New worktree draft', description: 'Create a new worktree and open a draft in it', customizable: true, + category: 'session', }, { id: 'new_mini_chat', @@ -245,6 +301,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'New Mini Chat window', description: 'Open a new Mini Chat draft window', customizable: true, + category: 'session', }, { id: 'submit_message', @@ -264,6 +321,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open keyboard shortcuts', description: 'Show the keyboard shortcuts help', customizable: true, + category: 'application', }, { id: 'toggle_context_plan', @@ -271,6 +329,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle plan context panel', description: 'Open or close plan in the context panel', customizable: true, + category: 'panels', }, { id: 'toggle_services_menu', @@ -278,6 +337,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Toggle services menu', description: 'Open or close the services menu', customizable: true, + category: 'panels', }, { id: 'cycle_services_tab', @@ -285,6 +345,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Cycle services tab', description: 'Cycle through tabs in the services menu', customizable: true, + category: 'navigation', }, { id: 'cycle_theme', @@ -292,6 +353,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Cycle theme', description: 'Cycle between light, dark, and system theme', customizable: true, + category: 'application', }, { id: 'open_model_selector', @@ -299,6 +361,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Open model selector', description: 'Open model selector while in chat', customizable: true, + category: 'models', }, { id: 'cycle_thinking_variant', @@ -312,6 +375,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Cycle agent', description: 'Cycle agent while the model selector is open', customizable: true, + category: 'models', }, { id: 'cycle_favorite_model_forward', @@ -319,6 +383,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Cycle favorite model forward', description: 'Cycle forward through starred models without opening the picker', customizable: true, + category: 'models', }, { id: 'cycle_favorite_model_backward', @@ -326,6 +391,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Cycle favorite model backward', description: 'Cycle backward through starred models without opening the picker', customizable: true, + category: 'models', }, { id: 'expand_input', @@ -333,6 +399,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Expand input', description: 'Toggle focus mode for the chat input', customizable: true, + category: 'session', }, { id: 'toggle_dictation', @@ -340,6 +407,7 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Voice input', description: 'Start dictation; press again to confirm and insert the transcript', customizable: true, + category: 'session', }, { id: 'abort_run', @@ -347,13 +415,79 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ label: 'Abort active run', description: 'Abort the currently running task (double press)', }, -] as const; + { + id: 'switch_tab_1', + defaultCombo: 'mod+1', + label: 'Switch to tab 1', + description: 'Switch to the first tab or project', + }, + { + id: 'switch_tab_2', + defaultCombo: 'mod+2', + label: 'Switch to tab 2', + description: 'Switch to the second tab or project', + }, + { + id: 'switch_tab_3', + defaultCombo: 'mod+3', + label: 'Switch to tab 3', + description: 'Switch to the third tab or project', + }, + { + id: 'switch_tab_4', + defaultCombo: 'mod+4', + label: 'Switch to tab 4', + description: 'Switch to the fourth tab or project', + }, + { + id: 'switch_tab_5', + defaultCombo: 'mod+5', + label: 'Switch to tab 5', + description: 'Switch to the fifth tab or project', + }, + { + id: 'switch_tab_6', + defaultCombo: 'mod+6', + label: 'Switch to tab 6', + description: 'Switch to the sixth tab or project', + }, + { + id: 'switch_tab_7', + defaultCombo: 'mod+7', + label: 'Switch to tab 7', + description: 'Switch to the seventh tab or project', + }, + { + id: 'switch_tab_8', + defaultCombo: 'mod+8', + label: 'Switch to tab 8', + description: 'Switch to the eighth tab or project', + }, + { + id: 'switch_tab_9', + defaultCombo: 'mod+9', + label: 'Switch to tab 9', + description: 'Switch to the ninth tab or project', + }, +] as const satisfies ReadonlyArray; + +export type ShortcutActionId = (typeof SHORTCUT_ACTIONS)[number]['id']; +export type ShortcutAction = Omit & { id: ShortcutActionId }; export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo { if (isUnassignedShortcut(combo)) { return UNASSIGNED_SHORTCUT; } + const chords = combo.trim().replace(/\s*\+\s*/g, '+').split(/\s+/).filter(Boolean); + if (chords.length === 0 || chords.length > 2) { + return ''; + } + + return chords.map(normalizeChord).join(' '); +} + +function normalizeChord(combo: ShortcutCombo): ShortcutCombo { const rawParts = combo .toLowerCase() .trim() @@ -384,42 +518,54 @@ function isValidShortcutCombo(combo: ShortcutCombo): boolean { } const parsed = parseShortcut(combo); - return parsed.key.trim().length > 0; + return parsed !== undefined && parsed.chords.every((chord) => chord.key.trim().length > 0); } -function parseShortcut(combo: ShortcutCombo): ParsedShortcut { +export function parseShortcut(combo: ShortcutCombo): ParsedShortcut | undefined { if (isUnassignedShortcut(combo)) { - return { modifiers: new Set(), key: UNASSIGNED_SHORTCUT }; + return { chords: [{ modifiers: new Set(), key: UNASSIGNED_SHORTCUT }] }; } const normalized = normalizeCombo(combo); - const parts = normalized.split('+'); - const modifiers: Set = new Set(); - let key: ShortcutKey = ''; - - for (const part of parts) { - const modifier = MODIFIER_KEY_MAP[part]; - if (modifier) { - modifiers.add(modifier); - } else { - key = part; - } + if (!normalized) { + return undefined; } - return { modifiers, key }; + const chords = normalized.split(' ').map((chord) => { + const parts = chord.split('+'); + const modifiers: Set = new Set(); + let key: ShortcutKey = ''; + + for (const part of parts) { + const modifier = MODIFIER_KEY_MAP[part]; + if (modifier) { + modifiers.add(modifier); + } else { + key = part; + } + } + + return { modifiers, key }; + }); + + return { chords }; } -export function formatShortcutForDisplay(combo: ShortcutCombo): string { +export function formatShortcutForDisplay(combo: ShortcutCombo, unassignedLabel = 'Unassigned'): string { if (isUnassignedShortcut(combo)) { - return 'Unassigned'; + return unassignedLabel; } const parsed = parseShortcut(combo); - if (!parsed.key && parsed.modifiers.size === 0) { - return 'Unassigned'; + if (!parsed || parsed.chords.some((chord) => !chord.key && chord.modifiers.size === 0)) { + return unassignedLabel; } + return parsed.chords.map(formatChordForDisplay).join(', '); +} + +function formatChordForDisplay(parsed: ParsedShortcutChord): string { const parts: string[] = []; for (const modifier of MODIFIER_PRIORITY) { @@ -441,7 +587,32 @@ export function getShortcutAction(id: string): ShortcutAction | undefined { } export function getCustomizableShortcutActions(): ReadonlyArray { - return SHORTCUT_ACTIONS.filter((action) => action.customizable === true); + return SHORTCUT_ACTIONS.filter((action) => 'customizable' in action && action.customizable === true); +} + +export function getShortcutCategory(action: ShortcutAction): ShortcutCategory { + return action.category ?? DEFAULT_SHORTCUT_CATEGORY; +} + +export function getShortcutConflict(left: ShortcutCombo, right: ShortcutCombo): ShortcutConflict | undefined { + const normalizedLeft = normalizeCombo(left); + const normalizedRight = normalizeCombo(right); + const hasInvalidBinding = !isValidShortcutCombo(normalizedLeft) + || !isValidShortcutCombo(normalizedRight); + const hasUnassignedBinding = normalizedLeft === UNASSIGNED_SHORTCUT + || normalizedRight === UNASSIGNED_SHORTCUT; + if (hasInvalidBinding || hasUnassignedBinding) { + return undefined; + } + + if (normalizedLeft === normalizedRight) { + return 'exact'; + } + + const leftChords = normalizedLeft.split(' '); + const rightChords = normalizedRight.split(' '); + const sharesLeader = leftChords[0] === rightChords[0]; + return sharesLeader && leftChords.length !== rightChords.length ? 'prefix' : undefined; } export function getEffectiveShortcutCombo( @@ -455,13 +626,9 @@ export function getEffectiveShortcutCombo( const override = overrides?.[actionId]; if (typeof override === 'string') { - if (override.trim().toLowerCase() === UNASSIGNED_SHORTCUT) { - return ''; - } - const normalized = normalizeCombo(override); if (normalized === UNASSIGNED_SHORTCUT) { - return UNASSIGNED_SHORTCUT; + return ''; } if (isValidShortcutCombo(normalized)) { @@ -478,13 +645,18 @@ export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean { } const parsed = parseShortcut(combo); - if (!parsed.modifiers.has('mod')) { + if (!parsed) { + return false; + } + const chord = parsed.chords[0]; + if (!chord.modifiers.has('mod')) { return false; } - const key = parsed.key.toLowerCase(); - const dangerousPrimary = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']); - return dangerousPrimary.has(key) && !parsed.modifiers.has('shift') && !parsed.modifiers.has('alt'); + const key = chord.key.toLowerCase(); + return RISKY_BROWSER_SHORTCUT_KEYS.has(key) + && !chord.modifiers.has('shift') + && !chord.modifiers.has('alt'); } export function eventMatchesShortcut( @@ -497,11 +669,15 @@ export function eventMatchesShortcut( } const parsed = parseShortcut(combo); + if (!parsed || parsed.chords.length !== 1) { + return false; + } + const chord = parsed.chords[0]; - const expectedMod = parsed.modifiers.has('mod'); - const expectedShift = parsed.modifiers.has('shift'); - const expectedAlt = parsed.modifiers.has('alt'); - const expectedCtrl = parsed.modifiers.has('ctrl'); + const expectedMod = chord.modifiers.has('mod'); + const expectedShift = chord.modifiers.has('shift'); + const expectedAlt = chord.modifiers.has('alt'); + const expectedCtrl = chord.modifiers.has('ctrl'); const isDesktopMac = isMacOS() && isDesktopShell(); const isMac = isMacOS(); @@ -548,7 +724,7 @@ export function eventMatchesShortcut( } const eventKey = keyToShortcutToken(eventKeyRaw); - const expectedKey = keyToShortcutToken(parsed.key); + const expectedKey = keyToShortcutToken(chord.key); return eventKey === expectedKey; } @@ -581,7 +757,8 @@ export function getEffectiveShortcutPrefix( } if (normalized) { const parsed = parseShortcut(normalized); - if (parsed.modifiers.size > 0 || parsed.key) { + const chord = parsed?.chords[0]; + if (chord && (chord.modifiers.size > 0 || chord.key)) { return normalized; } } @@ -601,15 +778,19 @@ export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: Reado } const parsed = parseShortcut(prefixCombo); + if (!parsed || parsed.chords.length !== 1) { + return false; + } + const chord = parsed.chords[0]; - for (const modifier of parsed.modifiers) { + for (const modifier of chord.modifiers) { const aliases = MODIFIER_KEY_ALIASES[modifier]; if (!aliases.some((alias) => heldKeys.has(alias))) { return false; } } - if (parsed.key && !heldKeys.has(parsed.key.toLowerCase())) { + if (chord.key && !heldKeys.has(chord.key.toLowerCase())) { return false; } @@ -632,11 +813,15 @@ export function eventMatchesShortcutPrefix( } const parsed = parseShortcut(prefixCombo); + if (!parsed || parsed.chords.length !== 1) { + return false; + } + const chord = parsed.chords[0]; - const expectedMod = parsed.modifiers.has('mod'); - const expectedShift = parsed.modifiers.has('shift'); - const expectedAlt = parsed.modifiers.has('alt'); - const expectedCtrl = parsed.modifiers.has('ctrl'); + const expectedMod = chord.modifiers.has('mod'); + const expectedShift = chord.modifiers.has('shift'); + const expectedAlt = chord.modifiers.has('alt'); + const expectedCtrl = chord.modifiers.has('ctrl'); const isDesktopMac = isMacOS() && isDesktopShell(); const isMac = isMacOS(); @@ -673,7 +858,7 @@ export function eventMatchesShortcutPrefix( } } - if (parsed.key && (!heldKeys || !heldKeys.has(parsed.key.toLowerCase()))) { + if (chord.key && (!heldKeys || !heldKeys.has(chord.key.toLowerCase()))) { return false; } diff --git a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md new file mode 100644 index 00000000..cf4d167d --- /dev/null +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -0,0 +1,29 @@ +# Registration boundary + +Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both register with the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command. + +Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `shortcuts.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components. + +# Module roles + +- `shortcuts.ts` owns action IDs, default bindings, categories, normalization, display, and conflict rules. +- `shortcutRegistry.ts` owns the active handler for each action ID. +- `shortcutDispatcher.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. + +# Binding rules + +Bindings remain persisted as `Record`. Each binding has one chord or at most two space-separated chords, such as `mod+s p`. `normalizeCombo`, `parseShortcut`, `formatShortcutForDisplay`, and `getShortcutConflict` provide the shared parsing and validation behavior. A single chord conflicts with a sequence sharing its first chord; sibling sequences are valid. + +Contextual internal commands may deliberately share a sequence leader. The single-chord handler gets the first chance to handle the event; returning `false` lets the dispatcher start the sequence. The active file editor therefore owns `mod+s` for saving, while a mounted but unfocused editor yields `mod+s p` and `mod+s g` to the draft target pickers. + +The settings recorder also stops at two chords. It keeps the recording local until the user explicitly saves, allows an exact conflict to replace the previous assignment, and blocks prefix conflicts because they make dispatch ambiguous. + +# 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. + +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. + +Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. From ab3ccf395d1352e43b0619f798a846900e5f3b40 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Thu, 30 Jul 2026 11:10:59 +0800 Subject: [PATCH 002/192] refactor(ui): register application shortcuts centrally --- packages/ui/src/components/layout/Header.tsx | 126 +-- .../ui/src/components/views/FilesView.tsx | 93 +- packages/ui/src/hooks/useKeyboardShortcuts.ts | 938 +++++++----------- .../src/hooks/useMiniChatKeyboardShortcuts.ts | 175 ++-- 4 files changed, 517 insertions(+), 815 deletions(-) diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 3633e6c6..5b344371 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -33,11 +33,12 @@ import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout'; +import { useKeybinds } from '@/hooks/useKeybind'; import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay'; import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControls'; import { UpdateDialog } from '@/components/ui/UpdateDialog'; import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device'; -import { cn, hasModifier } from '@/lib/utils'; +import { cn } from '@/lib/utils'; import { McpDropdownContent } from '@/components/mcp/McpDropdown'; import { McpIcon } from '@/components/icons/McpIcon'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; @@ -46,7 +47,11 @@ import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; import { PaceIndicator } from '@/components/sections/usage/PaceIndicator'; import { updateDesktopSettings } from '@/lib/persistence'; import { formatTimeForPreference } from '@/lib/timeFormat'; -import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { + formatShortcutForDisplay, + getEffectiveShortcutCombo, + type ShortcutActionId, +} from '@/lib/shortcuts'; import type { TimeFormatPreference } from '@/stores/useUIStore'; import { getAllModelFamilies, @@ -285,7 +290,7 @@ type DesktopServicesMenuProps = { rateLimitGroups: RateLimitGroup[]; expandedFamilies: Record; toggleFamilyExpanded: (providerId: string, familyId: string) => void; - shortcutLabel: (actionId: string) => string; + shortcutLabel: (actionId: ShortcutActionId) => string; showDevShutdown: boolean; isDevShutdownInFlight: boolean; onDevShutdown: () => Promise; @@ -1935,7 +1940,7 @@ export const Header: React.FC = ({ return []; }, [isMobile, showPlanTab, t]); - const shortcutLabel = React.useCallback((actionId: string) => { + const shortcutLabel = React.useCallback((actionId: ShortcutActionId) => { return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); }, [shortcutOverrides]); @@ -2043,82 +2048,53 @@ export const Header: React.FC = ({ ]; }, [t]); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (hasModifier(e) && !e.shiftKey && !e.altKey) { - const num = parseInt(e.key, 10); - if (num >= 1 && num <= tabs.length) { - e.preventDefault(); - if (isMobile) { - blurActiveElement(); - closeMobileHeaderPanels(); - } - setActiveMainTab(tabs[num - 1].id); - } - } - }; - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [blurActiveElement, closeMobileHeaderPanels, isMobile, setActiveMainTab, tabs]); + const switchToIndexedTab = (index: number) => { + const tab = tabs[index]; + if (!tab) return false; + if (isMobile) { + blurActiveElement(); + closeMobileHeaderPanels(); + } + setActiveMainTab(tab.id); + }; - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - const toggleServicesCombo = getEffectiveShortcutCombo('toggle_services_menu', shortcutOverrides); - if (eventMatchesShortcut(e, toggleServicesCombo)) { - e.preventDefault(); - - if (isDesktopServicesOpen) { - setIsDesktopServicesOpen(false); - } else { - setIsDesktopServicesOpen(true); - void refreshCurrentInstanceLabel(); - if (desktopServicesTab === 'usage' && quotaResults.length === 0) { - void fetchAllQuotas(); - } - } + useKeybinds({ + switch_tab_1: () => switchToIndexedTab(0), + switch_tab_2: () => switchToIndexedTab(1), + switch_tab_3: () => switchToIndexedTab(2), + switch_tab_4: () => switchToIndexedTab(3), + switch_tab_5: () => switchToIndexedTab(4), + switch_tab_6: () => switchToIndexedTab(5), + switch_tab_7: () => switchToIndexedTab(6), + switch_tab_8: () => switchToIndexedTab(7), + switch_tab_9: () => switchToIndexedTab(8), + toggle_services_menu: () => { + if (isDesktopServicesOpen) { + setIsDesktopServicesOpen(false); return; } - - const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides); - if (eventMatchesShortcut(e, cycleServicesCombo)) { - e.preventDefault(); - - const tabValues = servicesTabs.map((tab) => tab.value) as Array<'instance' | 'usage' | 'mcp'>; - if (tabValues.length === 0) { - return; - } - - const currentIndex = tabValues.indexOf(desktopServicesTab); - const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % tabValues.length; - const nextTab = tabValues[nextIndex]; - setDesktopServicesTab(nextTab); - setIsDesktopServicesOpen(true); - void refreshCurrentInstanceLabel(); - if (nextTab === 'usage' && quotaResults.length === 0) { - void fetchAllQuotas(); - } - return; + setIsDesktopServicesOpen(true); + void refreshCurrentInstanceLabel(); + if (desktopServicesTab === 'usage' && quotaResults.length === 0) { + void fetchAllQuotas(); } - - const toggleContextPlanCombo = getEffectiveShortcutCombo('toggle_context_plan', shortcutOverrides); - if (eventMatchesShortcut(e, toggleContextPlanCombo)) { - e.preventDefault(); - handleOpenContextPlan(); + }, + cycle_services_tab: () => { + const tabValues = servicesTabs.map((tab) => tab.value) as Array<'instance' | 'usage' | 'mcp'>; + if (tabValues.length === 0) return false; + const currentIndex = tabValues.indexOf(desktopServicesTab); + const nextTab = tabValues[currentIndex === -1 ? 0 : (currentIndex + 1) % tabValues.length]; + setDesktopServicesTab(nextTab); + setIsDesktopServicesOpen(true); + void refreshCurrentInstanceLabel(); + if (nextTab === 'usage' && quotaResults.length === 0) { + void fetchAllQuotas(); } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [ - shortcutOverrides, - isDesktopServicesOpen, - desktopServicesTab, - servicesTabs, - quotaResults.length, - fetchAllQuotas, - refreshCurrentInstanceLabel, - handleOpenContextPlan, - ]); + }, + toggle_context_plan: () => { + handleOpenContextPlan(); + }, + }); const renderTab = (tab: TabConfig) => { const isActive = activeMainTab === tab.id; diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 951e9dd2..c11fc5cc 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -43,7 +43,7 @@ import { import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useDeviceInfo } from '@/lib/device'; -import { cn, getModifierLabel, getRevealLabelKey, hasModifier } from '@/lib/utils'; +import { cn, getModifierLabel, getRevealLabelKey } from '@/lib/utils'; import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers'; import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave'; import { getRuntimeUrlResolver } from '@/lib/runtime-url'; @@ -52,6 +52,7 @@ import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; import { getOutsideFileGrant } from '@/lib/outsideFileGrants'; import { DiagramEditor } from '@/components/diagram'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useKeybind, useKeybinds } from '@/hooks/useKeybind'; import { EditorView } from '@codemirror/view'; import type { Extension } from '@codemirror/state'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -72,7 +73,6 @@ import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry'; import { getDefaultTheme } from '@/lib/theme/themes'; import { isBrowserClientRuntime, openDesktopFileInApp, openDesktopPath } from '@/lib/desktop'; import { useOpenInAppsStore } from '@/stores/useOpenInAppsStore'; -import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; @@ -1022,7 +1022,6 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { const setPendingFileNavigation = useUIStore((state) => state.setPendingFileNavigation); const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath); const setPendingFileFocusPath = useUIStore((state) => state.setPendingFileFocusPath); - const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const fileEditorKeymap = useUIStore((state) => state.fileEditorKeymap); const settingsDefaultFileViewerPreview = useConfigStore((state) => state.settingsDefaultFileViewerPreview); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); @@ -1767,35 +1766,28 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { setAutoSaveStatus('idle'); }, [selectedFile?.path]); - React.useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!hasModifier(e)) { - return; - } + useKeybinds({ + save_file: (event) => { + if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false; - if (e.key.toLowerCase() === 's') { - e.preventDefault(); - // Cancel pending auto-save; user wants immediate save - if (autoSaveTimerRef.current) { - clearTimeout(autoSaveTimerRef.current); - autoSaveTimerRef.current = null; - } - if (!isSaving) { - void saveDraft().then((saved) => { - if (!saved) return; - setAutoSaveStatus('saved'); - setTimeout(() => setAutoSaveStatus('idle'), 2000); - }); - } - } else if (e.key.toLowerCase() === 'f') { - e.preventDefault(); - setIsSearchOpen(true); + // Cancel pending auto-save because the explicit save should run immediately. + if (autoSaveTimerRef.current) { + clearTimeout(autoSaveTimerRef.current); + autoSaveTimerRef.current = null; } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isSaving, saveDraft]); + if (!isSaving) { + void saveDraft().then((saved) => { + if (!saved) return; + setAutoSaveStatus('saved'); + setTimeout(() => setAutoSaveStatus('idle'), 2000); + }); + } + }, + find_in_file: (event) => { + if (!(event.target instanceof Node) || !editorWrapperRef.current?.contains(event.target)) return false; + setIsSearchOpen(true); + }, + }); const loadSelectedFile = React.useCallback(async (node: FileNode) => { const loadId = activeFileLoadIdRef.current + 1; @@ -2908,42 +2900,21 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { }; }, [isMobile, nudgeEditorSelectionAboveKeyboard]); - React.useEffect(() => { + useKeybind('open_go_to_line', (event) => { if (!canEdit || textViewMode !== 'edit' || isMobile) { - return; + return false; } - const goToLineCombo = getEffectiveShortcutCombo('open_go_to_line', shortcutOverrides); + const target = event.target as Element | null; + if (target?.closest('[role="dialog"]')) return false; + if (!(target instanceof Node) || !editorWrapperRef.current?.contains(target)) return false; - const handleKeyDown = (event: KeyboardEvent) => { - const target = event.target as Element | null; - if (target?.closest('[role="dialog"]')) { - return; - } + const isEditorTarget = Boolean(target?.closest('.cm-editor')); + const isTypingTarget = Boolean(target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]')); + if (isTypingTarget && !isEditorTarget) return false; - const isEditorTarget = Boolean(target?.closest('.cm-editor')); - const isTypingTarget = Boolean( - target?.closest('input, textarea, [contenteditable="true"], [role="textbox"]') - ); - if (isTypingTarget && !isEditorTarget) { - return; - } - - const activeElement = document.activeElement as Element | null; - const editorHasFocus = Boolean(activeElement?.closest('.cm-editor')); - if (!editorHasFocus) { - return; - } - - if (eventMatchesShortcut(event, goToLineCombo)) { - event.preventDefault(); - setIsGoToLineOpen(true); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [canEdit, isMobile, shortcutOverrides, textViewMode]); + setIsGoToLineOpen(true); + }); const editorFontSize = useUIStore((state) => state.editorFontSize); diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 8e3ac00a..a4989491 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -6,6 +6,7 @@ import * as sessionActions from '@/sync/session-actions'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useCurrentSessionActivity } from '@/hooks/useSessionActivity'; +import { useKeybinds } from '@/hooks/useKeybind'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { useConfigStore } from '@/stores/useConfigStore'; import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop'; @@ -16,71 +17,55 @@ import { getEffectiveShortcutCombo, getEffectiveShortcutPrefix, normalizeCombo, + type ShortcutActionId, } from '@/lib/shortcuts'; +import { ShortcutDispatcher } from '@/lib/shortcutDispatcher'; +import { shortcutRegistry } from '@/lib/shortcutRegistry'; import { getVisibleContextRailSurfaces } from '@/lib/surfaces/registry'; import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { addSelectionToChat } from '@/lib/addSelectionToChat'; import { hasOpenDropdown } from './keyboard-shortcut-dom'; +const dropdownTargetSelector = [ + '[data-slot="dropdown-menu-content"]', '[data-slot="select-content"]', '[role="combobox"]', + '[role="listbox"]', '[role="menu"]', '[role="menuitem"]', '[role="option"]', + '[data-radix-popper-content-wrapper]', +].join(','); + export const useKeyboardShortcuts = () => { const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const armAbortPrompt = useSessionUIStore((s) => s.armAbortPrompt); const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); - const abortCurrentOperation = sessionActions.abortCurrentOperation; - const toggleCommandPalette = useUIStore((s) => s.toggleCommandPalette); - const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog); - const toggleSidebar = useUIStore((s) => s.toggleSidebar); - const currentShortcutDirectory = useDirectoryStore((s) => s.currentDirectory); - const effectiveDirectory = useEffectiveDirectory(); - - // The terminal lives in the context panel; these mirror the rail behavior. - const toggleTerminalSurface = React.useCallback(() => { - if (!currentShortcutDirectory) return; - useUIStore.getState().openContextSurface(normalizeContextPanelDirectoryKey(currentShortcutDirectory), 'terminal'); - }, [currentShortcutDirectory]); - - const toggleTerminalSurfaceExpanded = React.useCallback(() => { - if (!currentShortcutDirectory) return; - const key = normalizeContextPanelDirectoryKey(currentShortcutDirectory); - const state = useUIStore.getState(); - const panel = state.contextPanelByDirectory[key]; - const activeMode = panel?.isOpen ? panel.tabs.find((tab) => tab.id === panel.activeTabId)?.mode : null; - if (activeMode !== 'terminal') { - state.openContextSurface(key, 'terminal'); - } - state.toggleContextPanelExpanded(key); - }, [currentShortcutDirectory]); - const isMobile = useUIStore((s) => s.isMobile); - const setSessionSwitcherOpen = useUIStore((s) => s.setSessionSwitcherOpen); - const setActiveMainTab = useUIStore((s) => s.setActiveMainTab); - const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen); - const setModelSelectorOpen = useUIStore((s) => s.setModelSelectorOpen); - const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen); - const togglePromptNavigatorPanel = useUIStore((s) => s.togglePromptNavigatorPanel); - const setPromptNavigatorPanelOpen = useUIStore((s) => s.setPromptNavigatorPanelOpen); - const toggleExpandedInput = useUIStore((s) => s.toggleExpandedInput); - const shortcutOverrides = useUIStore((s) => s.shortcutOverrides); const currentDirectory = useDirectoryStore((s) => s.currentDirectory); + const effectiveDirectory = useEffectiveDirectory(); const activeProject = useProjectsStore((s) => s.getActiveProject()); const { themeMode, setThemeMode } = useThemeSystem(); const { phase: sessionPhase } = useCurrentSessionActivity(); const abortPrimedUntilRef = React.useRef(null); const abortPrimedTimeoutRef = React.useRef | null>(null); const themeModeRef = React.useRef(themeMode); - // Currently held physical keys (lowercased), used to match chord prefixes - // whose primary key must be held while the activating key is pressed. + const dispatcherRef = React.useRef(null); const heldKeysRef = React.useRef>(new Set()); - React.useEffect(() => { - themeModeRef.current = themeMode; - }, [themeMode]); + if (!dispatcherRef.current) { + dispatcherRef.current = new ShortcutDispatcher({ + registry: shortcutRegistry, + getBinding: (actionId) => getEffectiveShortcutCombo( + actionId, + useUIStore.getState().shortcutOverrides, + ), + }); + } + const dispatcher = dispatcherRef.current; + + React.useEffect(() => { themeModeRef.current = themeMode; }, [themeMode]); const resetAbortPriming = React.useCallback(() => { if (abortPrimedTimeoutRef.current) { @@ -91,630 +76,387 @@ export const useKeyboardShortcuts = () => { clearAbortPrompt(); }, [clearAbortPrompt]); + const toggleTerminalSurface = () => { + if (!currentDirectory) return; + useUIStore.getState().openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'terminal'); + }; + + const toggleTerminalSurfaceExpanded = () => { + if (!currentDirectory) return; + const key = normalizeContextPanelDirectoryKey(currentDirectory); + const state = useUIStore.getState(); + const panel = state.contextPanelByDirectory[key]; + if (panel?.isOpen ? panel.tabs.find((tab) => tab.id === panel.activeTabId)?.mode !== 'terminal' : true) { + state.openContextSurface(key, 'terminal'); + } + state.toggleContextPanelExpanded(key); + }; + + useKeybinds({ + open_command_palette: () => { + useUIStore.getState().toggleCommandPalette(); + }, + open_timeline_dialog: () => { + useUIStore.getState().setTimelineDialogOpen(true); + }, + toggle_prompt_navigator: () => { + const state = useUIStore.getState(); + const hasOverlay = state.isSettingsDialogOpen + || state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen + || state.isTimelineDialogOpen + || state.isMultiRunLauncherOpen + || state.isImagePreviewOpen; + if ( + !state.promptNavigatorEnabled + || state.isMobile + || isVSCodeRuntime() + || state.activeMainTab !== 'chat' + || hasOverlay + ) { + return false; + } + state.togglePromptNavigatorPanel(); + }, + open_status: () => { + void showOpenCodeStatus(); + }, + open_help: () => { + useUIStore.getState().toggleHelpDialog(); + }, + new_mini_chat: () => { + if (!canUseElectronDesktopIPC()) return false; + void invokeDesktop('desktop_open_draft_mini_chat_window', { + directory: currentDirectory || activeProject?.path || '', + projectId: activeProject?.id ?? null, + }).catch((error) => { + console.warn('[keyboard-shortcuts] failed to open draft mini chat window', error); + }); + }, + new_chat: () => { + const state = useUIStore.getState(); + state.setActiveMainTab('chat'); + state.setSessionSwitcherOpen(false); + openNewSessionDraft(); + }, + new_chat_worktree: () => { + const state = useUIStore.getState(); + state.setActiveMainTab('chat'); + state.setSessionSwitcherOpen(false); + if (!isVSCodeRuntime()) { + createWorktreeSession(); + return; + } + openNewSessionDraft(); + }, + cycle_theme: () => { + if (readEmbeddedThemeSearchParams() !== null && window.parent && window.parent !== window) { + window.parent.postMessage({ type: 'openchamber:cycle-theme-request' }, window.location.origin); + return; + } + const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; + const activeElement = document.activeElement as HTMLElement | null; + setThemeMode(modes[(modes.indexOf(themeModeRef.current) + 1) % modes.length]); + requestAnimationFrame(() => { + if (!document.hasFocus()) window.focus(); + if (activeElement && document.contains(activeElement)) activeElement.focus({ preventScroll: true }); + }); + }, + open_settings: () => { + const state = useUIStore.getState(); + state.setSettingsDialogOpen(!state.isSettingsDialogOpen); + }, + add_selection_to_chat: () => { + addSelectionToChat(); + }, + toggle_sidebar: () => { + const state = useUIStore.getState(); + if (state.isMobile) state.setSessionSwitcherOpen(!state.isSessionSwitcherOpen); + else state.toggleSidebar(); + }, + focus_input: () => { + focusChatInput(); + }, + cycle_agent: (event) => { + const state = useUIStore.getState(); + const hasOverlay = state.isSettingsDialogOpen + || state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen; + const isChatInputTarget = event.target instanceof Element + && Boolean(event.target.closest('[data-chat-input="true"]')); + if (hasOverlay || state.activeMainTab !== 'chat' || !isChatInputTarget) return false; + const combo = getEffectiveShortcutCombo('cycle_agent', state.shortcutOverrides); + const backward = combo && !combo.includes('shift') ? normalizeCombo(`shift+${combo}`) : ''; + const direction = backward && eventMatchesShortcut(event, backward) ? -1 : 1; + const config = useConfigStore.getState(); + const next = getCycledPrimaryAgentName(config.getVisibleAgents(), config.currentAgentName, direction); + if (!next) return false; + config.setAgent(next); + state.addRecentAgent(next); + const sessionId = useSessionUIStore.getState().currentSessionId; + if (sessionId) { + useSelectionStore.getState().saveSessionAgentSelection(sessionId, next); + } + }, + toggle_right_sidebar: () => { + const state = useUIStore.getState(); + if (state.isMobile || !currentDirectory) return false; + const directory = normalizeContextPanelDirectoryKey(currentDirectory); + const panel = state.contextPanelByDirectory[directory]; + if (panel?.isOpen) state.closeContextPanel(directory); + else if (panel?.activeTabId) state.setActiveContextPanelTab(directory, panel.activeTabId); + else state.openContextSurface(directory, 'git'); + }, + open_right_sidebar_git: () => { + const state = useUIStore.getState(); + if (state.isMobile || !currentDirectory) return false; + state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'git'); + }, + open_right_sidebar_files: () => { + const state = useUIStore.getState(); + if (state.isMobile || !currentDirectory) return false; + state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'file'); + }, + toggle_terminal: () => { + if (useUIStore.getState().isMobile) return false; + return toggleTerminalSurface(); + }, + toggle_terminal_expanded: () => { + if (useUIStore.getState().isMobile) return false; + return toggleTerminalSurfaceExpanded(); + }, + open_model_selector: () => { + const state = useUIStore.getState(); + const hasOverlay = state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen; + if (state.isSettingsDialogOpen || hasOverlay || state.activeMainTab !== 'chat') return false; + state.setModelSelectorOpen(!state.isModelSelectorOpen); + }, + cycle_thinking_variant: () => { + const state = useUIStore.getState(); + const hasOverlay = state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen; + if (state.isSettingsDialogOpen || hasOverlay || state.activeMainTab !== 'chat') return false; + const config = useConfigStore.getState(); + if (config.getCurrentModelVariants().length === 0) return false; + config.cycleCurrentVariant(); + const sessionId = useSessionUIStore.getState().currentSessionId; + const { currentVariant, currentAgentName, currentProviderId, currentModelId } = useConfigStore.getState(); + if (sessionId && currentAgentName && currentProviderId && currentModelId) { + useSelectionStore.getState().saveAgentModelVariantForSession( + sessionId, + currentAgentName, + currentProviderId, + currentModelId, + currentVariant, + ); + } + }, + cycle_favorite_model_forward: () => cycleFavoriteModel(1), + cycle_favorite_model_backward: () => cycleFavoriteModel(-1), + expand_input: () => { + if (useUIStore.getState().isMobile) return false; + useUIStore.getState().toggleExpandedInput(); + }, + toggle_dictation: () => { + const state = useUIStore.getState(); + if ( + state.activeMainTab !== 'chat' + || state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isSettingsDialogOpen + ) { + return false; + } + window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle')); + }, + }); + + function cycleFavoriteModel(delta: number): boolean | void { + const state = useUIStore.getState(); + const hasOverlay = state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen; + if ( + state.isSettingsDialogOpen + || hasOverlay + || state.activeMainTab !== 'chat' + || state.favoriteModels.length === 0 + ) { + return false; + } + const config = useConfigStore.getState(); + const index = state.favoriteModels.findIndex((model) => ( + model.providerID === config.currentProviderId && model.modelID === config.currentModelId + )); + const next = state.favoriteModels[(index + delta + state.favoriteModels.length) % state.favoriteModels.length]; + config.setProvider(next.providerID); + config.setModel(next.modelID); + state.addRecentModel(next.providerID, next.modelID); + } + React.useEffect(() => { - const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides); - const switchSurfacePrefix = getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides); - const dropdownTargetSelector = [ - '[data-slot="dropdown-menu-content"]', - '[data-slot="select-content"]', - '[role="combobox"]', - '[role="listbox"]', - '[role="menu"]', - '[role="menuitem"]', - '[role="option"]', - '[data-radix-popper-content-wrapper]', - ].join(','); - - const isDropdownEventTarget = (target: EventTarget | null) => { - return target instanceof Element && Boolean(target.closest(dropdownTargetSelector)); + const invokeRegistered = (actionId: ShortcutActionId, event: KeyboardEvent): boolean => { + const handler = shortcutRegistry.get(actionId); + return handler ? handler(event) !== false : false; }; - - const handleTerminalShortcutCapture = (e: KeyboardEvent) => { - if (!isTerminalEventTarget(e.target)) { - return; - } - - if (eventMatchesShortcut(e, combo('toggle_terminal'))) { - const { isMobile } = useUIStore.getState(); - if (isMobile) { - return; - } - e.preventDefault(); - e.stopPropagation(); - toggleTerminalSurface(); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_terminal_expanded'))) { - const { isMobile } = useUIStore.getState(); - if (isMobile) { - return; - } - e.preventDefault(); - e.stopPropagation(); - toggleTerminalSurfaceExpanded(); - return; + const handleTerminalShortcutCapture = (event: KeyboardEvent) => { + if (!isTerminalEventTarget(event.target)) return; + const getBinding = (actionId: ShortcutActionId) => getEffectiveShortcutCombo( + actionId, + useUIStore.getState().shortcutOverrides, + ); + const actionId = eventMatchesShortcut(event, getBinding('toggle_terminal')) ? 'toggle_terminal' + : eventMatchesShortcut(event, getBinding('toggle_terminal_expanded')) ? 'toggle_terminal_expanded' : null; + if (actionId && invokeRegistered(actionId, event)) { + event.preventDefault(); + event.stopPropagation(); } }; - - const handleEscapeKeyDownCapture = (e: KeyboardEvent) => { - if (e.key !== 'Escape') return; - - const target = e.target as Element | null; - const isInsideDialog = Boolean(target?.closest('[role="dialog"]')); - const isSettingsMounted = Boolean(document.querySelector('[data-settings-view="true"]')); - const isInsideTerminal = isTerminalEventTarget(target); - const hasDropdownInteraction = isDropdownEventTarget(target) || hasOpenDropdown(); - - const { - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - isMultiRunLauncherOpen, - isImagePreviewOpen, - activeMainTab, - isPromptNavigatorPanelOpen, - } = useUIStore.getState(); - - if (isInsideDialog || isInsideTerminal || hasDropdownInteraction) { + const handleEscapeKeyDownCapture = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + if (dispatcher.handleEscape()) { + event.preventDefault(); resetAbortPriming(); return; } - - if (isPromptNavigatorPanelOpen) { - e.preventDefault(); - setPromptNavigatorPanelOpen(false); + const target = event.target as Element | null; + const state = useUIStore.getState(); + const isDropdownTarget = target instanceof Element + && target.closest(dropdownTargetSelector); + if ( + target?.closest('[role="dialog"]') + || isTerminalEventTarget(target) + || isDropdownTarget + || hasOpenDropdown() + ) { resetAbortPriming(); return; } - - if (isSettingsDialogOpen) { - e.preventDefault(); - setSettingsDialogOpen(false); + if (state.isPromptNavigatorPanelOpen) { + event.preventDefault(); + state.setPromptNavigatorPanelOpen(false); resetAbortPriming(); return; } - - if (isSettingsMounted) { + if (state.isSettingsDialogOpen) { + event.preventDefault(); + state.setSettingsDialogOpen(false); resetAbortPriming(); return; } - - const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen || isMultiRunLauncherOpen || isImagePreviewOpen; - const isChatActive = activeMainTab === 'chat'; - - if (hasOverlay || !isChatActive) { + if (document.querySelector('[data-settings-view="true"]')) { resetAbortPriming(); return; } - - const sessionId = currentSessionId; - if (sessionPhase === 'idle' || !sessionId) { + const hasOverlay = state.isCommandPaletteOpen + || state.isHelpDialogOpen + || state.isSessionSwitcherOpen + || state.isAboutDialogOpen + || state.isMultiRunLauncherOpen + || state.isImagePreviewOpen; + if ( + hasOverlay + || state.activeMainTab !== 'chat' + || sessionPhase === 'idle' + || !currentSessionId + ) { resetAbortPriming(); return; } - const now = Date.now(); - const primedUntil = abortPrimedUntilRef.current; - - if (primedUntil && now < primedUntil) { - e.preventDefault(); + if (abortPrimedUntilRef.current && now < abortPrimedUntilRef.current) { + event.preventDefault(); resetAbortPriming(); - void abortCurrentOperation(sessionId); + void sessionActions.abortCurrentOperation(currentSessionId); return; } - - e.preventDefault(); + event.preventDefault(); const expiresAt = armAbortPrompt(3000) ?? now + 3000; abortPrimedUntilRef.current = expiresAt; - - if (abortPrimedTimeoutRef.current) { - clearTimeout(abortPrimedTimeoutRef.current); - } - - const delay = Math.max(expiresAt - now, 0); + if (abortPrimedTimeoutRef.current) clearTimeout(abortPrimedTimeoutRef.current); abortPrimedTimeoutRef.current = setTimeout(() => { if (abortPrimedUntilRef.current && Date.now() >= abortPrimedUntilRef.current) { resetAbortPriming(); } - }, delay || 0); + }, Math.max(expiresAt - now, 0)); }; - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape' || isTerminalEventTarget(e.target)) { + const handleKeyDown = (event: KeyboardEvent) => { + 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}`) : ''; + if (backward && eventMatchesShortcut(event, backward)) { + if (invokeRegistered('cycle_agent', event)) event.preventDefault(); return; } - const isChatInputTarget = (target: EventTarget | null) => { - return target instanceof Element && Boolean(target.closest('[data-chat-input="true"]')); - }; - - if (eventMatchesShortcut(e, combo('open_command_palette'))) { - e.preventDefault(); - toggleCommandPalette(); - return; - } - - if (eventMatchesShortcut(e, combo('open_timeline_dialog'))) { - e.preventDefault(); - setTimelineDialogOpen(true); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_prompt_navigator'))) { - const { - activeMainTab, - promptNavigatorEnabled, - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - isTimelineDialogOpen, - isMultiRunLauncherOpen, - isImagePreviewOpen, - } = useUIStore.getState(); - - if (!promptNavigatorEnabled || isMobile || isVSCodeRuntime() || activeMainTab !== 'chat') { - return; - } - - const hasOverlay = isSettingsDialogOpen - || isCommandPaletteOpen - || isHelpDialogOpen - || isSessionSwitcherOpen - || isAboutDialogOpen - || isTimelineDialogOpen - || isMultiRunLauncherOpen - || isImagePreviewOpen; - - if (hasOverlay) { - return; - } - - e.preventDefault(); - togglePromptNavigatorPanel(); - return; - } - - if (eventMatchesShortcut(e, combo('open_status'))) { - e.preventDefault(); - void showOpenCodeStatus(); - return; - } - - if (eventMatchesShortcut(e, combo('open_help'))) { - e.preventDefault(); - toggleHelpDialog(); - return; - } - - if (canUseElectronDesktopIPC() && eventMatchesShortcut(e, combo('new_mini_chat'))) { - e.preventDefault(); - void invokeDesktop('desktop_open_draft_mini_chat_window', { - directory: currentDirectory || activeProject?.path || '', - projectId: activeProject?.id ?? null, - }).catch((error) => { - console.warn('[keyboard-shortcuts] failed to open draft mini chat window', error); - }); - return; - } - - const matchedNewSessionShortcut = eventMatchesShortcut(e, combo('new_chat')); - const matchedWorktreeShortcut = eventMatchesShortcut(e, combo('new_chat_worktree')); - - if (matchedNewSessionShortcut || matchedWorktreeShortcut) { - e.preventDefault(); - - setActiveMainTab('chat'); - setSessionSwitcherOpen(false); - - if (!isVSCodeRuntime() && matchedWorktreeShortcut) { - createWorktreeSession(); - return; - } - - openNewSessionDraft(); - return; - } - - if (eventMatchesShortcut(e, combo('cycle_theme'))) { - e.preventDefault(); - if (readEmbeddedThemeSearchParams() !== null && window.parent && window.parent !== window) { - window.parent.postMessage({ type: 'openchamber:cycle-theme-request' }, window.location.origin); - return; - } - const modes: Array<'light' | 'dark' | 'system'> = ['light', 'dark', 'system']; - const activeElement = document.activeElement as HTMLElement | null; - const currentIndex = modes.indexOf(themeModeRef.current); - const nextIndex = (currentIndex + 1) % modes.length; - setThemeMode(modes[nextIndex]); - requestAnimationFrame(() => { - if (typeof document === 'undefined' || typeof window === 'undefined') { - return; - } - if (!document.hasFocus()) { - window.focus(); - } - if (activeElement && document.contains(activeElement)) { - activeElement.focus({ preventScroll: true }); - } - }); - return; - } - - if (eventMatchesShortcut(e, combo('open_settings'))) { - e.preventDefault(); - const { isSettingsDialogOpen } = useUIStore.getState(); - setSettingsDialogOpen(!isSettingsDialogOpen); - return; - } - - if (eventMatchesShortcut(e, combo('add_selection_to_chat'))) { - e.preventDefault(); - addSelectionToChat(); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_sidebar'))) { - e.preventDefault(); - const { isMobile, isSessionSwitcherOpen } = useUIStore.getState(); - if (isMobile) { - setSessionSwitcherOpen(!isSessionSwitcherOpen); - } else { - toggleSidebar(); - } - return; - } - - if (eventMatchesShortcut(e, combo('focus_input'))) { - e.preventDefault(); - focusChatInput(); - return; - } - - const cycleAgentCombo = combo('cycle_agent'); - const cycleAgentBackwardCombo = cycleAgentCombo && !cycleAgentCombo.includes('shift') - ? normalizeCombo(`shift+${cycleAgentCombo}`) - : ''; - const cycleAgentDirection = cycleAgentBackwardCombo && eventMatchesShortcut(e, cycleAgentBackwardCombo) - ? -1 - : eventMatchesShortcut(e, cycleAgentCombo) - ? 1 - : 0; - - if (cycleAgentDirection !== 0) { - const { - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - activeMainTab, - } = useUIStore.getState(); - - const hasOverlay = isSettingsDialogOpen || isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - if (hasOverlay || activeMainTab !== 'chat' || !isChatInputTarget(e.target)) { - return; - } - - const configState = useConfigStore.getState(); - const nextAgentName = getCycledPrimaryAgentName( - configState.getVisibleAgents(), - configState.currentAgentName, - cycleAgentDirection, - ); - - if (!nextAgentName) { - return; - } - - e.preventDefault(); - configState.setAgent(nextAgentName); - useUIStore.getState().addRecentAgent(nextAgentName); - - const sessionId = useSessionUIStore.getState().currentSessionId; - if (sessionId) { - useSelectionStore.getState().saveSessionAgentSelection(sessionId, nextAgentName); - } - return; - } - - // Legacy right-sidebar shortcuts now target the context surfaces that - // replaced the sidebar's tabs. - if (eventMatchesShortcut(e, combo('toggle_right_sidebar'))) { - const state = useUIStore.getState(); - if (state.isMobile || !currentDirectory) { - return; - } - e.preventDefault(); - const directory = normalizeContextPanelDirectoryKey(currentDirectory); - const panelState = state.contextPanelByDirectory[directory]; - if (panelState?.isOpen) { - state.closeContextPanel(directory); - } else if (panelState?.activeTabId) { - state.setActiveContextPanelTab(directory, panelState.activeTabId); - } else { - state.openContextSurface(directory, 'git'); - } - return; - } - - if (eventMatchesShortcut(e, combo('open_right_sidebar_git'))) { - const state = useUIStore.getState(); - if (state.isMobile || !currentDirectory) { - return; - } - e.preventDefault(); - state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'git'); - return; - } - - if (eventMatchesShortcut(e, combo('open_right_sidebar_files'))) { - const state = useUIStore.getState(); - if (state.isMobile || !currentDirectory) { - return; - } - e.preventDefault(); - state.openContextSurface(normalizeContextPanelDirectoryKey(currentDirectory), 'file'); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_terminal'))) { - const { isMobile } = useUIStore.getState(); - if (isMobile) { - return; - } - e.preventDefault(); - toggleTerminalSurface(); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_terminal_expanded'))) { - const { isMobile } = useUIStore.getState(); - if (isMobile) { - return; - } - e.preventDefault(); - toggleTerminalSurfaceExpanded(); - return; - } - - // Configured prefix + digit (default: Cmd/Ctrl + 1..9, with 0 for the - // 10th surface): open/close the matching context panel rail surface. The - // digit maps to the currently visible rail order, matching the number - // badges shown while holding the modifier. `e.repeat` guard keeps - // holding a digit from toggling. - const switchSurfaceDigit = e.key.length === 1 && e.key >= '0' && e.key <= '9' - ? (e.key === '0' ? 10 : Number(e.key)) + const switchSurfaceDigit = event.key.length === 1 && event.key >= '0' && event.key <= '9' + ? (event.key === '0' ? 10 : Number(event.key)) : null; - if (switchSurfaceDigit !== null - && !e.repeat - && eventMatchesShortcutPrefix(e, switchSurfacePrefix, heldKeysRef.current)) { + const switchSurfacePrefix = getEffectiveShortcutPrefix( + 'switch_context_surface', + useUIStore.getState().shortcutOverrides, + ); + if ( + switchSurfaceDigit !== null + && !event.repeat + && eventMatchesShortcutPrefix(event, switchSurfacePrefix, heldKeysRef.current) + ) { const state = useUIStore.getState(); - if (state.isMobile || !effectiveDirectory) { - return; - } + if (state.isMobile || !effectiveDirectory) return; const directory = normalizeContextPanelDirectoryKey(effectiveDirectory); - const panelState = state.contextPanelByDirectory[directory]; + const panel = state.contextPanelByDirectory[directory]; const visibleSurfaces = getVisibleContextRailSurfaces({ railOrder: state.contextRailOrder, planModeEnabled: useFeatureFlagsStore.getState().planModeEnabled, isVSCode: isVSCodeRuntime(), screenWidth: window.innerWidth, - tabs: panelState?.tabs ?? [], + tabs: panel?.tabs ?? [], }); const target = visibleSurfaces[switchSurfaceDigit - 1]; - if (!target) { - return; - } - e.preventDefault(); + if (!target) return; + event.preventDefault(); state.openContextSurface(directory, target.mode); return; } - // Cmd/Ctrl+Shift+M: Open model selector (same conditions as double-ESC: chat tab, no overlays) - if (eventMatchesShortcut(e, combo('open_model_selector'))) { - const { - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - activeMainTab, - isModelSelectorOpen, - } = useUIStore.getState(); - - // Skip if settings open - if (isSettingsDialogOpen) { - return; - } - - // Skip if any overlay open or not on chat tab - const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - const isChatActive = activeMainTab === 'chat'; - - if (hasOverlay || !isChatActive) { - return; - } - - e.preventDefault(); - setModelSelectorOpen(!isModelSelectorOpen); - return; - } - - // Cmd/Ctrl+Shift+T: Cycle thinking variant (same gating as Shift+M) - if (eventMatchesShortcut(e, combo('cycle_thinking_variant'))) { - const { - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - activeMainTab, - } = useUIStore.getState(); - - if (isSettingsDialogOpen) { - return; - } - - const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - const isChatActive = activeMainTab === 'chat'; - - if (hasOverlay || !isChatActive) { - return; - } - - const configState = useConfigStore.getState(); - const variants = configState.getCurrentModelVariants(); - if (variants.length === 0) { - return; - } - - e.preventDefault(); - configState.cycleCurrentVariant(); - - const nextVariant = useConfigStore.getState().currentVariant; - const sessionId = useSessionUIStore.getState().currentSessionId; - const agentName = useConfigStore.getState().currentAgentName; - const providerId = useConfigStore.getState().currentProviderId; - const modelId = useConfigStore.getState().currentModelId; - - if (sessionId && agentName && providerId && modelId) { - useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant); - } - - return; - } - - // Ctrl+] / Ctrl+[: Cycle through starred models (same gating as Shift+M) - if ( - eventMatchesShortcut(e, combo('cycle_favorite_model_forward')) || - eventMatchesShortcut(e, combo('cycle_favorite_model_backward')) - ) { - const { - isSettingsDialogOpen, - isCommandPaletteOpen, - isHelpDialogOpen, - isSessionSwitcherOpen, - isAboutDialogOpen, - activeMainTab, - favoriteModels, - addRecentModel, - } = useUIStore.getState(); - - if (isSettingsDialogOpen) { - return; - } - - const hasOverlay = isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isAboutDialogOpen; - const isChatActive = activeMainTab === 'chat'; - - if (hasOverlay || !isChatActive || favoriteModels.length === 0) { - return; - } - - e.preventDefault(); - - const { currentProviderId, currentModelId, setProvider, setModel } = useConfigStore.getState(); - const len = favoriteModels.length; - const currentIdx = favoriteModels.findIndex( - (f) => f.providerID === currentProviderId && f.modelID === currentModelId, - ); - const delta = eventMatchesShortcut(e, combo('cycle_favorite_model_forward')) ? 1 : -1; - const next = favoriteModels[(currentIdx + delta + len) % len]; - - setProvider(next.providerID); - setModel(next.modelID); - addRecentModel(next.providerID, next.modelID); - return; - } - - if (eventMatchesShortcut(e, combo('expand_input'))) { - if (isMobile) { - return; - } - e.preventDefault(); - toggleExpandedInput(); - return; - } - - if (eventMatchesShortcut(e, combo('toggle_dictation'))) { - const { activeMainTab, isCommandPaletteOpen, isHelpDialogOpen, isSessionSwitcherOpen, isSettingsDialogOpen } = useUIStore.getState(); - if (activeMainTab !== 'chat' || isCommandPaletteOpen || isHelpDialogOpen || isSessionSwitcherOpen || isSettingsDialogOpen) { - return; - } - e.preventDefault(); - // Dictation state lives inside the composer's isolated component; - // toggle it via an event instead of subscribing this hot hook to it. - window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle')); - return; - } - + if (dispatcher.dispatch(event)) event.preventDefault(); }; - - // Track held physical keys so chord prefixes (e.g. a configured - // `mod+p`) can require their primary key to stay held. Capture phase runs - // before handleKeyDown, so the set is current when chord matching runs. - const handleKeyHoldDown = (e: KeyboardEvent) => { - heldKeysRef.current.add(e.key.toLowerCase()); + const handleKeyHoldDown = (event: KeyboardEvent) => { + heldKeysRef.current.add(event.key.toLowerCase()); }; - const handleKeyUp = (e: KeyboardEvent) => { - heldKeysRef.current.delete(e.key.toLowerCase()); + const handleKeyUp = (event: KeyboardEvent) => { + heldKeysRef.current.delete(event.key.toLowerCase()); }; - const handleWindowBlur = () => { + const handleBlur = () => { heldKeysRef.current.clear(); + dispatcher.handleBlur(); }; - window.addEventListener('keydown', handleKeyHoldDown, true); window.addEventListener('keyup', handleKeyUp, true); - window.addEventListener('blur', handleWindowBlur); window.addEventListener('keydown', handleTerminalShortcutCapture, true); window.addEventListener('keydown', handleEscapeKeyDownCapture, true); window.addEventListener('keydown', handleKeyDown); - + window.addEventListener('blur', handleBlur); return () => { window.removeEventListener('keydown', handleKeyHoldDown, true); window.removeEventListener('keyup', handleKeyUp, true); - window.removeEventListener('blur', handleWindowBlur); window.removeEventListener('keydown', handleTerminalShortcutCapture, true); window.removeEventListener('keydown', handleEscapeKeyDownCapture, true); window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('blur', handleBlur); }; - }, [ - openNewSessionDraft, - abortCurrentOperation, - toggleCommandPalette, - toggleHelpDialog, - toggleSidebar, - toggleTerminalSurface, - toggleTerminalSurfaceExpanded, - isMobile, - setSessionSwitcherOpen, - setActiveMainTab, - setSettingsDialogOpen, - setModelSelectorOpen, - setTimelineDialogOpen, - togglePromptNavigatorPanel, - setPromptNavigatorPanelOpen, - toggleExpandedInput, - setThemeMode, - sessionPhase, - armAbortPrompt, - resetAbortPriming, - currentSessionId, - currentDirectory, - effectiveDirectory, - activeProject?.id, - activeProject?.path, - shortcutOverrides, - ]); + }, [armAbortPrompt, currentSessionId, dispatcher, effectiveDirectory, resetAbortPriming, sessionPhase]); - React.useEffect(() => { - return () => { - resetAbortPriming(); - }; - }, [resetAbortPriming]); + React.useEffect(() => () => resetAbortPriming(), [resetAbortPriming]); }; diff --git a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts index f3b24003..98a6d480 100644 --- a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts @@ -1,102 +1,115 @@ import React from 'react'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop'; -import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { ShortcutDispatcher } from '@/lib/shortcutDispatcher'; +import { shortcutRegistry } from '@/lib/shortcutRegistry'; +import { getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useKeybinds } from './useKeybind'; export const useMiniChatKeyboardShortcuts = () => { - const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const activeProject = useProjectsStore((state) => state.getActiveProject()); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); + const dispatcherRef = React.useRef(null); + + if (!dispatcherRef.current) { + dispatcherRef.current = new ShortcutDispatcher({ + registry: shortcutRegistry, + getBinding: (actionId) => getEffectiveShortcutCombo( + actionId, + useUIStore.getState().shortcutOverrides, + ), + }); + } + const dispatcher = dispatcherRef.current; + + const cycleFavoriteModel = (delta: number): boolean | void => { + const { favoriteModels, addRecentModel } = useUIStore.getState(); + if (favoriteModels.length === 0) return false; + + const { + currentProviderId, + currentModelId, + setProvider, + setModel, + } = useConfigStore.getState(); + const currentIndex = favoriteModels.findIndex( + (favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId, + ); + const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length]; + setProvider(next.providerID); + setModel(next.modelID); + addRecentModel(next.providerID, next.modelID); + }; + + useKeybinds({ + focus_input: () => { + focusChatInput(); + }, + new_mini_chat: () => { + if (!canUseElectronDesktopIPC()) return false; + void invokeDesktop('desktop_open_draft_mini_chat_window', { + directory: currentDirectory || activeProject?.path || '', + projectId: activeProject?.id ?? null, + })?.catch((error) => { + console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error); + }); + }, + new_chat: () => { + openNewSessionDraft({ + selectedProjectId: activeProject?.id ?? null, + directoryOverride: currentDirectory || activeProject?.path || null, + preserveDirectoryOverride: Boolean(currentDirectory || activeProject?.path), + }); + focusChatInput(); + }, + open_model_selector: () => { + const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState(); + setModelSelectorOpen(!isModelSelectorOpen); + }, + cycle_thinking_variant: () => { + const configState = useConfigStore.getState(); + if (configState.getCurrentModelVariants().length === 0) return false; + + configState.cycleCurrentVariant(); + const sessionId = useSessionUIStore.getState().currentSessionId; + const { + currentVariant, + currentAgentName, + currentProviderId, + currentModelId, + } = useConfigStore.getState(); + if (sessionId && currentAgentName && currentProviderId && currentModelId) { + useSelectionStore.getState().saveAgentModelVariantForSession( + sessionId, + currentAgentName, + currentProviderId, + currentModelId, + currentVariant, + ); + } + }, + cycle_favorite_model_forward: () => cycleFavoriteModel(1), + cycle_favorite_model_backward: () => cycleFavoriteModel(-1), + }); React.useEffect(() => { - const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides); - const handleKeyDown = (event: KeyboardEvent) => { - if (eventMatchesShortcut(event, combo('focus_input'))) { - event.preventDefault(); - focusChatInput(); - return; - } - - if (canUseElectronDesktopIPC() && eventMatchesShortcut(event, combo('new_mini_chat'))) { - event.preventDefault(); - void invokeDesktop('desktop_open_draft_mini_chat_window', { - directory: currentDirectory || activeProject?.path || '', - projectId: activeProject?.id ?? null, - })?.catch((error) => { - console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error); - }); - return; - } - - if (eventMatchesShortcut(event, combo('new_chat'))) { - event.preventDefault(); - openNewSessionDraft({ - selectedProjectId: activeProject?.id ?? null, - directoryOverride: currentDirectory || activeProject?.path || null, - preserveDirectoryOverride: Boolean(currentDirectory || activeProject?.path), - }); - focusChatInput(); - return; - } - - if (eventMatchesShortcut(event, combo('open_model_selector'))) { - event.preventDefault(); - const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState(); - setModelSelectorOpen(!isModelSelectorOpen); - return; - } - - if (eventMatchesShortcut(event, combo('cycle_thinking_variant'))) { - const configState = useConfigStore.getState(); - const variants = configState.getCurrentModelVariants(); - if (variants.length === 0) { - return; - } - - event.preventDefault(); - configState.cycleCurrentVariant(); - - const nextVariant = useConfigStore.getState().currentVariant; - const sessionId = useSessionUIStore.getState().currentSessionId; - const agentName = useConfigStore.getState().currentAgentName; - const providerId = useConfigStore.getState().currentProviderId; - const modelId = useConfigStore.getState().currentModelId; - - if (sessionId && agentName && providerId && modelId) { - useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant); - } - return; - } - - const cyclesForward = eventMatchesShortcut(event, combo('cycle_favorite_model_forward')); - const cyclesBackward = eventMatchesShortcut(event, combo('cycle_favorite_model_backward')); - if (cyclesForward || cyclesBackward) { - const { favoriteModels, addRecentModel } = useUIStore.getState(); - if (favoriteModels.length === 0) { - return; - } - - event.preventDefault(); - const { currentProviderId, currentModelId, setProvider, setModel } = useConfigStore.getState(); - const currentIndex = favoriteModels.findIndex((favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId); - const delta = cyclesForward ? 1 : -1; - const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length]; - - setProvider(next.providerID); - setModel(next.modelID); - addRecentModel(next.providerID, next.modelID); - } + if (dispatcher.dispatch(event)) event.preventDefault(); }; + const handleBlur = () => dispatcher.handleBlur(); window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [activeProject?.id, activeProject?.path, currentDirectory, openNewSessionDraft, shortcutOverrides]); + window.addEventListener('blur', handleBlur); + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('blur', handleBlur); + }; + }, [dispatcher]); }; From 1b9da5f9fa6dcbdecc2285e27f4742141d79c79d Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Thu, 30 Jul 2026 11:10:59 +0800 Subject: [PATCH 003/192] feat(ui): add draft target shortcut sequences --- .../components/chat/composer/DOCUMENTATION.md | 3 ++ .../chat/composer/ui/DraftTargetSelectors.tsx | 34 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 3544932a..ab40580a 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -101,6 +101,9 @@ and the send path reading the same grammar. - `state/useDraftTarget.ts` — the draft can target a directory that does not exist yet (a worktree being created). It must survive not appearing in the branch list, or the selector snaps back to the project root mid-creation. +- `ui/DraftTargetSelectors.tsx` owns the controlled project/worktree picker + state and registers its application shortcuts locally. The selectors only + consume their shared prefix while the draft target UI is mounted. ## Mobile diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 4025d7a2..0a9c3391 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -25,6 +25,7 @@ import { import { useI18n } from '@/lib/i18n'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { createWorktreeDraft } from '@/lib/worktreeSessionCreator'; +import { useKeybind } from '@/hooks/useKeybind'; import type { Theme } from '@/types/theme'; import { normalizePath } from '../attachments/filePaths'; import { getProjectDisplayLabel, type DraftTargetProject } from '../state/useDraftTarget'; @@ -103,14 +104,40 @@ export function DraftTargetSelectors(props: DraftTargetProps) { onDirectoryChange, theme, } = props; + const [openPicker, setOpenPicker] = React.useState<'project' | 'worktree' | null>(null); + const projectTriggerRef = React.useRef(null); + const worktreeTriggerRef = React.useRef(null); + + useKeybind('open_draft_project_picker', () => { + projectTriggerRef.current?.focus(); + setOpenPicker('project'); + }); + useKeybind('open_draft_worktree_picker', () => { + if (!showBranchSelector) return false; + worktreeTriggerRef.current?.focus(); + setOpenPicker('worktree'); + }); + + const handleProjectChange = (projectId: string) => { + onProjectChange(projectId); + setOpenPicker(null); + }; + + const handleDirectoryChange = (directory: string) => { + onDirectoryChange(directory); + setOpenPicker(null); + }; return (
setOpenPicker(open ? 'worktree' : null)} + onValueChange={handleDirectoryChange} > From 4c5421a7af894d380398b91ed6c06929e62d1117 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Thu, 30 Jul 2026 11:10:59 +0800 Subject: [PATCH 004/192] feat(ui): redesign shortcut settings --- .../openchamber/KeyboardShortcutsSettings.tsx | 396 +++++------------- .../openchamber/ShortcutRecordingDialog.tsx | 209 +++++++++ packages/ui/src/components/ui/HelpDialog.tsx | 105 +++-- .../ui/src/lib/i18n/messages/en.settings.ts | 22 +- .../ui/src/lib/i18n/messages/es.settings.ts | 22 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 22 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 22 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 22 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 23 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 22 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 22 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 22 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 22 +- 13 files changed, 583 insertions(+), 348 deletions(-) create mode 100644 packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx diff --git a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx index 1956615c..9f83af25 100644 --- a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx @@ -1,12 +1,7 @@ import React from 'react'; import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { - SettingsSection, - SettingsFieldRow, -} from '@/components/sections/shared/SettingsSection'; +import { SettingsFieldRow, SettingsSection } from '@/components/sections/shared/SettingsSection'; import { useUIStore } from '@/stores/useUIStore'; -import { cn } from '@/lib/utils'; import { updateDesktopSettings } from '@/lib/persistence'; import { isVSCodeRuntime } from '@/lib/desktop'; import { @@ -14,314 +9,133 @@ import { getCustomizableShortcutActions, getEffectiveShortcutCombo, getEffectiveShortcutPrefix, - isRiskyBrowserShortcut, - keyToShortcutToken, - normalizeCombo, + getShortcutCategory, UNASSIGNED_SHORTCUT, + type ShortcutAction, + type ShortcutActionId, + type ShortcutCategory, type ShortcutCombo, } from '@/lib/shortcuts'; import { useI18n } from '@/lib/i18n'; +import { ShortcutRecordingDialog } from './ShortcutRecordingDialog'; -const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']); - -const keyboardEventToCombo = (event: React.KeyboardEvent): ShortcutCombo | null => { - if (MODIFIER_KEYS.has(event.key.toLowerCase())) { - return null; - } - - const parts: string[] = []; - - if (event.metaKey || event.ctrlKey) { - parts.push('mod'); - } - if (event.shiftKey) { - parts.push('shift'); - } - if (event.altKey) { - parts.push('alt'); - } - - const keyToken = keyToShortcutToken(event.key); - if (!keyToken) { - return null; - } - - parts.push(keyToken); - return normalizeCombo(parts.join('+')); -}; - -// Prefix capture for chord-style shortcuts (e.g. "switch context panel -// surface"): a bare modifier press is accepted so the prefix can be just the -// primary modifier (default) or a modifier + key chord like `mod+p`. -const keyboardEventToPrefixCombo = (event: React.KeyboardEvent): 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'); - } - - if (MODIFIER_KEYS.has(event.key.toLowerCase())) { - return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; - } - - const keyToken = keyToShortcutToken(event.key); - if (!keyToken) { - return null; - } - - parts.push(keyToken); - return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; -}; +const CATEGORIES: ShortcutCategory[] = ['session', 'models', 'panels', 'navigation', 'application']; export const KeyboardShortcutsSettings: React.FC = () => { const { t } = useI18n(); - const tUnsafe = React.useCallback((key: string) => t(key as Parameters[0]), [t]); + const tUnsafe = (key: string) => t(key as Parameters[0]); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const setShortcutOverride = useUIStore((state) => state.setShortcutOverride); const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride); const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides); + const [editingAction, setEditingAction] = React.useState(null); const actions = React.useMemo(() => { const all = getCustomizableShortcutActions(); - if (!isVSCodeRuntime()) { - return all; - } - return all.filter((action) => action.id !== 'toggle_prompt_navigator'); + return isVSCodeRuntime() ? all.filter((action) => action.id !== 'toggle_prompt_navigator') : all; }, []); - const actionLabel = React.useCallback((id: string, fallbackLabel: string): string => { - const key = `settings.openchamber.keyboardShortcuts.action.${id}.label`; + const actionLabel = (action: ShortcutAction): string => { + const key = `settings.openchamber.keyboardShortcuts.action.${action.id}.label`; const translated = tUnsafe(key); - return translated === key ? fallbackLabel : translated; - }, [tUnsafe]); - - const [capturingActionId, setCapturingActionId] = React.useState(null); - const [draftByAction, setDraftByAction] = React.useState>({}); - const [errorText, setErrorText] = React.useState(''); - const [warningText, setWarningText] = React.useState(''); - const [pendingOverwrite, setPendingOverwrite] = React.useState<{ - actionId: string; - combo: ShortcutCombo; - conflictActionId: string; - } | null>(null); - - const persistShortcutOverrides = React.useCallback((nextOverrides: Record) => { + return translated === key ? action.label : translated; + }; + const persist = (nextOverrides: Record) => { void updateDesktopSettings({ shortcutOverrides: nextOverrides }); - }, []); - - const findConflict = React.useCallback((actionId: string, combo: ShortcutCombo): string | null => { - const normalized = normalizeCombo(combo); - for (const action of actions) { - if (action.id === actionId) { - continue; - } - const existing = getEffectiveShortcutCombo(action.id, shortcutOverrides); - if (normalizeCombo(existing) === normalized) { - return action.id; - } - } - return null; - }, [actions, shortcutOverrides]); - - const saveCombo = React.useCallback((actionId: string, combo: ShortcutCombo) => { - const normalized = normalizeCombo(combo); - const conflictActionId = findConflict(actionId, normalized); - if (conflictActionId) { - setPendingOverwrite({ actionId, combo: normalized, conflictActionId }); - setErrorText(''); - return; - } - - const nextOverrides = { ...shortcutOverrides, [actionId]: normalized }; - setShortcutOverride(actionId, normalized); - persistShortcutOverrides(nextOverrides); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(isRiskyBrowserShortcut(normalized) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : ''); - setDraftByAction((current) => { - const rest = { ...current }; - delete rest[actionId]; - return rest; - }); - }, [findConflict, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]); - - const confirmOverwrite = React.useCallback(() => { - if (!pendingOverwrite) { - return; - } - - const nextOverrides = { - ...shortcutOverrides, - [pendingOverwrite.conflictActionId]: UNASSIGNED_SHORTCUT, - [pendingOverwrite.actionId]: pendingOverwrite.combo, - }; - setShortcutOverride(pendingOverwrite.conflictActionId, UNASSIGNED_SHORTCUT); - setShortcutOverride(pendingOverwrite.actionId, pendingOverwrite.combo); - persistShortcutOverrides(nextOverrides); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(isRiskyBrowserShortcut(pendingOverwrite.combo) ? t('settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut') : ''); - setDraftByAction((current) => { - const rest = { ...current }; - delete rest[pendingOverwrite.actionId]; - return rest; - }); - }, [pendingOverwrite, persistShortcutOverrides, setShortcutOverride, shortcutOverrides, t]); - - const resetOne = React.useCallback((actionId: string) => { + }; + const save = ( + actionId: ShortcutActionId, + combo: ShortcutCombo, + replaceActionId?: ShortcutActionId, + ) => { + const nextOverrides = { ...shortcutOverrides, [actionId]: combo }; + if (replaceActionId) nextOverrides[replaceActionId] = UNASSIGNED_SHORTCUT; + setShortcutOverride(actionId, combo); + if (replaceActionId) setShortcutOverride(replaceActionId, UNASSIGNED_SHORTCUT); + persist(nextOverrides); + }; + const resetOne = (actionId: ShortcutActionId) => { const nextOverrides = { ...shortcutOverrides }; delete nextOverrides[actionId]; clearShortcutOverride(actionId); - persistShortcutOverrides(nextOverrides); - setDraftByAction((current) => { - const rest = { ...current }; - delete rest[actionId]; - return rest; - }); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(''); - }, [clearShortcutOverride, persistShortcutOverrides, shortcutOverrides]); + persist(nextOverrides); + }; + const shortcutDisplay = (action: ShortcutAction): string => { + const isSurfaceSwitch = action.id === 'switch_context_surface'; + const combo = isSurfaceSwitch + ? getEffectiveShortcutPrefix(action.id, shortcutOverrides) + : getEffectiveShortcutCombo(action.id, shortcutOverrides); + const formatted = formatShortcutForDisplay( + combo, + t('settings.openchamber.keyboardShortcuts.unassigned'), + ); + return isSurfaceSwitch && combo && combo !== UNASSIGNED_SHORTCUT + ? `${formatted}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}` + : formatted; + }; return ( - { - resetAllShortcutOverrides(); - persistShortcutOverrides({}); - setDraftByAction({}); - setPendingOverwrite(null); - setErrorText(''); - setWarningText(''); - }} - > - {t('settings.openchamber.keyboardShortcuts.actions.resetAll')} - - )} - > - {(errorText || warningText || pendingOverwrite) && ( -
- {pendingOverwrite && ( -
- - {t('settings.openchamber.keyboardShortcuts.overwritePrompt')} - -
- - -
+ <> + {CATEGORIES.map((category, categoryIndex) => { + const categoryActions = actions.filter((action) => getShortcutCategory(action) === category); + if (categoryActions.length === 0) return null; + return ( + { + resetAllShortcutOverrides(); + persist({}); + }}> + {t('settings.openchamber.keyboardShortcuts.actions.resetAll')} + + ) : undefined} + > +
+ {categoryActions.map((action) => ( + + + {shortcutDisplay(action)} + + + + + ))}
- )} - {errorText && ( -
- {errorText} -
- )} - {warningText && ( -
- {warningText} -
- )} -
- )} - -
- {actions.map((action, index) => { - const isSurfaceSwitch = action.id === 'switch_context_surface'; - const effective = isSurfaceSwitch - ? getEffectiveShortcutPrefix(action.id, shortcutOverrides) - : getEffectiveShortcutCombo(action.id, shortcutOverrides); - const draft = draftByAction[action.id]; - const displayCombo = draft ?? effective; - const hasDraft = typeof draft === 'string' && normalizeCombo(draft) !== normalizeCombo(effective); - const isUnassignedDisplay = displayCombo === '' || normalizeCombo(displayCombo) === UNASSIGNED_SHORTCUT; - const displayValue = capturingActionId === action.id - ? t('settings.openchamber.keyboardShortcuts.field.pressKeys') - : isSurfaceSwitch && !isUnassignedDisplay - ? `${formatShortcutForDisplay(displayCombo)}${t('settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix')}` - : formatShortcutForDisplay(displayCombo); - - return ( -
0 && "border-t border-border/40")}> - - { - setCapturingActionId(action.id); - setErrorText(''); - }} - onBlur={() => { - if (capturingActionId === action.id) { - setCapturingActionId(null); - } - }} - onKeyDown={(event) => { - event.preventDefault(); - event.stopPropagation(); - - if (event.key === 'Escape') { - setCapturingActionId(null); - return; - } - - const combo = isSurfaceSwitch ? keyboardEventToPrefixCombo(event) : keyboardEventToCombo(event); - if (!combo) { - return; - } - - setDraftByAction((current) => ({ - ...current, - [action.id]: combo, - })); - setCapturingActionId(null); - setPendingOverwrite(null); - setErrorText(''); - }} - className="h-7 w-40 min-w-0 typography-ui-label text-center" - /> - - - -
- ); - })} -
- + + ); + })} + { + if (!open) setEditingAction(null); + }} + /> + ); }; diff --git a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx new file mode 100644 index 00000000..c36a599b --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx @@ -0,0 +1,209 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + formatShortcutForDisplay, + getEffectiveShortcutCombo, + getEffectiveShortcutPrefix, + getShortcutConflict, + isRiskyBrowserShortcut, + keyToShortcutToken, + normalizeCombo, + type ShortcutAction, + type ShortcutActionId, + type ShortcutCombo, +} from '@/lib/shortcuts'; +import { useI18n } from '@/lib/i18n'; + +const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']); + +interface ShortcutRecordingDialogProps { + action: ShortcutAction | null; + actions: ReadonlyArray; + overrides: Record; + actionLabel: (action: ShortcutAction) => string; + onSave: ( + actionId: ShortcutActionId, + combo: ShortcutCombo, + replaceActionId?: ShortcutActionId, + ) => void; + onOpenChange: (open: boolean) => void; +} + +function keyboardEventToCombo(event: React.KeyboardEvent): ShortcutCombo | null { + if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null; + + const key = keyToShortcutToken(event.key); + if (!key) return null; + + const parts: string[] = []; + if (event.metaKey || event.ctrlKey) parts.push('mod'); + if (event.shiftKey) parts.push('shift'); + if (event.altKey) parts.push('alt'); + parts.push(key); + return normalizeCombo(parts.join('+')); +} + +function modifierKeyUpToCombo(event: React.KeyboardEvent): ShortcutCombo | null { + const key = event.key.toLowerCase(); + if (!MODIFIER_KEYS.has(key)) return null; + + const parts: string[] = []; + if (event.metaKey || event.ctrlKey || key === 'meta' || key === 'control') parts.push('mod'); + if (event.shiftKey || key === 'shift') parts.push('shift'); + if (event.altKey || key === 'alt') parts.push('alt'); + return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; +} + +export const ShortcutRecordingDialog: React.FC = ({ + action, + actions, + overrides, + actionLabel, + onSave, + onOpenChange, +}) => { + const { t } = useI18n(); + const [chords, setChords] = React.useState([]); + const recordingRef = React.useRef(null); + + React.useEffect(() => { + if (!action) return; + setChords([]); + }, [action]); + + const combo = normalizeCombo(chords.join(' ')); + const conflicts = React.useMemo(() => { + if (!action || !combo) return []; + const result: Array<{ action: ShortcutAction; kind: 'exact' | 'prefix' }> = []; + for (const candidate of actions) { + if (candidate.id === action.id) continue; + const candidateCombo = candidate.id === 'switch_context_surface' + ? getEffectiveShortcutPrefix(candidate.id, overrides) + : getEffectiveShortcutCombo(candidate.id, overrides); + const kind = getShortcutConflict(combo, candidateCombo); + if (kind) result.push({ action: candidate, kind }); + } + return result; + }, [action, actions, combo, overrides]); + const prefixConflict = conflicts.find((conflict) => conflict.kind === 'prefix'); + const exactConflict = conflicts.find((conflict) => conflict.kind === 'exact'); + + const close = () => onOpenChange(false); + + return ( + + + + + {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]); + }} + > + {[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')} + +
+ ))} + {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 ? ( + + ) : ( + + )} + +
+
+ ); +}; diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 6ed250ea..b1ac15b4 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -13,13 +13,14 @@ import { getShortcutAction, getModifierLabel, formatShortcutForDisplay, + type ShortcutActionId, } from "@/lib/shortcuts"; import { useI18n, type I18nKey } from "@/lib/i18n"; import { isVSCodeRuntime } from "@/lib/desktop"; import type { IconName } from "@/components/icon/icons"; type ShortcutItem = { - id?: string; + id?: ShortcutActionId; keys: string | string[]; descriptionKey: I18nKey; icon: IconName | null; @@ -30,9 +31,14 @@ type ShortcutSection = { items: ShortcutItem[]; }; -const renderShortcut = (id: string, fallbackCombo: string, overrides: Record) => { +const renderShortcut = ( + id: ShortcutActionId, + fallbackCombo: string, + overrides: Record, + unassignedLabel: string, +) => { const action = getShortcutAction(id); - return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides)) : fallbackCombo; + return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides), unassignedLabel) : fallbackCombo; }; export const HelpDialog: React.FC = () => { @@ -121,6 +127,18 @@ export const HelpDialog: React.FC = () => { icon: "git-branch", keys: '', }, + { + id: 'open_draft_project_picker', + descriptionKey: 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label', + icon: 'folder', + keys: '', + }, + { + id: 'open_draft_worktree_picker', + descriptionKey: 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label', + icon: 'git-branch', + keys: '', + }, { id: 'focus_input', descriptionKey: "helpDialog.item.focusChatInput", icon: "text", keys: '' }, { id: 'toggle_prompt_navigator', @@ -214,7 +232,7 @@ export const HelpDialog: React.FC = () => { ]; return ( - + @@ -237,40 +255,48 @@ export const HelpDialog: React.FC = () => { {section.items .filter((shortcut) => !(isVSCode && shortcut.id === 'toggle_prompt_navigator')) .map((shortcut) => { - const displayKeys = shortcut.id - ? renderShortcut(shortcut.id, Array.isArray(shortcut.keys) ? shortcut.keys[0] : shortcut.keys, shortcutOverrides) - : (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / ")); + const fallbackKeys = Array.isArray(shortcut.keys) + ? shortcut.keys[0] + : shortcut.keys; + const displayKeys = shortcut.id + ? renderShortcut( + shortcut.id, + fallbackKeys, + shortcutOverrides, + t('settings.openchamber.keyboardShortcuts.unassigned'), + ) + : (Array.isArray(shortcut.keys) ? shortcut.keys : shortcut.keys.split(" / ")); - return ( -
-
- {shortcut.icon && ( - - )} - - {t(shortcut.descriptionKey)} - + return ( +
+
+ {shortcut.icon && ( + + )} + + {t(shortcut.descriptionKey)} + +
+
+ {(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => ( + + {i > 0 && ( + + {t('helpDialog.keyCombiner.or')} + + )} + + {keyCombo} + + + ))} +
-
- {(Array.isArray(displayKeys) ? displayKeys : [displayKeys]).map((keyCombo: string, i: number) => ( - - {i > 0 && ( - - {t('helpDialog.keyCombiner.or')} - - )} - - {keyCombo} - - - ))} -
-
- ); - })} + ); + })}
))} @@ -284,7 +310,12 @@ export const HelpDialog: React.FC = () => {
  • • {t('helpDialog.proTips.commandPalette', { - shortcut: renderShortcut('open_command_palette', `${mod} P`, shortcutOverrides), + shortcut: renderShortcut( + 'open_command_palette', + `${mod} P`, + shortcutOverrides, + t('settings.openchamber.keyboardShortcuts.unassigned'), + ), })}
  • diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index c021f305..1772694f 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1096,7 +1096,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': 'This combo is already used by another shortcut. Overwrite and clear that other mapping?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Press keys...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capture a shortcut first.', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'This shortcut can conflict with browser defaults. It is still saved.', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'This shortcut can conflict with browser defaults. You can still save it.', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Go to line (files editor)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Open command palette', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Focus input', @@ -1125,7 +1125,25 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Expand input', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Open conversation timeline', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Toggle prompt navigator', - 'settings.projects.sidebar.total': 'Total {count}', + 'settings.openchamber.keyboardShortcuts.category.session': 'Session Controls', + 'settings.openchamber.keyboardShortcuts.category.models': 'Models & Agents', + 'settings.openchamber.keyboardShortcuts.category.panels': 'Panels & Tools', + '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.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.firstChord': 'First combination', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Second combination', + 'settings.openchamber.keyboardShortcuts.dialog.recording': 'Press keys…', + 'settings.openchamber.keyboardShortcuts.unassigned': 'Unassigned', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'This conflicts with the sequence used by {action}. Choose a different combination.', + '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.toggle_dictation.label': 'Voice input', + 'settings.projects.sidebar.total': 'Total {count}', 'settings.projects.sidebar.actions.addProject': 'Add project', 'settings.projects.page.empty.noProjects': 'No projects available.', 'settings.projects.page.title.default': 'Project Settings', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 74db714b..5ee1ebb0 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1063,7 +1063,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.overwritePrompt": "Esta combinación ya está usada por otro atajo. ¿Sobrescribir y limpiar esa otra asignación?", "settings.openchamber.keyboardShortcuts.field.pressKeys": "Pulsa las teclas...", "settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura un atajo primero.", - "settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atajo puede entrar en conflicto con los predeterminados del navegador. Todavía se guarda.", + "settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atajo puede entrar en conflicto con los predeterminados del navegador. Aun así, puedes guardarlo.", "settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Ir a línea (editor de archivos)", "settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Abrir paleta de comandos", "settings.openchamber.keyboardShortcuts.action.focus_input.label": "Enfocar entrada", @@ -1092,7 +1092,25 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.expand_input.label": "Expandir entrada", "settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Abrir línea de tiempo de conversación", "settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Mostrar u ocultar navegador de prompts", - "settings.projects.sidebar.total": "Total {count}", + "settings.openchamber.keyboardShortcuts.category.session": "Controles de sesión", + "settings.openchamber.keyboardShortcuts.category.models": "Modelos y agentes", + "settings.openchamber.keyboardShortcuts.category.panels": "Paneles y herramientas", + "settings.openchamber.keyboardShortcuts.category.navigation": "Navegación", + "settings.openchamber.keyboardShortcuts.category.application": "Aplicación", + "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.firstChord": "Primera combinación", + "settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinación", + "settings.openchamber.keyboardShortcuts.dialog.recording": "Pulse las teclas…", + "settings.openchamber.keyboardShortcuts.unassigned": "Sin asignar", + "settings.openchamber.keyboardShortcuts.error.prefixConflict": "Esto entra en conflicto con la secuencia usada por {action}. Elija otra combinación.", + "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.toggle_dictation.label": "Entrada de voz", + "settings.projects.sidebar.total": "Total {count}", "settings.projects.sidebar.actions.addProject": "Añadir proyecto", "settings.projects.page.empty.noProjects": "No hay proyectos disponibles.", "settings.projects.page.title.default": "Configuración del proyecto", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index f0c98448..46e8f21f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -984,7 +984,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': 'Ce combo est déjà utilisé par un autre raccourci. Écraser et effacer cet autre mappage ?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': 'Appuyez sur les touches...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': 'Capturez d\'abord un raccourci.', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ce raccourci peut entrer en conflit avec les paramètres par défaut du navigateur. Il est toujours sauvegardé.', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ce raccourci peut entrer en conflit avec les paramètres par défaut du navigateur. Vous pouvez tout de même l’enregistrer.', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Aller à la ligne (éditeur de fichiers)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Ouvrir la palette de commandes', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Entrée de mise au point', @@ -1013,7 +1013,25 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Développer l\'entrée', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Chronologie de la conversation ouverte', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Afficher ou masquer le navigateur de prompts', - 'settings.projects.sidebar.total': 'Total {count}', + 'settings.openchamber.keyboardShortcuts.category.session': 'Commandes de session', + 'settings.openchamber.keyboardShortcuts.category.models': 'Modèles et agents', + 'settings.openchamber.keyboardShortcuts.category.panels': 'Panneaux et outils', + 'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation', + 'settings.openchamber.keyboardShortcuts.category.application': 'Application', + '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.firstChord': 'Première combinaison', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Deuxième combinaison', + 'settings.openchamber.keyboardShortcuts.dialog.recording': 'Appuyez sur les touches…', + 'settings.openchamber.keyboardShortcuts.unassigned': 'Non attribué', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'Cela entre en conflit avec la séquence utilisée par {action}. Choisissez une autre combinaison.', + '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.toggle_dictation.label': 'Saisie vocale', + 'settings.projects.sidebar.total': 'Total {count}', 'settings.projects.sidebar.actions.addProject': 'Ajouter un projet', 'settings.projects.page.empty.noProjects': 'Aucun projet disponible.', 'settings.projects.page.title.default': 'Paramètres du projet', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 0c82f734..a02f3cc2 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1096,7 +1096,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': 'このキーコンボは別のショートカットで既に使用されています。上書きしてそのマッピングをクリアしますか?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': 'キーを押してください...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': '最初にショートカットを設定してください。', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'このショートカットはブラウザのデフォルトと競合する可能性があります。それでも保存されます。', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'このショートカットはブラウザのデフォルトと競合する可能性がありますが、そのまま保存できます。', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '指定行に移動(ファイルエディター)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'コマンドパレットを開く', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': '入力をフォーカス', @@ -1125,7 +1125,25 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'モデルセレクターを開く', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '会話タイムラインを開く', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'プロンプトナビゲーターの表示切替', - 'settings.projects.sidebar.total': '合計 {count}', + 'settings.openchamber.keyboardShortcuts.category.session': 'セッション操作', + 'settings.openchamber.keyboardShortcuts.category.models': 'モデルとエージェント', + 'settings.openchamber.keyboardShortcuts.category.panels': 'パネルとツール', + 'settings.openchamber.keyboardShortcuts.category.navigation': 'ナビゲーション', + 'settings.openchamber.keyboardShortcuts.category.application': 'アプリケーション', + '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.firstChord': '最初の組み合わせ', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '2番目の組み合わせ', + 'settings.openchamber.keyboardShortcuts.dialog.recording': 'キーを押してください…', + 'settings.openchamber.keyboardShortcuts.unassigned': '未割り当て', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': '{action} のシーケンスと競合しています。別の組み合わせを選択してください。', + '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.toggle_dictation.label': '音声入力', + 'settings.projects.sidebar.total': '合計 {count}', 'settings.projects.sidebar.actions.addProject': 'プロジェクトを追加', 'settings.projects.page.empty.noProjects': '利用可能なプロジェクトがありません。', 'settings.projects.page.title.default': 'プロジェクト設定', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index aa53cbfd..a56696cf 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1063,7 +1063,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': '이 조합은 이미 다른 단축키에서 사용 중입니다. 덮어쓰고 기존 매핑을 지울까요?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': '키를 누르세요...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': '먼저 단축키를 입력하세요.', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '이 단축키는 브라우저 기본값과 충돌할 수 있습니다. 그래도 저장됩니다.', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '이 단축키는 브라우저 기본값과 충돌할 수 있지만 그래도 저장할 수 있습니다.', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '줄로 이동(파일 편집기)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '명령 팔레트 열기', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': '입력에 포커스', @@ -1092,7 +1092,25 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.expand_input.label': '입력 확장', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '대화 타임라인 열기', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '프롬프트 탐색기 표시/숨기기', - 'settings.projects.sidebar.total': '총 {count}개', + 'settings.openchamber.keyboardShortcuts.category.session': '세션 제어', + 'settings.openchamber.keyboardShortcuts.category.models': '모델 및 에이전트', + 'settings.openchamber.keyboardShortcuts.category.panels': '패널 및 도구', + 'settings.openchamber.keyboardShortcuts.category.navigation': '탐색', + 'settings.openchamber.keyboardShortcuts.category.application': '애플리케이션', + '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.firstChord': '첫 번째 조합', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '두 번째 조합', + 'settings.openchamber.keyboardShortcuts.dialog.recording': '키를 누르세요…', + 'settings.openchamber.keyboardShortcuts.unassigned': '할당되지 않음', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': '{action}에서 사용하는 시퀀스와 충돌합니다. 다른 조합을 선택하세요.', + '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.toggle_dictation.label': '음성 입력', + 'settings.projects.sidebar.total': '총 {count}개', 'settings.projects.sidebar.actions.addProject': '프로젝트 추가', 'settings.projects.page.empty.noProjects': '사용 가능한 프로젝트가 없습니다.', 'settings.projects.page.title.default': '프로젝트 설정', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index aa3af5d6..ee8c127b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -838,7 +838,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': 'Ta kombinacja jest już używana przez inny skrót. Nadpisać i wyczyścić to inne przypisanie?', 'settings.openchamber.keyboardShortcuts.title': 'Skróty klawiszowe', 'settings.openchamber.keyboardShortcuts.tooltip': 'Przechwyć nową kombinację klawiszy, zapisz ją, a przypisania zostaną natychmiast zaktualizowane.', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ten skrót może kolidować z domyślnymi skrótami przeglądarki. Został jednak zapisany.', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': 'Ten skrót może kolidować z domyślnymi skrótami przeglądarki. Nadal możesz go zapisać.', 'settings.openchamber.opencodeCli.actions.browse': 'Przeglądaj', 'settings.openchamber.opencodeCli.actions.browseAria': 'Przeglądaj ścieżkę do pliku binarnego OpenCode', 'settings.openchamber.opencodeCli.actions.restartingOpenCode': 'Restartowanie OpenCode...', @@ -1360,7 +1360,26 @@ export const settingsDict = { 'settings.projects.page.toast.removeIconFailed': 'Nie udało się usunąć ikony projektu', 'settings.projects.page.toast.uploadIconFailed': 'Nie udało się przesłać ikony projektu', 'settings.projects.sidebar.actions.addProject': 'Dodaj projekt', - 'settings.projects.sidebar.total': 'Suma: {count}', + 'settings.openchamber.keyboardShortcuts.category.session': 'Sterowanie sesją', + 'settings.openchamber.keyboardShortcuts.category.models': 'Modele i agenci', + 'settings.openchamber.keyboardShortcuts.category.panels': 'Panele i narzędzia', + 'settings.openchamber.keyboardShortcuts.category.navigation': 'Nawigacja', + 'settings.openchamber.keyboardShortcuts.category.application': 'Aplikacja', + '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.firstChord': 'Pierwsza kombinacja', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Druga kombinacja', + 'settings.openchamber.keyboardShortcuts.dialog.recording': 'Naciśnij klawisze…', + 'settings.openchamber.keyboardShortcuts.unassigned': 'Nieprzypisany', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'To koliduje z sekwencją używaną przez {action}. Wybierz inną kombinację.', + '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_timeline_dialog.label': 'Otwórz oś czasu rozmowy', + 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Wprowadzanie głosowe', + 'settings.projects.sidebar.total': 'Suma: {count}', 'settings.providers.page.actions.complete': 'Zakończ', 'settings.providers.page.actions.continue': 'Kontynuuj', 'settings.providers.page.actions.cancel': 'Anuluj', 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 c321f233..d54be549 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1063,7 +1063,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.overwritePrompt": "Esta combinação já está sendo usada por outro atalho. Sobrescrever e limpar essa outra atribuição?", "settings.openchamber.keyboardShortcuts.field.pressKeys": "Pressione as teclas...", "settings.openchamber.keyboardShortcuts.error.captureFirst": "Captura um atalho primeiro.", - "settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atalho pode entrar em conflito com os padrões do navegador. Ainda assim, ele será salvo.", + "settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Este atalho pode entrar em conflito com os padrões do navegador. Ainda assim, você pode salvá-lo.", "settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Ir para linha (editor de arquivos)", "settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Abrir paleta de comandos", "settings.openchamber.keyboardShortcuts.action.focus_input.label": "Focar entrada", @@ -1092,7 +1092,25 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.expand_input.label": "Expandir entrada", "settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Abrir linha do tempo da conversa", "settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Mostrar ou ocultar navegador de prompts", - "settings.projects.sidebar.total": "Total {count}", + "settings.openchamber.keyboardShortcuts.category.session": "Controles de sessão", + "settings.openchamber.keyboardShortcuts.category.models": "Modelos e agentes", + "settings.openchamber.keyboardShortcuts.category.panels": "Painéis e ferramentas", + "settings.openchamber.keyboardShortcuts.category.navigation": "Navegação", + "settings.openchamber.keyboardShortcuts.category.application": "Aplicação", + "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.firstChord": "Primeira combinação", + "settings.openchamber.keyboardShortcuts.dialog.secondChord": "Segunda combinação", + "settings.openchamber.keyboardShortcuts.dialog.recording": "Pressione as teclas…", + "settings.openchamber.keyboardShortcuts.unassigned": "Não atribuído", + "settings.openchamber.keyboardShortcuts.error.prefixConflict": "Isto entra em conflito com a sequência usada por {action}. Escolha outra combinação.", + "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.toggle_dictation.label": "Entrada por voz", + "settings.projects.sidebar.total": "Total {count}", "settings.projects.sidebar.actions.addProject": "Adicionar projeto", "settings.projects.page.empty.noProjects": "Não há projetos disponíveis.", "settings.projects.page.title.default": "Configurações do projeto", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index c5afd784..62ecc7b0 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1063,7 +1063,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.overwritePrompt": "Ця комбінація вже використовується іншою комбінацією клавіш. Перезаписати та очистити інше зіставлення?", "settings.openchamber.keyboardShortcuts.field.pressKeys": "Натисніть клавіші...", "settings.openchamber.keyboardShortcuts.error.captureFirst": "Спочатку запишіть комбінацію клавіш.", - "settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Ця комбінація клавіш може конфліктувати зі стандартними скороченнями браузера. Її все одно збережено.", + "settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut": "Ця комбінація клавіш може конфліктувати зі стандартними скороченнями браузера. Ви все одно можете її зберегти.", "settings.openchamber.keyboardShortcuts.action.open_go_to_line.label": "Перейти до рядка (редактор файлів)", "settings.openchamber.keyboardShortcuts.action.open_command_palette.label": "Відкрити палітру команд", "settings.openchamber.keyboardShortcuts.action.focus_input.label": "Фокус на полі вводу", @@ -1092,7 +1092,25 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.expand_input.label": "Розгорнути введення", "settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label": "Відкрити хронологію розмови", "settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label": "Показати або приховати навігатор промптів", - "settings.projects.sidebar.total": "Усього {count}", + "settings.openchamber.keyboardShortcuts.category.session": "Керування сесією", + "settings.openchamber.keyboardShortcuts.category.models": "Моделі й агенти", + "settings.openchamber.keyboardShortcuts.category.panels": "Панелі та інструменти", + "settings.openchamber.keyboardShortcuts.category.navigation": "Навігація", + "settings.openchamber.keyboardShortcuts.category.application": "Застосунок", + "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.firstChord": "Перша комбінація", + "settings.openchamber.keyboardShortcuts.dialog.secondChord": "Друга комбінація", + "settings.openchamber.keyboardShortcuts.dialog.recording": "Натисніть клавіші…", + "settings.openchamber.keyboardShortcuts.unassigned": "Не призначено", + "settings.openchamber.keyboardShortcuts.error.prefixConflict": "Це конфліктує з послідовністю, яку використовує {action}. Виберіть іншу комбінацію.", + "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.toggle_dictation.label": "Голосове введення", + "settings.projects.sidebar.total": "Усього {count}", "settings.projects.sidebar.actions.addProject": "Додати проєкт", "settings.projects.page.empty.noProjects": "Немає доступних проєктів.", "settings.projects.page.title.default": "Параметри проєкту", 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 2d111132..ae6cc3b1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1063,7 +1063,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.overwritePrompt': '该组合已被其他快捷键使用。是否覆盖并清除原映射?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按键...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': '请先录入一个快捷键。', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '该快捷键可能与浏览器默认快捷键冲突,但仍已保存。', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '该快捷键可能与浏览器默认快捷键冲突,但仍可保存。', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '跳转到行(文件编辑器)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '打开命令面板', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦输入框', @@ -1092,7 +1092,25 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.expand_input.label': '展开输入框', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '打开对话时间线', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '显示或隐藏提示词导航', - 'settings.projects.sidebar.total': '总计 {count}', + 'settings.openchamber.keyboardShortcuts.category.session': '会话控制', + 'settings.openchamber.keyboardShortcuts.category.models': '模型和智能体', + 'settings.openchamber.keyboardShortcuts.category.panels': '面板和工具', + 'settings.openchamber.keyboardShortcuts.category.navigation': '导航', + 'settings.openchamber.keyboardShortcuts.category.application': '应用程序', + '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.firstChord': '第一个组合', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二个组合', + 'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按键…', + 'settings.openchamber.keyboardShortcuts.unassigned': '未分配', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': '这与 {action} 使用的序列冲突。请选择其他组合。', + '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.toggle_dictation.label': '语音输入', + 'settings.projects.sidebar.total': '总计 {count}', 'settings.projects.sidebar.actions.addProject': '添加项目', 'settings.projects.page.empty.noProjects': '暂无项目。', 'settings.projects.page.title.default': '项目设置', 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 4118c83d..a5b3d6ad 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -970,7 +970,7 @@ 'settings.openchamber.keyboardShortcuts.overwritePrompt': '該組合已被其他快速鍵使用。是否覆寫並清除原對應?', 'settings.openchamber.keyboardShortcuts.field.pressKeys': '按下按鍵...', 'settings.openchamber.keyboardShortcuts.error.captureFirst': '請先錄入一個快速鍵。', - 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '該快速鍵可能與瀏覽器預設快速鍵衝突,但仍已儲存。', + 'settings.openchamber.keyboardShortcuts.warning.riskyBrowserShortcut': '該快速鍵可能與瀏覽器預設快速鍵衝突,但仍可儲存。', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': '跳轉到行(檔案編輯器)', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': '開啟命令面板', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': '聚焦輸入方塊', @@ -999,7 +999,25 @@ 'settings.openchamber.keyboardShortcuts.action.expand_input.label': '展開輸入方塊', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': '開啟對話時間軸', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': '顯示或隱藏提示詞導覽', - 'settings.projects.sidebar.total': '總計 {count}', + 'settings.openchamber.keyboardShortcuts.category.session': '工作階段控制', + 'settings.openchamber.keyboardShortcuts.category.models': '模型與代理', + 'settings.openchamber.keyboardShortcuts.category.panels': '面板與工具', + 'settings.openchamber.keyboardShortcuts.category.navigation': '導覽', + 'settings.openchamber.keyboardShortcuts.category.application': '應用程式', + '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.firstChord': '第一個組合', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': '第二個組合', + 'settings.openchamber.keyboardShortcuts.dialog.recording': '按下按鍵…', + 'settings.openchamber.keyboardShortcuts.unassigned': '未指派', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': '這與 {action} 使用的序列衝突。請選擇其他組合。', + '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.toggle_dictation.label': '語音輸入', + 'settings.projects.sidebar.total': '總計 {count}', 'settings.projects.sidebar.actions.addProject': '新增專案', 'settings.projects.page.empty.noProjects': '暫無專案。', 'settings.projects.page.title.default': '專案設定', From bb25b6865793eabc18e9e242399c795451e95cc1 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Thu, 30 Jul 2026 12:20:57 +0800 Subject: [PATCH 005/192] refactor(ui): centralize shortcut schema --- packages/ui/src/App.tsx | 26 +- .../openchamber/KeyboardShortcutsSettings.tsx | 18 +- .../openchamber/ShortcutRecordingDialog.tsx | 11 +- packages/ui/src/components/ui/HelpDialog.tsx | 21 +- .../ui/src/components/views/FilesView.tsx | 3 +- .../ui/src/components/views/SettingsView.tsx | 3 +- packages/ui/src/hooks/useKeybind.ts | 3 +- packages/ui/src/hooks/useKeyboardShortcuts.ts | 43 +- .../src/hooks/useMiniChatKeyboardShortcuts.ts | 4 +- packages/ui/src/lib/shortcuts.ts | 866 ------------------ .../ui/src/lib/shortcuts/DOCUMENTATION.md | 18 +- .../bindings.test.ts} | 12 +- packages/ui/src/lib/shortcuts/bindings.ts | 314 +++++++ .../dispatcher.test.ts} | 4 +- .../dispatcher.ts} | 16 +- packages/ui/src/lib/shortcuts/index.ts | 29 + .../registry.test.ts} | 2 +- .../registry.ts} | 9 +- packages/ui/src/lib/shortcuts/schema.test.ts | 38 + packages/ui/src/lib/shortcuts/schema.ts | 145 +++ packages/ui/src/lib/utils.ts | 19 - 21 files changed, 610 insertions(+), 994 deletions(-) delete mode 100644 packages/ui/src/lib/shortcuts.ts rename packages/ui/src/lib/{shortcuts.test.ts => shortcuts/bindings.test.ts} (87%) create mode 100644 packages/ui/src/lib/shortcuts/bindings.ts rename packages/ui/src/lib/{shortcutDispatcher.test.ts => shortcuts/dispatcher.test.ts} (98%) rename packages/ui/src/lib/{shortcutDispatcher.ts => shortcuts/dispatcher.ts} (89%) create mode 100644 packages/ui/src/lib/shortcuts/index.ts rename packages/ui/src/lib/{shortcutRegistry.test.ts => shortcuts/registry.test.ts} (94%) rename packages/ui/src/lib/{shortcutRegistry.ts => shortcuts/registry.ts} (83%) create mode 100644 packages/ui/src/lib/shortcuts/schema.test.ts create mode 100644 packages/ui/src/lib/shortcuts/schema.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ec79df9d..37f82a98 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -17,7 +17,7 @@ import { useWebNotificationStream } from '@/hooks/useWebNotificationStream'; import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt'; import { useWindowTitle } from '@/hooks/useWindowTitle'; import { useConfigStore } from '@/stores/useConfigStore'; -import { hasModifier } from '@/lib/utils'; +import { useKeybind } from '@/hooks/useKeybind'; import { isDesktopLocalOriginActive, isDesktopShell, restartDesktopApp, invokeDesktop } from '@/lib/desktop'; import { getInjectedBootOutcome, @@ -699,26 +699,10 @@ function App({ apis }: AppProps) { useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled }); - React.useEffect(() => { - if (embeddedSessionChat) { - return; - } - - const handleKeyDown = (e: KeyboardEvent) => { - const isDebugShortcut = hasModifier(e) - && e.shiftKey - && !e.altKey - && (e.code === 'KeyD' || e.key.toLowerCase() === 'd'); - - if (isDebugShortcut) { - e.preventDefault(); - setShowMemoryDebug(prev => !prev); - } - }; - - window.addEventListener('keydown', handleKeyDown, true); - return () => window.removeEventListener('keydown', handleKeyDown, true); - }, [embeddedSessionChat]); + useKeybind('toggle_memory_debug', () => { + if (embeddedSessionChat) return false; + setShowMemoryDebug((previous) => !previous); + }); React.useEffect(() => { if (embeddedSessionChat) { diff --git a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx index 9f83af25..15591474 100644 --- a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx @@ -9,12 +9,11 @@ import { getCustomizableShortcutActions, getEffectiveShortcutCombo, getEffectiveShortcutPrefix, - getShortcutCategory, UNASSIGNED_SHORTCUT, - type ShortcutAction, type ShortcutActionId, type ShortcutCategory, type ShortcutCombo, + type CustomizableShortcutAction, } from '@/lib/shortcuts'; import { useI18n } from '@/lib/i18n'; import { ShortcutRecordingDialog } from './ShortcutRecordingDialog'; @@ -23,22 +22,16 @@ const CATEGORIES: ShortcutCategory[] = ['session', 'models', 'panels', 'navigati export const KeyboardShortcutsSettings: React.FC = () => { const { t } = useI18n(); - const tUnsafe = (key: string) => t(key as Parameters[0]); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const setShortcutOverride = useUIStore((state) => state.setShortcutOverride); const clearShortcutOverride = useUIStore((state) => state.clearShortcutOverride); const resetAllShortcutOverrides = useUIStore((state) => state.resetAllShortcutOverrides); - const [editingAction, setEditingAction] = React.useState(null); + const [editingAction, setEditingAction] = React.useState(null); const actions = React.useMemo(() => { const all = getCustomizableShortcutActions(); return isVSCodeRuntime() ? all.filter((action) => action.id !== 'toggle_prompt_navigator') : all; }, []); - const actionLabel = (action: ShortcutAction): string => { - const key = `settings.openchamber.keyboardShortcuts.action.${action.id}.label`; - const translated = tUnsafe(key); - return translated === key ? action.label : translated; - }; const persist = (nextOverrides: Record) => { void updateDesktopSettings({ shortcutOverrides: nextOverrides }); }; @@ -59,7 +52,7 @@ export const KeyboardShortcutsSettings: React.FC = () => { clearShortcutOverride(actionId); persist(nextOverrides); }; - const shortcutDisplay = (action: ShortcutAction): string => { + const shortcutDisplay = (action: CustomizableShortcutAction): string => { const isSurfaceSwitch = action.id === 'switch_context_surface'; const combo = isSurfaceSwitch ? getEffectiveShortcutPrefix(action.id, shortcutOverrides) @@ -76,7 +69,7 @@ export const KeyboardShortcutsSettings: React.FC = () => { return ( <> {CATEGORIES.map((category, categoryIndex) => { - const categoryActions = actions.filter((action) => getShortcutCategory(action) === category); + const categoryActions = actions.filter((action) => action.category === category); if (categoryActions.length === 0) return null; return ( { >
    {categoryActions.map((action) => ( - + @@ -130,7 +123,6 @@ export const KeyboardShortcutsSettings: React.FC = () => { action={editingAction} actions={actions} overrides={shortcutOverrides} - actionLabel={actionLabel} onSave={save} onOpenChange={(open) => { if (!open) setEditingAction(null); diff --git a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx index c36a599b..08f40047 100644 --- a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx @@ -16,19 +16,18 @@ import { isRiskyBrowserShortcut, keyToShortcutToken, normalizeCombo, - type ShortcutAction, type ShortcutActionId, type ShortcutCombo, + type CustomizableShortcutAction, } from '@/lib/shortcuts'; import { useI18n } from '@/lib/i18n'; const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']); interface ShortcutRecordingDialogProps { - action: ShortcutAction | null; - actions: ReadonlyArray; + action: CustomizableShortcutAction | null; + actions: ReadonlyArray; overrides: Record; - actionLabel: (action: ShortcutAction) => string; onSave: ( actionId: ShortcutActionId, combo: ShortcutCombo, @@ -66,11 +65,11 @@ export const ShortcutRecordingDialog: React.FC = ( action, actions, overrides, - actionLabel, onSave, onOpenChange, }) => { const { t } = useI18n(); + const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey); const [chords, setChords] = React.useState([]); const recordingRef = React.useRef(null); @@ -82,7 +81,7 @@ export const ShortcutRecordingDialog: React.FC = ( const combo = normalizeCombo(chords.join(' ')); const conflicts = React.useMemo(() => { if (!action || !combo) return []; - const result: Array<{ action: ShortcutAction; kind: 'exact' | 'prefix' }> = []; + const result: Array<{ action: CustomizableShortcutAction; kind: 'exact' | 'prefix' }> = []; for (const candidate of actions) { if (candidate.id === action.id) continue; const candidateCombo = candidate.id === 'switch_context_surface' diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index b1ac15b4..363d0615 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -22,7 +22,7 @@ import type { IconName } from "@/components/icon/icons"; type ShortcutItem = { id?: ShortcutActionId; keys: string | string[]; - descriptionKey: I18nKey; + descriptionKey?: I18nKey; icon: IconName | null; }; @@ -33,12 +33,10 @@ type ShortcutSection = { const renderShortcut = ( id: ShortcutActionId, - fallbackCombo: string, overrides: Record, unassignedLabel: string, ) => { - const action = getShortcutAction(id); - return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides), unassignedLabel) : fallbackCombo; + return formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides), unassignedLabel); }; export const HelpDialog: React.FC = () => { @@ -129,13 +127,11 @@ export const HelpDialog: React.FC = () => { }, { id: 'open_draft_project_picker', - descriptionKey: 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label', icon: 'folder', keys: '', }, { id: 'open_draft_worktree_picker', - descriptionKey: 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label', icon: 'git-branch', keys: '', }, @@ -255,13 +251,13 @@ export const HelpDialog: React.FC = () => { {section.items .filter((shortcut) => !(isVSCode && shortcut.id === 'toggle_prompt_navigator')) .map((shortcut) => { - const fallbackKeys = Array.isArray(shortcut.keys) - ? shortcut.keys[0] - : shortcut.keys; + const action = shortcut.id ? getShortcutAction(shortcut.id) : undefined; + const descriptionKey = shortcut.descriptionKey + ?? (action?.customizable ? action.settingsLabelKey : undefined); + if (!descriptionKey) return null; const displayKeys = shortcut.id ? renderShortcut( shortcut.id, - fallbackKeys, shortcutOverrides, t('settings.openchamber.keyboardShortcuts.unassigned'), ) @@ -269,7 +265,7 @@ export const HelpDialog: React.FC = () => { return (
    @@ -277,7 +273,7 @@ export const HelpDialog: React.FC = () => { )} - {t(shortcut.descriptionKey)} + {t(descriptionKey)}
    @@ -312,7 +308,6 @@ export const HelpDialog: React.FC = () => { • {t('helpDialog.proTips.commandPalette', { shortcut: renderShortcut( 'open_command_palette', - `${mod} P`, shortcutOverrides, t('settings.openchamber.keyboardShortcuts.unassigned'), ), diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index c11fc5cc..4de4f8f2 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -43,7 +43,7 @@ import { import { useDebouncedValue } from '@/hooks/useDebouncedValue'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useDeviceInfo } from '@/lib/device'; -import { cn, getModifierLabel, getRevealLabelKey } from '@/lib/utils'; +import { cn, getRevealLabelKey } from '@/lib/utils'; import { getLanguageFromExtension, getImageMimeType, isBinaryFile, isDrawioFile, isImageFile, isPdfFile, isSvgFile, looksLikeBinaryText } from '@/lib/toolHelpers'; import { shouldAllowFileDraftSave, shouldScheduleFileAutosave } from '@/lib/fileEditorAutosave'; import { getRuntimeUrlResolver } from '@/lib/runtime-url'; @@ -53,6 +53,7 @@ import { getOutsideFileGrant } from '@/lib/outsideFileGrants'; import { DiagramEditor } from '@/components/diagram'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useKeybind, useKeybinds } from '@/hooks/useKeybind'; +import { getModifierLabel } from '@/lib/shortcuts'; import { EditorView } from '@codemirror/view'; import type { Extension } from '@codemirror/state'; import { useThemeSystem } from '@/contexts/useThemeSystem'; diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 74505195..325e73c3 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { cn, getModifierLabel } from '@/lib/utils'; +import { cn } from '@/lib/utils'; +import { getModifierLabel } from '@/lib/shortcuts'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useAgentsStore } from '@/stores/useAgentsStore'; diff --git a/packages/ui/src/hooks/useKeybind.ts b/packages/ui/src/hooks/useKeybind.ts index f97fa57f..8b4b0397 100644 --- a/packages/ui/src/hooks/useKeybind.ts +++ b/packages/ui/src/hooks/useKeybind.ts @@ -1,6 +1,5 @@ import React from 'react'; -import { shortcutRegistry, type ShortcutHandler } from '@/lib/shortcutRegistry'; -import type { ShortcutActionId } from '@/lib/shortcuts'; +import { shortcutRegistry, type ShortcutActionId, type ShortcutHandler } from '@/lib/shortcuts'; export function useKeybind(actionId: ShortcutActionId, handler: ShortcutHandler): void { const handlerRef = React.useRef(handler); diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index a4989491..97d45bf2 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -17,10 +17,10 @@ import { getEffectiveShortcutCombo, getEffectiveShortcutPrefix, normalizeCombo, + ShortcutDispatcher, + shortcutRegistry, type ShortcutActionId, } from '@/lib/shortcuts'; -import { ShortcutDispatcher } from '@/lib/shortcutDispatcher'; -import { shortcutRegistry } from '@/lib/shortcutRegistry'; import { getVisibleContextRailSurfaces } from '@/lib/surfaces/registry'; import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstrap'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; @@ -279,6 +279,10 @@ export const useKeyboardShortcuts = () => { } window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle')); }, + abort_run: () => { + if (sessionPhase === 'idle' || !currentSessionId) return false; + void sessionActions.abortCurrentOperation(currentSessionId); + }, }); function cycleFavoriteModel(delta: number): boolean | void { @@ -376,9 +380,8 @@ export const useKeyboardShortcuts = () => { } const now = Date.now(); if (abortPrimedUntilRef.current && now < abortPrimedUntilRef.current) { - event.preventDefault(); resetAbortPriming(); - void sessionActions.abortCurrentOperation(currentSessionId); + if (invokeRegistered('abort_run', event)) event.preventDefault(); return; } event.preventDefault(); @@ -413,21 +416,23 @@ export const useKeyboardShortcuts = () => { && eventMatchesShortcutPrefix(event, switchSurfacePrefix, heldKeysRef.current) ) { const state = useUIStore.getState(); - if (state.isMobile || !effectiveDirectory) return; - const directory = normalizeContextPanelDirectoryKey(effectiveDirectory); - const panel = state.contextPanelByDirectory[directory]; - const visibleSurfaces = getVisibleContextRailSurfaces({ - railOrder: state.contextRailOrder, - planModeEnabled: useFeatureFlagsStore.getState().planModeEnabled, - isVSCode: isVSCodeRuntime(), - screenWidth: window.innerWidth, - tabs: panel?.tabs ?? [], - }); - const target = visibleSurfaces[switchSurfaceDigit - 1]; - if (!target) return; - event.preventDefault(); - state.openContextSurface(directory, target.mode); - return; + if (!state.isMobile && effectiveDirectory) { + const directory = normalizeContextPanelDirectoryKey(effectiveDirectory); + const panel = state.contextPanelByDirectory[directory]; + const visibleSurfaces = getVisibleContextRailSurfaces({ + railOrder: state.contextRailOrder, + planModeEnabled: useFeatureFlagsStore.getState().planModeEnabled, + isVSCode: isVSCodeRuntime(), + screenWidth: window.innerWidth, + tabs: panel?.tabs ?? [], + }); + const target = visibleSurfaces[switchSurfaceDigit - 1]; + if (target) { + event.preventDefault(); + state.openContextSurface(directory, target.mode); + return; + } + } } if (dispatcher.dispatch(event)) event.preventDefault(); diff --git a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts index 98a6d480..3b135aa5 100644 --- a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts @@ -1,9 +1,7 @@ import React from 'react'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop'; -import { ShortcutDispatcher } from '@/lib/shortcutDispatcher'; -import { shortcutRegistry } from '@/lib/shortcutRegistry'; -import { getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { ShortcutDispatcher, getEffectiveShortcutCombo, shortcutRegistry } from '@/lib/shortcuts'; import { useConfigStore } from '@/stores/useConfigStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts deleted file mode 100644 index 18b1679a..00000000 --- a/packages/ui/src/lib/shortcuts.ts +++ /dev/null @@ -1,866 +0,0 @@ -import { isMacOS } from '@/lib/utils'; -import { isDesktopShell } from '@/lib/desktop'; - -type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'option' | 'ctrl'; -type ShortcutKey = string; -export type ShortcutCombo = string; -export type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application'; - -export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__'; - -interface ShortcutActionDefinition { - id: string; - defaultCombo: ShortcutCombo; - label: string; - description?: string; - customizable?: boolean; - /** Metadata for shortcut browsers; omitted actions use the application category. */ - category?: ShortcutCategory; -} - -interface ParsedShortcutChord { - modifiers: Set; - key: ShortcutKey; -} - -export interface ParsedShortcut { - chords: ReadonlyArray; -} - -export type ShortcutConflict = 'exact' | 'prefix'; - -const DEFAULT_SHORTCUT_CATEGORY: ShortcutCategory = 'application'; - -const MODIFIER_KEY_MAP: Record = { - 'mod': 'mod', - 'shift': 'shift', - 'alt': 'alt', - 'option': 'alt', - 'ctrl': 'ctrl', - 'meta': 'mod', - 'cmd': 'mod', - 'command': 'mod', -}; - -const DISPLAY_LABEL_MAP: Record = { - 'mod': isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl', - 'shift': '⇧', - 'alt': '⌥', - 'option': '⌥', - 'ctrl': '⌃', -}; - -// Physical `event.key` values (lowercased) that satisfy each modifier while a -// chord is being held. `mod` maps to the platform primary key; on web macOS it -// accepts either Meta or Ctrl, matching eventMatchesShortcut. -const MODIFIER_KEY_ALIASES: Record = { - 'mod': isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'], - 'shift': ['shift'], - 'alt': ['alt'], - 'option': ['alt'], - 'ctrl': ['control'], -}; - -const KEY_LABEL_MAP: Record = { - 'comma': ',', - 'period': '.', - 'enter': 'Enter', - 'escape': 'Esc', - 'tab': 'Tab', - 'space': 'Space', - 'backspace': '⌫', - 'delete': '⌦', - 'arrowup': '↑', - 'arrowdown': '↓', - 'arrowleft': '←', - 'arrowright': '→', - 'home': 'Home', - 'end': 'End', - 'pageup': 'Page Up', - 'pagedown': 'Page Down', -}; - -const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt']; -const RISKY_BROWSER_SHORTCUT_KEYS = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']); - -const SHIFTED_KEY_BASE_MAP: Record = { - '{': '[', - '}': ']', - ':': ';', - '"': "'", - '<': ',', - '>': '.', - '?': '/', - '|': '\\', - '~': '`', - '!': '1', - '@': '2', - '#': '3', - '$': '4', - '%': '5', - '^': '6', - '&': '7', - '*': '8', - '(': '9', - ')': '0', -}; - -function isUnassignedShortcut(combo: ShortcutCombo): boolean { - return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT; -} - -export function keyToShortcutToken(key: string): string { - const lowered = key.toLowerCase(); - - if (lowered === ',') return 'comma'; - if (lowered === '.') return 'period'; - if (lowered === ' ') return 'space'; - if (lowered === 'esc') return 'escape'; - if (lowered === '+') return 'plus'; - if (lowered === '-' || lowered === '_') return 'minus'; - if (lowered === 'arrowup') return 'arrowup'; - if (lowered === 'arrowdown') return 'arrowdown'; - if (lowered === 'arrowleft') return 'arrowleft'; - if (lowered === 'arrowright') return 'arrowright'; - - return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered; -} - -const SHORTCUT_ACTIONS = [ - { - id: 'save_file', - defaultCombo: 'mod+s', - label: 'Save file', - description: 'Save the active file editor', - }, - { - id: 'find_in_file', - defaultCombo: 'mod+f', - label: 'Find in file', - description: 'Search in the active file editor', - }, - { - id: 'open_go_to_line', - defaultCombo: 'alt+g', - label: 'Go to line (files editor)', - description: 'Open go to line in the files editor', - customizable: true, - category: 'navigation', - }, - { - id: 'open_command_palette', - defaultCombo: 'mod+p', - label: 'Open command palette', - description: 'Open the command palette', - customizable: true, - category: 'application', - }, - { - id: 'focus_input', - defaultCombo: 'mod+i', - label: 'Focus input', - description: 'Focus the chat input field', - customizable: true, - category: 'session', - }, - { - id: 'open_status', - defaultCombo: 'mod+shift+o', - label: 'Open OpenCode status', - description: 'Open the OpenCode status dialog', - }, - { - id: 'open_settings', - defaultCombo: 'mod+comma', - label: 'Open settings', - description: 'Open the settings panel', - customizable: true, - category: 'application', - }, - { - id: 'toggle_terminal', - defaultCombo: 'mod+j', - label: 'Toggle terminal dock', - description: 'Toggle the bottom terminal dock', - customizable: true, - category: 'panels', - }, - { - id: 'toggle_terminal_expanded', - defaultCombo: 'mod+shift+j', - label: 'Toggle terminal expanded', - description: 'Toggle terminal expanded or collapsed', - customizable: true, - category: 'panels', - }, - { - id: 'toggle_files', - defaultCombo: 'mod+shift+f', - label: 'Toggle files', - description: 'Toggle the files panel', - }, - { - id: 'add_selection_to_chat', - defaultCombo: 'mod+l', - label: 'Add selection to chat', - description: 'Add the selected text to the chat input', - customizable: true, - category: 'session', - }, - { - id: 'toggle_sidebar', - defaultCombo: 'mod+alt+l', - label: 'Toggle sidebar', - description: 'Toggle the session sidebar', - customizable: true, - category: 'panels', - }, - { - id: 'open_timeline_dialog', - defaultCombo: 'mod+t', - label: 'Open conversation timeline', - description: 'Search and navigate within current conversation', - customizable: true, - category: 'session', - }, - { - id: 'toggle_prompt_navigator', - defaultCombo: 'mod+alt+p', - label: 'Toggle prompt navigator', - description: 'Show or hide the prompt navigator panel in chat', - customizable: true, - category: 'panels', - }, - { - id: 'toggle_right_sidebar', - defaultCombo: 'mod+b', - label: 'Toggle right sidebar', - description: 'Toggle the right sidebar', - customizable: true, - category: 'panels', - }, - { - id: 'open_right_sidebar_git', - defaultCombo: 'mod+shift+g', - label: 'Open right sidebar Git tab', - description: 'Open right sidebar and select Git', - customizable: true, - category: 'panels', - }, - { - id: 'open_right_sidebar_files', - defaultCombo: 'mod+shift+f', - label: 'Open right sidebar Files tab', - description: 'Open right sidebar and select Files', - customizable: true, - category: 'panels', - }, - { - id: 'switch_context_surface', - defaultCombo: 'mod', - label: 'Switch context panel surface', - description: 'Hold the modifier and press a number to open or close the matching rail icon', - customizable: true, - category: 'panels', - }, - { - id: 'new_chat', - defaultCombo: 'mod+n', - label: 'New session', - description: 'Start a new session', - customizable: true, - category: 'session', - }, - { - id: 'open_draft_project_picker', - defaultCombo: 'mod+s p', - label: 'Open draft project picker', - description: 'Choose a project for a new draft', - customizable: true, - category: 'session', - }, - { - id: 'open_draft_worktree_picker', - defaultCombo: 'mod+s g', - label: 'Open draft worktree picker', - description: 'Choose a worktree for a new draft', - customizable: true, - category: 'session', - }, - { - id: 'new_chat_worktree', - defaultCombo: 'mod+shift+n', - label: 'New worktree draft', - description: 'Create a new worktree and open a draft in it', - customizable: true, - category: 'session', - }, - { - id: 'new_mini_chat', - defaultCombo: 'mod+alt+n', - label: 'New Mini Chat window', - description: 'Open a new Mini Chat draft window', - customizable: true, - category: 'session', - }, - { - id: 'submit_message', - defaultCombo: 'mod+enter', - label: 'Submit message', - description: 'Submit the current message', - }, - { - id: 'clear_input', - defaultCombo: 'escape', - label: 'Clear input', - description: 'Clear the input field', - }, - { - id: 'open_help', - defaultCombo: 'mod+.', - label: 'Open keyboard shortcuts', - description: 'Show the keyboard shortcuts help', - customizable: true, - category: 'application', - }, - { - id: 'toggle_context_plan', - defaultCombo: 'mod+shift+p', - label: 'Toggle plan context panel', - description: 'Open or close plan in the context panel', - customizable: true, - category: 'panels', - }, - { - id: 'toggle_services_menu', - defaultCombo: 'mod+shift+s', - label: 'Toggle services menu', - description: 'Open or close the services menu', - customizable: true, - category: 'panels', - }, - { - id: 'cycle_services_tab', - defaultCombo: 'mod+shift+[', - label: 'Cycle services tab', - description: 'Cycle through tabs in the services menu', - customizable: true, - category: 'navigation', - }, - { - id: 'cycle_theme', - defaultCombo: 'mod+/', - label: 'Cycle theme', - description: 'Cycle between light, dark, and system theme', - customizable: true, - category: 'application', - }, - { - id: 'open_model_selector', - defaultCombo: 'mod+shift+m', - label: 'Open model selector', - description: 'Open model selector while in chat', - customizable: true, - category: 'models', - }, - { - id: 'cycle_thinking_variant', - defaultCombo: 'mod+shift+t', - label: 'Cycle thinking variant', - description: 'Cycle thinking variant while in chat', - }, - { - id: 'cycle_agent', - defaultCombo: 'tab', - label: 'Cycle agent', - description: 'Cycle agent while the model selector is open', - customizable: true, - category: 'models', - }, - { - id: 'cycle_favorite_model_forward', - defaultCombo: 'ctrl+]', - label: 'Cycle favorite model forward', - description: 'Cycle forward through starred models without opening the picker', - customizable: true, - category: 'models', - }, - { - id: 'cycle_favorite_model_backward', - defaultCombo: 'ctrl+[', - label: 'Cycle favorite model backward', - description: 'Cycle backward through starred models without opening the picker', - customizable: true, - category: 'models', - }, - { - id: 'expand_input', - defaultCombo: 'mod+shift+e', - label: 'Expand input', - description: 'Toggle focus mode for the chat input', - customizable: true, - category: 'session', - }, - { - id: 'toggle_dictation', - defaultCombo: 'mod+alt+v', - label: 'Voice input', - description: 'Start dictation; press again to confirm and insert the transcript', - customizable: true, - category: 'session', - }, - { - id: 'abort_run', - defaultCombo: 'escape', - label: 'Abort active run', - description: 'Abort the currently running task (double press)', - }, - { - id: 'switch_tab_1', - defaultCombo: 'mod+1', - label: 'Switch to tab 1', - description: 'Switch to the first tab or project', - }, - { - id: 'switch_tab_2', - defaultCombo: 'mod+2', - label: 'Switch to tab 2', - description: 'Switch to the second tab or project', - }, - { - id: 'switch_tab_3', - defaultCombo: 'mod+3', - label: 'Switch to tab 3', - description: 'Switch to the third tab or project', - }, - { - id: 'switch_tab_4', - defaultCombo: 'mod+4', - label: 'Switch to tab 4', - description: 'Switch to the fourth tab or project', - }, - { - id: 'switch_tab_5', - defaultCombo: 'mod+5', - label: 'Switch to tab 5', - description: 'Switch to the fifth tab or project', - }, - { - id: 'switch_tab_6', - defaultCombo: 'mod+6', - label: 'Switch to tab 6', - description: 'Switch to the sixth tab or project', - }, - { - id: 'switch_tab_7', - defaultCombo: 'mod+7', - label: 'Switch to tab 7', - description: 'Switch to the seventh tab or project', - }, - { - id: 'switch_tab_8', - defaultCombo: 'mod+8', - label: 'Switch to tab 8', - description: 'Switch to the eighth tab or project', - }, - { - id: 'switch_tab_9', - defaultCombo: 'mod+9', - label: 'Switch to tab 9', - description: 'Switch to the ninth tab or project', - }, -] as const satisfies ReadonlyArray; - -export type ShortcutActionId = (typeof SHORTCUT_ACTIONS)[number]['id']; -export type ShortcutAction = Omit & { id: ShortcutActionId }; - -export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo { - if (isUnassignedShortcut(combo)) { - return UNASSIGNED_SHORTCUT; - } - - const chords = combo.trim().replace(/\s*\+\s*/g, '+').split(/\s+/).filter(Boolean); - if (chords.length === 0 || chords.length > 2) { - return ''; - } - - return chords.map(normalizeChord).join(' '); -} - -function normalizeChord(combo: ShortcutCombo): ShortcutCombo { - const rawParts = combo - .toLowerCase() - .trim() - .split('+') - .map((part) => part.trim()) - .filter(Boolean); - - const modifiers = new Set(); - let key = ''; - - for (const rawPart of rawParts) { - const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart; - const modifier = MODIFIER_KEY_MAP[part]; - if (modifier) { - modifiers.add(modifier); - continue; - } - key = part; - } - - const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier)); - return [...orderedModifiers, key].filter(Boolean).join('+'); -} - -function isValidShortcutCombo(combo: ShortcutCombo): boolean { - if (isUnassignedShortcut(combo)) { - return true; - } - - const parsed = parseShortcut(combo); - return parsed !== undefined && parsed.chords.every((chord) => chord.key.trim().length > 0); -} - -export function parseShortcut(combo: ShortcutCombo): ParsedShortcut | undefined { - if (isUnassignedShortcut(combo)) { - return { chords: [{ modifiers: new Set(), key: UNASSIGNED_SHORTCUT }] }; - } - - const normalized = normalizeCombo(combo); - if (!normalized) { - return undefined; - } - - const chords = normalized.split(' ').map((chord) => { - const parts = chord.split('+'); - const modifiers: Set = new Set(); - let key: ShortcutKey = ''; - - for (const part of parts) { - const modifier = MODIFIER_KEY_MAP[part]; - if (modifier) { - modifiers.add(modifier); - } else { - key = part; - } - } - - return { modifiers, key }; - }); - - return { chords }; -} - -export function formatShortcutForDisplay(combo: ShortcutCombo, unassignedLabel = 'Unassigned'): string { - if (isUnassignedShortcut(combo)) { - return unassignedLabel; - } - - const parsed = parseShortcut(combo); - - if (!parsed || parsed.chords.some((chord) => !chord.key && chord.modifiers.size === 0)) { - return unassignedLabel; - } - - return parsed.chords.map(formatChordForDisplay).join(', '); -} - -function formatChordForDisplay(parsed: ParsedShortcutChord): string { - const parts: string[] = []; - - for (const modifier of MODIFIER_PRIORITY) { - if (parsed.modifiers.has(modifier)) { - parts.push(DISPLAY_LABEL_MAP[modifier]); - } - } - - if (parsed.key) { - const keyLabel = KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase(); - parts.push(keyLabel); - } - - return parts.join(' + '); -} - -export function getShortcutAction(id: string): ShortcutAction | undefined { - return SHORTCUT_ACTIONS.find((action) => action.id === id); -} - -export function getCustomizableShortcutActions(): ReadonlyArray { - return SHORTCUT_ACTIONS.filter((action) => 'customizable' in action && action.customizable === true); -} - -export function getShortcutCategory(action: ShortcutAction): ShortcutCategory { - return action.category ?? DEFAULT_SHORTCUT_CATEGORY; -} - -export function getShortcutConflict(left: ShortcutCombo, right: ShortcutCombo): ShortcutConflict | undefined { - const normalizedLeft = normalizeCombo(left); - const normalizedRight = normalizeCombo(right); - const hasInvalidBinding = !isValidShortcutCombo(normalizedLeft) - || !isValidShortcutCombo(normalizedRight); - const hasUnassignedBinding = normalizedLeft === UNASSIGNED_SHORTCUT - || normalizedRight === UNASSIGNED_SHORTCUT; - if (hasInvalidBinding || hasUnassignedBinding) { - return undefined; - } - - if (normalizedLeft === normalizedRight) { - return 'exact'; - } - - const leftChords = normalizedLeft.split(' '); - const rightChords = normalizedRight.split(' '); - const sharesLeader = leftChords[0] === rightChords[0]; - return sharesLeader && leftChords.length !== rightChords.length ? 'prefix' : undefined; -} - -export function getEffectiveShortcutCombo( - actionId: string, - overrides?: Record -): ShortcutCombo { - const action = getShortcutAction(actionId); - if (!action) { - return ''; - } - - const override = overrides?.[actionId]; - if (typeof override === 'string') { - const normalized = normalizeCombo(override); - if (normalized === UNASSIGNED_SHORTCUT) { - return ''; - } - - if (isValidShortcutCombo(normalized)) { - return normalized; - } - } - - return action.defaultCombo; -} - -export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean { - if (isUnassignedShortcut(combo)) { - return false; - } - - const parsed = parseShortcut(combo); - if (!parsed) { - return false; - } - const chord = parsed.chords[0]; - if (!chord.modifiers.has('mod')) { - return false; - } - - const key = chord.key.toLowerCase(); - return RISKY_BROWSER_SHORTCUT_KEYS.has(key) - && !chord.modifiers.has('shift') - && !chord.modifiers.has('alt'); -} - -export function eventMatchesShortcut( - event: KeyboardEvent | React.KeyboardEvent, - shortcut: ShortcutAction | ShortcutCombo -): boolean { - const combo = typeof shortcut === 'string' ? shortcut : shortcut.defaultCombo; - if (isUnassignedShortcut(combo)) { - return false; - } - - const parsed = parseShortcut(combo); - if (!parsed || parsed.chords.length !== 1) { - return false; - } - const chord = parsed.chords[0]; - - const expectedMod = chord.modifiers.has('mod'); - const expectedShift = chord.modifiers.has('shift'); - const expectedAlt = chord.modifiers.has('alt'); - const expectedCtrl = chord.modifiers.has('ctrl'); - const isDesktopMac = isMacOS() && isDesktopShell(); - const isMac = isMacOS(); - - const modMatches = isDesktopMac - ? event.metaKey - : isMac - ? (event.metaKey || event.ctrlKey) - : event.ctrlKey; - - if (expectedMod && !modMatches) { - return false; - } - - if (!expectedMod && event.metaKey) { - return false; - } - - if (expectedShift !== event.shiftKey) { - return false; - } - - if (expectedAlt !== event.altKey) { - return false; - } - - if (expectedCtrl) { - if (!event.ctrlKey) { - return false; - } - } else { - const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey; - if (event.ctrlKey && !ctrlUsedAsMod) { - return false; - } - } - - let eventKeyRaw = event.key; - if (event.altKey) { - if (event.code.startsWith('Key') && event.code.length === 4) { - eventKeyRaw = event.code.slice(3).toLowerCase(); - } else if (event.code.startsWith('Digit') && event.code.length === 6) { - eventKeyRaw = event.code.slice(5); - } - } - - const eventKey = keyToShortcutToken(eventKeyRaw); - const expectedKey = keyToShortcutToken(chord.key); - - return eventKey === expectedKey; -} - -export function getModifierLabel(): string { - return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl'; -} - -/** - * Resolves the configurable prefix for chord-style shortcuts such as - * "switch context panel surface", where a trailing digit key completes the - * combo. Unlike getEffectiveShortcutCombo, modifier-only overrides (e.g. the - * bare `mod` primary key) are honored so the prefix can omit a primary key. - * Returns UNASSIGNED_SHORTCUT when the user explicitly unassigned the prefix. - */ -export function getEffectiveShortcutPrefix( - actionId: string, - overrides?: Record, -): ShortcutCombo { - const action = getShortcutAction(actionId); - if (!action) { - return ''; - } - - const override = overrides?.[actionId]; - if (typeof override === 'string' && override.trim() !== '') { - const normalized = normalizeCombo(override); - if (normalized === UNASSIGNED_SHORTCUT) { - return UNASSIGNED_SHORTCUT; - } - if (normalized) { - const parsed = parseShortcut(normalized); - const chord = parsed?.chords[0]; - if (chord && (chord.modifiers.size > 0 || chord.key)) { - return normalized; - } - } - } - - return action.defaultCombo; -} - -/** - * True when the physical keys required to "arm" a prefix combo are currently - * held. For modifiers with multiple aliases (e.g. `mod` on web macOS), at - * least one alias must be held. - */ -export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet): boolean { - if (isUnassignedShortcut(prefixCombo)) { - return false; - } - - const parsed = parseShortcut(prefixCombo); - if (!parsed || parsed.chords.length !== 1) { - return false; - } - const chord = parsed.chords[0]; - - for (const modifier of chord.modifiers) { - const aliases = MODIFIER_KEY_ALIASES[modifier]; - if (!aliases.some((alias) => heldKeys.has(alias))) { - return false; - } - } - - if (chord.key && !heldKeys.has(chord.key.toLowerCase())) { - return false; - } - - return true; -} - -/** - * Matches an activating keydown (the caller checks the event's own key, e.g. a - * digit) against a chord prefix: the event's modifier state must match the - * prefix's modifiers, and when the prefix has a primary key that key must - * currently be held. - */ -export function eventMatchesShortcutPrefix( - event: KeyboardEvent | React.KeyboardEvent, - prefixCombo: ShortcutCombo, - heldKeys?: ReadonlySet, -): boolean { - if (isUnassignedShortcut(prefixCombo)) { - return false; - } - - const parsed = parseShortcut(prefixCombo); - if (!parsed || parsed.chords.length !== 1) { - return false; - } - const chord = parsed.chords[0]; - - const expectedMod = chord.modifiers.has('mod'); - const expectedShift = chord.modifiers.has('shift'); - const expectedAlt = chord.modifiers.has('alt'); - const expectedCtrl = chord.modifiers.has('ctrl'); - const isDesktopMac = isMacOS() && isDesktopShell(); - const isMac = isMacOS(); - - const modMatches = isDesktopMac - ? event.metaKey - : isMac - ? (event.metaKey || event.ctrlKey) - : event.ctrlKey; - - if (expectedMod && !modMatches) { - return false; - } - - if (!expectedMod && event.metaKey) { - return false; - } - - if (expectedShift !== event.shiftKey) { - return false; - } - - if (expectedAlt !== event.altKey) { - return false; - } - - if (expectedCtrl) { - if (!event.ctrlKey) { - return false; - } - } else { - const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey; - if (event.ctrlKey && !ctrlUsedAsMod) { - return false; - } - } - - if (chord.key && (!heldKeys || !heldKeys.has(chord.key.toLowerCase()))) { - return false; - } - - return true; -} diff --git a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md index cf4d167d..d758a776 100644 --- a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -2,13 +2,21 @@ Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both register with the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command. -Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `shortcuts.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components. +Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `schema.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components. + +# Schema contract + +`SHORTCUT_SCHEMA` is the single static source of truth for application commands. Every entry declares an ID, default binding, category, and whether users can customize it. Customizable entries also derive their Settings translation key in the schema, so Settings must not maintain an action-ID switch or English fallback labels. + +Component interaction keys that are not application commands, such as list navigation or text editing, do not belong in the schema. Contextual application commands do belong there even when they are not customizable; `save_file` and `find_in_file` are examples. # Module roles -- `shortcuts.ts` owns action IDs, default bindings, categories, normalization, display, and conflict rules. -- `shortcutRegistry.ts` owns the active handler for each action ID. -- `shortcutDispatcher.ts` resolves current bindings and turns keyboard events into registered command calls. +- `index.ts` is the only public import surface, exposed as `@/lib/shortcuts`. +- `schema.ts` owns `SHORTCUT_SCHEMA`, derived action types, customizable metadata, 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. +- `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. @@ -18,6 +26,8 @@ Bindings remain persisted as `Record`. Each binding has one chor Contextual internal commands may deliberately share a sequence leader. The single-chord handler gets the first chance to handle the event; returning `false` lets the dispatcher start the sequence. The active file editor therefore owns `mod+s` for saving, while a mounted but unfocused editor yields `mod+s p` and `mod+s g` to the draft target pickers. +Runtime-specific commands may also share an exact binding when their handlers are mutually exclusive. `open_diff_panel` handles `mod+2` on desktop, while `switch_tab_2` handles it on mobile; each returns `false` outside its runtime so the dispatcher can try the next registered action. + The settings recorder also stops at two chords. It keeps the recording local until the user explicitly saves, allows an exact conflict to replace the previous assignment, and blocks prefix conflicts because they make dispatch ambiguous. # Dispatching diff --git a/packages/ui/src/lib/shortcuts.test.ts b/packages/ui/src/lib/shortcuts/bindings.test.ts similarity index 87% rename from packages/ui/src/lib/shortcuts.test.ts rename to packages/ui/src/lib/shortcuts/bindings.test.ts index a97abd37..b16d9e84 100644 --- a/packages/ui/src/lib/shortcuts.test.ts +++ b/packages/ui/src/lib/shortcuts/bindings.test.ts @@ -4,16 +4,13 @@ import { eventMatchesShortcutPrefix, formatShortcutForDisplay, getEffectiveShortcutPrefix, - getCustomizableShortcutActions, - getShortcutAction, - getShortcutCategory, getShortcutConflict, isRiskyBrowserShortcut, isShortcutPrefixHeld, normalizeCombo, parseShortcut, UNASSIGNED_SHORTCUT, -} from './shortcuts'; +} from './index'; describe('getEffectiveShortcutPrefix', () => { test('falls back to the action default (bare mod) when unset', () => { @@ -108,11 +105,4 @@ describe('shortcut sequences', () => { test('warns when a sequence leader conflicts with a browser shortcut', () => { expect(isRiskyBrowserShortcut('mod+s p')).toBe(true); }); - - test('categorizes customizable actions and includes draft picker sequences', () => { - expect(getCustomizableShortcutActions().every((action) => action.category !== undefined)).toBe(true); - expect(getShortcutAction('open_draft_project_picker')?.defaultCombo).toBe('mod+s p'); - expect(getShortcutAction('open_draft_worktree_picker')?.defaultCombo).toBe('mod+s g'); - expect(getShortcutCategory(getShortcutAction('focus_input')!)).toBe('session'); - }); }); diff --git a/packages/ui/src/lib/shortcuts/bindings.ts b/packages/ui/src/lib/shortcuts/bindings.ts new file mode 100644 index 00000000..6c53b2d1 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/bindings.ts @@ -0,0 +1,314 @@ +import type React from 'react'; +import { isDesktopShell } from '@/lib/desktop'; +import { isMacOS } from '@/lib/utils'; + +type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'ctrl'; +type ShortcutKey = string; + +export type ShortcutCombo = string; +export type ShortcutConflict = 'exact' | 'prefix'; + +export const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__'; + +interface ParsedShortcutChord { + modifiers: Set; + key: ShortcutKey; +} + +export interface ParsedShortcut { + chords: ReadonlyArray; +} + +const MODIFIER_KEY_MAP: Record = { + mod: 'mod', + shift: 'shift', + alt: 'alt', + option: 'alt', + ctrl: 'ctrl', + meta: 'mod', + cmd: 'mod', + command: 'mod', +}; + +const DISPLAY_LABEL_MAP: Record = { + mod: isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl', + shift: '⇧', + alt: '⌥', + ctrl: '⌃', +}; + +const KEY_LABEL_MAP: Record = { + comma: ',', + period: '.', + enter: 'Enter', + escape: 'Esc', + tab: 'Tab', + space: 'Space', + backspace: '⌫', + delete: '⌦', + arrowup: '↑', + arrowdown: '↓', + arrowleft: '←', + arrowright: '→', + home: 'Home', + end: 'End', + pageup: 'Page Up', + pagedown: 'Page Down', +}; + +const MODIFIER_PRIORITY: ShortcutModifier[] = ['mod', 'ctrl', 'shift', 'alt']; +const RISKY_BROWSER_SHORTCUT_KEYS = new Set(['w', 't', 'r', 'p', 's', 'f', 'l', 'n']); +const MODIFIER_KEY_ALIASES: Record = { + mod: isMacOS() && isDesktopShell() ? ['meta'] : isMacOS() ? ['meta', 'control'] : ['control'], + shift: ['shift'], + alt: ['alt'], + ctrl: ['control'], +}; + +const SHIFTED_KEY_BASE_MAP: Record = { + '{': '[', + '}': ']', + ':': ';', + '"': "'", + '<': ',', + '>': '.', + '?': '/', + '|': '\\', + '~': '`', + '!': '1', + '@': '2', + '#': '3', + '$': '4', + '%': '5', + '^': '6', + '&': '7', + '*': '8', + '(': '9', + ')': '0', +}; + +function isUnassignedShortcut(combo: ShortcutCombo): boolean { + return combo.trim().toLowerCase() === UNASSIGNED_SHORTCUT; +} + +export function keyToShortcutToken(key: string): string { + const lowered = key.toLowerCase(); + + if (lowered === ',') return 'comma'; + if (lowered === '.') return 'period'; + if (lowered === ' ') return 'space'; + if (lowered === 'esc') return 'escape'; + if (lowered === '+') return 'plus'; + if (lowered === '-' || lowered === '_') return 'minus'; + if (lowered === 'arrowup') return 'arrowup'; + if (lowered === 'arrowdown') return 'arrowdown'; + if (lowered === 'arrowleft') return 'arrowleft'; + if (lowered === 'arrowright') return 'arrowright'; + + return SHIFTED_KEY_BASE_MAP[lowered] ?? lowered; +} + +export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo { + if (isUnassignedShortcut(combo)) return UNASSIGNED_SHORTCUT; + + const chords = combo + .trim() + .replace(/\s*\+\s*/g, '+') + .split(/\s+/) + .filter(Boolean); + if (chords.length === 0 || chords.length > 2) return ''; + + return chords.map(normalizeChord).join(' '); +} + +function normalizeChord(combo: ShortcutCombo): ShortcutCombo { + const rawParts = combo + .toLowerCase() + .trim() + .split('+') + .map((part) => part.trim()) + .filter(Boolean); + const modifiers = new Set(); + let key = ''; + + for (const rawPart of rawParts) { + const part = rawPart === ',' ? 'comma' : rawPart === '.' ? 'period' : rawPart; + const modifier = MODIFIER_KEY_MAP[part]; + if (modifier) { + modifiers.add(modifier); + } else { + key = part; + } + } + + const orderedModifiers = MODIFIER_PRIORITY.filter((modifier) => modifiers.has(modifier)); + return [...orderedModifiers, key].filter(Boolean).join('+'); +} + +export function isValidShortcutCombo(combo: ShortcutCombo): boolean { + if (isUnassignedShortcut(combo)) return true; + const parsed = parseShortcut(combo); + return parsed !== undefined && parsed.chords.every((chord) => chord.key.trim().length > 0); +} + +export function parseShortcut(combo: ShortcutCombo): ParsedShortcut | undefined { + if (isUnassignedShortcut(combo)) { + return { chords: [{ modifiers: new Set(), key: UNASSIGNED_SHORTCUT }] }; + } + + const normalized = normalizeCombo(combo); + if (!normalized) return undefined; + + return { + chords: normalized.split(' ').map((chord) => { + const modifiers = new Set(); + let key: ShortcutKey = ''; + for (const part of chord.split('+')) { + const modifier = MODIFIER_KEY_MAP[part]; + if (modifier) { + modifiers.add(modifier); + } else { + key = part; + } + } + return { modifiers, key }; + }), + }; +} + +export function formatShortcutForDisplay(combo: ShortcutCombo, unassignedLabel = 'Unassigned'): string { + if (isUnassignedShortcut(combo)) return unassignedLabel; + const parsed = parseShortcut(combo); + if (!parsed || parsed.chords.some((chord) => !chord.key && chord.modifiers.size === 0)) { + return unassignedLabel; + } + return parsed.chords.map(formatChordForDisplay).join(', '); +} + +function formatChordForDisplay(parsed: ParsedShortcutChord): string { + const parts = MODIFIER_PRIORITY + .filter((modifier) => parsed.modifiers.has(modifier)) + .map((modifier) => DISPLAY_LABEL_MAP[modifier]); + if (parsed.key) { + parts.push(KEY_LABEL_MAP[parsed.key.toLowerCase()] || parsed.key.toUpperCase()); + } + return parts.join(' + '); +} + +export function getShortcutConflict(left: ShortcutCombo, right: ShortcutCombo): ShortcutConflict | undefined { + const normalizedLeft = normalizeCombo(left); + const normalizedRight = normalizeCombo(right); + const hasInvalidBinding = !isValidShortcutCombo(normalizedLeft) || !isValidShortcutCombo(normalizedRight); + const hasUnassignedBinding = normalizedLeft === UNASSIGNED_SHORTCUT + || normalizedRight === UNASSIGNED_SHORTCUT; + if (hasInvalidBinding || hasUnassignedBinding) return undefined; + if (normalizedLeft === normalizedRight) return 'exact'; + + const leftChords = normalizedLeft.split(' '); + const rightChords = normalizedRight.split(' '); + const sharesLeader = leftChords[0] === rightChords[0]; + return sharesLeader && leftChords.length !== rightChords.length ? 'prefix' : undefined; +} + +export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean { + if (isUnassignedShortcut(combo)) return false; + const parsed = parseShortcut(combo); + if (!parsed) return false; + const chord = parsed.chords[0]; + if (!chord.modifiers.has('mod')) return false; + + return RISKY_BROWSER_SHORTCUT_KEYS.has(chord.key.toLowerCase()) + && !chord.modifiers.has('shift') + && !chord.modifiers.has('alt'); +} + +export function eventMatchesShortcut( + event: KeyboardEvent | React.KeyboardEvent, + combo: ShortcutCombo, +): boolean { + if (isUnassignedShortcut(combo)) return false; + const parsed = parseShortcut(combo); + if (!parsed || parsed.chords.length !== 1) return false; + const chord = parsed.chords[0]; + + const expectedMod = chord.modifiers.has('mod'); + const expectedShift = chord.modifiers.has('shift'); + const expectedAlt = chord.modifiers.has('alt'); + const expectedCtrl = chord.modifiers.has('ctrl'); + const isDesktopMac = isMacOS() && isDesktopShell(); + const isMac = isMacOS(); + let modMatches = event.ctrlKey; + if (isDesktopMac) { + modMatches = event.metaKey; + } else if (isMac) { + modMatches = event.metaKey || event.ctrlKey; + } + + if (expectedMod && !modMatches) return false; + if (!expectedMod && event.metaKey) return false; + if (expectedShift !== event.shiftKey) return false; + if (expectedAlt !== event.altKey) return false; + if (expectedCtrl) { + if (!event.ctrlKey) return false; + } else { + const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey; + if (event.ctrlKey && !ctrlUsedAsMod) return false; + } + + let eventKeyRaw = event.key; + if (event.altKey) { + if (event.code.startsWith('Key') && event.code.length === 4) { + eventKeyRaw = event.code.slice(3).toLowerCase(); + } else if (event.code.startsWith('Digit') && event.code.length === 6) { + eventKeyRaw = event.code.slice(5); + } + } + + return keyToShortcutToken(eventKeyRaw) === keyToShortcutToken(chord.key); +} + +export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet): boolean { + if (isUnassignedShortcut(prefixCombo)) return false; + const parsed = parseShortcut(prefixCombo); + if (!parsed || parsed.chords.length !== 1) return false; + const chord = parsed.chords[0]; + + for (const modifier of chord.modifiers) { + if (!MODIFIER_KEY_ALIASES[modifier].some((alias) => heldKeys.has(alias))) return false; + } + return !chord.key || heldKeys.has(chord.key.toLowerCase()); +} + +export function eventMatchesShortcutPrefix( + event: KeyboardEvent | React.KeyboardEvent, + prefixCombo: ShortcutCombo, + heldKeys?: ReadonlySet, +): boolean { + if (isUnassignedShortcut(prefixCombo)) return false; + const parsed = parseShortcut(prefixCombo); + if (!parsed || parsed.chords.length !== 1) return false; + const chord = parsed.chords[0]; + const expectedMod = chord.modifiers.has('mod'); + const expectedShift = chord.modifiers.has('shift'); + const expectedAlt = chord.modifiers.has('alt'); + const expectedCtrl = chord.modifiers.has('ctrl'); + const isDesktopMac = isMacOS() && isDesktopShell(); + const isMac = isMacOS(); + const modMatches = isDesktopMac ? event.metaKey : isMac ? event.metaKey || event.ctrlKey : event.ctrlKey; + + if (expectedMod && !modMatches) return false; + if (!expectedMod && event.metaKey) return false; + if (expectedShift !== event.shiftKey || expectedAlt !== event.altKey) return false; + if (expectedCtrl) { + if (!event.ctrlKey) return false; + } else { + const ctrlUsedAsMod = expectedMod && !isDesktopMac && event.ctrlKey; + if (event.ctrlKey && !ctrlUsedAsMod) return false; + } + + return !chord.key || Boolean(heldKeys?.has(chord.key.toLowerCase())); +} + +export function getModifierLabel(): string { + return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl'; +} diff --git a/packages/ui/src/lib/shortcutDispatcher.test.ts b/packages/ui/src/lib/shortcuts/dispatcher.test.ts similarity index 98% rename from packages/ui/src/lib/shortcutDispatcher.test.ts rename to packages/ui/src/lib/shortcuts/dispatcher.test.ts index 9df23ef3..1ba04344 100644 --- a/packages/ui/src/lib/shortcutDispatcher.test.ts +++ b/packages/ui/src/lib/shortcuts/dispatcher.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test'; -import { ShortcutDispatcher } from './shortcutDispatcher'; -import { ShortcutRegistry } from './shortcutRegistry'; +import { ShortcutDispatcher } from './dispatcher'; +import { ShortcutRegistry } from './registry'; function key(key: string, options: Partial = {}): KeyboardEvent { return { diff --git a/packages/ui/src/lib/shortcutDispatcher.ts b/packages/ui/src/lib/shortcuts/dispatcher.ts similarity index 89% rename from packages/ui/src/lib/shortcutDispatcher.ts rename to packages/ui/src/lib/shortcuts/dispatcher.ts index 775bc0b9..a4bd8cbb 100644 --- a/packages/ui/src/lib/shortcutDispatcher.ts +++ b/packages/ui/src/lib/shortcuts/dispatcher.ts @@ -3,10 +3,10 @@ import { normalizeCombo, parseShortcut, UNASSIGNED_SHORTCUT, - type ShortcutActionId, type ShortcutCombo, -} from './shortcuts'; -import { type ShortcutHandler, ShortcutRegistry } from './shortcutRegistry'; +} from './bindings'; +import { type ShortcutHandler, ShortcutRegistry } from './registry'; +import type { ShortcutActionId } from './schema'; const SEQUENCE_TIMEOUT_MS = 1500; const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']); @@ -106,13 +106,15 @@ export class ShortcutDispatcher { const matches: BindingMatch[] = []; for (const actionId of this.options.registry.actionIds()) { const handler = this.options.registry.get(actionId); + if (!handler) continue; + const binding = normalizeCombo(this.options.getBinding(actionId)); const parsed = parseShortcut(binding); - const isDispatchable = parsed - && parsed.chords.every((chord) => chord.key && chord.key !== UNASSIGNED_SHORTCUT); - if (handler && isDispatchable) { - matches.push({ chords: binding.split(' '), handler }); + if (!parsed || parsed.chords.some((chord) => !chord.key || chord.key === UNASSIGNED_SHORTCUT)) { + continue; } + + matches.push({ chords: binding.split(' '), handler }); } return matches; } diff --git a/packages/ui/src/lib/shortcuts/index.ts b/packages/ui/src/lib/shortcuts/index.ts new file mode 100644 index 00000000..7ef4ab99 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/index.ts @@ -0,0 +1,29 @@ +export { + eventMatchesShortcut, + eventMatchesShortcutPrefix, + formatShortcutForDisplay, + getModifierLabel, + getShortcutConflict, + isRiskyBrowserShortcut, + isShortcutPrefixHeld, + keyToShortcutToken, + normalizeCombo, + parseShortcut, + UNASSIGNED_SHORTCUT, +} from './bindings'; +export type { ShortcutCombo } from './bindings'; +export { ShortcutDispatcher } from './dispatcher'; +export { shortcutRegistry } from './registry'; +export type { ShortcutHandler } from './registry'; +export { + getCustomizableShortcutActions, + getEffectiveShortcutCombo, + getEffectiveShortcutPrefix, + getShortcutAction, + SHORTCUT_SCHEMA, +} from './schema'; +export type { + CustomizableShortcutAction, + ShortcutActionId, + ShortcutCategory, +} from './schema'; diff --git a/packages/ui/src/lib/shortcutRegistry.test.ts b/packages/ui/src/lib/shortcuts/registry.test.ts similarity index 94% rename from packages/ui/src/lib/shortcutRegistry.test.ts rename to packages/ui/src/lib/shortcuts/registry.test.ts index 658b46d2..ecf90cd1 100644 --- a/packages/ui/src/lib/shortcutRegistry.test.ts +++ b/packages/ui/src/lib/shortcuts/registry.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test'; -import { ShortcutRegistry } from './shortcutRegistry'; +import { ShortcutRegistry } from './registry'; test('the first registration wins and a later unregister cannot remove it', () => { const registry = new ShortcutRegistry(); diff --git a/packages/ui/src/lib/shortcutRegistry.ts b/packages/ui/src/lib/shortcuts/registry.ts similarity index 83% rename from packages/ui/src/lib/shortcutRegistry.ts rename to packages/ui/src/lib/shortcuts/registry.ts index 06c05527..4b47ab3d 100644 --- a/packages/ui/src/lib/shortcutRegistry.ts +++ b/packages/ui/src/lib/shortcuts/registry.ts @@ -1,10 +1,9 @@ -import type { ShortcutActionId } from './shortcuts'; +import type { ShortcutActionId } from './schema'; export type ShortcutHandler = (event: KeyboardEvent) => boolean | void; interface RegisteredHandler { handler: ShortcutHandler; - token: symbol; } /** Active application command handlers, keyed by shortcut action ID. */ @@ -12,14 +11,14 @@ export class ShortcutRegistry { private readonly handlers = new Map(); register(actionId: ShortcutActionId, handler: ShortcutHandler): () => void { - const token = Symbol(actionId); + const registration = { handler }; const registered = this.handlers.get(actionId) ?? []; - registered.push({ handler, token }); + registered.push(registration); this.handlers.set(actionId, registered); return () => { const current = this.handlers.get(actionId); if (!current) return; - const index = current.findIndex((entry) => entry.token === token); + const index = current.indexOf(registration); if (index === -1) return; current.splice(index, 1); if (current.length === 0) { diff --git a/packages/ui/src/lib/shortcuts/schema.test.ts b/packages/ui/src/lib/shortcuts/schema.test.ts new file mode 100644 index 00000000..4a7ad4c6 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/schema.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; +import { + getCustomizableShortcutActions, + getEffectiveShortcutCombo, + getShortcutAction, + parseShortcut, + SHORTCUT_SCHEMA, +} from './index'; + +describe('shortcut schema', () => { + test('declares unique IDs and valid bindings for every application shortcut', () => { + const ids = SHORTCUT_SCHEMA.map((action) => action.id); + expect(new Set(ids).size).toBe(ids.length); + expect(SHORTCUT_SCHEMA.every((action) => { + const chordCount = parseShortcut(action.defaultBinding)?.chords.length; + return Boolean(action.category) && chordCount !== undefined && chordCount >= 1 && chordCount <= 2; + })).toBe(true); + }); + + test('derives settings labels for every customizable shortcut', () => { + const customizable = getCustomizableShortcutActions(); + expect(customizable.length).toBeGreaterThan(0); + expect(customizable.every((action) => ( + action.settingsLabelKey === `settings.openchamber.keyboardShortcuts.action.${action.id}.label` + ))).toBe(true); + }); + + test('includes draft prefix bindings and session metadata', () => { + expect(getShortcutAction('open_draft_project_picker')?.defaultBinding).toBe('mod+s p'); + expect(getShortcutAction('open_draft_worktree_picker')?.defaultBinding).toBe('mod+s g'); + expect(getShortcutAction('focus_input')?.category).toBe('session'); + }); + + test('preserves valid overrides and falls back from malformed bindings', () => { + expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k' })).toBe('mod+k'); + expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k x y' })).toBe('mod+n'); + }); +}); diff --git a/packages/ui/src/lib/shortcuts/schema.ts b/packages/ui/src/lib/shortcuts/schema.ts new file mode 100644 index 00000000..deb8e366 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/schema.ts @@ -0,0 +1,145 @@ +import { + isValidShortcutCombo, + normalizeCombo, + parseShortcut, + UNASSIGNED_SHORTCUT, + type ShortcutCombo, +} from './bindings'; + +export type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application'; + +interface ShortcutDefinition { + id: Id; + defaultBinding: ShortcutCombo; + category: ShortcutCategory; +} + +interface CustomizableShortcutDefinition extends ShortcutDefinition { + customizable: true; + settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${Id}.label`; +} + +interface InternalShortcutDefinition extends ShortcutDefinition { + customizable: false; +} + +function internalShortcut( + id: Id, + defaultBinding: ShortcutCombo, + category: ShortcutCategory, +): InternalShortcutDefinition { + return { id, defaultBinding, category, customizable: false }; +} + +function customizableShortcut( + id: Id, + defaultBinding: ShortcutCombo, + category: ShortcutCategory, +): CustomizableShortcutDefinition { + return { + id, + defaultBinding, + category, + customizable: true, + settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${id}.label`, + }; +} + +/** The single static source of truth for every application-level shortcut. */ +export const SHORTCUT_SCHEMA = [ + internalShortcut('save_file', 'mod+s', 'navigation'), + internalShortcut('find_in_file', 'mod+f', 'navigation'), + customizableShortcut('open_go_to_line', 'alt+g', 'navigation'), + customizableShortcut('open_command_palette', 'mod+p', 'application'), + customizableShortcut('focus_input', 'mod+i', 'session'), + internalShortcut('open_status', 'mod+shift+o', 'application'), + customizableShortcut('open_settings', 'mod+comma', 'application'), + customizableShortcut('toggle_terminal', 'mod+j', 'panels'), + customizableShortcut('toggle_terminal_expanded', 'mod+shift+j', 'panels'), + customizableShortcut('add_selection_to_chat', 'mod+l', 'session'), + customizableShortcut('toggle_sidebar', 'mod+alt+l', 'panels'), + customizableShortcut('open_timeline_dialog', 'mod+t', 'session'), + customizableShortcut('toggle_prompt_navigator', 'mod+alt+p', 'panels'), + customizableShortcut('toggle_right_sidebar', 'mod+b', 'panels'), + customizableShortcut('open_right_sidebar_git', 'mod+shift+g', 'panels'), + customizableShortcut('open_right_sidebar_files', 'mod+shift+f', 'panels'), + customizableShortcut('switch_context_surface', 'mod', 'panels'), + customizableShortcut('new_chat', 'mod+n', 'session'), + customizableShortcut('open_draft_project_picker', 'mod+s p', 'session'), + customizableShortcut('open_draft_worktree_picker', 'mod+s g', 'session'), + customizableShortcut('new_chat_worktree', 'mod+shift+n', 'session'), + customizableShortcut('new_mini_chat', 'mod+alt+n', 'session'), + customizableShortcut('open_help', 'mod+.', 'application'), + customizableShortcut('toggle_context_plan', 'mod+shift+p', 'panels'), + customizableShortcut('toggle_services_menu', 'mod+shift+s', 'panels'), + customizableShortcut('cycle_services_tab', 'mod+shift+[', 'navigation'), + customizableShortcut('cycle_theme', 'mod+/', 'application'), + customizableShortcut('open_model_selector', 'mod+shift+m', 'models'), + internalShortcut('cycle_thinking_variant', 'mod+shift+t', 'models'), + customizableShortcut('cycle_agent', 'tab', 'models'), + customizableShortcut('cycle_favorite_model_forward', 'ctrl+]', 'models'), + customizableShortcut('cycle_favorite_model_backward', 'ctrl+[', 'models'), + customizableShortcut('expand_input', 'mod+shift+e', 'session'), + customizableShortcut('toggle_dictation', 'mod+alt+v', 'session'), + internalShortcut('abort_run', 'escape', 'session'), + internalShortcut('toggle_memory_debug', 'mod+shift+d', 'application'), + internalShortcut('switch_tab_1', 'mod+1', 'navigation'), + // Mobile tab shortcuts share numeric bindings with desktop-only panel commands. + internalShortcut('switch_tab_2', 'mod+2', 'navigation'), + internalShortcut('switch_tab_3', 'mod+3', 'navigation'), + internalShortcut('switch_tab_4', 'mod+4', 'navigation'), + internalShortcut('switch_tab_5', 'mod+5', 'navigation'), + internalShortcut('switch_tab_6', 'mod+6', 'navigation'), + internalShortcut('switch_tab_7', 'mod+7', 'navigation'), + internalShortcut('switch_tab_8', 'mod+8', 'navigation'), + internalShortcut('switch_tab_9', 'mod+9', 'navigation'), +] as const; + +export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number]; +export type ShortcutActionId = ShortcutAction['id']; +export type CustomizableShortcutAction = Extract; + +export function getShortcutAction(id: string): ShortcutAction | undefined { + return SHORTCUT_SCHEMA.find((action) => action.id === id); +} + +export function getCustomizableShortcutActions(): ReadonlyArray { + return SHORTCUT_SCHEMA.filter( + (action): action is CustomizableShortcutAction => action.customizable, + ); +} + +export function getEffectiveShortcutCombo( + actionId: string, + overrides?: Record, +): ShortcutCombo { + const action = getShortcutAction(actionId); + if (!action) return ''; + + const override = overrides?.[actionId]; + if (typeof override === 'string') { + const normalized = normalizeCombo(override); + if (normalized === UNASSIGNED_SHORTCUT) return ''; + if (isValidShortcutCombo(normalized)) return normalized; + } + + return action.defaultBinding; +} + +export function getEffectiveShortcutPrefix( + actionId: string, + overrides?: Record, +): ShortcutCombo { + const action = getShortcutAction(actionId); + if (!action) return ''; + + const override = overrides?.[actionId]; + if (typeof override === 'string' && override.trim() !== '') { + const normalized = normalizeCombo(override); + if (normalized === UNASSIGNED_SHORTCUT) return UNASSIGNED_SHORTCUT; + const chord = parseShortcut(normalized)?.chords[0]; + if (chord && (chord.modifiers.size > 0 || chord.key)) return normalized; + } + + return action.defaultBinding; +} diff --git a/packages/ui/src/lib/utils.ts b/packages/ui/src/lib/utils.ts index 393f43a8..f6e009f8 100644 --- a/packages/ui/src/lib/utils.ts +++ b/packages/ui/src/lib/utils.ts @@ -1,6 +1,5 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; -import { isDesktopShell } from "@/lib/desktop"; import { matchesFuzzyQuery } from "@/lib/search/fuzzySearch"; import type { I18nKey } from "@/lib/i18n"; @@ -28,24 +27,6 @@ export const getRevealLabelKey = (): I18nKey => { return 'common.revealPath.fileManager'; }; -/** - * Checks if the platform-appropriate modifier key is pressed. - * On macOS desktop app: Cmd (metaKey), on other platforms or web: Ctrl (ctrlKey). - * Browser intercepts Cmd shortcuts, so we only use Cmd in the desktop app. - */ -export const hasModifier = (e: KeyboardEvent | React.KeyboardEvent): boolean => { - return isMacOS() && isDesktopShell() ? e.metaKey : e.ctrlKey; -}; - -/** - * Returns the platform-appropriate modifier key label. - * On macOS desktop app: "⌘", on other platforms or web: "Ctrl" - * Browser intercepts Cmd shortcuts, so we only show Cmd in the desktop app. - */ -export const getModifierLabel = (): string => { - return isMacOS() && isDesktopShell() ? '⌘' : 'Ctrl'; -}; - export const truncatePathMiddle = ( value: string, options?: { maxLength?: number } From 51fd947de88cf3825bd714a67dadc819dc7221aa Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Thu, 30 Jul 2026 12:48:06 +0800 Subject: [PATCH 006/192] refactor(ui): separate shortcut configuration --- .../ui/src/lib/shortcuts/DOCUMENTATION.md | 26 +- packages/ui/src/lib/shortcuts/config.ts | 265 ++++++++++++++++++ packages/ui/src/lib/shortcuts/schema.test.ts | 31 +- packages/ui/src/lib/shortcuts/schema.ts | 91 +----- 4 files changed, 318 insertions(+), 95 deletions(-) create mode 100644 packages/ui/src/lib/shortcuts/config.ts diff --git a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md index d758a776..bf5d6582 100644 --- a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -2,18 +2,21 @@ Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both register with the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command. -Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `schema.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components. +Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `config.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components. # Schema contract -`SHORTCUT_SCHEMA` is the single static source of truth for application commands. Every entry declares an ID, default binding, category, and whether users can customize it. Customizable entries also derive their Settings translation key in the schema, so Settings must not maintain an action-ID switch or English fallback labels. +`config.ts` is the declaration-only source for application commands. It organizes entries into `session`, `models`, `panels`, `navigation`, and `application` groups, then explicitly concatenates them into `SHORTCUT_SCHEMA`. Every entry declares an ID, default binding, and whether users can customize it. Customizable entries also declare their Settings translation key, so Settings must not maintain an action-ID switch or English fallback labels. + +Configuration must not contain lookup functions, override resolution, event matching, registry state, or runtime handlers. Those concerns belong to the owning modules below. Keeping configuration declarative makes the complete shortcut inventory reviewable without reading execution code. Component interaction keys that are not application commands, such as list navigation or text editing, do not belong in the schema. Contextual application commands do belong there even when they are not customizable; `save_file` and `find_in_file` are examples. # Module roles - `index.ts` is the only public import surface, exposed as `@/lib/shortcuts`. -- `schema.ts` owns `SHORTCUT_SCHEMA`, derived action types, customizable metadata, and effective binding resolution. +- `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. - `dispatcher.ts` resolves current bindings and turns keyboard events into registered command calls. @@ -37,3 +40,20 @@ The settings recorder also stops at two chords. It keeps the recording local unt 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. Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. + +# Adding shortcuts + +1. Add the command to the matching group in `config.ts`. Use a stable action ID and a normalized default binding. Keep sequences to at most two chords. +2. Mark the command `customizable: true` only when it should appear in Settings. Add its `settingsLabelKey` and provide that key in every locale in the same change. +3. Register the handler with `useKeybind` or `useKeybinds` near the state or UI that owns the behavior. Do not pass shortcut callbacks through unrelated components or move local UI state into a global store. +4. Return `false` when the mounted handler is not applicable in the current runtime or focus context. This lets another command sharing the binding or prefix continue dispatching. +5. Add or update schema, binding, registry, or dispatcher tests for the changed contract. Update Help Dialog metadata when the command should be discoverable there. + +# Best practices + +- Import production APIs only from `@/lib/shortcuts`; deep imports are reserved for files and tests inside this module. +- Keep `config.ts` declarative and grouped. Do not add helpers there for querying state or executing behavior. +- Every application command must appear exactly once in `SHORTCUT_SCHEMA`, including internal and debug commands. Component-only editing and navigation keys stay local and out of the schema. +- Avoid exact default-binding conflicts. When runtime-exclusive commands intentionally share one, document the reason beside both declarations and make each handler return `false` outside its runtime. +- Persist bindings as normalized strings. Never change the `Record` override contract without an explicit migration and compatibility tests. +- Preserve the two-chord maximum in configuration, recording UI, parsing, conflict detection, display, and tests. diff --git a/packages/ui/src/lib/shortcuts/config.ts b/packages/ui/src/lib/shortcuts/config.ts new file mode 100644 index 00000000..810d5a4e --- /dev/null +++ b/packages/ui/src/lib/shortcuts/config.ts @@ -0,0 +1,265 @@ +import type { ShortcutCombo } from './bindings'; + +type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application'; + +type ShortcutConfig = { + id: string; + defaultBinding: ShortcutCombo; +} & ( + | { customizable: false } + | { + customizable: true; + settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${string}.label`; + } +); + +const SHORTCUT_GROUPS = { + session: [ + { + id: 'add_selection_to_chat', + defaultBinding: 'mod+l', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label', + }, + { + id: 'focus_input', + defaultBinding: 'mod+i', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.focus_input.label', + }, + { + id: 'open_timeline_dialog', + defaultBinding: 'mod+t', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label', + }, + { + id: 'new_chat', + defaultBinding: 'mod+n', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_chat.label', + }, + { + id: 'open_draft_project_picker', + defaultBinding: 'mod+s p', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label', + }, + { + id: 'open_draft_worktree_picker', + defaultBinding: 'mod+s g', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label', + }, + { + id: 'new_chat_worktree', + defaultBinding: 'mod+shift+n', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label', + }, + { + id: 'new_mini_chat', + defaultBinding: 'mod+alt+n', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label', + }, + { + id: 'expand_input', + defaultBinding: 'mod+shift+e', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.expand_input.label', + }, + { + id: 'toggle_dictation', + defaultBinding: 'mod+alt+v', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label', + }, + { id: 'abort_run', defaultBinding: 'escape', customizable: false }, + ], + models: [ + { + id: 'open_model_selector', + defaultBinding: 'mod+shift+m', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label', + }, + { id: 'cycle_thinking_variant', defaultBinding: 'mod+shift+t', customizable: false }, + { + id: 'cycle_agent', + defaultBinding: 'tab', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label', + }, + { + id: 'cycle_favorite_model_forward', + defaultBinding: 'ctrl+]', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label', + }, + { + id: 'cycle_favorite_model_backward', + defaultBinding: 'ctrl+[', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label', + }, + ], + panels: [ + { + id: 'toggle_terminal', + defaultBinding: 'mod+j', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label', + }, + { + id: 'toggle_terminal_expanded', + defaultBinding: 'mod+shift+j', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label', + }, + { + id: 'toggle_sidebar', + defaultBinding: 'mod+alt+l', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label', + }, + { + id: 'toggle_prompt_navigator', + defaultBinding: 'mod+alt+p', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label', + }, + { + id: 'toggle_right_sidebar', + defaultBinding: 'mod+b', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label', + }, + { + id: 'open_right_sidebar_git', + defaultBinding: 'mod+shift+g', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label', + }, + { + id: 'open_right_sidebar_files', + defaultBinding: 'mod+shift+f', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label', + }, + { + id: 'switch_context_surface', + defaultBinding: 'mod', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label', + }, + { + id: 'toggle_context_plan', + defaultBinding: 'mod+shift+p', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label', + }, + { + id: 'toggle_services_menu', + defaultBinding: 'mod+shift+s', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label', + }, + ], + navigation: [ + { id: 'save_file', defaultBinding: 'mod+s', customizable: false }, + { id: 'find_in_file', defaultBinding: 'mod+f', customizable: false }, + { + id: 'open_go_to_line', + defaultBinding: 'alt+g', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label', + }, + { + id: 'cycle_services_tab', + defaultBinding: 'mod+shift+[', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label', + }, + { id: 'switch_tab_1', defaultBinding: 'mod+1', customizable: false }, + // Mobile tab shortcuts may share numeric bindings with desktop-only panel commands. + { id: 'switch_tab_2', defaultBinding: 'mod+2', customizable: false }, + { id: 'switch_tab_3', defaultBinding: 'mod+3', customizable: false }, + { id: 'switch_tab_4', defaultBinding: 'mod+4', customizable: false }, + { id: 'switch_tab_5', defaultBinding: 'mod+5', customizable: false }, + { id: 'switch_tab_6', defaultBinding: 'mod+6', customizable: false }, + { id: 'switch_tab_7', defaultBinding: 'mod+7', customizable: false }, + { id: 'switch_tab_8', defaultBinding: 'mod+8', customizable: false }, + { id: 'switch_tab_9', defaultBinding: 'mod+9', customizable: false }, + ], + application: [ + { + id: 'open_command_palette', + defaultBinding: 'mod+p', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label', + }, + { id: 'open_status', defaultBinding: 'mod+shift+o', customizable: false }, + { + id: 'open_settings', + defaultBinding: 'mod+comma', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_settings.label', + }, + { + id: 'open_help', + defaultBinding: 'mod+.', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_help.label', + }, + { + id: 'cycle_theme', + defaultBinding: 'mod+/', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label', + }, + { id: 'toggle_memory_debug', defaultBinding: 'mod+shift+d', customizable: false }, + ], +} as const satisfies Record; + +/** All application shortcuts, flattened in the same order used by Settings. */ +export const SHORTCUT_SCHEMA = [ + ...SHORTCUT_GROUPS.session.map((shortcut) => ({ + ...shortcut, + category: 'session' as const, + })), + ...SHORTCUT_GROUPS.models.map((shortcut) => ({ + ...shortcut, + category: 'models' as const, + })), + ...SHORTCUT_GROUPS.panels.map((shortcut) => ({ + ...shortcut, + category: 'panels' as const, + })), + ...SHORTCUT_GROUPS.navigation.map((shortcut) => ({ + ...shortcut, + category: 'navigation' as const, + })), + ...SHORTCUT_GROUPS.application.map((shortcut) => ({ + ...shortcut, + category: 'application' as const, + })), +] as const; diff --git a/packages/ui/src/lib/shortcuts/schema.test.ts b/packages/ui/src/lib/shortcuts/schema.test.ts index 4a7ad4c6..08f75353 100644 --- a/packages/ui/src/lib/shortcuts/schema.test.ts +++ b/packages/ui/src/lib/shortcuts/schema.test.ts @@ -5,16 +5,39 @@ import { getShortcutAction, parseShortcut, SHORTCUT_SCHEMA, + type ShortcutCategory, } from './index'; describe('shortcut schema', () => { test('declares unique IDs and valid bindings for every application shortcut', () => { const ids = SHORTCUT_SCHEMA.map((action) => action.id); - expect(new Set(ids).size).toBe(ids.length); - expect(SHORTCUT_SCHEMA.every((action) => { + const hasValidMetadata = SHORTCUT_SCHEMA.every((action) => { const chordCount = parseShortcut(action.defaultBinding)?.chords.length; - return Boolean(action.category) && chordCount !== undefined && chordCount >= 1 && chordCount <= 2; - })).toBe(true); + return Boolean(action.category) + && chordCount !== undefined + && chordCount >= 1 + && chordCount <= 2; + }); + + expect(new Set(ids).size).toBe(ids.length); + expect(hasValidMetadata).toBe(true); + }); + + test('keeps the flattened schema grouped in Settings order', () => { + const groupOrder: ShortcutCategory[] = []; + for (const action of SHORTCUT_SCHEMA) { + if (groupOrder.at(-1) !== action.category) { + groupOrder.push(action.category); + } + } + + expect(groupOrder).toEqual([ + 'session', + 'models', + 'panels', + 'navigation', + 'application', + ]); }); test('derives settings labels for every customizable shortcut', () => { diff --git a/packages/ui/src/lib/shortcuts/schema.ts b/packages/ui/src/lib/shortcuts/schema.ts index deb8e366..23ad1077 100644 --- a/packages/ui/src/lib/shortcuts/schema.ts +++ b/packages/ui/src/lib/shortcuts/schema.ts @@ -5,98 +5,13 @@ import { UNASSIGNED_SHORTCUT, type ShortcutCombo, } from './bindings'; +import { SHORTCUT_SCHEMA } from './config'; -export type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application'; - -interface ShortcutDefinition { - id: Id; - defaultBinding: ShortcutCombo; - category: ShortcutCategory; -} - -interface CustomizableShortcutDefinition extends ShortcutDefinition { - customizable: true; - settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${Id}.label`; -} - -interface InternalShortcutDefinition extends ShortcutDefinition { - customizable: false; -} - -function internalShortcut( - id: Id, - defaultBinding: ShortcutCombo, - category: ShortcutCategory, -): InternalShortcutDefinition { - return { id, defaultBinding, category, customizable: false }; -} - -function customizableShortcut( - id: Id, - defaultBinding: ShortcutCombo, - category: ShortcutCategory, -): CustomizableShortcutDefinition { - return { - id, - defaultBinding, - category, - customizable: true, - settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${id}.label`, - }; -} - -/** The single static source of truth for every application-level shortcut. */ -export const SHORTCUT_SCHEMA = [ - internalShortcut('save_file', 'mod+s', 'navigation'), - internalShortcut('find_in_file', 'mod+f', 'navigation'), - customizableShortcut('open_go_to_line', 'alt+g', 'navigation'), - customizableShortcut('open_command_palette', 'mod+p', 'application'), - customizableShortcut('focus_input', 'mod+i', 'session'), - internalShortcut('open_status', 'mod+shift+o', 'application'), - customizableShortcut('open_settings', 'mod+comma', 'application'), - customizableShortcut('toggle_terminal', 'mod+j', 'panels'), - customizableShortcut('toggle_terminal_expanded', 'mod+shift+j', 'panels'), - customizableShortcut('add_selection_to_chat', 'mod+l', 'session'), - customizableShortcut('toggle_sidebar', 'mod+alt+l', 'panels'), - customizableShortcut('open_timeline_dialog', 'mod+t', 'session'), - customizableShortcut('toggle_prompt_navigator', 'mod+alt+p', 'panels'), - customizableShortcut('toggle_right_sidebar', 'mod+b', 'panels'), - customizableShortcut('open_right_sidebar_git', 'mod+shift+g', 'panels'), - customizableShortcut('open_right_sidebar_files', 'mod+shift+f', 'panels'), - customizableShortcut('switch_context_surface', 'mod', 'panels'), - customizableShortcut('new_chat', 'mod+n', 'session'), - customizableShortcut('open_draft_project_picker', 'mod+s p', 'session'), - customizableShortcut('open_draft_worktree_picker', 'mod+s g', 'session'), - customizableShortcut('new_chat_worktree', 'mod+shift+n', 'session'), - customizableShortcut('new_mini_chat', 'mod+alt+n', 'session'), - customizableShortcut('open_help', 'mod+.', 'application'), - customizableShortcut('toggle_context_plan', 'mod+shift+p', 'panels'), - customizableShortcut('toggle_services_menu', 'mod+shift+s', 'panels'), - customizableShortcut('cycle_services_tab', 'mod+shift+[', 'navigation'), - customizableShortcut('cycle_theme', 'mod+/', 'application'), - customizableShortcut('open_model_selector', 'mod+shift+m', 'models'), - internalShortcut('cycle_thinking_variant', 'mod+shift+t', 'models'), - customizableShortcut('cycle_agent', 'tab', 'models'), - customizableShortcut('cycle_favorite_model_forward', 'ctrl+]', 'models'), - customizableShortcut('cycle_favorite_model_backward', 'ctrl+[', 'models'), - customizableShortcut('expand_input', 'mod+shift+e', 'session'), - customizableShortcut('toggle_dictation', 'mod+alt+v', 'session'), - internalShortcut('abort_run', 'escape', 'session'), - internalShortcut('toggle_memory_debug', 'mod+shift+d', 'application'), - internalShortcut('switch_tab_1', 'mod+1', 'navigation'), - // Mobile tab shortcuts share numeric bindings with desktop-only panel commands. - internalShortcut('switch_tab_2', 'mod+2', 'navigation'), - internalShortcut('switch_tab_3', 'mod+3', 'navigation'), - internalShortcut('switch_tab_4', 'mod+4', 'navigation'), - internalShortcut('switch_tab_5', 'mod+5', 'navigation'), - internalShortcut('switch_tab_6', 'mod+6', 'navigation'), - internalShortcut('switch_tab_7', 'mod+7', 'navigation'), - internalShortcut('switch_tab_8', 'mod+8', 'navigation'), - internalShortcut('switch_tab_9', 'mod+9', 'navigation'), -] as const; +export { SHORTCUT_SCHEMA } from './config'; export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number]; export type ShortcutActionId = ShortcutAction['id']; +export type ShortcutCategory = ShortcutAction['category']; export type CustomizableShortcutAction = Extract; export function getShortcutAction(id: string): ShortcutAction | undefined { From a5d5d07d3e4ba304243389f932ad4d50c80de577 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Fri, 31 Jul 2026 00:14:09 +0800 Subject: [PATCH 007/192] feat(ui): add session list shortcut --- packages/ui/src/components/ui/HelpDialog.tsx | 5 +++++ packages/ui/src/hooks/useKeyboardShortcuts.ts | 5 +++++ packages/ui/src/lib/i18n/messages/en.settings.ts | 1 + packages/ui/src/lib/i18n/messages/es.settings.ts | 1 + packages/ui/src/lib/i18n/messages/fr.settings.ts | 1 + packages/ui/src/lib/i18n/messages/ja.settings.ts | 1 + packages/ui/src/lib/i18n/messages/ko.settings.ts | 1 + packages/ui/src/lib/i18n/messages/pl.settings.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.settings.ts | 1 + packages/ui/src/lib/i18n/messages/uk.settings.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.settings.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.settings.ts | 1 + packages/ui/src/lib/shortcuts/DOCUMENTATION.md | 2 +- packages/ui/src/lib/shortcuts/config.ts | 7 +++++++ packages/ui/src/lib/shortcuts/schema.test.ts | 3 ++- 15 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 363d0615..7ce7df29 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -135,6 +135,11 @@ export const HelpDialog: React.FC = () => { icon: 'git-branch', keys: '', }, + { + id: 'open_session_list', + icon: 'list-unordered', + keys: '', + }, { id: 'focus_input', descriptionKey: "helpDialog.item.focusChatInput", icon: "text", keys: '' }, { id: 'toggle_prompt_navigator', diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 97d45bf2..08f87108 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -99,6 +99,11 @@ export const useKeyboardShortcuts = () => { open_timeline_dialog: () => { useUIStore.getState().setTimelineDialogOpen(true); }, + open_session_list: () => { + const state = useUIStore.getState(); + if (state.isMobile) state.setSessionSwitcherOpen(true); + else state.setSessionDropdownOpen(true); + }, toggle_prompt_navigator: () => { const state = useUIStore.getState(); const hasOverlay = state.isSettingsDialogOpen diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 1772694f..3e095b78 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1142,6 +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.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 5ee1ebb0..c2fdd90f 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1109,6 +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.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 46e8f21f..f2957df1 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1030,6 +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.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 a02f3cc2..e26df24d 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1142,6 +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.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 a56696cf..23bc6322 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1109,6 +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.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 ee8c127b..45453328 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1377,6 +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_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 d54be549..86b58337 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1109,6 +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.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 62ecc7b0..3eb4cf88 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1109,6 +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.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 ae6cc3b1..5048c4a9 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1109,6 +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.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 a5b3d6ad..90e9e345 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1016,6 +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.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 bf5d6582..a44150c4 100644 --- a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -27,7 +27,7 @@ Component interaction keys that are not application commands, such as list navig Bindings remain persisted as `Record`. Each binding has one chord or at most two space-separated chords, such as `mod+s p`. `normalizeCombo`, `parseShortcut`, `formatShortcutForDisplay`, and `getShortcutConflict` provide the shared parsing and validation behavior. A single chord conflicts with a sequence sharing its first chord; sibling sequences are valid. -Contextual internal commands may deliberately share a sequence leader. The single-chord handler gets the first chance to handle the event; returning `false` lets the dispatcher start the sequence. The active file editor therefore owns `mod+s` for saving, while a mounted but unfocused editor yields `mod+s p` and `mod+s g` to the draft target pickers. +Contextual internal commands may deliberately share a sequence leader. The single-chord handler gets the first chance to handle the event; returning `false` lets the dispatcher start the sequence. The active file editor therefore owns `mod+s` for saving, while a mounted but unfocused editor yields `mod+s p`, `mod+s g`, and `mod+s l` to the draft target pickers and session list. Runtime-specific commands may also share an exact binding when their handlers are mutually exclusive. `open_diff_panel` handles `mod+2` on desktop, while `switch_tab_2` handles it on mobile; each returns `false` outside its runtime so the dispatcher can try the next registered action. diff --git a/packages/ui/src/lib/shortcuts/config.ts b/packages/ui/src/lib/shortcuts/config.ts index 810d5a4e..5c657a0c 100644 --- a/packages/ui/src/lib/shortcuts/config.ts +++ b/packages/ui/src/lib/shortcuts/config.ts @@ -55,6 +55,13 @@ const SHORTCUT_GROUPS = { settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label', }, + { + id: 'open_session_list', + defaultBinding: 'mod+s l', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label', + }, { id: 'new_chat_worktree', defaultBinding: 'mod+shift+n', diff --git a/packages/ui/src/lib/shortcuts/schema.test.ts b/packages/ui/src/lib/shortcuts/schema.test.ts index 08f75353..463b92a0 100644 --- a/packages/ui/src/lib/shortcuts/schema.test.ts +++ b/packages/ui/src/lib/shortcuts/schema.test.ts @@ -48,9 +48,10 @@ describe('shortcut schema', () => { ))).toBe(true); }); - test('includes draft prefix bindings and session metadata', () => { + test('includes session prefix bindings and metadata', () => { expect(getShortcutAction('open_draft_project_picker')?.defaultBinding).toBe('mod+s p'); expect(getShortcutAction('open_draft_worktree_picker')?.defaultBinding).toBe('mod+s g'); + expect(getShortcutAction('open_session_list')?.defaultBinding).toBe('mod+s l'); expect(getShortcutAction('focus_input')?.category).toBe('session'); }); From aba10476c65c289fdde2262d4abfe8135a075258 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Fri, 31 Jul 2026 01:24:00 +0800 Subject: [PATCH 008/192] refactor(ui): enforce shortcut registration IDs --- packages/ui/src/hooks/useKeybind.test.ts | 21 +++++++++++++++++++ packages/ui/src/hooks/useKeybind.ts | 10 ++++++--- .../ui/src/lib/shortcuts/DOCUMENTATION.md | 2 +- 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 packages/ui/src/hooks/useKeybind.test.ts diff --git a/packages/ui/src/hooks/useKeybind.test.ts b/packages/ui/src/hooks/useKeybind.test.ts new file mode 100644 index 00000000..498ab7bb --- /dev/null +++ b/packages/ui/src/hooks/useKeybind.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from 'bun:test'; +import type { ShortcutHandler } from '@/lib/shortcuts'; +import type { ShortcutBindings } from './useKeybind'; + +const handler: ShortcutHandler = () => {}; +const validBindings = { + open_session_list: handler, +}; +const mixedBindingsWithTypo = { + open_session_list: handler, + open_session_lsit: handler, +}; + +const acceptedBindings: ShortcutBindings = validBindings; +// @ts-expect-error A misspelled key must fail even when the object also contains a valid ID. +const rejectedBindings: ShortcutBindings = mixedBindingsWithTypo; +void rejectedBindings; + +test('accepts bindings whose IDs are declared in the shortcut schema', () => { + expect(Object.keys(acceptedBindings)).toEqual(['open_session_list']); +}); diff --git a/packages/ui/src/hooks/useKeybind.ts b/packages/ui/src/hooks/useKeybind.ts index 8b4b0397..8076a26c 100644 --- a/packages/ui/src/hooks/useKeybind.ts +++ b/packages/ui/src/hooks/useKeybind.ts @@ -8,9 +8,13 @@ export function useKeybind(actionId: ShortcutActionId, handler: ShortcutHandler) React.useEffect(() => shortcutRegistry.register(actionId, (event) => handlerRef.current(event)), [actionId]); } -export function useKeybinds( - bindings: Partial>, -): void { +export type ShortcutBindings< + Bindings extends Partial>, +> = Bindings & Record, never>; + +export function useKeybinds< + const Bindings extends Partial>, +>(bindings: ShortcutBindings): void { const handlersRef = React.useRef(bindings); handlersRef.current = bindings; const actionIdsKey = Object.keys(bindings).sort().join('\0'); diff --git a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md index a44150c4..3bb74106 100644 --- a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -1,6 +1,6 @@ # Registration boundary -Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both register with the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command. +Application commands use `useKeybind(actionId, handler)` or `useKeybinds(bindings)`. Both accept only action IDs derived from `SHORTCUT_SCHEMA`. Batch registration also rejects undeclared keys in prebuilt objects, including objects that mix valid and misspelled IDs. Both hooks use the shared `shortcutRegistry`, so components never receive a registry. The first registration for an action ID wins until it unregisters, then the next mounted registration takes over. A component-local interaction, such as editor navigation or an open menu, remains local event handling rather than a registered application command. Do not add a component-level `window` or `document` keydown listener for an application command. Declare the action in `config.ts`, then register its handler near the state or UI it owns. This keeps definitions and dispatch centralized without lifting component state or passing callbacks through unrelated components. From 6420460dfc6bb47651e776c646496744b2284a28 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Sat, 1 Aug 2026 09:26:26 +0800 Subject: [PATCH 009/192] fix(ui): refine shortcut and recent session interactions --- .../ShortcutRecordingDialog.test.ts | 37 +++ .../openchamber/ShortcutRecordingDialog.tsx | 220 +++++++++++------- .../session/SessionSwitcherDropdown.tsx | 45 +++- .../sidebar/hooks/useSwitcherItems.test.ts | 44 ++++ .../session/sidebar/hooks/useSwitcherItems.ts | 81 ++++++- .../components/ui/dropdown-menu-keyboard.ts | 6 + .../src/components/ui/dropdown-menu.test.ts | 23 ++ .../ui/src/components/ui/dropdown-menu.tsx | 48 +++- packages/ui/src/hooks/useKeyboardShortcuts.ts | 11 + .../src/hooks/useMiniChatKeyboardShortcuts.ts | 10 + .../ui/src/lib/i18n/messages/en.settings.ts | 6 +- .../ui/src/lib/i18n/messages/es.settings.ts | 4 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 4 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 4 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 4 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 4 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 4 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 4 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 4 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 4 +- .../ui/src/lib/shortcuts/DOCUMENTATION.md | 8 +- .../ui/src/lib/shortcuts/dispatcher.test.ts | 29 +++ packages/ui/src/lib/shortcuts/dispatcher.ts | 35 ++- .../ui/src/lib/shortcuts/registry.test.ts | 17 ++ packages/ui/src/lib/shortcuts/registry.ts | 22 ++ 25 files changed, 545 insertions(+), 133 deletions(-) create mode 100644 packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts create mode 100644 packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.test.ts create mode 100644 packages/ui/src/components/ui/dropdown-menu-keyboard.ts create mode 100644 packages/ui/src/components/ui/dropdown-menu.test.ts 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(); } From 669f1603d42f0b1c6774539b7da79f4093ae27c1 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Sat, 1 Aug 2026 09:56:37 +0800 Subject: [PATCH 010/192] fix(ui): handle IME prefixes and select shortcut conflicts --- .../chat/composer/ui/DraftTargetSelectors.tsx | 2 + .../src/components/ui/dropdown-menu.test.ts | 16 +++---- .../ui/src/components/ui/dropdown-menu.tsx | 7 +-- ...enu-keyboard.ts => dropdown-navigation.ts} | 2 +- packages/ui/src/components/ui/select.tsx | 48 ++++++++++++++++++- .../ui/src/lib/shortcuts/DOCUMENTATION.md | 4 +- .../ui/src/lib/shortcuts/dispatcher.test.ts | 28 +++++++++++ packages/ui/src/lib/shortcuts/dispatcher.ts | 25 +++++++--- 8 files changed, 110 insertions(+), 22 deletions(-) rename packages/ui/src/components/ui/{dropdown-menu-keyboard.ts => dropdown-navigation.ts} (57%) diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 0a9c3391..0d62e91f 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -135,6 +135,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) { open={openPicker === 'project'} onOpenChange={(open) => setOpenPicker(open ? 'project' : null)} onValueChange={handleProjectChange} + disableGlobalShortcuts > setOpenPicker(open ? 'worktree' : null)} onValueChange={handleDirectoryChange} + disableGlobalShortcuts > > = {}) { return { @@ -13,11 +13,11 @@ function keyEvent(key: string, modifiers: Partial { - 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); + expect(getDropdownNavigationKey(keyEvent('n', { ctrlKey: true }))).toBe('ArrowDown'); + expect(getDropdownNavigationKey(keyEvent('p', { ctrlKey: true }))).toBe('ArrowUp'); + expect(getDropdownNavigationKey(keyEvent('N', { ctrlKey: true }))).toBe('ArrowDown'); + expect(getDropdownNavigationKey(keyEvent('n'))).toBe(null); + expect(getDropdownNavigationKey(keyEvent('n', { ctrlKey: true, shiftKey: true }))).toBe(null); + expect(getDropdownNavigationKey(keyEvent('p', { ctrlKey: true, altKey: true }))).toBe(null); + expect(getDropdownNavigationKey(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 81e91380..c9adb503 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -4,7 +4,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 { isIMECompositionEvent } from "@/lib/ime"; +import { getDropdownNavigationKey } from "./dropdown-navigation"; import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles"; type AsChildProps = { asChild?: boolean }; @@ -141,8 +142,8 @@ function DropdownMenuContent({ const handleKeyDown: NonNullable['onKeyDown']> = (event) => { onKeyDown?.(event); - if (event.defaultPrevented || event.isPropagationStopped() || event.nativeEvent.isComposing) return; - const navigationKey = getDropdownMenuNavigationKey(event); + if (event.defaultPrevented || event.isPropagationStopped() || isIMECompositionEvent(event)) return; + const navigationKey = getDropdownNavigationKey(event); if (!navigationKey) return; event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { diff --git a/packages/ui/src/components/ui/dropdown-menu-keyboard.ts b/packages/ui/src/components/ui/dropdown-navigation.ts similarity index 57% rename from packages/ui/src/components/ui/dropdown-menu-keyboard.ts rename to packages/ui/src/components/ui/dropdown-navigation.ts index 66f32699..5e3c1a4c 100644 --- a/packages/ui/src/components/ui/dropdown-menu-keyboard.ts +++ b/packages/ui/src/components/ui/dropdown-navigation.ts @@ -1,4 +1,4 @@ -export function getDropdownMenuNavigationKey(event: Pick): 'ArrowDown' | 'ArrowUp' | null { +export function getDropdownNavigationKey(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'; diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index e3ea6220..0d4c19bb 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -8,6 +8,9 @@ import { cn } from "@/lib/utils" import { dropdownTriggerVariants } from "@/components/ui/dropdown-trigger" import { ScrollableOverlay } from "@/components/ui/ScrollableOverlay"; import { Icon } from "@/components/icon/Icon"; +import { shortcutRegistry } from "@/lib/shortcuts"; +import { isIMECompositionEvent } from "@/lib/ime"; +import { getDropdownNavigationKey } from "./dropdown-navigation"; type AsChildProps = { asChild?: boolean }; type AsChildRenderProps = { @@ -36,14 +39,21 @@ type SelectRootProps = Omit< value?: Value; defaultValue?: Value; onValueChange?: (value: Value, eventDetails: SelectRootChangeEventDetails) => void; + disableGlobalShortcuts?: boolean; }; function Select({ onValueChange, modal = false, + disableGlobalShortcuts = false, + open, + defaultOpen, + onOpenChange, ...props }: SelectRootProps) { const [portalContainer, setPortalContainer] = React.useState(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false); + const isOpen = open ?? uncontrolledOpen; const portalContextValue = React.useMemo(() => ({ portalContainer, setPortalContainer, @@ -58,9 +68,26 @@ function Select({ [onValueChange] ); + 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 ( - + ) } @@ -174,12 +201,28 @@ function SelectContent({ sideOffset, side, align, + onKeyDown, ...props }: React.ComponentProps & SelectContentExtra) { const portalContext = React.useContext(SelectPortalContext); const alignItemWithTrigger = position === "item-aligned"; const portalContainer = portalContext?.portalContainer ?? null; + const handleKeyDown: NonNullable['onKeyDown']> = (event) => { + onKeyDown?.(event); + if (event.defaultPrevented || event.isPropagationStopped() || isIMECompositionEvent(event)) return; + const navigationKey = getDropdownNavigationKey(event); + if (!navigationKey) return; + + event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { + key: navigationKey, + bubbles: true, + cancelable: true, + })); + event.preventDefault(); + event.stopPropagation(); + }; + return ( { expect(calls).toEqual(['sequence']); }); + test('consumes a matching captured prefix key during IME composition', () => { + for (const compositionState of [{ isComposing: true }, { keyCode: 229 }]) { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_session_list', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' }); + const secondKey = key('l', compositionState); + + expect(dispatcher.dispatch(key('s', { ctrlKey: true }))).toBe(true); + expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(true); + expect(dispatcher.consumeCapturedPrefixEvent(secondKey)).toBe(true); + expect(calls).toEqual(['sequence']); + } + }); + + test('clears an active prefix but preserves an unmatched IME key', () => { + const registry = new ShortcutRegistry(); + const calls: string[] = []; + registry.register('open_session_list', () => { calls.push('sequence'); }); + const dispatcher = new ShortcutDispatcher({ registry, getBinding: () => 'mod+s l' }); + const secondKey = key('x', { isComposing: true }); + + dispatcher.dispatch(key('s', { ctrlKey: true })); + expect(dispatcher.dispatchActivePrefix(secondKey)).toBe(false); + expect(dispatcher.hasActivePrefix()).toBe(false); + expect(calls).toEqual([]); + }); + 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 deb9f8ab..a2348506 100644 --- a/packages/ui/src/lib/shortcuts/dispatcher.ts +++ b/packages/ui/src/lib/shortcuts/dispatcher.ts @@ -7,6 +7,7 @@ import { } from './bindings'; import { type ShortcutHandler, ShortcutRegistry } from './registry'; import type { ShortcutActionId } from './schema'; +import { isIMECompositionEvent } from '../ime'; const SEQUENCE_TIMEOUT_MS = 1500; const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']); @@ -38,7 +39,7 @@ export class ShortcutDispatcher { } dispatch(event: KeyboardEvent): boolean { - if (event.repeat || event.isComposing || MODIFIER_KEYS.has(event.key.toLowerCase())) { + if (event.repeat || isIMECompositionEvent(event) || MODIFIER_KEYS.has(event.key.toLowerCase())) { return false; } if (event.key === 'Escape' && this.hasActivePrefix()) { @@ -48,11 +49,7 @@ export class ShortcutDispatcher { const matches = this.getMatches(); if (this.prefix) { - const pending = matches.filter((match) => ( - match.chords.length === 2 - && match.chords[0] === this.prefix - && eventMatchesShortcut(event, match.chords[1]) - )); + const pending = this.getPrefixMatches(matches, event); if (pending.length > 0) { this.clear(); return this.invoke(pending, event); @@ -109,6 +106,14 @@ export class ShortcutDispatcher { dispatchActivePrefix(event: KeyboardEvent): boolean { this.capturedPrefixEvents.add(event); + if (isIMECompositionEvent(event)) { + if (event.repeat || MODIFIER_KEYS.has(event.key.toLowerCase()) || !this.hasActivePrefix()) { + return false; + } + const pending = this.getPrefixMatches(this.getMatches(), event); + this.clear(); + return pending.length > 0 ? this.invoke(pending, event) : false; + } return this.dispatch(event); } @@ -127,6 +132,14 @@ export class ShortcutDispatcher { return false; } + private getPrefixMatches(matches: BindingMatch[], event: KeyboardEvent): BindingMatch[] { + return matches.filter((match) => ( + match.chords.length === 2 + && match.chords[0] === this.prefix + && eventMatchesShortcut(event, match.chords[1]) + )); + } + private getMatches(): BindingMatch[] { const matches: BindingMatch[] = []; for (const actionId of this.options.registry.actionIds()) { From 8d968f3d715769496210453b2bb46d267a598fdd Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Thu, 6 Aug 2026 01:33:31 +0800 Subject: [PATCH 011/192] fix(ui): enforce shortcut conflict rules --- .../chat/composer/ui/DraftTargetSelectors.tsx | 8 +- .../openchamber/KeyboardShortcutsSettings.tsx | 1 - .../ShortcutRecordingDialog.test.ts | 26 ++-- .../openchamber/ShortcutRecordingDialog.tsx | 116 +++++++++--------- packages/ui/src/components/ui/select.tsx | 6 +- .../ui/src/lib/i18n/messages/de.settings.ts | 20 +++ .../ui/src/lib/i18n/messages/en.settings.ts | 7 +- .../ui/src/lib/i18n/messages/es.settings.ts | 7 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 5 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 5 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 5 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 5 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 5 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 5 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 5 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 5 +- .../ui/src/lib/shortcuts/DOCUMENTATION.md | 6 +- packages/ui/src/lib/shortcuts/index.ts | 2 + packages/ui/src/lib/shortcuts/schema.test.ts | 22 ++++ packages/ui/src/lib/shortcuts/schema.ts | 25 ++++ 20 files changed, 184 insertions(+), 102 deletions(-) diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index 0d62e91f..32c5dbbb 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -148,7 +148,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) { {projects.map((project) => ( - + {} ))} @@ -176,7 +176,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) { {projectRootBranchOption ? ( {t('chat.chatInput.projectRoot')} - + {projectRootBranchOption.label} @@ -195,13 +195,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
    {worktreeBranchOptions.map((option) => ( - + {option.pending ? '⏳ ' : ''}{option.label} ))} {selectedDirectory && !selectedBranchIsKnown ? ( - + {selectedBranchLabel} ) : null} diff --git a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx index 15591474..bd36e40d 100644 --- a/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/KeyboardShortcutsSettings.tsx @@ -121,7 +121,6 @@ export const KeyboardShortcutsSettings: React.FC = () => { })} { diff --git a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts index 75fba52f..962d42f8 100644 --- a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts @@ -10,28 +10,28 @@ function keyEvent(key: string, modifiers: Partial { 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(); + expect(pressed.livePreview).toBe('mod+shift'); + expect(updateShortcutRecordingState(pressed, keyEvent('Control'), 'keyup').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']); + const second = updateShortcutRecordingState(first, keyEvent('p', { ctrlKey: true }), 'keydown'); + const third = updateShortcutRecordingState(second, keyEvent('x', { ctrlKey: true }), 'keydown'); + expect(first.chords).toEqual(['mod+k']); + expect(second.chords).toEqual(['mod+k', 'mod+p']); + expect(third.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); + expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), repeat: true }, 'keydown')).toEqual(emptyState); + expect(updateShortcutRecordingState(emptyState, { ...keyEvent('k', { ctrlKey: true }), isComposing: true }, 'keydown')).toEqual(emptyState); }); - test('uses Enter and Escape for dialog actions and Backspace to remove the final chord', () => { + test('records Enter and Escape while Backspace removes 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']); + expect(updateShortcutRecordingState(emptyState, keyEvent('Enter'), 'keydown').chords).toEqual(['enter']); + expect(updateShortcutRecordingState(emptyState, keyEvent('Escape'), 'keydown').chords).toEqual(['escape']); + expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').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 57c83458..68353904 100644 --- a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx @@ -10,13 +10,12 @@ import { import { Button } from '@/components/ui/button'; import { formatShortcutForDisplay, - getEffectiveShortcutCombo, - getEffectiveShortcutPrefix, - getShortcutConflict, + getShortcutBindingConflicts, isRiskyBrowserShortcut, keyToShortcutToken, normalizeCombo, type ShortcutActionId, + type ShortcutBindingConflict, type ShortcutCombo, type CustomizableShortcutAction, } from '@/lib/shortcuts'; @@ -39,11 +38,8 @@ interface ShortcutRecordingState { livePreview: ShortcutCombo | null; } -type ShortcutRecordingAction = 'cancel' | 'none' | 'save'; - interface ShortcutRecordingDialogProps { action: CustomizableShortcutAction | null; - actions: ReadonlyArray; overrides: Record; onSave: ( actionId: ShortcutActionId, @@ -53,6 +49,12 @@ interface ShortcutRecordingDialogProps { onOpenChange: (open: boolean) => void; } +function isCustomizableConflict( + conflict: ShortcutBindingConflict, +): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } { + return conflict.action.customizable; +} + function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null { const parts: string[] = []; if (event.metaKey || event.ctrlKey) parts.push('mod'); @@ -91,35 +93,29 @@ export function updateShortcutRecordingState( state: ShortcutRecordingState, event: RecordingKeyboardEvent, phase: 'keydown' | 'keyup', -): { action: ShortcutRecordingAction; state: ShortcutRecordingState } { - if (event.repeat || event.isComposing) return { action: 'none', state }; +): ShortcutRecordingState { + if (event.repeat || event.isComposing) return state; if (phase === 'keyup') { - return { action: 'none', state: { ...state, livePreview: getModifierPreview(event) } }; + return { ...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 } }; + return { 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, - }, + chords: state.chords.length < 2 ? [...state.chords, chord] : state.chords, + livePreview: null, }; } - return { action: 'none', state: { ...state, livePreview: getModifierPreview(event) } }; + return { ...state, livePreview: getModifierPreview(event) }; } export const ShortcutRecordingDialog: React.FC = ({ action, - actions, overrides, onSave, onOpenChange, @@ -136,26 +132,19 @@ export const ShortcutRecordingDialog: React.FC = ( }, [action]); const combo = normalizeCombo(recording.chords.join(' ')); - const conflicts = React.useMemo(() => { - if (!action || !combo) return []; - const result: Array<{ action: CustomizableShortcutAction; kind: 'exact' | 'prefix' }> = []; - for (const candidate of actions) { - if (candidate.id === action.id) continue; - const candidateCombo = candidate.id === 'switch_context_surface' - ? getEffectiveShortcutPrefix(candidate.id, overrides) - : getEffectiveShortcutCombo(candidate.id, overrides); - const kind = getShortcutConflict(combo, candidateCombo); - if (kind) result.push({ action: candidate, kind }); - } - return result; - }, [action, actions, combo, overrides]); - const prefixConflict = conflicts.find((conflict) => conflict.kind === 'prefix'); - const exactConflict = conflicts.find((conflict) => conflict.kind === 'exact'); + const conflicts = React.useMemo( + () => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [], + [action, combo, overrides], + ); + const protectedConflict = conflicts.find((conflict) => !conflict.action.customizable); + const customizableConflicts = conflicts.filter(isCustomizableConflict); + const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix'); + const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact'); const close = () => onOpenChange(false); - const save = () => { - if (!action || !combo || prefixConflict || exactConflict) return; - onSave(action.id, combo); + const confirm = () => { + if (!action || !combo || protectedConflict || prefixConflict) return; + onSave(action.id, combo, exactConflict?.action.id); close(); }; const handleRecordingEvent = (event: React.KeyboardEvent, phase: 'keydown' | 'keyup') => { @@ -168,7 +157,7 @@ export const ShortcutRecordingDialog: React.FC = ( return; } } - const result = updateShortcutRecordingState(recording, { + const nextRecording = updateShortcutRecordingState(recording, { altKey: event.altKey, ctrlKey: event.ctrlKey, isComposing: event.nativeEvent.isComposing, @@ -177,16 +166,21 @@ export const ShortcutRecordingDialog: React.FC = ( 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(); + setRecording(action?.id === 'switch_context_surface' && nextRecording.chords.length > 1 + ? { ...nextRecording, chords: nextRecording.chords.slice(0, 1) } + : nextRecording); }; return ( - - + { + if (!open) { + eventDetails.cancel(); + } + }} + > + {action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''} @@ -221,12 +215,16 @@ export const ShortcutRecordingDialog: React.FC = (
    - {prefixConflict ? ( + {protectedConflict ? ( +

    + {t('settings.openchamber.keyboardShortcuts.error.internalConflict')} +

    + ) : prefixConflict ? (

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

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

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

    @@ -237,17 +235,19 @@ export const ShortcutRecordingDialog: React.FC = (

    ) : null} - {exactConflict && !prefixConflict ? ( - - - - ) : null} + + + + ); diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index 0d4c19bb..7cc3b7f6 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -286,13 +286,17 @@ function SelectLabel({ function SelectItem({ className, children, + showSelectedBackground = true, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + showSelectedBackground?: boolean; +}) { return ( `. Each binding has one chor Contextual internal commands may deliberately share a sequence leader. The single-chord handler gets the first chance to handle the event; returning `false` lets the dispatcher start the sequence. The active file editor therefore owns `mod+s` for saving, while a mounted but unfocused editor yields `mod+s p`, `mod+s g`, and `mod+s l` to the draft target pickers and session list. -Runtime-specific commands may also share an exact binding when their handlers are mutually exclusive. `open_diff_panel` handles `mod+2` on desktop, while `switch_tab_2` handles it on mobile; each returns `false` outside its runtime so the dispatcher can try the next registered action. +The internal `switch_tab_*` bindings remain available to mobile handlers. Desktop numeric context-surface switching is resolved by the configurable `switch_context_surface` prefix before normal dispatcher matching and falls through on mobile. -The settings recorder also stops at two chords. It keeps the recording local until the user explicitly saves, allows an exact conflict to replace the previous assignment, and blocks prefix conflicts because they make dispatch ambiguous. +The settings recorder also stops at two chords and checks the complete schema, not only customizable actions. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts because they make dispatch ambiguous. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced. # Dispatching @@ -43,7 +43,7 @@ Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGloba 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. -Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. +Local key handling remains appropriate for text editing, IME composition, menu and list navigation, dialog confirmation, terminal input, and other interactions that do not represent configurable application commands. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording. # Adding shortcuts diff --git a/packages/ui/src/lib/shortcuts/index.ts b/packages/ui/src/lib/shortcuts/index.ts index 7ef4ab99..53f0f1b0 100644 --- a/packages/ui/src/lib/shortcuts/index.ts +++ b/packages/ui/src/lib/shortcuts/index.ts @@ -17,6 +17,7 @@ export { shortcutRegistry } from './registry'; export type { ShortcutHandler } from './registry'; export { getCustomizableShortcutActions, + getShortcutBindingConflicts, getEffectiveShortcutCombo, getEffectiveShortcutPrefix, getShortcutAction, @@ -24,6 +25,7 @@ export { } from './schema'; export type { CustomizableShortcutAction, + ShortcutBindingConflict, ShortcutActionId, ShortcutCategory, } from './schema'; diff --git a/packages/ui/src/lib/shortcuts/schema.test.ts b/packages/ui/src/lib/shortcuts/schema.test.ts index 463b92a0..a40512cc 100644 --- a/packages/ui/src/lib/shortcuts/schema.test.ts +++ b/packages/ui/src/lib/shortcuts/schema.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'; import { getCustomizableShortcutActions, getEffectiveShortcutCombo, + getShortcutBindingConflicts, getShortcutAction, parseShortcut, SHORTCUT_SCHEMA, @@ -59,4 +60,25 @@ describe('shortcut schema', () => { expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k' })).toBe('mod+k'); expect(getEffectiveShortcutCombo('new_chat', { new_chat: 'mod+k x y' })).toBe('mod+n'); }); + + test('keeps internal bindings authoritative over persisted overrides', () => { + expect(getEffectiveShortcutCombo('save_file', { save_file: 'mod+k' })).toBe('mod+s'); + expect(getEffectiveShortcutCombo('save_file', { save_file: '__unassigned__' })).toBe('mod+s'); + }); + + test('detects conflicts against customizable and internal bindings', () => { + const customizableConflict = getShortcutBindingConflicts('new_chat', 'mod+p') + .find((conflict) => conflict.action.id === 'open_command_palette'); + const internalConflict = getShortcutBindingConflicts('new_chat', 'mod+f') + .find((conflict) => conflict.action.id === 'find_in_file'); + const internalPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+s x') + .find((conflict) => conflict.action.id === 'save_file'); + + expect(customizableConflict?.kind).toBe('exact'); + expect(customizableConflict?.action.customizable).toBe(true); + expect(internalConflict?.kind).toBe('exact'); + expect(internalConflict?.action.customizable).toBe(false); + expect(internalPrefixConflict?.kind).toBe('prefix'); + expect(internalPrefixConflict?.action.customizable).toBe(false); + }); }); diff --git a/packages/ui/src/lib/shortcuts/schema.ts b/packages/ui/src/lib/shortcuts/schema.ts index 23ad1077..514b3e13 100644 --- a/packages/ui/src/lib/shortcuts/schema.ts +++ b/packages/ui/src/lib/shortcuts/schema.ts @@ -1,9 +1,11 @@ import { + getShortcutConflict, isValidShortcutCombo, normalizeCombo, parseShortcut, UNASSIGNED_SHORTCUT, type ShortcutCombo, + type ShortcutConflict, } from './bindings'; import { SHORTCUT_SCHEMA } from './config'; @@ -13,6 +15,10 @@ export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number]; export type ShortcutActionId = ShortcutAction['id']; export type ShortcutCategory = ShortcutAction['category']; export type CustomizableShortcutAction = Extract; +export type ShortcutBindingConflict = { + action: ShortcutAction; + kind: ShortcutConflict; +}; export function getShortcutAction(id: string): ShortcutAction | undefined { return SHORTCUT_SCHEMA.find((action) => action.id === id); @@ -30,6 +36,7 @@ export function getEffectiveShortcutCombo( ): ShortcutCombo { const action = getShortcutAction(actionId); if (!action) return ''; + if (!action.customizable) return action.defaultBinding; const override = overrides?.[actionId]; if (typeof override === 'string') { @@ -47,6 +54,7 @@ export function getEffectiveShortcutPrefix( ): ShortcutCombo { const action = getShortcutAction(actionId); if (!action) return ''; + if (!action.customizable) return action.defaultBinding; const override = overrides?.[actionId]; if (typeof override === 'string' && override.trim() !== '') { @@ -58,3 +66,20 @@ export function getEffectiveShortcutPrefix( return action.defaultBinding; } + +export function getShortcutBindingConflicts( + actionId: ShortcutActionId, + combo: ShortcutCombo, + overrides?: Record, +): ShortcutBindingConflict[] { + const conflicts: ShortcutBindingConflict[] = []; + for (const candidate of SHORTCUT_SCHEMA) { + if (candidate.id === actionId) continue; + const candidateCombo = candidate.id === 'switch_context_surface' + ? getEffectiveShortcutPrefix(candidate.id, overrides) + : getEffectiveShortcutCombo(candidate.id, overrides); + const kind = getShortcutConflict(combo, candidateCombo); + if (kind) conflicts.push({ action: candidate, kind }); + } + return conflicts; +} From 45a792d6576d507fea57d01a35526195f6c7bdcb Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Thu, 6 Aug 2026 08:49:44 +0800 Subject: [PATCH 012/192] feat(ui): show platform-specific shortcut labels --- .../chat/composer/ui/FocusModeButton.tsx | 17 ++++++-- .../comments/InlineCommentInput.tsx | 6 ++- .../openchamber/OpenChamberVisualSettings.tsx | 6 ++- .../session/DirectoryExplorerDialog.tsx | 5 +-- packages/ui/src/components/ui/HelpDialog.tsx | 6 +-- .../ui/src/components/views/FilesView.tsx | 9 ++-- .../ui/src/components/views/SettingsView.tsx | 20 +++++++-- .../ui/src/components/views/TerminalView.tsx | 5 ++- .../ui/src/lib/i18n/messages/de.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/de.ts | 2 +- .../ui/src/lib/i18n/messages/en.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/en.ts | 2 +- .../ui/src/lib/i18n/messages/es.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/es.ts | 2 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/fr.ts | 2 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/ja.ts | 2 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/ko.ts | 2 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/pl.ts | 2 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/uk.ts | 2 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 4 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 +- .../ui/src/lib/shortcuts/DOCUMENTATION.md | 2 +- .../ui/src/lib/shortcuts/bindings.test.ts | 20 +++++++++ packages/ui/src/lib/shortcuts/bindings.ts | 43 +++++++++++++------ packages/ui/src/lib/shortcuts/index.ts | 1 - 34 files changed, 135 insertions(+), 71 deletions(-) diff --git a/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx b/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx index 74251911..d740de34 100644 --- a/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx +++ b/packages/ui/src/components/chat/composer/ui/FocusModeButton.tsx @@ -5,7 +5,12 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useI18n } from '@/lib/i18n'; -import { cn, isMacOS } from '@/lib/utils'; +import { + formatShortcutForDisplay, + getEffectiveShortcutCombo, +} from '@/lib/shortcuts'; +import { cn } from '@/lib/utils'; +import { useUIStore } from '@/stores/useUIStore'; type FocusModeButtonProps = { footerIconButtonClass: string; @@ -17,6 +22,12 @@ type FocusModeButtonProps = { export const FocusModeButton = React.memo(function FocusModeButton(props: FocusModeButtonProps) { const { footerIconButtonClass, iconSizeClass, isExpandedInput, onToggle } = props; const { t } = useI18n(); + const expandInputShortcutOverride = useUIStore((state) => state.shortcutOverrides.expand_input); + const expandInputCombo = getEffectiveShortcutCombo( + 'expand_input', + expandInputShortcutOverride === undefined ? undefined : { expand_input: expandInputShortcutOverride }, + ); + const shortcut = expandInputCombo ? formatShortcutForDisplay(expandInputCombo) : null; return ( @@ -43,9 +54,7 @@ export const FocusModeButton = React.memo(function FocusModeButton(props: FocusM
    {t('chat.chatInput.focusMode.label')} - - {isMacOS() ? '⌘⇧E' : 'Ctrl+Shift+E'} - + {shortcut ? {shortcut} : null}
    diff --git a/packages/ui/src/components/comments/InlineCommentInput.tsx b/packages/ui/src/components/comments/InlineCommentInput.tsx index 7b76b5aa..bfe8a573 100644 --- a/packages/ui/src/components/comments/InlineCommentInput.tsx +++ b/packages/ui/src/components/comments/InlineCommentInput.tsx @@ -5,6 +5,7 @@ import { Textarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; import { useDeviceInfo } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; export interface InlineCommentInputProps { initialText?: string; @@ -35,6 +36,7 @@ export function InlineCommentInput({ const { isMobile } = useDeviceInfo(); const [text, setText] = React.useState(initialText); const textareaRef = useRef(null); + const saveShortcut = formatShortcutForDisplay('mod+enter'); const handleTextChange = (value: string) => { setText(value); @@ -154,7 +156,9 @@ export function InlineCommentInput({ value={text} onChange={(e) => handleTextChange(e.target.value)} onKeyDown={handleKeyDown} - placeholder={isMobile ? t('inlineComment.input.placeholderShort') : t('inlineComment.input.placeholder')} + placeholder={isMobile + ? t('inlineComment.input.placeholderShort') + : t('inlineComment.input.placeholder', { shortcut: saveShortcut })} outerClassName="rounded-[var(--radius-xl)] bg-[var(--surface-subtle)] ring-1 ring-inset ring-border/60 focus-within:ring-2 focus-within:ring-[var(--interactive-focus-ring)]" className="min-h-[80px] px-3 py-2.5 text-sm resize-y" /> diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 73d76470..820fde9d 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -64,6 +64,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import type { TerminalShellOption } from '@/lib/api/types'; import { isTerminalShell } from '@/lib/terminalShell'; import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; interface Option { id: T; @@ -1543,7 +1544,10 @@ export const OpenChamberVisualSettings: React.FC label={t('settings.openchamber.visual.field.terminalQuickKeys')} ariaLabel={t('settings.openchamber.visual.field.terminalQuickKeysAria')} settingsItem="appearance.terminal-quick-keys" - info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip')} + info={t('settings.openchamber.visual.field.terminalQuickKeysTooltip', { + control: formatShortcutForDisplay('ctrl'), + alt: formatShortcutForDisplay('alt'), + })} /> )} {showTerminalShellSetting && ( diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index 81d998a4..0035e44b 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -22,6 +22,7 @@ import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { Icon } from "@/components/icon/Icon"; import { opencodeClient } from '@/lib/opencode/client'; import { useI18n } from '@/lib/i18n'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; interface DirectoryExplorerDialogProps { open: boolean; @@ -338,9 +339,7 @@ export const DirectoryExplorerDialog: React.FC = ( const hasHighlightedBrowseItem = Boolean( highlightedRow && (highlightedRow.type === 'up' || (highlightedRow.type === 'directory' && !highlightedRow.disabled)) ); - const submitModifierLabel = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform) - ? '⌘' - : 'Ctrl'; + const submitModifierLabel = formatShortcutForDisplay('mod'); const submitActionLabel = isAlreadyAdded ? t('directoryExplorerDialog.actions.alreadyAdded') : isCloneMode diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 7ce7df29..6080ac29 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -11,7 +11,6 @@ import { useUIStore } from "@/stores/useUIStore"; import { getEffectiveShortcutCombo, getShortcutAction, - getModifierLabel, formatShortcutForDisplay, type ShortcutActionId, } from "@/lib/shortcuts"; @@ -44,7 +43,6 @@ export const HelpDialog: React.FC = () => { const isHelpDialogOpen = useUIStore((state) => state.isHelpDialogOpen); const setHelpDialogOpen = useUIStore((state) => state.setHelpDialogOpen); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const mod = getModifierLabel(); const isVSCode = isVSCodeRuntime(); const shortcuts: ShortcutSection[] = [ @@ -104,7 +102,7 @@ export const HelpDialog: React.FC = () => { keys: '', }, { - keys: [`Shift + Alt + ${mod} + N`], + keys: [formatShortcutForDisplay('mod+shift+alt+n')], descriptionKey: "helpDialog.item.newWindow", icon: "window", }, @@ -195,7 +193,7 @@ export const HelpDialog: React.FC = () => { keys: '', }, { - keys: [`${mod} + 1...0`], + keys: [`${formatShortcutForDisplay('mod')} + 1...0`], descriptionKey: "helpDialog.item.switchContextSurface", icon: "layout-right", }, diff --git a/packages/ui/src/components/views/FilesView.tsx b/packages/ui/src/components/views/FilesView.tsx index 4de4f8f2..8ac76a40 100644 --- a/packages/ui/src/components/views/FilesView.tsx +++ b/packages/ui/src/components/views/FilesView.tsx @@ -53,7 +53,7 @@ import { getOutsideFileGrant } from '@/lib/outsideFileGrants'; import { DiagramEditor } from '@/components/diagram'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useKeybind, useKeybinds } from '@/hooks/useKeybind'; -import { getModifierLabel } from '@/lib/shortcuts'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { EditorView } from '@codemirror/view'; import type { Extension } from '@codemirror/state'; import { useThemeSystem } from '@/contexts/useThemeSystem'; @@ -3167,6 +3167,7 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { } const docked = layout === 'docked'; + const saveShortcut = formatShortcutForDisplay(getEffectiveShortcutCombo('save_file')); const wrapperCls = docked ? 'pointer-events-auto flex flex-wrap items-center gap-1' : 'pointer-events-auto flex items-center gap-1 rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] p-1 shadow-sm'; @@ -3196,14 +3197,14 @@ export const FilesView: React.FC = ({ mode = 'full' }) => { {t('filesView.editor.saved')} - ) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: `${getModifierLabel()}+S` }), + ) : isDirty ? withTooltip(t(autoSaveEnabled ? 'filesView.editor.saveNowTitle' : 'filesView.editor.saveNowManualTitle', { shortcut: saveShortcut }), diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index 325e73c3..9d706cc1 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { cn } from '@/lib/utils'; -import { getModifierLabel } from '@/lib/shortcuts'; +import { + formatShortcutForDisplay, + getEffectiveShortcutCombo, +} from '@/lib/shortcuts'; import { useUIStore } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useAgentsStore } from '@/stores/useAgentsStore'; @@ -240,6 +243,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile const settingsPageRaw = useUIStore((state) => state.settingsPage); const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen); const setSettingsPage = useUIStore((state) => state.setSettingsPage); + const openSettingsShortcutOverride = useUIStore((state) => state.shortcutOverrides.open_settings); const settingsSlug = resolveSettingsSlug(settingsPageRaw); const [mobileStage, setMobileStage] = React.useState(initialMobileStage); @@ -751,7 +755,15 @@ export const SettingsView: React.FC = ({ onClose, forceMobile : showBackButton ? t('settings.view.actions.backToSettings') : t('settings.view.actions.closeSettings'); - const shortcutKey = getModifierLabel(); + const openSettingsCombo = getEffectiveShortcutCombo( + 'open_settings', + openSettingsShortcutOverride === undefined ? undefined : { open_settings: openSettingsShortcutOverride }, + ); + const closeSettingsTitle = openSettingsCombo + ? t('settings.view.actions.closeSettingsWithShortcut', { + shortcut: formatShortcutForDisplay(openSettingsCombo), + }) + : t('settings.view.actions.closeSettings'); const pushMobileSplitDetailHistory = React.useCallback((slug: SettingsPageSlug) => { if (typeof window === 'undefined' || runtimeCtx.isVSCode) { @@ -1119,7 +1131,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile type="button" onClick={onClose} aria-label={t('settings.view.actions.closeSettings')} - title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })} + title={closeSettingsTitle} className="inline-flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg p-2 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" > @@ -1147,7 +1159,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile type="button" onClick={onClose} aria-label={t('settings.view.actions.closeSettings')} - title={t('settings.view.actions.closeSettingsWithShortcut', { shortcut: shortcutKey })} + title={closeSettingsTitle} className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary" > diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index c97f608e..fce15637 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -21,6 +21,7 @@ import { useI18n } from '@/lib/i18n'; import { PROJECT_ACTION_ICON_MAP, type ProjectActionIconKey } from '@/lib/projectActions'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; import { applyTerminalModifier, terminalControlCharacter, terminalSequenceForKey, type TerminalModifier as Modifier, type TerminalQuickKey as MobileKey } from '@/lib/terminalInput'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; type TerminalViewProps = { visible?: boolean; @@ -924,7 +925,7 @@ export const TerminalView: React.FC = ({ visible }) => { onClick={() => handleModifierToggle('ctrl')} disabled={quickKeysDisabled} > - {t('terminalView.quickKeys.controlLabel')} + {formatShortcutForDisplay('ctrl')} {t('terminalView.quickKeys.controlModifierAria')}
    - {protectedConflict ? ( + {recording.settled && protectedConflict ? (

    {t('settings.openchamber.keyboardShortcuts.error.internalConflict')}

    - ) : prefixConflict ? ( + ) : recording.settled && prefixConflict ? (

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

    ) : null} - {exactConflict && !protectedConflict && !prefixConflict ? ( + {recording.settled && exactConflict && !protectedConflict && !prefixConflict ? (

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

    ) : null} - {combo && isRiskyBrowserShortcut(combo) ? ( + {recording.settled && contextualPrefixConflict && !protectedConflict && !prefixConflict ? ( +

    + {t('settings.openchamber.keyboardShortcuts.warning.contextualPrefix', { + action: conflictActionLabel(contextualPrefixConflict), + })} +

    + ) : null} + {recording.settled && combo && isRiskyBrowserShortcut(combo) ? (

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

    @@ -242,7 +299,7 @@ export const ShortcutRecordingDialog: React.FC = ( {showBranchSelector ? ( @@ -275,7 +281,7 @@ export function MobileDraftTargetSheets( onOpenPickerChange(null); }} > - {} + {project.id === selectedProject.id ? ( ) : null} diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 37d9cac8..1dfa2275 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -22,6 +22,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionWorktreeStore } from '@/sync/session-worktree-store'; import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract'; import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionMessagesResolved } from '@/sync/sync-context'; +import { useDirectoryStore as useAppDirectoryStore } from '@/stores/useDirectoryStore'; +import { isChatDirectoryForHome } from '@/lib/chatDirectories'; import { useSync } from '@/sync/use-sync'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; @@ -1052,6 +1054,10 @@ export const Header: React.FC = ({ } return normalize(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? ''); }); + const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target); + const draftProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId); + const selectedSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); + const homeDirectory = useAppDirectoryStore((state) => state.homeDirectory); const openDirectory = React.useMemo(() => { return worktreeDirectory || sessionDirectory || draftDirectory; @@ -1080,10 +1086,13 @@ export const Header: React.FC = ({ const gitBranchForDirectory = useGitBranchLabel(openDirectory || null); const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch; + const isChatContext = isNewSessionDraftOpen + ? draftTarget === 'chat' + : isChatDirectoryForHome(sessionDirectory || selectedSessionDirectory, homeDirectory); // Whether the title carries a second line under it. Hoisted because the // session menu's vertical alignment depends on the same answer. - const showHeaderMetaRow = !workStatusPanelVisible + const showHeaderMetaRow = !isChatContext && !workStatusPanelVisible && Boolean(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind)); @@ -1423,14 +1432,14 @@ export const Header: React.FC = ({ const handleOpenDraftMiniChat = React.useCallback(() => { void invokeDesktop('desktop_open_draft_mini_chat_window', { - directory: normalize(openDirectory || activeProject?.path || ''), - projectId: activeProject?.id ?? null, + directory: isChatContext ? '' : draftDirectory, + projectId: isChatContext ? null : draftProjectId, apiBaseUrl: getRuntimeApiBaseUrl(), clientToken: getRuntimeBearerTokenSync(), }).catch((error) => { console.warn('[header] failed to open draft mini chat window', error); }); - }, [activeProject?.id, activeProject?.path, openDirectory]); + }, [draftDirectory, draftProjectId, isChatContext]); const handleOpenCurrentMiniChat = React.useCallback(() => { if (isNewSessionDraftOpen) { @@ -1443,13 +1452,13 @@ export const Header: React.FC = ({ } void invokeDesktop('desktop_open_session_mini_chat_window', { sessionId: currentSessionId, - directory: normalize(openDirectory || activeProject?.path || ''), + directory: sessionDirectory || normalize(selectedSessionDirectory || '') || worktreeDirectory, apiBaseUrl: getRuntimeApiBaseUrl(), clientToken: getRuntimeBearerTokenSync(), }).catch((error) => { console.warn('[header] failed to open session mini chat window', error); }); - }, [activeProject?.path, currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, openDirectory]); + }, [currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, selectedSessionDirectory, sessionDirectory, worktreeDirectory]); const handleOpenContextPanel = React.useCallback(() => { const directory = normalize(openDirectory || ''); diff --git a/packages/ui/src/components/layout/RightSidebarTabs.tsx b/packages/ui/src/components/layout/RightSidebarTabs.tsx index cd8b2f1e..8138bef1 100644 --- a/packages/ui/src/components/layout/RightSidebarTabs.tsx +++ b/packages/ui/src/components/layout/RightSidebarTabs.tsx @@ -5,6 +5,9 @@ import { useGitStore } from '@/stores/useGitStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { formatDirectoryName } from '@/lib/utils'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories'; +import { useI18n } from '@/lib/i18n'; export const ProjectContextPanel: React.FC<{ onActionComplete?: () => void; @@ -13,16 +16,28 @@ export const ProjectContextPanel: React.FC<{ const activeProjectId = useProjectsStore((state) => state.activeProjectId); const projects = useProjectsStore((state) => state.projects); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); + const { t } = useI18n(); const gitDirectories = useGitStore((state) => state.directories); + const isChatContext = useSessionUIStore((state) => ( + state.newSessionDraft.open + ? state.newSessionDraft.target === 'chat' + : isChatDirectoryPath(state.currentSessionDirectory) + )); + const chatSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory); + const chatsRoot = getChatsRootFromDirectory(chatSessionDirectory) ?? getChatsRootForHome(homeDirectory); const activeProject = React.useMemo(() => { + if (isChatContext) return null; if (activeProjectId) { return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null; } return projects[0] ?? null; - }, [activeProjectId, projects]); + }, [activeProjectId, isChatContext, projects]); const projectRef = React.useMemo(() => { + if (isChatContext && chatsRoot) { + return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot }; + } if (!activeProject) { return null; } @@ -30,16 +45,17 @@ export const ProjectContextPanel: React.FC<{ id: activeProject.id, path: activeProject.path, }; - }, [activeProject]); + }, [activeProject, chatsRoot, isChatContext]); const projectLabel = React.useMemo(() => { + if (isChatContext) return t('sessions.sidebar.activity.chatsTitle'); if (!activeProject) { return null; } return activeProject.label?.trim() || formatDirectoryName(activeProject.path, homeDirectory) || activeProject.path; - }, [activeProject, homeDirectory]); + }, [activeProject, homeDirectory, isChatContext, t]); const canCreateWorktree = React.useMemo(() => { if (!activeProject) { diff --git a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx index a32779a1..6e4eeec8 100644 --- a/packages/ui/src/components/mini-chat/MiniChatLayout.tsx +++ b/packages/ui/src/components/mini-chat/MiniChatLayout.tsx @@ -20,6 +20,7 @@ import { Icon } from "@/components/icon/Icon"; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; +import { isChatDirectoryPath } from '@/lib/chatDirectories'; type MiniChatMode = 'session' | 'draft'; @@ -51,6 +52,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const draftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); + const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target); const draftProjectId = useSessionUIStore((state) => state.newSessionDraft?.selectedProjectId ?? null); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const projects = useProjectsStore((state) => state.projects); @@ -99,6 +101,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { const worktreeDirectory = normalizePath(worktreePath || sessionWorktreeMetadata?.path || worktreeAttachment?.cwd || worktreeAttachment?.worktreeRoot || ''); const currentDirectoryNormalized = normalizePath(currentDirectory); const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory || currentDirectoryNormalized; + const isChatContext = draftOpen ? draftTarget === 'chat' : isChatDirectoryPath(sessionDirectory); const directoryLabel = compactPath(openDirectory); const catalogWorktreeBranch = useSessionUIStore((state) => { const candidateDirectory = normalizePath(worktreeDirectory || sessionDirectory || ''); @@ -111,9 +114,9 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { return null; }); React.useEffect(() => { - if (!openDirectory) return; + if (!openDirectory || isChatContext) return; void ensureGitStatus(openDirectory, runtimeApis.git).catch(() => {}); - }, [ensureGitStatus, openDirectory, runtimeApis.git]); + }, [ensureGitStatus, isChatContext, openDirectory, runtimeApis.git]); const pathMatchedProject = React.useMemo(() => { const projectDirectory = normalizePath(sessionWorktreeMetadata?.projectDirectory ?? worktreeAttachment?.worktreeRoot ?? null); @@ -125,13 +128,14 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { .sort((left, right) => right.path.length - left.path.length)[0] ?? null; }, [openDirectory, projects, sessionWorktreeMetadata?.projectDirectory, worktreeAttachment?.worktreeRoot]); const projectLabel = React.useMemo(() => { + if (isChatContext) return null; const project = pathMatchedProject ?? activeProject; if (!project) return directoryLabel || 'OpenChamber'; const label = project.label?.trim(); if (label) return label; const segments = project.path.split(/[\\/]/).filter(Boolean); return segments.at(-1) ?? project.path; - }, [activeProject, directoryLabel, pathMatchedProject]); + }, [activeProject, directoryLabel, isChatContext, pathMatchedProject]); const gitBranchForDirectory = useGitBranchLabel(openDirectory || null); const rawBranchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch; const branchLabel = rawBranchLabel && rawBranchLabel !== 'HEAD' ? rawBranchLabel : null; @@ -241,7 +245,11 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { const handleOpenMainApp = React.useCallback(() => { const payload = currentSessionId ? { sessionId: currentSessionId, directory: (session as { directory?: string | null } | null)?.directory ?? currentDirectory ?? '' } - : { mode: 'draft', directory: openDirectory || currentDirectory || '', projectId: draftProjectId }; + : { + mode: 'draft', + directory: isChatContext ? '' : openDirectory || currentDirectory || '', + projectId: isChatContext ? null : draftProjectId, + }; void invokeDesktop<{ focused?: boolean }>('desktop_focus_main_window', payload) .then((result) => { if (result?.focused === true) { @@ -249,7 +257,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { } return null; }); - }, [currentDirectory, currentSessionId, draftProjectId, openDirectory, session]); + }, [currentDirectory, currentSessionId, draftProjectId, isChatContext, openDirectory, session]); return (
    = ({ mode }) => { {title} - + {!isChatContext ? {projectLabel} {branchLabel ? ( @@ -281,7 +289,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { {branchLabel} ) : null} - + : null}
    diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 1d14494f..11922074 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { isChatDirectoryForHome, isChatDirectoryPath } from '@/lib/chatDirectories'; +import { mergeSidebarSessionSources } from './sidebar/sidebarSessionSources'; import type { Session } from '@opencode-ai/sdk/v2'; import { toast } from '@/components/ui'; import { useI18n } from '@/lib/i18n'; @@ -439,6 +441,7 @@ const SessionSidebarComponent: React.FC = ({ const liveSessionIndex = getAllSyncSessionMap(); const liveSessions = React.useMemo(() => Array.from(liveSessionIndex.values()), [liveSessionIndex]); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); + const runtimeKey = getRuntimeKey(); const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready'); const activeSessionStructure = useGlobalSessionsStore(useShallow( (state) => state.activeSessions.map(getSessionStructuralSignature).sort(), @@ -506,20 +509,15 @@ const SessionSidebarComponent: React.FC = ({ ); const sessions = React.useMemo(() => { - const merged = [...globalActiveSessions]; - const seenIds = new Set(merged.map((session) => session.id)); + const merged = mergeSidebarSessionSources(globalActiveSessions, liveFallbackSessions); - liveFallbackSessions.forEach((session) => { - if (seenIds.has(session.id)) { - return; - } - merged.push(session); - }); - - return merged.filter((session) => isKnownActiveSessionDirectory(session, knownSessionDirectories, { - allowUnknownDirectory: !isVSCode, - allowEmptyDirectorySet: !isVSCode, - })); + return merged.filter((session) => ( + (!isVSCode && isChatDirectoryPath(session.directory)) + || isKnownActiveSessionDirectory(session, knownSessionDirectories, { + allowUnknownDirectory: !isVSCode, + allowEmptyDirectorySet: !isVSCode, + }) + )); }, [globalActiveSessions, isVSCode, knownSessionDirectories, liveFallbackSessions]); const persistenceSessions = React.useMemo( @@ -532,7 +530,6 @@ const SessionSidebarComponent: React.FC = ({ syncSessionsSnapshotRef.current = liveSessions; }, [liveSessions]); - const runtimeKey = getRuntimeKey(); const projectWorktreeDiscoveryKey = React.useMemo( () => `${runtimeKey}|${projects .map((project) => `${project.id}:${normalizePath(project.path) ?? ''}`) @@ -1369,9 +1366,13 @@ const SessionSidebarComponent: React.FC = ({ return []; } - return deriveRecentSessions(sessions, activeSessionIdSet) + return deriveRecentSessions(sessions.filter((session) => !isChatDirectoryForHome(session.directory, homeDirectory)), activeSessionIdSet) .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); - }, [activeSessionIdSet, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, showRecentSection]); + }, [activeSessionIdSet, homeDirectory, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions, showRecentSection]); + + const chatSessions = React.useMemo(() => sessions + .filter((session) => !session.parentID && !session.time?.archived && isChatDirectoryForHome(session.directory, homeDirectory)) + .sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)), [homeDirectory, pinnedSessionIds, sessionOrderRanks, sessions]); // Prefetch is wired below, after recentSessions is computed. @@ -1379,13 +1380,13 @@ const SessionSidebarComponent: React.FC = ({ // VS Code renders the full grouped project view (one group per open // workspace, folders + pinned native); the flat "recent" activity list is // web/desktop-only. - if (isVSCode || !showRecentSection) { + if (isVSCode) { return []; } const toItem = (session: Session) => { const existing = sessionSidebarMetaById.get(session.id); - const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null); + const sessionDirectory = normalizePath(session.directory ?? null); const node = existing?.node ?? { session, children: [], worktree: null }; const filteredNodes = hasSessionSearchQuery ? filterSessionNodesForSearch([node], normalizedSessionSearchQuery) @@ -1408,17 +1409,21 @@ const SessionSidebarComponent: React.FC = ({ }; }; - const items = recentSessions + const recentItems = showRecentSection ? recentSessions + .map(toItem) + .filter((item): item is NonNullable> => item !== null) : []; + + const chatItems = chatSessions .map(toItem) .filter((item): item is NonNullable> => item !== null); - return [ - { key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items }, + { key: 'chats' as const, title: t('sessions.sidebar.activity.chatsTitle'), items: chatItems }, + { key: 'active-now' as const, title: t('sessions.sidebar.activity.recentTitle'), items: recentItems }, ]; - }, [filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, recentSessions, sessionSidebarMetaById, showRecentSection, t]); + }, [chatSessions, filterSessionNodesForSearch, hasSessionSearchQuery, isVSCode, normalizedSessionSearchQuery, recentSessions, sessionSidebarMetaById, showRecentSection, t]); const hasActivitySectionItems = React.useMemo( - () => activitySections.some((section) => section.items.length > 0), + () => activitySections.some((section) => section.key === 'chats' || section.items.length > 0), [activitySections], ); @@ -1736,8 +1741,17 @@ const SessionSidebarComponent: React.FC = ({ ], ); + const handleOpenNewSessionDraftFromHeader = React.useCallback(() => { + useUIStore.getState().closeMainSurfaces(); + setActiveMainTab('chat'); + if (mobileVariant) { + setSessionSwitcherOpen(false); + } + openNewSessionDraft(); + }, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]); + const topContent = React.useMemo( - () => (!isVSCode && showRecentSection && !hasSessionSearchQuery) ? ( + () => (!isVSCode && !hasSessionSearchQuery && hasActivitySectionItems) ? ( = ({ expansionState={recentExpandedParents} variant="section" isDesktopShellRuntime={isDesktopShellRuntime} + onNewChat={handleOpenNewSessionDraftFromHeader} + alwaysShowActions={alwaysShowSidebarActions} /> ) : null, - [activitySections, editingId, hasSessionSearchQuery, isDesktopShellRuntime, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode, showRecentSection], + [activitySections, alwaysShowSidebarActions, editingId, handleOpenNewSessionDraftFromHeader, hasActivitySectionItems, hasSessionSearchQuery, isDesktopShellRuntime, isVSCode, openSidebarMenuKey, recentExpandedParents, renderSessionNode], ); const isInlineEditing = Boolean(renamingFolderId || editingId || editingProjectDialogId); @@ -1789,15 +1805,6 @@ const SessionSidebarComponent: React.FC = ({ openMultiRunLauncher(); }, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]); - const handleOpenNewSessionDraftFromHeader = React.useCallback(() => { - useUIStore.getState().closeMainSurfaces(); - setActiveMainTab('chat'); - if (mobileVariant) { - setSessionSwitcherOpen(false); - } - openNewSessionDraft(); - }, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]); - return ( // One shared tooltip provider for the whole sidebar: session tooltips open // instantly, and moving between rows hands the tooltip over (grouping) diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index ddaf8840..42bf3fb8 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -29,7 +29,7 @@ - `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all). - A successful add/create/clone from the project-directory dialog transitions to a new-session draft targeted at that project, matching the project's `+` action; changing project metadata alone must not leave the visible session or draft on a different directory. - `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code. -- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only, styled as a zone header. +- `SidebarActivitySections.tsx`: Global top section renderer for project-only `recent` sessions followed by OpenChamber-managed `chats`, styled as zone headers. - `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions. - `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder. - `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows. diff --git a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx index 6387552c..d3f95a35 100644 --- a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx @@ -10,6 +10,7 @@ import { resolveMenuOpenSessionId, } from './sessionNodeItemUtils'; import type { SessionNodeRenderExtras } from './sessionNodeItemUtils'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; type ActivityItem = { node: SessionNode; @@ -22,7 +23,7 @@ type ActivityItem = { }; type ActivitySection = { - key: 'active-now'; + key: 'active-now' | 'chats'; title: string; items: ActivityItem[]; }; @@ -46,6 +47,8 @@ type Props = { initialVisibleCount?: number; batchSize?: number; isDesktopShellRuntime: boolean; + onNewChat?: () => void; + alwaysShowActions?: boolean; }; type RenderExtras = SessionNodeRenderExtras; @@ -129,7 +132,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode { }); }, [editingId, openSidebarMenuKey]); - const visibleSections = sections.filter((section) => section.items.length > 0); + const visibleSections = sections.filter((section) => ( + section.items.length > 0 || (section.key === 'chats' && props.onNewChat) + )); if (visibleSections.length === 0) { return null; } @@ -179,23 +184,54 @@ export function SidebarActivitySections(props: Props): React.ReactNode { return (
    + {section.key === 'chats' && props.onNewChat ? ( +
    + + + + + +

    {t('sessions.sidebar.header.actions.newSession')}

    +
    +
    +
    + ) : null}
    {!isCollapsed ? (
    diff --git a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts index 1329daab..42e3e05b 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSwitcherItems.ts @@ -9,6 +9,8 @@ import type { SessionNode } from '../types'; import { isPathWithinProject } from '../utils'; import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering'; import { useSessionUIStore } from '@/sync/session-ui-store'; +import { isVSCodeRuntime } from '@/lib/desktop'; +import { isChatDirectoryPath } from '@/lib/chatDirectories'; export type SwitcherItem = { node: SessionNode; @@ -51,6 +53,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions const sessionOrderRanks = useSessionOrderingStore((state) => state.rankById); const branchesByDirectory = useGitAllBranches(); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); + const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); // Worktree sessions live OUTSIDE their project's path, so prefix matching // can't resolve their project — and their branch is known from worktree @@ -114,6 +117,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions const parents = activeSessions .filter((session) => !session.time?.archived) + .filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session))) .filter((session) => !(session as Session & { parentID?: string | null }).parentID) .filter((session) => { if (!scopeProjectId) return true; @@ -151,7 +155,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions }, }; }); - }, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); + }, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); return items; }; diff --git a/packages/ui/src/components/session/sidebar/sidebarSessionSources.test.ts b/packages/ui/src/components/session/sidebar/sidebarSessionSources.test.ts new file mode 100644 index 00000000..e19213f7 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sidebarSessionSources.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; + +import { mergeSidebarSessionSources } from './sidebarSessionSources'; + +const session = (id: string, title: string): Session => ({ + id, + slug: id, + title, + directory: `/home/.config/openchamber/chats/2026-08-21/${id}`, + projectID: 'managed-chats', + version: '1', + time: { created: 1, updated: 1 }, +}); + +describe('sidebar session source merge', () => { + test('shows one row when the same cached global chat also exists live', () => { + const live = session('session-a', 'Live title'); + const cached = session('session-a', 'Cached title'); + + expect(mergeSidebarSessionSources([cached], [live])).toEqual([cached]); + }); + + test('prefers global authority over live fallback', () => { + const global = session('session-a', 'Global title'); + expect(mergeSidebarSessionSources([global], [session('session-a', 'Live title')])).toEqual([global]); + }); +}); diff --git a/packages/ui/src/components/session/sidebar/sidebarSessionSources.ts b/packages/ui/src/components/session/sidebar/sidebarSessionSources.ts new file mode 100644 index 00000000..41e22d04 --- /dev/null +++ b/packages/ui/src/components/session/sidebar/sidebarSessionSources.ts @@ -0,0 +1,19 @@ +import type { Session } from '@opencode-ai/sdk/v2'; + +export function mergeSidebarSessionSources( + globalSessions: readonly Session[], + liveSessions: readonly Session[], +): Session[] { + const merged = [...globalSessions]; + const seenIds = new Set(merged.map((session) => session.id)); + const appendMissing = (sessions: readonly Session[]) => { + sessions.forEach((session) => { + if (seenIds.has(session.id)) return; + seenIds.add(session.id); + merged.push(session); + }); + }; + + appendMissing(liveSessions); + return merged; +} diff --git a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts index f3b24003..b7f9eeb8 100644 --- a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts @@ -3,16 +3,12 @@ import { focusChatInput } from '@/components/chat/composer/editor/dom'; import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useDirectoryStore } from '@/stores/useDirectoryStore'; -import { useProjectsStore } from '@/stores/useProjectsStore'; import { useUIStore } from '@/stores/useUIStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useSessionUIStore } from '@/sync/session-ui-store'; export const useMiniChatKeyboardShortcuts = () => { const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const currentDirectory = useDirectoryStore((state) => state.currentDirectory); - const activeProject = useProjectsStore((state) => state.getActiveProject()); const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft); React.useEffect(() => { @@ -28,8 +24,8 @@ export const useMiniChatKeyboardShortcuts = () => { if (canUseElectronDesktopIPC() && eventMatchesShortcut(event, combo('new_mini_chat'))) { event.preventDefault(); void invokeDesktop('desktop_open_draft_mini_chat_window', { - directory: currentDirectory || activeProject?.path || '', - projectId: activeProject?.id ?? null, + directory: '', + projectId: null, })?.catch((error) => { console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error); }); @@ -38,11 +34,7 @@ export const useMiniChatKeyboardShortcuts = () => { if (eventMatchesShortcut(event, combo('new_chat'))) { event.preventDefault(); - openNewSessionDraft({ - selectedProjectId: activeProject?.id ?? null, - directoryOverride: currentDirectory || activeProject?.path || null, - preserveDirectoryOverride: Boolean(currentDirectory || activeProject?.path), - }); + openNewSessionDraft(); focusChatInput(); return; } @@ -98,5 +90,5 @@ export const useMiniChatKeyboardShortcuts = () => { window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [activeProject?.id, activeProject?.path, currentDirectory, openNewSessionDraft, shortcutOverrides]); + }, [openNewSessionDraft, shortcutOverrides]); }; diff --git a/packages/ui/src/lib/chatDirectories.test.ts b/packages/ui/src/lib/chatDirectories.test.ts new file mode 100644 index 00000000..6d6958b4 --- /dev/null +++ b/packages/ui/src/lib/chatDirectories.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +const createdDirectories: string[] = []; +const createDirectoryOptions: Array<{ allowOutsideWorkspace?: boolean } | undefined> = []; +const deletedDirectories: string[] = []; + +mock.module('@/lib/opencode/client', () => ({ + opencodeClient: { + getFilesystemHome: mock(async () => '/Users/tester'), + createDirectory: mock(async (path: string, options?: { allowOutsideWorkspace?: boolean }) => { + createdDirectories.push(path); + createDirectoryOptions.push(options); + return { success: true, path }; + }), + }, +})); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: mock(async (_path: string, init?: RequestInit) => { + deletedDirectories.push(JSON.parse(String(init?.body)).path); + return new Response(null, { status: 200 }); + }), +})); + +const { createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath } = await import('./chatDirectories'); + +describe('chat directories', () => { + beforeEach(() => { + createdDirectories.length = 0; + createDirectoryOptions.length = 0; + deletedDirectories.length = 0; + }); + + test('creates one isolated directory beneath the dated chats root', async () => { + const directory = await createChatDirectory(new Date(2026, 7, 21, 12)); + expect(createdDirectories[0]).toBe(directory); + expect(directory.startsWith('/Users/tester/.config/openchamber/chats/2026-08-21/session-')).toBe(true); + expect(createdDirectories).toEqual([directory]); + expect(createDirectoryOptions).toEqual([undefined]); + }); + + test('recognizes only descendants of the managed chats root', () => { + expect(isChatDirectoryForHome('/Users/tester/.config/openchamber/chats/2026-08-21/session-a', '/Users/tester')).toBe(true); + expect(isChatDirectoryForHome('/Users/tester/project', '/Users/tester')).toBe(false); + expect(isChatDirectoryForHome('/remote/home/.config/openchamber/chats/2026-08-21/session-a', '/Users/tester')).toBe(true); + expect(isChatDirectoryPath('/remote/home/.config/openchamber/chats/2026-08-21/session-a')).toBe(true); + expect(getChatsRootFromDirectory('/remote/home/.config/openchamber/chats/2026-08-21/session-a')).toBe('/remote/home/.config/openchamber/chats'); + }); + + test('deletes managed chat directories but leaves project directories alone', async () => { + await deleteChatDirectory('/Users/tester/.config/openchamber/chats/2026-08-21/session-a'); + await deleteChatDirectory('/Users/tester/project'); + expect(deletedDirectories).toEqual(['/Users/tester/.config/openchamber/chats/2026-08-21/session-a']); + }); +}); diff --git a/packages/ui/src/lib/chatDirectories.ts b/packages/ui/src/lib/chatDirectories.ts new file mode 100644 index 00000000..c7656dee --- /dev/null +++ b/packages/ui/src/lib/chatDirectories.ts @@ -0,0 +1,88 @@ +import { opencodeClient } from '@/lib/opencode/client'; +import { normalizePath } from '@/lib/pathNormalization'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeKey } from '@/lib/runtime-switch'; + +export const CHAT_DRAFT_PROJECT_ID = 'openchamber:chats'; +const MANAGED_CHATS_PATH_SEGMENT = '/.config/openchamber/chats/'; +const chatsRootByRuntime = new Map>(); + +const joinPath = (base: string, ...parts: string[]): string => { + const separator = base.includes('\\') ? '\\' : '/'; + return [base.replace(/[\\/]+$/, ''), ...parts].join(separator); +}; + +export function isChatDirectoryForHome(directory: string | null | undefined, home: string | null | undefined): boolean { + const normalized = normalizePath(directory ?? null); + if (normalized?.includes(MANAGED_CHATS_PATH_SEGMENT)) return true; + const normalizedHome = normalizePath(home ?? null); + if (!normalized || !normalizedHome) return false; + const root = normalizePath(joinPath(normalizedHome, '.config', 'openchamber', 'chats')); + return Boolean(root && normalized.startsWith(`${root}/`)); +} + +export function isChatDirectoryPath(directory: string | null | undefined): boolean { + return normalizePath(directory ?? null)?.includes(MANAGED_CHATS_PATH_SEGMENT) === true; +} + +export function getChatsRootFromDirectory(directory: string | null | undefined): string | null { + const normalized = normalizePath(directory ?? null); + const index = normalized?.indexOf(MANAGED_CHATS_PATH_SEGMENT) ?? -1; + return normalized && index >= 0 + ? normalized.slice(0, index + MANAGED_CHATS_PATH_SEGMENT.length - 1) + : null; +} + +export function getChatsRootForHome(home: string | null | undefined): string | null { + const normalizedHome = normalizePath(home ?? null); + return normalizedHome ? normalizePath(joinPath(normalizedHome, '.config', 'openchamber', 'chats')) : null; +} + +async function getChatsRootDirectory(): Promise { + const runtimeKey = getRuntimeKey(); + const existing = chatsRootByRuntime.get(runtimeKey); + if (existing) return existing; + + const pending = opencodeClient.getFilesystemHome().then((home) => { + if (!home) throw new Error('Unable to resolve the home directory'); + return joinPath(home, '.config', 'openchamber', 'chats'); + }).catch((error) => { + chatsRootByRuntime.delete(runtimeKey); + throw error; + }); + chatsRootByRuntime.set(runtimeKey, pending); + return pending; +} + +export function warmChatsRootDirectory(): void { + void getChatsRootDirectory().catch(() => undefined); +} + +export async function createChatDirectory(now = new Date()): Promise { + const root = await getChatsRootDirectory(); + const date = [now.getFullYear(), String(now.getMonth() + 1).padStart(2, '0'), String(now.getDate()).padStart(2, '0')].join('-'); + const dateDirectory = joinPath(root, date); + const id = globalThis.crypto?.randomUUID?.() ?? `${now.getTime()}-${Math.random().toString(36).slice(2)}`; + const directory = joinPath(dateDirectory, `session-${id}`); + await opencodeClient.createDirectory(directory); + return directory; +} + +async function isChatDirectory(directory: string | null | undefined): Promise { + const normalized = normalizePath(directory ?? null); + if (!normalized) return false; + const root = normalizePath(await getChatsRootDirectory()); + return Boolean(root && (normalized === root || normalized.startsWith(`${root}/`))); +} + +export async function deleteChatDirectory(directory: string): Promise { + if (!await isChatDirectory(directory)) return; + const response = await runtimeFetch('/api/fs/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: directory }), + }); + if (!response.ok && response.status !== 404) { + throw new Error(`Failed to delete chat directory (${response.status})`); + } +} diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index fec60411..b40072d7 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -415,6 +415,8 @@ export const dict = { 'sessions.sidebar.empty.noMatches.title': 'Keine passenden Sitzungen', 'sessions.sidebar.empty.noMatches.description': 'Versuchen Sie einen anderen Titel, Branch, Ordner oder Pfad.', 'sessions.sidebar.activity.recentTitle': 'kürzlich', + 'sessions.sidebar.activity.chatsTitle': 'Chats', + 'chat.chatInput.chooseProject': 'Projekt auswählen', 'sessions.switcher.openAria': 'Sitzungswechsler öffnen', 'sessions.switcher.empty': 'Keine kürzlichen Sitzungen', 'sessions.switcher.draftTitle': 'Neue Sitzung', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 4abb4e1d..a2b229fc 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -437,6 +437,8 @@ export const dict = { 'sessions.sidebar.empty.noMatches.title': 'No matching sessions', 'sessions.sidebar.empty.noMatches.description': 'Try a different title, branch, folder, or path.', 'sessions.sidebar.activity.recentTitle': 'recent', + 'sessions.sidebar.activity.chatsTitle': 'chats', + 'chat.chatInput.chooseProject': 'Choose project', 'sessions.archivePage.allDirectories': 'All directories', 'sessions.sidebar.header.displayMode.stickyHeaders': 'Sticky project headers', 'sessions.sidebar.header.grouping.label': 'Group sessions', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 458a6adc..36c30760 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -438,6 +438,8 @@ export const dict: Record = { "sessions.sidebar.empty.noMatches.title": "No hay sesiones coincidentes", "sessions.sidebar.empty.noMatches.description": "Inténtalo con un título, rama, carpeta o ruta diferente.", "sessions.sidebar.activity.recentTitle": "reciente", + "sessions.sidebar.activity.chatsTitle": "chats", + "chat.chatInput.chooseProject": "Elegir proyecto", "sessions.archivePage.allDirectories": "Todos los directorios", "sessions.sidebar.header.displayMode.stickyHeaders": "Encabezados de proyecto fijos", "sessions.sidebar.header.grouping.label": "Agrupar sesiones", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index bba07ce2..40e496bf 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -268,6 +268,8 @@ export const dict = { 'sessions.sidebar.empty.noMatches.title': 'Aucune session correspondante', 'sessions.sidebar.empty.noMatches.description': 'Essayez un autre titre, branche, dossier ou chemin.', 'sessions.sidebar.activity.recentTitle': 'récent', + 'sessions.sidebar.activity.chatsTitle': 'discussions', + 'chat.chatInput.chooseProject': 'Choisir un projet', 'sessions.archivePage.allDirectories': 'Tous les répertoires', 'sessions.sidebar.header.displayMode.stickyHeaders': 'Épingler les en-têtes de projet', 'sessions.sidebar.header.grouping.label': 'Regrouper les sessions', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 7d26028d..980973e3 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -438,6 +438,8 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '一致するセッションがありません', 'sessions.sidebar.empty.noMatches.description': '別のタイトル、ブランチ、フォルダ、パスをお試しください。', 'sessions.sidebar.activity.recentTitle': '最近', + 'sessions.sidebar.activity.chatsTitle': 'チャット', + 'chat.chatInput.chooseProject': 'プロジェクトを選択', 'sessions.archivePage.allDirectories': 'すべてのディレクトリ', 'sessions.sidebar.header.displayMode.stickyHeaders': 'プロジェクトヘッダーを固定', 'sessions.sidebar.header.grouping.label': 'セッションのグループ化', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index b7a0accd..6b409fdf 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -438,6 +438,8 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '일치하는 세션 없음', 'sessions.sidebar.empty.noMatches.description': '다른 제목, 브랜치, 폴더 또는 경로로 검색해 보세요.', 'sessions.sidebar.activity.recentTitle': '최근', + 'sessions.sidebar.activity.chatsTitle': '채팅', + 'chat.chatInput.chooseProject': '프로젝트 선택', 'sessions.archivePage.allDirectories': '모든 디렉터리', 'sessions.sidebar.header.displayMode.stickyHeaders': '프로젝트 헤더 고정', 'sessions.sidebar.header.grouping.label': '세션 그룹화', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index d9247510..33ba4418 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -249,6 +249,8 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': 'Brak pasujących sesji', 'sessions.sidebar.empty.noMatches.description': 'Spróbuj innego tytułu, gałęzi, folderu lub ścieżki.', 'sessions.sidebar.activity.recentTitle': 'ostatnie', + 'sessions.sidebar.activity.chatsTitle': 'czaty', + 'chat.chatInput.chooseProject': 'Wybierz projekt', 'sessions.archivePage.allDirectories': 'Wszystkie katalogi', 'sessions.sidebar.header.displayMode.stickyHeaders': 'Przyklejone nagłówki projektów', 'sessions.sidebar.header.grouping.label': 'Grupowanie sesji', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index b5dd9ae7..48904a98 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -438,6 +438,8 @@ export const dict: Record = { "sessions.sidebar.empty.noMatches.title": "Não há sessões coincidentes", "sessions.sidebar.empty.noMatches.description": "Tente com outro título, branch, pasta ou caminho.", "sessions.sidebar.activity.recentTitle": "recente", + "sessions.sidebar.activity.chatsTitle": "conversas", + "chat.chatInput.chooseProject": "Escolher projeto", "sessions.archivePage.allDirectories": "Todos os diretórios", "sessions.sidebar.header.displayMode.stickyHeaders": "Cabeçalhos de projeto fixos", "sessions.sidebar.header.grouping.label": "Agrupar sessões", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index e30dea88..110ceb18 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -438,6 +438,8 @@ export const dict: Record = { "sessions.sidebar.empty.noMatches.title": "Немає відповідних сесій", "sessions.sidebar.empty.noMatches.description": "Спробуйте інший заголовок, гілку, папку або шлях.", "sessions.sidebar.activity.recentTitle": "Останні", + "sessions.sidebar.activity.chatsTitle": "Чати", + "chat.chatInput.chooseProject": "Вибрати проєкт", "sessions.archivePage.allDirectories": "Всі директорії", "sessions.sidebar.header.displayMode.stickyHeaders": "Липкі заголовки проектів", "sessions.sidebar.header.grouping.label": "Групування сесій", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 0b1034e3..fdfc55a7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -438,6 +438,8 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '没有匹配的会话', 'sessions.sidebar.empty.noMatches.description': '请尝试其他标题、分支、文件夹或路径。', 'sessions.sidebar.activity.recentTitle': '最近', + 'sessions.sidebar.activity.chatsTitle': '聊天', + 'chat.chatInput.chooseProject': '选择项目', 'sessions.archivePage.allDirectories': '所有目录', 'sessions.sidebar.header.displayMode.stickyHeaders': '固定项目标题', 'sessions.sidebar.header.grouping.label': '会话分组', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 4b2caab8..aad95ad1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -451,6 +451,8 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '沒有符合的會話', 'sessions.sidebar.empty.noMatches.description': '請嘗試其他標題、分支、資料夾或路徑。', 'sessions.sidebar.activity.recentTitle': '最近', + 'sessions.sidebar.activity.chatsTitle': '聊天', + 'chat.chatInput.chooseProject': '選擇專案', 'sessions.archivePage.allDirectories': '所有目錄', 'sessions.sidebar.header.displayMode.stickyHeaders': '固定專案標題', 'sessions.sidebar.header.grouping.label': '工作階段分組', diff --git a/packages/ui/src/stores/globalSessions.test.ts b/packages/ui/src/stores/globalSessions.test.ts index 5001bd40..10421d0c 100644 --- a/packages/ui/src/stores/globalSessions.test.ts +++ b/packages/ui/src/stores/globalSessions.test.ts @@ -1,7 +1,29 @@ import { describe, expect, test } from 'bun:test' -import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2' -import { listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions' +import { filterManagedChatsForRuntime, listGlobalSessionPages, splitGlobalSessionsByArchived } from './globalSessions' + +describe('managed Chats runtime visibility', () => { + const session = (id: string, directory: string): Session => ({ + id, + slug: id, + projectID: 'project', + directory, + title: id, + version: '1', + time: { created: 1, updated: 1 }, + }) + const chat = session('chat', '/home/user/.config/openchamber/chats/2026-08-21/session-a') + const project = session('project', '/workspace/project') + + test('VS Code rejects managed Chats before they enter global state', () => { + expect(filterManagedChatsForRuntime([chat, project], true)).toEqual([project]) + }) + + test('other runtimes retain managed Chats', () => { + expect(filterManagedChatsForRuntime([chat, project], false)).toEqual([chat, project]) + }) +}) describe('listGlobalSessionPages', () => { test('sanitizes session list records before returning them', async () => { diff --git a/packages/ui/src/stores/globalSessions.ts b/packages/ui/src/stores/globalSessions.ts index da8ed9ea..5ee695b9 100644 --- a/packages/ui/src/stores/globalSessions.ts +++ b/packages/ui/src/stores/globalSessions.ts @@ -3,6 +3,7 @@ import { runBackgroundNetworkTask } from '@/lib/background-network'; import { retry } from "@/sync/retry"; import { stripSessionListDetails } from "@/sync/sanitize"; import { startSessionLoadPerformanceEvent } from "@/sync/session-load-performance"; +import { isChatDirectoryPath } from '@/lib/chatDirectories'; export type GlobalSessionRecord = Session & { project?: { @@ -12,6 +13,12 @@ export type GlobalSessionRecord = Session & { } | null; }; +export const filterManagedChatsForRuntime = (sessions: Session[], vscode: boolean): Session[] => ( + vscode + ? sessions.filter((session) => !isChatDirectoryPath(session.directory)) + : sessions +); + const toNumber = (value: string | null): number | null => { if (!value) { return null; diff --git a/packages/ui/src/stores/useGlobalSessionsStore.ts b/packages/ui/src/stores/useGlobalSessionsStore.ts index 7f6f3854..68919369 100644 --- a/packages/ui/src/stores/useGlobalSessionsStore.ts +++ b/packages/ui/src/stores/useGlobalSessionsStore.ts @@ -1,12 +1,14 @@ import { create } from 'zustand'; import type { OpencodeClient, Session } from '@opencode-ai/sdk/v2'; import { opencodeClient } from '@/lib/opencode/client'; -import { listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions'; +import { filterManagedChatsForRuntime, listGlobalSessionPages, splitGlobalSessionsByArchived } from '@/stores/globalSessions'; import { getReviewTransferDirection, type ReviewTransferDirection } from '@/lib/reviewFlow'; import { getOriginalSessionID, getReviewSessionID } from '@/lib/sessionReviewMetadata'; import { normalizePath } from '@/lib/pathNormalization'; import { raiseSessionOrderingBaselines } from '@/sync/session-ordering'; import { mapWithConcurrency } from '@/lib/concurrency'; +import { persistManagedChatSessions, readManagedChatSessions } from '@/sync/persist-cache'; +import { isVSCodeRuntime } from '@/lib/desktop'; type GlobalSessionsStatus = 'idle' | 'loading' | 'ready' | 'error'; @@ -363,6 +365,10 @@ const applySnapshot = ( archivedSessions: Session[], status: GlobalSessionsStatus, ): Partial | GlobalSessionsState => { + if (isVSCodeRuntime()) { + activeSessions = filterManagedChatsForRuntime(activeSessions, true); + archivedSessions = filterManagedChatsForRuntime(archivedSessions, true); + } const nextActiveSessions = sameSessionList(state.activeSessions, activeSessions) ? state.activeSessions : activeSessions; @@ -430,6 +436,10 @@ const mutationRevisionPatch = (state: GlobalSessionsState, ids: Iterable }; const applySessionUpserts = (state: GlobalSessionsState, sessions: Session[]): Partial => { + if (isVSCodeRuntime()) { + sessions = filterManagedChatsForRuntime(sessions, true); + if (sessions.length === 0) return state; + } const revisionPatch = mutationRevisionPatch(state, sessions.map((session) => session.id)); let nextActiveSessions = state.activeSessions; let nextArchivedSessions = state.archivedSessions; @@ -483,11 +493,13 @@ const buildReviewTransferMap = (sessions: Session[]): Map((set, get) => ({ - activeSessions: [], + activeSessions: initialManagedChatSessions, archivedSessions: [], - sessionsByDirectory: new Map(), - reviewTransferBySessionId: new Map(), + sessionsByDirectory: buildSessionsByDirectory(initialManagedChatSessions), + reviewTransferBySessionId: buildReviewTransferMap(initialManagedChatSessions), mutationRevision: 0, mutationRevisionBySessionId: new Map(), hasLoaded: false, @@ -504,11 +516,12 @@ export const useGlobalSessionsStore = create((set, get) => resetForRuntimeSwitch: () => { loadGeneration += 1; inflightLoad = null; + const managedChatSessions = readManagedChatSessions(); set({ - activeSessions: [], + activeSessions: managedChatSessions, archivedSessions: [], - sessionsByDirectory: new Map(), - reviewTransferBySessionId: new Map(), + sessionsByDirectory: buildSessionsByDirectory(managedChatSessions), + reviewTransferBySessionId: buildReviewTransferMap(managedChatSessions), mutationRevision: 0, mutationRevisionBySessionId: new Map(), hasLoaded: false, @@ -722,6 +735,15 @@ export const useGlobalSessionsStore = create((set, get) => }, })); +useGlobalSessionsStore.subscribe((state, previous) => { + if ( + state.activeSessions !== previous.activeSessions + && (state.status !== 'idle' || state.activeSessions.length > 0) + ) { + persistManagedChatSessions(state.activeSessions); + } +}); + export const ensureGlobalSessionsLoaded = async (fallbackActive?: Session[]): Promise => { const state = useGlobalSessionsStore.getState(); if (state.hasLoaded && state.status !== 'error') { diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index e883d128..c88021a3 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -324,6 +324,16 @@ metadata and the next authoritative load reconciles it. ## The golden rule +### Managed chat directories + +Ordinary user-created drafts default to the OpenChamber-managed Chat target. The first submit creates one isolated directory under `~/.config/openchamber/chats/YYYY-MM-DD/session-` before creating the OpenCode session. The shared `~/.config/openchamber/chats` root acts as a system project owner for sidebar membership and Notes, Todo, Plans, pinned knowledge, and project memory, but it is never persisted or rendered as a user project and exposes no Git/worktree controls. Project and worktree actions remain explicit targets. Archiving retains a chat directory so restore remains lossless. Confirmed deletion removes that managed directory and never removes project directories. + +Typing the first character in a managed Chat draft starts one deduplicated directory preparation for that draft. Materialization consumes the prepared directory before `createSession`, removing filesystem creation from the usual submit path. Closing the draft, changing it to a project target, or completing preparation after the runtime/draft changed deletes the unclaimed directory. A create failure also deletes the consumed directory. + +The global sessions store persists and hydrates one bounded, runtime-scoped startup snapshot containing only active managed chat sessions. Every global session surface, including the main sidebar and Electron Mini Chat switcher, sees that stale snapshot while the global list is unresolved or failed; the first authoritative global snapshot replaces it. Runtime reset to idle must hydrate rather than erase the destination runtime's snapshot; authoritative empty, archive, and delete updates do persist the resulting empty or reduced list. + +VS Code intentionally has no managed Chats mode. It neither reads nor writes the managed Chats startup cache, regular drafts continue to target the open workspace, and the global session store rejects managed chat sessions from both snapshots and live upserts before any VS Code surface can consume them. Sidebar and switcher filters repeat that exclusion defensively. + When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly. ```typescript diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 5768c3b2..70a3048e 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -73,6 +73,8 @@ mock.module("@/stores/utils/safeStorage", () => ({ mock.module("@/lib/opencode/client", () => ({ opencodeClient: { getDirectory: () => null, + getFilesystemHome: mock(async () => "/home/test"), + createDirectory: mock(async (path: string) => ({ success: true, path })), setDirectory: mock(() => undefined), }, })) @@ -327,9 +329,11 @@ describe("issue 2039 draft auto-accept", () => { currentSessionId: null, currentSessionDirectory: null, newSessionDraft: { + draftId: 0, open: false, directoryOverride: null, parentID: null, + target: "chat", }, }) }) diff --git a/packages/ui/src/sync/persist-cache.test.ts b/packages/ui/src/sync/persist-cache.test.ts index 7830f5f5..2afc3889 100644 --- a/packages/ui/src/sync/persist-cache.test.ts +++ b/packages/ui/src/sync/persist-cache.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" import type { Session } from "@opencode-ai/sdk/v2/client" import { switchRuntimeEndpoint } from "@/lib/runtime-switch" -import { persistSessions, readDirCache } from "./persist-cache" +import { persistManagedChatSessions, persistSessions, readDirCache, readManagedChatSessions } from "./persist-cache" import { getSyncPerformanceDiagnostics, setSyncPerformanceDiagnosticsEnabled } from "./performance-diagnostics" class TestStorage implements Storage { @@ -81,6 +81,17 @@ afterEach(() => { }) describe("persisted directory sessions", () => { + test("keeps one runtime-scoped startup snapshot for managed chats", async () => { + const chat = session(1, 2, "Chat", "/home/user/.config/openchamber/chats/2026-08-21/session-a") + persistManagedChatSessions([session(2, 3), chat]) + await waitForPersistence() + + expect(readManagedChatSessions().map((item) => item.id)).toEqual([chat.id]) + + switchRuntimeEndpoint({ apiBaseUrl: "https://runtime-other.test", runtimeKey: "runtime-other" }) + expect(readManagedChatSessions()).toEqual([]) + }) + test("keeps the 50 most recently updated sessions across restart reads", async () => { const sessions = Array.from({ length: 60 }, (_, updated) => session(59 - updated, updated)) diff --git a/packages/ui/src/sync/persist-cache.ts b/packages/ui/src/sync/persist-cache.ts index a6fe49c0..51d3e580 100644 --- a/packages/ui/src/sync/persist-cache.ts +++ b/packages/ui/src/sync/persist-cache.ts @@ -10,11 +10,14 @@ import type { Session, VcsInfo } from "@opencode-ai/sdk/v2/client" import type { ProjectMeta } from "./types" import { getRuntimeKey, subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch" import { countSyncPersistenceSerialization, countSyncPersistenceStorageWrite } from "./performance-diagnostics" +import { isChatDirectoryPath } from "@/lib/chatDirectories" +import { isVSCodeRuntime } from "@/lib/desktop" /** Cap persisted session lists so localStorage stays bounded per directory. */ const PERSISTED_SESSION_LIMIT = 50 const SESSION_CACHE_FALLBACK_LIMITS = [PERSISTED_SESSION_LIMIT, 25, 10, 5, 1] as const const SESSION_PERSIST_DEBOUNCE_MS = 50 +const MANAGED_CHATS_CACHE_SCOPE = "openchamber:managed-chats" type PendingSessionWrite = { runtimeKey: string @@ -241,6 +244,21 @@ export function persistSessions(directory: string, sessions: Session[] | undefin scheduleSessionCacheWrite(directory, sessions) } +export function readManagedChatSessions(expectedRuntimeKey = getRuntimeKey()): Session[] { + if (isVSCodeRuntime()) return [] + if (expectedRuntimeKey !== getRuntimeKey()) return [] + return readDirCache(MANAGED_CHATS_CACHE_SCOPE).sessions?.filter((session) => ( + isChatDirectoryPath(session.directory) + )) ?? [] +} + +export function persistManagedChatSessions(sessions: Session[]): void { + if (isVSCodeRuntime()) return + persistSessions(MANAGED_CHATS_CACHE_SCOPE, sessions.filter((session) => ( + isChatDirectoryPath(session.directory) + ))) +} + /** Write vcs info to cache */ export function persistVcs(directory: string, vcs: VcsInfo | undefined): void { writeCache(directory, "vcs", vcs) diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 4870e39f..758d99ed 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -125,6 +125,7 @@ mock.module("@/lib/opencode/client", () => ({ return mockScopedClient }, getDirectory: () => "/test/project", + getFilesystemHome: mock(async () => "/home/test"), getSdkClient: () => mockSdk, replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => { replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } }) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 806cc913..7ab6e370 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -35,6 +35,7 @@ import { getStaleRunningToolMessageID } from "./materialization" import { normalizePath } from "@/lib/pathNormalization" import { mergeMessages } from "./optimistic" import { messagesBefore, messagesFrom } from "./message-ordering" +import { deleteChatDirectory } from "@/lib/chatDirectories" const MESSAGE_REFETCH_LIMIT = 100 const SEND_CONFIRMATION_REFETCH_LIMIT = 30 @@ -919,6 +920,15 @@ function finalizeConfirmedSessionDeletion( } } +async function cleanupDeletedChatDirectory(directory: string | undefined, deleteDirectory: boolean): Promise { + if (!directory || !deleteDirectory) return + try { + await deleteChatDirectory(directory) + } catch (error) { + console.warn("[session-actions] deleted chat directory cleanup failed", error) + } +} + export type DeleteSessionOptions = { /** * Runtime key the deletion is scoped to. Defaults to the active runtime when @@ -947,6 +957,8 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey() if (isStaleRuntime(expectedRuntimeKey)) return false const sessionDirectory = getSessionDirectory(sessionId) + const sessionSnapshot = getGlobalSessionSnapshot(sessionId) + const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null) try { await cleanupReviewMetadataBeforeDelete(sessionId, sessionDirectory, expectedRuntimeKey) if (isStaleRuntime(expectedRuntimeKey)) return false @@ -956,6 +968,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp throw new Error("session.delete failed: server did not confirm deletion") } finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey) + await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory) return true } catch (error) { console.error("[session-actions] deleteSession failed", error) @@ -965,6 +978,7 @@ export async function deleteSession(sessionId: string, options?: DeleteSessionOp if ((error as { status?: number })?.status === 404) { if (isStaleRuntime(expectedRuntimeKey)) return false finalizeConfirmedSessionDeletion(sessionId, sessionDirectory, expectedRuntimeKey) + await cleanupDeletedChatDirectory(sessionDirectory, deleteManagedDirectory) return true } return false @@ -978,6 +992,8 @@ export async function deleteSessionInDirectory( expectedRuntimeKey = getRuntimeKey(), ): Promise { if (isStaleRuntime(expectedRuntimeKey)) return false + const sessionSnapshot = getGlobalSessionSnapshot(sessionId) + const deleteManagedDirectory = Boolean(sessionSnapshot && sessionSnapshot.parentID == null) try { await cleanupReviewMetadataBeforeDelete(sessionId, directory, expectedRuntimeKey) if (isStaleRuntime(expectedRuntimeKey)) return false @@ -987,12 +1003,14 @@ export async function deleteSessionInDirectory( throw new Error("session.delete failed: server did not confirm deletion") } finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey) + await cleanupDeletedChatDirectory(directory, deleteManagedDirectory) return true } catch (error) { console.error("[session-actions] deleteSessionInDirectory failed", error) if ((error as { status?: number })?.status === 404) { if (isStaleRuntime(expectedRuntimeKey)) return false finalizeConfirmedSessionDeletion(sessionId, directory, expectedRuntimeKey) + await cleanupDeletedChatDirectory(directory, deleteManagedDirectory) return true } return false diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index 07a8a19f..6f2fdc5e 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -370,16 +370,17 @@ describe('openNewSessionDraft project binding', () => { useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false }); }); - test('keeps implicit draft on current directory when active project differs', () => { + test('defaults an implicit draft to Chat when active project differs', () => { useSessionUIStore.getState().openNewSessionDraft(); const draft = useSessionUIStore.getState().newSessionDraft; expect(draft.open).toBe(true); - expect(draft.selectedProjectId).toBe(projectB.id); - expect(draft.directoryOverride).toBe(projectB.path); + expect(draft.target).toBe('chat'); + expect(draft.selectedProjectId).toBeNull(); + expect(draft.directoryOverride).toBeNull(); }); - test('does not attach active project when current directory is unmatched', () => { + test('defaults an implicit draft to Chat when current directory is unmatched', () => { useDirectoryStore.getState().setDirectory('/external/worktree', { showOverlay: false }); useSessionUIStore.getState().openNewSessionDraft(); @@ -387,7 +388,8 @@ describe('openNewSessionDraft project binding', () => { expect(draft.open).toBe(true); expect(draft.selectedProjectId).toBeNull(); - expect(draft.directoryOverride).toBe('/external/worktree'); + expect(draft.target).toBe('chat'); + expect(draft.directoryOverride).toBeNull(); }); test('respects explicit directoryOverride over active project', () => { @@ -464,7 +466,7 @@ describe('createSession draft lifecycle', () => { useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false }); opencodeClient.getDirectoryAvailability = async () => 'missing'; - useSessionUIStore.getState().openNewSessionDraft(); + useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' }); await Bun.sleep(0); expect(useSessionUIStore.getState().newSessionDraft.directoryOverride).toBe('/projects/main'); @@ -482,7 +484,7 @@ describe('createSession draft lifecycle', () => { activeProjectId: 'project-active', }); useDirectoryStore.getState().setDirectory('/private/deleted-worktree', { showOverlay: false }); - useSessionUIStore.getState().openNewSessionDraft(); + useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' }); opencodeClient.getDirectoryAvailability = async () => 'missing'; opencodeClient.createSession = async (_params, directory) => { createSessionCalls.push(directory); @@ -542,7 +544,7 @@ describe('createSession draft lifecycle', () => { activeProjectId: 'project-main', }); useDirectoryStore.getState().setDirectory('/private/unavailable-worktree', { showOverlay: false }); - useSessionUIStore.getState().openNewSessionDraft(); + useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/unavailable-worktree' }); opencodeClient.getDirectoryAvailability = async () => 'unknown'; opencodeClient.createSession = async (_params, directory) => { createSessionCalls.push(directory); @@ -571,7 +573,7 @@ describe('createSession draft lifecycle', () => { return { id: 'session-race', directory }; }; - useSessionUIStore.getState().openNewSessionDraft(); + useSessionUIStore.getState().openNewSessionDraft({ directoryOverride: '/private/deleted-worktree' }); const createPromise = useSessionUIStore.getState().createSession('Draft title', '/private/deleted-worktree'); expect(availabilityResolvers.length).toBe(2); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 7793de6a..ff337e17 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -29,6 +29,8 @@ import { useSkillsStore } from "@/stores/useSkillsStore" import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { normalizePath } from "@/lib/pathNormalization" +import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, warmChatsRootDirectory } from "@/lib/chatDirectories" +import { isVSCodeRuntime } from "@/lib/desktop" import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice" @@ -258,6 +260,7 @@ function notifyMessageSent(sessionId: string): void { // --------------------------------------------------------------------------- export type NewSessionDraftState = { + draftId: number open: boolean selectedProjectId?: string | null directoryOverride: string | null @@ -271,6 +274,8 @@ export type NewSessionDraftState = { syntheticParts?: SyntheticContextPart[] targetFolderId?: string projectContextPins?: { notes: string[]; plans: string[] } + target: "chat" | "project" + preparedChatDirectory?: string | null } export type ViewportAnchor = { @@ -316,6 +321,7 @@ export type SessionUIState = { prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void openNewSessionDraft: (options?: Partial & { automatic?: boolean }) => void + prepareChatDraftDirectory: () => Promise closeNewSessionDraft: () => void setNewSessionDraftTarget: (target: { projectId?: string | null; selectedProjectId?: string | null; directoryOverride?: string | null }, options?: { force?: boolean }) => void setDraftPreserveDirectoryOverride: (value: boolean) => void @@ -548,10 +554,14 @@ const activateConfigForDirectory = async (directory: string | null | undefined): } const DEFAULT_DRAFT: NewSessionDraftState = { + draftId: 0, open: false, directoryOverride: null, parentID: null, + target: "chat", } +let nextDraftId = 1 +const pendingChatDirectoryByDraft = new Map>() const activeSessionByRuntime = new Map() type RuntimeSessionMemory = { @@ -726,6 +736,18 @@ export async function materializeOpenDraftSession(selection: { store.resolvePendingDraftWorktreeTarget(draft.pendingWorktreeRequestId, draftDirectoryOverride) } + const isChatDraft = draft.target === "chat" + if (isChatDraft) { + draftDirectoryOverride = await store.prepareChatDraftDirectory() + if (!draftDirectoryOverride) throw new Error("Failed to prepare chat directory") + const currentDraft = useSessionUIStore.getState().newSessionDraft + if (currentDraft.draftId === draft.draftId) { + useSessionUIStore.setState({ + newSessionDraft: { ...currentDraft, preparedChatDirectory: null }, + }) + } + } + await waitForWorktreeBootstrapIfConfigured(draftDirectoryOverride, draftProjectId) const draftPins = draft.projectContextPins ?? { notes: [], plans: [] } @@ -737,7 +759,12 @@ export async function materializeOpenDraftSession(selection: { ? { openchamber: { project_context_pins: draftPins } } : undefined, ) - if (!created?.id) throw new Error("Failed to create session") + if (!created?.id) { + if (isChatDraft && draftDirectoryOverride) { + await deleteChatDirectory(draftDirectoryOverride).catch(() => undefined) + } + throw new Error("Failed to create session") + } // The server response is authoritative. It may canonicalize a requested // worktree path (for example through a symlink or platform path casing). @@ -989,7 +1016,16 @@ export const useSessionUIStore = create()((set, get) => ({ const explicitDirectory = options?.directoryOverride !== undefined ? normalizePath(options.directoryOverride) : null - const explicitProject = options?.selectedProjectId + let target = isVSCodeRuntime() ? "project" : options?.target + if (!target) { + const hasExplicitProjectTarget = options?.directoryOverride !== undefined + || (options?.selectedProjectId !== undefined && options.selectedProjectId !== CHAT_DRAFT_PROJECT_ID) + || isVSCodeRuntime() + target = options?.selectedProjectId === CHAT_DRAFT_PROJECT_ID || !hasExplicitProjectTarget + ? "chat" + : "project" + } + const explicitProject = target === "project" && options?.selectedProjectId ? projects.find((p) => p.id === options.selectedProjectId) ?? null : null @@ -1006,14 +1042,14 @@ export const useSessionUIStore = create()((set, get) => ({ const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null) const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory) - const selectedProject = (() => { + const selectedProject = target === "chat" ? null : (() => { if (explicitProject) return explicitProject if (explicitDirectory !== null) return inferredProjectFromDir if (currentDirectory) return currentDirProject return persistedProjectByDir ?? persistedProjectById ?? fallbackProject })() - const directory = (() => { + const directory = target === "chat" ? null : (() => { if (explicitDirectory !== null) return explicitDirectory if (explicitProject) return normalizePath(explicitProject.path ?? null) if (currentDirectory) return currentDirectory @@ -1021,10 +1057,17 @@ export const useSessionUIStore = create()((set, get) => ({ return normalizePath(selectedProject?.path ?? null) })() + if (target === "chat") { + warmChatsRootDirectory() + } + persistDraftTarget({ projectId: selectedProject?.id ?? null, directory }) const nextDraft: NewSessionDraftState = { + draftId: nextDraftId++, open: true, + target, + preparedChatDirectory: null, selectedProjectId: selectedProject?.id ?? null, directoryOverride: directory, permissionAutoAcceptEnabled: options?.permissionAutoAcceptEnabled === true, @@ -1040,9 +1083,7 @@ export const useSessionUIStore = create()((set, get) => ({ } set({ - newSessionDraft: { - ...nextDraft, - }, + newSessionDraft: nextDraft, currentSessionId: null, currentSessionDirectory: null, error: null, @@ -1078,11 +1119,44 @@ export const useSessionUIStore = create()((set, get) => ({ void recoverStaleDraftDirectory(nextDraft) }, + prepareChatDraftDirectory: async () => { + const draft = get().newSessionDraft + if (!draft.open || draft.target !== "chat") return null + if (draft.preparedChatDirectory) return draft.preparedChatDirectory + + const runtimeKey = getRuntimeKey() + const key = `${runtimeKey}:${draft.draftId}` + const existing = pendingChatDirectoryByDraft.get(key) + if (existing) return existing + + const pending = createChatDirectory().then(async (directory) => { + const current = get().newSessionDraft + if ( + getRuntimeKey() !== runtimeKey + || !current.open + || current.target !== "chat" + || current.draftId !== draft.draftId + ) { + await deleteChatDirectory(directory).catch(() => undefined) + return null + } + set({ newSessionDraft: { ...current, preparedChatDirectory: directory } }) + return directory + }).finally(() => { + pendingChatDirectoryByDraft.delete(key) + }) + pendingChatDirectoryByDraft.set(key, pending) + return pending + }, + // --------------------------------------------------------------------------- // closeNewSessionDraft // --------------------------------------------------------------------------- closeNewSessionDraft: () => { const currentDraft = get().newSessionDraft + if (currentDraft.preparedChatDirectory) { + void deleteChatDirectory(currentDraft.preparedChatDirectory).catch(() => undefined) + } if ( !currentDraft.open && currentDraft.selectedProjectId == null @@ -1100,18 +1174,21 @@ export const useSessionUIStore = create()((set, get) => ({ return } const nextDraft: NewSessionDraftState = { - open: false, - selectedProjectId: null, - directoryOverride: null, - pendingWorktreeRequestId: null, - bootstrapPendingDirectory: null, - preserveDirectoryOverride: false, - parentID: null, - title: undefined, - initialPrompt: undefined, - syntheticParts: undefined, - targetFolderId: undefined, - } + draftId: currentDraft.draftId, + open: false, + target: "chat", + preparedChatDirectory: null, + selectedProjectId: null, + directoryOverride: null, + pendingWorktreeRequestId: null, + bootstrapPendingDirectory: null, + preserveDirectoryOverride: false, + parentID: null, + title: undefined, + initialPrompt: undefined, + syntheticParts: undefined, + targetFolderId: undefined, + } set({ newSessionDraft: nextDraft, }) @@ -1119,14 +1196,21 @@ export const useSessionUIStore = create()((set, get) => ({ }, setNewSessionDraftTarget: (target) => { + if (isVSCodeRuntime() && target.projectId === CHAT_DRAFT_PROJECT_ID) return + const previousDraft = get().newSessionDraft + if (previousDraft.preparedChatDirectory && target.projectId !== CHAT_DRAFT_PROJECT_ID) { + void deleteChatDirectory(previousDraft.preparedChatDirectory).catch(() => undefined) + } let nextDirectory: string | null = null set((s) => { nextDirectory = normalizePath(target.directoryOverride ?? s.newSessionDraft.directoryOverride) return { newSessionDraft: { ...s.newSessionDraft, + target: target.projectId === CHAT_DRAFT_PROJECT_ID ? "chat" : "project", + preparedChatDirectory: target.projectId === CHAT_DRAFT_PROJECT_ID ? s.newSessionDraft.preparedChatDirectory : null, selectedProjectId: target.projectId ?? target.selectedProjectId ?? s.newSessionDraft.selectedProjectId, - directoryOverride: target.directoryOverride ?? s.newSessionDraft.directoryOverride, + directoryOverride: target.projectId === CHAT_DRAFT_PROJECT_ID ? null : target.directoryOverride ?? s.newSessionDraft.directoryOverride, }, } }) diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 470c63f1..31d8ff26 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -1291,6 +1291,7 @@ const resolveMemoryProjectId = createMemoryProjectResolver({ return sanitizeProjects(settings?.projects || []).map((project) => project.path); }, resolvePrimaryWorktreeRoot, + managedProjectRoots: [path.join(OPENCHAMBER_USER_CONFIG_ROOT, 'chats')], }); /** diff --git a/packages/web/server/lib/agent-memory/project-resolution.js b/packages/web/server/lib/agent-memory/project-resolution.js index 85442897..03bb00de 100644 --- a/packages/web/server/lib/agent-memory/project-resolution.js +++ b/packages/web/server/lib/agent-memory/project-resolution.js @@ -24,7 +24,8 @@ const normalize = (value) => { }; export const createMemoryProjectResolver = (dependencies) => { - const { listProjectPaths, resolvePrimaryWorktreeRoot } = dependencies; + const { listProjectPaths, resolvePrimaryWorktreeRoot, managedProjectRoots = [] } = dependencies; + const managedRoots = managedProjectRoots.map(normalize).filter(Boolean); return async (directory) => { const resolved = normalize(directory); @@ -32,6 +33,14 @@ export const createMemoryProjectResolver = (dependencies) => { return ''; } + const managedRoot = managedRoots.find((root) => { + const relative = path.relative(root, resolved); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); + }); + if (managedRoot) { + return createProjectIdFromPath(managedRoot); + } + let configured = []; try { configured = ((await listProjectPaths()) || []).map(normalize).filter(Boolean); diff --git a/packages/web/server/lib/agent-memory/project-resolution.test.js b/packages/web/server/lib/agent-memory/project-resolution.test.js index f38c2bbf..9ca6d098 100644 --- a/packages/web/server/lib/agent-memory/project-resolution.test.js +++ b/packages/web/server/lib/agent-memory/project-resolution.test.js @@ -51,6 +51,15 @@ describe('resolving a session directory to its project', () => { expect(await resolve('/tmp/loose')).toBe(createProjectIdFromPath('/tmp/loose')); }); + test('managed chat session directories share the Chats root store', async () => { + const chatsRoot = '/Users/x/.config/openchamber/chats'; + const resolve = createResolver({ managedProjectRoots: [chatsRoot] }); + + expect(await resolve(`${chatsRoot}/2026-08-21/session-a`)).toBe(createProjectIdFromPath(chatsRoot)); + expect(await resolve(`${chatsRoot}/2026-08-21/session-b`)).toBe(createProjectIdFromPath(chatsRoot)); + expect(await resolve('/Users/x/.config/openchamber/chats-other/session-a')).not.toBe(createProjectIdFromPath(chatsRoot)); + }); + test('no directory resolves to nothing rather than to some default project', async () => { const resolve = createResolver(); diff --git a/packages/web/server/lib/project-context/DOCUMENTATION.md b/packages/web/server/lib/project-context/DOCUMENTATION.md index 52a584d3..ecd0205a 100644 --- a/packages/web/server/lib/project-context/DOCUMENTATION.md +++ b/packages/web/server/lib/project-context/DOCUMENTATION.md @@ -3,6 +3,8 @@ Server-owned storage for the Project Notes surface: free-form notes, todos, and plan markdown files. +The managed Chats root (`~/.config/openchamber/chats`) is also one context owner. Every dated per-session directory beneath it resolves to that root, so Notes, Todo, Plans, pinned knowledge, and project memory are shared across ordinary chats without registering Chats as a user project. + ## Ownership | Path | Owner | Contents | diff --git a/packages/web/server/lib/session-knowledge/DOCUMENTATION.md b/packages/web/server/lib/session-knowledge/DOCUMENTATION.md index aa13a095..3aaeb9fd 100644 --- a/packages/web/server/lib/session-knowledge/DOCUMENTATION.md +++ b/packages/web/server/lib/session-knowledge/DOCUMENTATION.md @@ -24,6 +24,8 @@ attached to that session. Pins never come from project-wide note or plan state. A new-session draft passes its pins into this metadata when its first message creates the session. +Directories beneath the managed `~/.config/openchamber/chats` root resolve to that root before project context and project memory are read. Every ordinary chat therefore shares one Chats knowledge owner instead of creating an unreachable context store for each dated session directory. + `session.metadata.openchamber.knowledge_context_delivered` holds the signature of what the session is carrying. It lives with the session, so it survives the tab closing and is visible to every sender, including the ones with no tab. From 59df2963ae95b0d0390fb3822bc8d91ceb0d51c5 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 21 Aug 2026 12:49:40 +0300 Subject: [PATCH 017/192] fix(sidebar): keep sticky activity label in sync Track Chats and Recent section sentinels so the desktop sticky overlay changes identity only when the corresponding section reaches the top of the sidebar. --- .../session/sidebar/DOCUMENTATION.md | 2 +- .../sidebar/SidebarActivitySections.tsx | 5 ++ .../session/sidebar/SidebarProjectsList.tsx | 46 ++++++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index 42bf3fb8..f3e66647 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -29,7 +29,7 @@ - `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all). - A successful add/create/clone from the project-directory dialog transitions to a new-session draft targeted at that project, matching the project's `+` action; changing project metadata alone must not leave the visible session or draft on a different directory. - `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code. -- `SidebarActivitySections.tsx`: Global top section renderer for project-only `recent` sessions followed by OpenChamber-managed `chats`, styled as zone headers. +- `SidebarActivitySections.tsx`: Global top section renderer for OpenChamber-managed `chats` followed by optional project-only `recent` sessions, styled as zone headers. The desktop sticky identity overlay follows the activity header whose sentinel has crossed the scroller edge, so a small scroll cannot relabel Chats as Recent. - `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions. - `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder. - `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows. diff --git a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx index d3f95a35..d61b66b3 100644 --- a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx @@ -183,6 +183,11 @@ export function SidebarActivitySections(props: Props): React.ReactNode { return (
    +