From 82f099be0ac26307757c035e4c6d3cd5017ecf88 Mon Sep 17 00:00:00 2001 From: ChangeHow Date: Thu, 30 Jul 2026 11:10:59 +0800 Subject: [PATCH 01/49] 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 02/49] 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 03/49] 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 04/49] 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 05/49] 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 06/49] 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 07/49] 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 08/49] 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 09/49] 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 10/49] 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 11/49] 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 12/49] 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 = ( - )} - > - {(errorText || warningText || pendingOverwrite) && ( -
    - {pendingOverwrite && ( -
    - - {t('settings.openchamber.keyboardShortcuts.overwritePrompt')} - -
    - - -
    + <> + {CATEGORIES.map((category, categoryIndex) => { + const categoryActions = actions.filter((action) => action.category === 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.test.ts b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts new file mode 100644 index 00000000..256383a1 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test'; +import { settleShortcutRecordingState, updateShortcutRecordingState } from './ShortcutRecordingDialog'; + +const emptyState = { chords: [], livePreview: null, settled: false }; + +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.livePreview).toBe('mod+shift'); + expect(updateShortcutRecordingState(pressed, keyEvent('Control'), 'keyup').livePreview).toBeNull(); + }); + + test('waits after the first chord and settles when a second chord is recorded', () => { + const first = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown'); + const second = updateShortcutRecordingState(first, keyEvent('p'), 'keydown'); + const third = updateShortcutRecordingState(second, keyEvent('x'), 'keydown'); + expect(first.chords).toEqual(['mod+s']); + expect(first.settled).toBe(false); + expect(second.chords).toEqual(['mod+s', 'p']); + expect(second.settled).toBe(true); + expect(third.chords).toEqual(['x']); + expect(third.settled).toBe(false); + }); + + test('settles a single chord for timeout and Confirm validation', () => { + const waiting = updateShortcutRecordingState(emptyState, keyEvent('s', { ctrlKey: true }), 'keydown'); + expect(settleShortcutRecordingState(waiting)).toEqual({ chords: ['mod+s'], livePreview: null, settled: true }); + }); + + test('records at most three simultaneous keys', () => { + const previous = { chords: ['mod+k'], livePreview: null, settled: false }; + const threeKeys = updateShortcutRecordingState( + previous, + keyEvent('s', { ctrlKey: true, shiftKey: true }), + 'keydown', + ); + const fourKeys = updateShortcutRecordingState( + previous, + keyEvent('s', { ctrlKey: true, metaKey: true, shiftKey: true }), + 'keydown', + ); + + expect(threeKeys.chords).toEqual(['mod+k', 'mod+shift+s']); + expect(fourKeys.chords).toEqual(['mod+k']); + }); + + test('ignores repeat and IME events', () => { + 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('records Enter and Escape while Backspace removes the final chord', () => { + const state = { chords: ['mod+k', 'mod+p'], livePreview: null, settled: true }; + 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']); + expect(updateShortcutRecordingState(state, keyEvent('Backspace'), 'keydown').settled).toBe(false); + expect(updateShortcutRecordingState({ chords: ['mod+k'], livePreview: null, settled: false }, keyEvent('Backspace'), 'keydown')).toEqual(emptyState); + }); +}); 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..a04fb8a1 --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx @@ -0,0 +1,311 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { + formatShortcutForDisplay, + getShortcutBindingConflicts, + isRiskyBrowserShortcut, + keyToShortcutToken, + normalizeCombo, + type ShortcutActionId, + type ShortcutBindingConflict, + type ShortcutCombo, + type CustomizableShortcutAction, +} from '@/lib/shortcuts'; +import { useI18n } from '@/lib/i18n'; + +const MODIFIER_KEYS = new Set(['shift', 'control', 'alt', 'meta']); +const MAX_SHORTCUT_KEY_COUNT = 3; +const SECOND_CHORD_TIMEOUT_MS = 3000; + +interface RecordingKeyboardEvent { + altKey: boolean; + ctrlKey: boolean; + isComposing: boolean; + key: string; + metaKey: boolean; + repeat: boolean; + shiftKey: boolean; +} + +interface ShortcutRecordingState { + chords: ShortcutCombo[]; + livePreview: ShortcutCombo | null; + settled: boolean; +} + +interface ShortcutRecordingDialogProps { + action: CustomizableShortcutAction | null; + overrides: Record; + onSave: ( + actionId: ShortcutActionId, + combo: ShortcutCombo, + replaceActionId?: ShortcutActionId, + ) => void; + onOpenChange: (open: boolean) => void; +} + +function getPhysicalKeyCount( + event: Pick, + includeEventKey = false, +): number { + const keys = new Set(); + if (event.altKey) keys.add('alt'); + if (event.ctrlKey) keys.add('control'); + if (event.metaKey) keys.add('meta'); + if (event.shiftKey) keys.add('shift'); + if (includeEventKey) keys.add(event.key.toLowerCase()); + return keys.size; +} + +function isCustomizableConflict( + conflict: ShortcutBindingConflict, +): conflict is ShortcutBindingConflict & { action: CustomizableShortcutAction } { + return conflict.action.customizable; +} + +function getModifierPreview(event: RecordingKeyboardEvent): ShortcutCombo | null { + if (getPhysicalKeyCount(event) > MAX_SHORTCUT_KEY_COUNT) 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'); + return parts.length > 0 ? normalizeCombo(parts.join('+')) : null; +} + +function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | null { + if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null; + if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) 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; + if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) 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; +} + +// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition +export function settleShortcutRecordingState(state: ShortcutRecordingState): ShortcutRecordingState { + return state.chords.length > 0 ? { ...state, livePreview: null, settled: true } : state; +} + +// eslint-disable-next-line react-refresh/only-export-components -- tested pure recording state transition +export function updateShortcutRecordingState( + state: ShortcutRecordingState, + event: RecordingKeyboardEvent, + phase: 'keydown' | 'keyup', +): ShortcutRecordingState { + if (event.repeat || event.isComposing) return state; + if (phase === 'keyup') { + return { ...state, livePreview: getModifierPreview(event) }; + } + + if (event.key === 'Backspace') { + return { chords: state.chords.slice(0, -1), livePreview: null, settled: false }; + } + + const chord = keyboardEventToCombo(event); + if (chord) { + if (state.settled) { + return { chords: [chord], livePreview: null, settled: false }; + } + const chords = state.chords.length < 2 ? [...state.chords, chord] : state.chords; + return { + chords, + livePreview: null, + settled: chords.length === 2, + }; + } + + return { ...state, livePreview: getModifierPreview(event) }; +} + +export const ShortcutRecordingDialog: React.FC = ({ + action, + overrides, + onSave, + onOpenChange, +}) => { + const { t } = useI18n(); + const actionLabel = (shortcut: CustomizableShortcutAction) => t(shortcut.settingsLabelKey); + const conflictActionLabel = (conflict: ShortcutBindingConflict) => ( + conflict.action.customizable + ? actionLabel(conflict.action) + : formatShortcutForDisplay(conflict.action.defaultBinding) + ); + const [recording, setRecording] = React.useState({ chords: [], livePreview: null, settled: false }); + const recordingRef = React.useRef(null); + + React.useEffect(() => { + if (!action) return; + setRecording({ chords: [], livePreview: null, settled: false }); + recordingRef.current?.focus(); + }, [action]); + + const waitingForSecondChord = recording.chords.length === 1 && !recording.settled; + + React.useEffect(() => { + if (!waitingForSecondChord) return; + const timeout = window.setTimeout( + () => setRecording(settleShortcutRecordingState), + SECOND_CHORD_TIMEOUT_MS, + ); + return () => window.clearTimeout(timeout); + }, [waitingForSecondChord]); + + const combo = normalizeCombo(recording.chords.join(' ')); + const conflicts = React.useMemo( + () => action && combo ? getShortcutBindingConflicts(action.id, combo, overrides) : [], + [action, combo, overrides], + ); + const protectedConflict = conflicts.find((conflict) => ( + !conflict.action.customizable && conflict.kind !== 'contextual-prefix' + )); + const customizableConflicts = conflicts.filter(isCustomizableConflict); + const prefixConflict = customizableConflicts.find((conflict) => conflict.kind === 'prefix'); + const exactConflict = customizableConflicts.find((conflict) => conflict.kind === 'exact'); + const contextualPrefixConflict = conflicts.find((conflict) => conflict.kind === 'contextual-prefix'); + + const close = () => onOpenChange(false); + const confirm = () => { + if (!recording.settled) setRecording(settleShortcutRecordingState); + if (!action || !combo || protectedConflict || prefixConflict) return; + onSave(action.id, combo, exactConflict?.action.id); + 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, settled: true }); + return; + } + } + const nextRecording = 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' && nextRecording.chords.length > 1 + ? recording + : nextRecording); + }; + + return ( + { + if (!open) { + eventDetails.cancel(); + } + }} + > + + + + {action ? t('settings.openchamber.keyboardShortcuts.dialog.title', { action: actionLabel(action) }) : ''} + + {t('settings.openchamber.keyboardShortcuts.dialog.instructions')} + + +
    handleRecordingEvent(event, 'keydown')} + onKeyUp={(event) => handleRecordingEvent(event, 'keyup')} + onBlur={() => setRecording((current) => ({ ...current, livePreview: null }))} + > +
    + {recording.chords.map((chord, index) => ( + + {formatShortcutForDisplay(chord)} + + ))} + {recording.livePreview ? ( + + {formatShortcutForDisplay(recording.livePreview)} + + ) : null} + {recording.chords.length === 0 && !recording.livePreview ? ( + + {t('settings.openchamber.keyboardShortcuts.dialog.recording')} + + ) : null} +
    +
    + + {recording.settled && protectedConflict ? ( +

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

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

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

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

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

    + ) : null} + {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')} +

    + ) : null} + + + + + +
    +
    + ); +}; diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 6ed250ea..6080ac29 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -11,17 +11,17 @@ import { useUIStore } from "@/stores/useUIStore"; import { getEffectiveShortcutCombo, 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; + descriptionKey?: I18nKey; icon: IconName | null; }; @@ -30,9 +30,12 @@ type ShortcutSection = { items: ShortcutItem[]; }; -const renderShortcut = (id: string, fallbackCombo: string, overrides: Record) => { - const action = getShortcutAction(id); - return action ? formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides)) : fallbackCombo; +const renderShortcut = ( + id: ShortcutActionId, + overrides: Record, + unassignedLabel: string, +) => { + return formatShortcutForDisplay(getEffectiveShortcutCombo(id, overrides), unassignedLabel); }; export const HelpDialog: React.FC = () => { @@ -40,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[] = [ @@ -100,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", }, @@ -121,6 +123,21 @@ export const HelpDialog: React.FC = () => { icon: "git-branch", keys: '', }, + { + id: 'open_draft_project_picker', + icon: 'folder', + keys: '', + }, + { + id: 'open_draft_worktree_picker', + 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', @@ -176,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", }, @@ -214,7 +231,7 @@ export const HelpDialog: React.FC = () => { ]; return ( - + @@ -237,40 +254,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 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, + 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(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 +309,11 @@ export const HelpDialog: React.FC = () => {
    • • {t('helpDialog.proTips.commandPalette', { - shortcut: renderShortcut('open_command_palette', `${mod} P`, shortcutOverrides), + shortcut: renderShortcut( + 'open_command_palette', + shortcutOverrides, + t('settings.openchamber.keyboardShortcuts.unassigned'), + ), })}
    • diff --git a/packages/ui/src/components/ui/dropdown-navigation.ts b/packages/ui/src/components/ui/dropdown-navigation.ts new file mode 100644 index 00000000..bba31b73 --- /dev/null +++ b/packages/ui/src/components/ui/dropdown-navigation.ts @@ -0,0 +1,45 @@ +import type React from 'react'; + +import { isIMECompositionEvent } from '@/lib/ime'; + +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'; + return null; +} + +type DropdownNavigationEvent = Pick< + React.KeyboardEvent, + | 'altKey' + | 'ctrlKey' + | 'defaultPrevented' + | 'isPropagationStopped' + | 'key' + | 'metaKey' + | 'preventDefault' + | 'shiftKey' + | 'stopPropagation' +>; + +export function handleDropdownNavigationKey( + event: DropdownNavigationEvent, + navigate: (key: 'ArrowDown' | 'ArrowUp') => void, +): boolean { + if (event.defaultPrevented || event.isPropagationStopped()) return false; + const navigationKey = getDropdownNavigationKey(event); + if (!navigationKey) return false; + + // Do not add an IME guard: exact Ctrl+N/P remain intentional commands, while + // every other composing key falls through without being handled. + navigate(navigationKey); + event.preventDefault(); + event.stopPropagation(); + return true; +} + +export function shouldDismissDropdown( + event: KeyboardEvent | React.KeyboardEvent, +): boolean { + return event.key === 'Escape' && !isIMECompositionEvent(event); +} diff --git a/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts b/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts index 608bb203..f475c3ae 100644 --- a/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts +++ b/packages/ui/src/hooks/keyboard-shortcut-dom.test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'bun:test'; -import { hasOpenDropdown } from './keyboard-shortcut-dom'; +import { hasOpenDropdown, shouldStopDropdownImeEscape } from './keyboard-shortcut-dom'; test('does not treat an unrelated visible listbox as an open dropdown', () => { const promptNavigator = {} as Element; @@ -28,3 +28,10 @@ test('detects an open select popup', () => { expect(hasOpenDropdown(root)).toBe(true); }); + +test('stops IME Escape before an open dropdown dismiss listener', () => { + expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, true)).toBe(true); + expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 229 }, true)).toBe(true); + expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: false, keyCode: 27 }, true)).toBe(false); + expect(shouldStopDropdownImeEscape({ key: 'Escape', isComposing: true, keyCode: 0 }, false)).toBe(false); +}); diff --git a/packages/ui/src/hooks/keyboard-shortcut-dom.ts b/packages/ui/src/hooks/keyboard-shortcut-dom.ts index 413b6be2..97400b3f 100644 --- a/packages/ui/src/hooks/keyboard-shortcut-dom.ts +++ b/packages/ui/src/hooks/keyboard-shortcut-dom.ts @@ -6,3 +6,12 @@ const OPEN_DROPDOWN_SELECTOR = [ export function hasOpenDropdown(root: ParentNode = document): boolean { return Boolean(root.querySelector(OPEN_DROPDOWN_SELECTOR)); } + +export function shouldStopDropdownImeEscape( + event: Pick, + dropdownOpen: boolean, +): boolean { + return dropdownOpen + && event.key === 'Escape' + && (event.isComposing || event.keyCode === 229); +} 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 new file mode 100644 index 00000000..8076a26c --- /dev/null +++ b/packages/ui/src/hooks/useKeybind.ts @@ -0,0 +1,30 @@ +import React from 'react'; +import { shortcutRegistry, type ShortcutActionId, type ShortcutHandler } 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 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'); + + 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/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 1f1a2f48..c52e96e8 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1086,6 +1086,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Kontextpanel-Oberfläche wechseln', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Sitzungs-Tab schließen', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Tastenkürzel öffnen', @@ -1100,6 +1101,27 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Eingabe erweitern', 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label': 'Konversations-Zeitleiste öffnen', 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Prompt-Navigator umschalten', + 'settings.openchamber.keyboardShortcuts.warning.contextualPrefix': 'Diese Sequenz teilt ein kontextabhängiges Präfix mit {action}. Wenn dessen Kontext aktiv ist, hat diese Aktion Vorrang.', + 'settings.openchamber.keyboardShortcuts.category.session': 'Sitzungssteuerung', + 'settings.openchamber.keyboardShortcuts.category.models': 'Modelle und Agenten', + 'settings.openchamber.keyboardShortcuts.category.panels': 'Panels und Werkzeuge', + 'settings.openchamber.keyboardShortcuts.category.navigation': 'Navigation', + 'settings.openchamber.keyboardShortcuts.category.application': 'Anwendung', + 'settings.openchamber.keyboardShortcuts.actions.edit': 'Bearbeiten', + 'settings.openchamber.keyboardShortcuts.actions.confirm': 'Bestätigen', + 'settings.openchamber.keyboardShortcuts.dialog.title': '{action} bearbeiten', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Drücken Sie bis zu zwei Tastenkombinationen mit jeweils höchstens drei Tasten. Warten Sie nach der ersten bis zu 3 Sekunden auf eine zweite Kombination. Wählen Sie Bestätigen zum Anwenden oder Abbrechen zum Verwerfen. Mit der Rücktaste entfernen Sie die letzte.', + 'settings.openchamber.keyboardShortcuts.dialog.firstChord': 'Erste Kombination', + 'settings.openchamber.keyboardShortcuts.dialog.secondChord': 'Zweite Kombination', + 'settings.openchamber.keyboardShortcuts.dialog.recording': 'Tasten drücken…', + 'settings.openchamber.keyboardShortcuts.unassigned': 'Nicht zugewiesen', + 'settings.openchamber.keyboardShortcuts.error.prefixConflict': 'Dies kollidiert mit der von {action} verwendeten Sequenz. Wählen Sie eine andere Kombination.', + 'settings.openchamber.keyboardShortcuts.error.exactConflict': 'Diese Kombination wird bereits von {action} verwendet.', + 'settings.openchamber.keyboardShortcuts.error.internalConflict': 'Diese Kombination kollidiert mit einem integrierten Tastenkürzel, das nicht ersetzt werden kann.', + 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label': 'Projektauswahl für Entwurf öffnen', + 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label': 'Worktree-Auswahl für Entwurf öffnen', + 'settings.openchamber.keyboardShortcuts.action.open_session_list.label': 'Letzte Sitzungen öffnen', + 'settings.openchamber.keyboardShortcuts.action.toggle_dictation.label': 'Spracheingabe', 'settings.projects.sidebar.total': 'Gesamt {count}', 'settings.projects.sidebar.actions.addProject': 'Projekt hinzufügen', 'settings.projects.page.empty.noProjects': 'Keine Projekte verfügbar.', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 44b048d4..15735541 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1498,7 +1498,7 @@ export const dict = { 'rightSidebar.contextNotesTodo.toast.planImported': 'Plan importiert', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Fehler beim Lesen der Plan-Datei', 'inlineComment.range.lines': 'Zeilen {start}-{end}', - 'inlineComment.input.placeholder': 'Kommentar hinzufügen... (Cmd+Enter zum Speichern)', + 'inlineComment.input.placeholder': 'Kommentar hinzufügen... ({shortcut} zum Speichern)', 'inlineComment.input.placeholderShort': 'Kommentar hinzufügen...', 'inlineComment.actions.cancel': 'Abbrechen', 'inlineComment.actions.save': 'Speichern', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index b8718cda..f48bdd83 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1133,7 +1133,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', @@ -1148,6 +1148,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Switch context panel surface', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Close session tab', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Open keyboard shortcuts', @@ -1162,6 +1163,27 @@ 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.openchamber.keyboardShortcuts.warning.contextualPrefix': 'This sequence shares a contextual prefix with {action}. That action takes priority while its context is active.', + '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.confirm': 'Confirm', + 'settings.openchamber.keyboardShortcuts.dialog.title': 'Edit {action}', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Press up to two key combinations, with at most three keys each. After the first, wait up to 3 seconds for a second combination. Use Confirm to apply or Cancel to discard. Backspace removes 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.error.internalConflict': 'This combination conflicts with a built-in shortcut, which cannot be replaced.', + '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 recent sessions', + '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.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index a4840295..c33afc63 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1668,7 +1668,7 @@ export const dict = { 'rightSidebar.contextNotesTodo.toast.planImported': 'Plan imported', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Failed to read plan file', 'inlineComment.range.lines': 'Lines {start}-{end}', - 'inlineComment.input.placeholder': 'Add a comment... (Cmd+Enter to save)', + 'inlineComment.input.placeholder': 'Add a comment... ({shortcut} to save)', 'inlineComment.input.placeholderShort': 'Add a comment...', 'inlineComment.actions.cancel': 'Cancel', 'inlineComment.actions.save': 'Save', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index b05c568c..67ea6135 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1101,7 +1101,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", @@ -1116,6 +1116,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Cambiar superficie del panel de contexto", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión", + "settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Cerrar pestaña de sesión", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atajos de teclado", @@ -1130,6 +1131,27 @@ 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.openchamber.keyboardShortcuts.warning.contextualPrefix": "Esta secuencia comparte un prefijo contextual con {action}. Cuando su contexto está activo, esa acción tiene prioridad.", + "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.confirm": "Confirmar", + "settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}", + "settings.openchamber.keyboardShortcuts.dialog.instructions": "Pulse hasta dos combinaciones de teclas, con un máximo de tres teclas cada una. Tras la primera, espere hasta 3 segundos por una segunda combinación. Use Confirmar para aplicar o Cancelar para descartar. Retroceso elimina 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.error.internalConflict": "Esta combinación entra en conflicto con un atajo integrado, que no se puede reemplazar.", + "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 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", "settings.projects.page.empty.noProjects": "No hay proyectos disponibles.", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 1f6b829e..7999cdb7 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1646,7 +1646,7 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.toast.planImported": "Plan importado", "rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "No se pudo leer el archivo del plan", "inlineComment.range.lines": "Líneas {start}-{end}", - "inlineComment.input.placeholder": "Añadir un comentario... (Cmd+Enter para guardar)", + "inlineComment.input.placeholder": "Añadir un comentario... ({shortcut} para guardar)", "inlineComment.input.placeholderShort": "Añadir un comentario...", "inlineComment.actions.cancel": "Cancelar", "inlineComment.actions.save": "Guardar", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 345e0c08..80d982e6 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1019,7 +1019,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', @@ -1034,6 +1034,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Basculer la surface du panneau contextuel', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Fermer l’onglet de session', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Ouvrir les raccourcis clavier', @@ -1048,6 +1049,27 @@ 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.openchamber.keyboardShortcuts.warning.contextualPrefix': 'Cette séquence partage un préfixe contextuel avec {action}. Lorsque son contexte est actif, cette action est prioritaire.', + '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.confirm': 'Confirmer', + 'settings.openchamber.keyboardShortcuts.dialog.title': 'Modifier {action}', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Appuyez sur deux combinaisons de touches au maximum, avec trois touches au plus chacune. Après la première, attendez jusqu’à 3 secondes une seconde combinaison. Utilisez Confirmer pour appliquer ou Annuler pour abandonner. Retour arrière supprime 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.error.internalConflict': 'Cette combinaison entre en conflit avec un raccourci intégré qui ne peut pas être remplacé.', + '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 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', 'settings.projects.page.empty.noProjects': 'Aucun projet disponible.', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 1b8ccd3a..f16dee7b 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1433,7 +1433,7 @@ export const dict = { 'rightSidebar.contextNotesTodo.toast.planImported': 'Forfait importé', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': 'Échec de la lecture du fichier de plan', 'inlineComment.range.lines': 'Lignes {start}-{end}', - 'inlineComment.input.placeholder': 'Ajouter un commentaire... (Cmd+Entrée pour enregistrer)', + 'inlineComment.input.placeholder': 'Ajouter un commentaire... ({shortcut} pour enregistrer)', 'inlineComment.actions.cancel': 'Annuler', 'inlineComment.actions.save': 'Sauvegarder', 'inlineComment.actions.comment': 'Commentaire', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 8d6f1c1b..98d45ab8 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1134,7 +1134,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': '入力をフォーカス', @@ -1149,6 +1149,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'コンテキストパネルのサーフェスを切り替え', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'セッションタブを閉じる', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'キーボードショートカットを開く', @@ -1163,6 +1164,27 @@ 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.openchamber.keyboardShortcuts.warning.contextualPrefix': 'このシーケンスは {action} とコンテキスト依存のプレフィックスを共有しています。そのコンテキストが有効な間は、この操作が優先されます。', + '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.confirm': '確認', + 'settings.openchamber.keyboardShortcuts.dialog.title': '{action} を編集', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'キーの組み合わせを最大2つ入力でき、各組み合わせは最大3キーです。最初の組み合わせの後、2つ目の組み合わせを最大3秒待ちます。適用するには確認、破棄するにはキャンセルを選択してください。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.error.internalConflict': 'この組み合わせは組み込みショートカットと競合しています。組み込みショートカットは置き換えられません。', + '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': 'プロジェクトを追加', 'settings.projects.page.empty.noProjects': '利用可能なプロジェクトがありません。', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index fe7f1868..3b511318 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1664,7 +1664,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.planImported': '計画をインポートしました', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '計画ファイルの読み込みに失敗しました', 'inlineComment.range.lines': '{start}行目~{end}行目', - 'inlineComment.input.placeholder': 'コメントを追加...(Cmd+Enterで保存)', + 'inlineComment.input.placeholder': 'コメントを追加...({shortcut}で保存)', 'inlineComment.input.placeholderShort': 'コメントを追加...', 'inlineComment.actions.cancel': 'キャンセル', 'inlineComment.actions.save': '保存', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 07a8e7d0..70314bdb 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1101,7 +1101,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': '입력에 포커스', @@ -1116,6 +1116,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '컨텍스트 패널 서피스 전환', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '세션 탭 닫기', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창', 'settings.openchamber.keyboardShortcuts.action.open_help.label': '키보드 단축키 열기', @@ -1130,6 +1131,27 @@ 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.openchamber.keyboardShortcuts.warning.contextualPrefix': '이 시퀀스는 {action}과 컨텍스트 접두사를 공유합니다. 해당 컨텍스트가 활성화된 동안에는 그 동작이 우선합니다.', + '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.confirm': '확인', + 'settings.openchamber.keyboardShortcuts.dialog.title': '{action} 편집', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': '키 조합을 최대 두 개까지 누르세요. 각 조합에는 최대 세 개의 키를 사용할 수 있습니다. 첫 번째 조합 뒤에는 두 번째 조합을 위해 최대 3초 동안 기다립니다. 적용하려면 확인을, 취소하려면 취소를 선택하세요. 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.error.internalConflict': '이 조합은 바꿀 수 없는 기본 제공 단축키와 충돌합니다.', + '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': '프로젝트 추가', 'settings.projects.page.empty.noProjects': '사용 가능한 프로젝트가 없습니다.', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index a2b73918..4fef8b87 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1670,7 +1670,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.planImported': '플랜 가져옴', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '플랜 파일 읽기 실패', 'inlineComment.range.lines': '줄 {start}-{end}', - 'inlineComment.input.placeholder': '댓글 추가… (Cmd+Enter로 저장)', + 'inlineComment.input.placeholder': '댓글 추가… ({shortcut}로 저장)', 'inlineComment.input.placeholderShort': '댓글 추가…', 'inlineComment.actions.cancel': '취소', 'inlineComment.actions.save': '저장', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 1ce9c512..5a9fccc0 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -825,6 +825,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Przełącz nawigator promptów', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Skup pole wprowadzania', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nowa sesja', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Zamknij kartę sesji', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nowy szkic obszaru roboczego', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nowe okno Mini Chat', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Otwórz paletę poleceń', @@ -849,7 +850,29 @@ 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.keyboardShortcuts.warning.contextualPrefix': 'Ta sekwencja współdzieli prefiks kontekstowy z działaniem {action}. Gdy jego kontekst jest aktywny, to działanie ma pierwszeństwo.', + '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.confirm': 'Potwierdź', + 'settings.openchamber.keyboardShortcuts.dialog.title': 'Edytuj: {action}', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': 'Naciśnij maksymalnie dwie kombinacje klawiszy, po najwyżej trzy klawisze każda. Po pierwszej odczekaj do 3 sekund na drugą kombinację. Wybierz Potwierdź, aby zastosować, lub Anuluj, aby odrzucić. Backspace usuwa 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.error.internalConflict': 'Ta kombinacja koliduje z wbudowanym skrótem, którego nie można zastąpić.', + '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 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.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...', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 2f3ef7b6..476aff49 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2490,7 +2490,7 @@ export const dict: Record = { 'inlineComment.actions.save': 'Zapisz', 'inlineComment.actions.showLess': 'Show less', 'inlineComment.actions.showMore': 'Show more', - 'inlineComment.input.placeholder': 'Add a comment... (Cmd+Enter to save)', + 'inlineComment.input.placeholder': 'Dodaj komentarz... ({shortcut}, aby zapisać)', 'inlineComment.input.placeholderShort': 'Dodaj komentarz...', 'inlineComment.range.lines': 'Lines {start}-{end}', 'inlineComment.toast.selectSessionToSave': 'Select a session to save comment', 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 f095bb16..f536f6e3 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1101,7 +1101,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", @@ -1116,6 +1116,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Alternar superfície do painel de contexto", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão", + "settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Fechar aba da sessão", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atalhos de teclado", @@ -1130,6 +1131,27 @@ 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.openchamber.keyboardShortcuts.warning.contextualPrefix": "Esta sequência compartilha um prefixo contextual com {action}. Quando esse contexto está ativo, essa ação tem prioridade.", + "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.confirm": "Confirmar", + "settings.openchamber.keyboardShortcuts.dialog.title": "Editar {action}", + "settings.openchamber.keyboardShortcuts.dialog.instructions": "Pressione até duas combinações de teclas, com no máximo três teclas em cada uma. Após a primeira, aguarde até 3 segundos por uma segunda combinação. Use Confirmar para aplicar ou Cancelar para descartar. Backspace remove 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.error.internalConflict": "Esta combinação entra em conflito com um atalho integrado, que não pode ser substituído.", + "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 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", "settings.projects.page.empty.noProjects": "Não há projetos disponíveis.", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 2723fd6e..36a06ede 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1646,7 +1646,7 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.toast.planImported": "Plano importado", "rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "Não foi possível ler o arquivo do plano", "inlineComment.range.lines": "Linhas {start}-{end}", - "inlineComment.input.placeholder": "Adicionar um comentário... (Cmd+Enter para salvar)", + "inlineComment.input.placeholder": "Adicionar um comentário... ({shortcut} para salvar)", "inlineComment.input.placeholderShort": "Adicionar um comentário...", "inlineComment.actions.cancel": "Cancelar", "inlineComment.actions.save": "Salvar", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 49c34b87..11afb49c 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1101,7 +1101,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": "Фокус на полі вводу", @@ -1116,6 +1116,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Перемкнути поверхню панелі контексту", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія", + "settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Закрити вкладку сесії", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Відкрити комбінації клавіш", @@ -1130,6 +1131,27 @@ 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.openchamber.keyboardShortcuts.warning.contextualPrefix": "Ця послідовність має спільний контекстний префікс із дією {action}. Коли її контекст активний, ця дія має пріоритет.", + "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.confirm": "Підтвердити", + "settings.openchamber.keyboardShortcuts.dialog.title": "Редагувати {action}", + "settings.openchamber.keyboardShortcuts.dialog.instructions": "Натисніть до двох комбінацій клавіш, не більше трьох клавіш у кожній. Після першої зачекайте до 3 секунд на другу комбінацію. Виберіть Підтвердити, щоб застосувати, або Скасувати, щоб відхилити. 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.error.internalConflict": "Ця комбінація конфліктує з вбудованим скороченням, яке не можна замінити.", + "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": "Додати проєкт", "settings.projects.page.empty.noProjects": "Немає доступних проєктів.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 90c85f21..61163aa0 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1646,7 +1646,7 @@ export const dict: Record = { "rightSidebar.contextNotesTodo.toast.planImported": "План імпортовано", "rightSidebar.contextNotesTodo.toast.readPlanFileFailed": "Не вдалося прочитати файл плану", "inlineComment.range.lines": "Рядки {start}-{end}", - "inlineComment.input.placeholder": "Додайте коментар... (Cmd+Enter, щоб зберегти)", + "inlineComment.input.placeholder": "Додайте коментар... ({shortcut}, щоб зберегти)", "inlineComment.input.placeholderShort": "Додайте коментар...", "inlineComment.actions.cancel": "Скасувати", "inlineComment.actions.save": "Зберегти", 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 7874ce8e..59714a11 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1101,7 +1101,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': '聚焦输入框', @@ -1116,6 +1116,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切换上下文面板界面', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '关闭会话标签页', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口', 'settings.openchamber.keyboardShortcuts.action.open_help.label': '打开键盘快捷键', @@ -1130,6 +1131,27 @@ 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.openchamber.keyboardShortcuts.warning.contextualPrefix': '此序列与“{action}”共享上下文前缀。对应上下文生效时,该操作会优先执行。', + '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.confirm': '确认', + 'settings.openchamber.keyboardShortcuts.dialog.title': '编辑{action}', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多输入两个按键组合,每个组合最多同时按下三个按键。输入第一个组合后,最多等待 3 秒以输入第二个组合。点击确认应用,或点击取消放弃;按 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.error.internalConflict': '此组合与内置快捷键冲突,内置快捷键不能被替换。', + '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': '添加项目', 'settings.projects.page.empty.noProjects': '暂无项目。', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 007a243b..6ebc17c6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1634,7 +1634,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.planImported': '计划已导入', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '读取计划文件失败', 'inlineComment.range.lines': '行 {start}-{end}', - 'inlineComment.input.placeholder': '添加评论...(Cmd+Enter 保存)', + 'inlineComment.input.placeholder': '添加评论...({shortcut} 保存)', 'inlineComment.input.placeholderShort': '添加评论…', 'inlineComment.actions.cancel': '取消', 'inlineComment.actions.save': '保存', 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 273a5631..af6b2fcb 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1008,7 +1008,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': '聚焦輸入方塊', @@ -1023,6 +1023,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切換上下文面板介面', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建工作階段', + 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '關閉工作階段分頁', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗', 'settings.openchamber.keyboardShortcuts.action.open_help.label': '開啟鍵盤快速鍵', @@ -1037,6 +1038,27 @@ 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.openchamber.keyboardShortcuts.warning.contextualPrefix': '此序列與「{action}」共用情境前綴。對應情境生效時,該操作會優先執行。', + '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.confirm': '確認', + 'settings.openchamber.keyboardShortcuts.dialog.title': '編輯 {action}', + 'settings.openchamber.keyboardShortcuts.dialog.instructions': '最多輸入兩個按鍵組合,每個組合最多同時按下三個按鍵。輸入第一個組合後,最多等待 3 秒以輸入第二個組合。點擊確認套用,或點擊取消放棄;按 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.error.internalConflict': '此組合與內建快捷鍵衝突,內建快捷鍵不能被取代。', + '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': '新增專案', 'settings.projects.page.empty.noProjects': '暫無專案。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 1e4750e3..c3cecc5e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1644,7 +1644,7 @@ export const dict: Record = { 'rightSidebar.contextNotesTodo.toast.planImported': '計畫已匯入', 'rightSidebar.contextNotesTodo.toast.readPlanFileFailed': '讀取計畫檔案失敗', 'inlineComment.range.lines': '行 {start}-{end}', - 'inlineComment.input.placeholder': '新增留言...(Cmd+Enter 儲存)', + 'inlineComment.input.placeholder': '新增留言...({shortcut} 儲存)', 'inlineComment.input.placeholderShort': '新增留言…', 'inlineComment.actions.cancel': '取消', 'inlineComment.actions.save': '儲存', diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts deleted file mode 100644 index aab2f9f1..00000000 --- a/packages/ui/src/lib/shortcuts.ts +++ /dev/null @@ -1,688 +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 const UNASSIGNED_SHORTCUT: ShortcutCombo = '__unassigned__'; - -export interface ShortcutAction { - id: string; - defaultCombo: ShortcutCombo; - label: string; - description?: string; - customizable?: boolean; -} - -interface ParsedShortcut { - modifiers: Set; - key: ShortcutKey; -} - -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 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: ReadonlyArray = [ - { - 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, - }, - { - id: 'open_command_palette', - defaultCombo: 'mod+p', - label: 'Open command palette', - description: 'Open the command palette', - customizable: true, - }, - { - id: 'focus_input', - defaultCombo: 'mod+i', - label: 'Focus input', - description: 'Focus the chat input field', - customizable: true, - }, - { - 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, - }, - { - id: 'toggle_terminal', - defaultCombo: 'mod+j', - label: 'Toggle terminal dock', - description: 'Toggle the bottom terminal dock', - customizable: true, - }, - { - id: 'toggle_terminal_expanded', - defaultCombo: 'mod+shift+j', - label: 'Toggle terminal expanded', - description: 'Toggle terminal expanded or collapsed', - customizable: true, - }, - { - 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, - }, - { - id: 'toggle_sidebar', - defaultCombo: 'mod+alt+l', - label: 'Toggle sidebar', - description: 'Toggle the session sidebar', - customizable: true, - }, - { - id: 'open_timeline_dialog', - defaultCombo: 'mod+t', - label: 'Open conversation timeline', - description: 'Search and navigate within current conversation', - customizable: true, - }, - { - id: 'toggle_prompt_navigator', - defaultCombo: 'mod+alt+p', - label: 'Toggle prompt navigator', - description: 'Show or hide the prompt navigator panel in chat', - customizable: true, - }, - { - id: 'toggle_right_sidebar', - defaultCombo: 'mod+b', - label: 'Toggle right sidebar', - description: 'Toggle the right sidebar', - customizable: true, - }, - { - id: 'open_right_sidebar_git', - defaultCombo: 'mod+shift+g', - label: 'Open right sidebar Git tab', - description: 'Open right sidebar and select Git', - customizable: true, - }, - { - id: 'open_right_sidebar_files', - defaultCombo: 'mod+shift+f', - label: 'Open right sidebar Files tab', - description: 'Open right sidebar and select Files', - customizable: true, - }, - { - 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, - }, - { - id: 'new_chat', - defaultCombo: 'mod+n', - label: 'New session', - description: 'Start a new session', - customizable: true, - }, - { - 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, - }, - { - id: 'close_session_tab', - defaultCombo: 'alt+w', - label: 'Close session tab', - description: 'Close the active session tab in the header (the session itself stays)', - customizable: true, - }, - { - id: 'new_mini_chat', - defaultCombo: 'mod+alt+n', - label: 'New Mini Chat window', - description: 'Open a new Mini Chat draft window', - customizable: true, - }, - { - 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, - }, - { - id: 'toggle_context_plan', - defaultCombo: 'mod+shift+p', - label: 'Toggle plan context panel', - description: 'Open or close plan in the context panel', - customizable: true, - }, - { - id: 'toggle_services_menu', - defaultCombo: 'mod+shift+s', - label: 'Toggle services menu', - description: 'Open or close the services menu', - customizable: true, - }, - { - id: 'cycle_services_tab', - defaultCombo: 'mod+shift+[', - label: 'Cycle services tab', - description: 'Cycle through tabs in the services menu', - customizable: true, - }, - { - id: 'cycle_theme', - defaultCombo: 'mod+/', - label: 'Cycle theme', - description: 'Cycle between light, dark, and system theme', - customizable: true, - }, - { - id: 'open_model_selector', - defaultCombo: 'mod+shift+m', - label: 'Open model selector', - description: 'Open model selector while in chat', - customizable: true, - }, - { - 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, - }, - { - id: 'cycle_favorite_model_forward', - defaultCombo: 'ctrl+]', - label: 'Cycle favorite model forward', - description: 'Cycle forward through starred models without opening the picker', - customizable: true, - }, - { - id: 'cycle_favorite_model_backward', - defaultCombo: 'ctrl+[', - label: 'Cycle favorite model backward', - description: 'Cycle backward through starred models without opening the picker', - customizable: true, - }, - { - id: 'expand_input', - defaultCombo: 'mod+shift+e', - label: 'Expand input', - description: 'Toggle focus mode for the chat input', - customizable: true, - }, - { - id: 'toggle_dictation', - defaultCombo: 'mod+alt+v', - label: 'Voice input', - description: 'Start dictation; press again to confirm and insert the transcript', - customizable: true, - }, - { - id: 'abort_run', - defaultCombo: 'escape', - label: 'Abort active run', - description: 'Abort the currently running task (double press)', - }, -] as const; - -export function normalizeCombo(combo: ShortcutCombo): ShortcutCombo { - if (isUnassignedShortcut(combo)) { - return UNASSIGNED_SHORTCUT; - } - - 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.key.trim().length > 0; -} - -function parseShortcut(combo: ShortcutCombo): ParsedShortcut { - if (isUnassignedShortcut(combo)) { - return { 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; - } - } - - return { modifiers, key }; -} - -export function formatShortcutForDisplay(combo: ShortcutCombo): string { - if (isUnassignedShortcut(combo)) { - return 'Unassigned'; - } - - const parsed = parseShortcut(combo); - - if (!parsed.key && parsed.modifiers.size === 0) { - return 'Unassigned'; - } - - 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) => action.customizable === true); -} - -export function getEffectiveShortcutCombo( - actionId: string, - overrides?: Record -): ShortcutCombo { - const action = getShortcutAction(actionId); - if (!action) { - return ''; - } - - 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; - } - - 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.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'); -} - -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); - - 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 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(parsed.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); - if (parsed.modifiers.size > 0 || parsed.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); - - for (const modifier of parsed.modifiers) { - const aliases = MODIFIER_KEY_ALIASES[modifier]; - if (!aliases.some((alias) => heldKeys.has(alias))) { - return false; - } - } - - if (parsed.key && !heldKeys.has(parsed.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); - - 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 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 (parsed.key && (!heldKeys || !heldKeys.has(parsed.key.toLowerCase()))) { - return false; - } - - return true; -} diff --git a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md new file mode 100644 index 00000000..e2d3db84 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -0,0 +1,65 @@ +# Registration boundary + +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. + +# Schema contract + +`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`. +- `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 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. + +# Binding rules + +Bindings remain persisted as `Record`. Each binding has one chord or at most two space-separated chords, such as `mod+s p`. `mod` is the platform-neutral primary modifier (Command on macOS, Control elsewhere), while `alt` is the platform-neutral alternate modifier (Option on macOS, Alt elsewhere); `command`, `cmd`, `meta`, and `option` are accepted input aliases but normalize to those canonical tokens. `normalizeCombo`, `parseShortcut`, `formatShortcutForDisplay`, and `getShortcutConflict` provide the shared parsing and validation behavior. Display formatting uses macOS keyboard symbols (`⌘`, `⌥`, `⌃`, `⇧`) on macOS and named modifiers (`Ctrl`, `Alt`, `Shift`) elsewhere, including tooltip and accessible text consumers. 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`, `mod+s g`, and `mod+s l` to the draft target pickers and session list. + +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 captures up to two chords with at most three simultaneous physical keys per chord and checks the complete schema, not only customizable actions. After the first chord it waits up to 3000ms for a second; conflict and browser-risk feedback appears only when the second chord, timeout, or Confirm settles the recording. It keeps the recording local until the user clicks Confirm, allows an exact customizable conflict to replace the previous assignment, and blocks prefix conflicts unless the single-chord action explicitly allows sequence fallback. Those contextual prefixes remain saveable with a warning because their handler yields outside its owning context. Internal bindings are authoritative: persisted overrides cannot change or unassign them, and recorder conflicts with them cannot be replaced. + +`add_selection_to_chat` is contextual. A visible text-selection toolbar publishes its Add to chat and dismiss actions, suspends the shared application registry, and clears both synchronously when hidden or unmounted. The main application route also gates directly on active toolbar ownership before global dispatch, so unrelated shortcuts cannot escape the scoped interaction even if runtime bundling isolates registry state. The newest visible toolbar owns a dedicated scoped dispatcher; it ignores IME composition, stops IME Escape before the global Escape route without preventing its native default, handles non-IME Escape and the configured Add to chat binding (including a two-chord binding), and lets native input continue for unrelated keys. The application handler returns `false` when no toolbar action is active, so an unselected or stale DOM range can instead become a sequence leader. Opening, closing, or replacing a toolbar invalidates any pending scoped or global prefix. + +# Dispatching + +`ShortcutDispatcher` is DOM-independent. It invokes only currently registered handlers, resolves bindings when dispatching, and holds an active sequence prefix for 3000ms. 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; an exact second key remains eligible during IME composition and is prevented when handled, while an IME mismatch clears the prefix and retains normal composition input. 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 global dispatcher prefix, so stale second keys and Escape cannot consume it. Interaction surfaces that need shortcuts while suspended must own a dedicated scoped dispatcher and process it before the global route. + +Shared `DropdownMenu` and `Select` can opt into this boundary with `disableGlobalShortcuts`; they suspend while open for both controlled and uncontrolled popups and resume on close or unmount. Exact `Ctrl+N` and `Ctrl+P` chords are translated to menu navigation even when the native event reports IME composition; no other composing key is intercepted. Window capture stops an IME Escape before Base UI's document-level dismiss listener without preventing the native IME action. Controlled draft project and worktree pickers close on non-IME Escape from either the trigger or portaled popup. + +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. The settings recorder treats Enter and Escape as recordable keys; only its explicit Confirm and Cancel buttons apply or discard a recording. + +# 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/bindings.test.ts b/packages/ui/src/lib/shortcuts/bindings.test.ts new file mode 100644 index 00000000..be955841 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/bindings.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, test } from 'bun:test'; + +import { + eventMatchesShortcutPrefix, + formatShortcutForDisplay, + getEffectiveShortcutPrefix, + getShortcutConflict, + isRiskyBrowserShortcut, + isShortcutPrefixHeld, + normalizeCombo, + parseShortcut, + UNASSIGNED_SHORTCUT, +} from './index'; + +describe('getEffectiveShortcutPrefix', () => { + test('falls back to the action default (bare mod) when unset', () => { + expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod'); + }); + + test('honors modifier + key overrides', () => { + expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'mod+p' })).toBe('mod+p'); + }); + + test('honors modifier-only overrides', () => { + expect(getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: 'shift' })).toBe('shift'); + }); + + test('returns UNASSIGNED for an explicit unassignment', () => { + expect( + getEffectiveShortcutPrefix('switch_context_surface', { switch_context_surface: UNASSIGNED_SHORTCUT }), + ).toBe(UNASSIGNED_SHORTCUT); + }); + + test('returns empty string for an unknown action', () => { + expect(getEffectiveShortcutPrefix('does_not_exist', {})).toBe(''); + }); +}); + +describe('isShortcutPrefixHeld', () => { + test('false for an unassigned prefix', () => { + expect(isShortcutPrefixHeld(UNASSIGNED_SHORTCUT, new Set(['control']))).toBe(false); + }); + + test('requires the prefix primary key to be held', () => { + expect(isShortcutPrefixHeld('mod+p', new Set(['control']))).toBe(false); + expect(isShortcutPrefixHeld('mod+p', new Set(['control', 'p']))).toBe(true); + }); + + test('requires every prefix modifier to be held', () => { + expect(isShortcutPrefixHeld('mod+shift', new Set(['control']))).toBe(false); + expect(isShortcutPrefixHeld('mod+shift', new Set(['control', 'shift']))).toBe(true); + }); +}); + +const keydown = (key: string, mods: { meta?: boolean; ctrl?: boolean; shift?: boolean; alt?: boolean }): KeyboardEvent => + ({ + key, + metaKey: mods.meta ?? false, + ctrlKey: mods.ctrl ?? false, + shiftKey: mods.shift ?? false, + altKey: mods.alt ?? false, + }) as KeyboardEvent; + +describe('eventMatchesShortcutPrefix', () => { + test('matches a bare mod prefix when the primary modifier is held', () => { + expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod')).toBe(true); + }); + + test('rejects a bare mod prefix without the primary modifier', () => { + expect(eventMatchesShortcutPrefix(keydown('1', {}), 'mod')).toBe(false); + }); + + test('rejects when the event carries modifiers the prefix does not expect', () => { + expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true, shift: true }), 'mod')).toBe(false); + }); + + test('requires the prefix primary key to be held at match time', () => { + expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control']))).toBe(false); + expect(eventMatchesShortcutPrefix(keydown('1', { ctrl: true }), 'mod+p', new Set(['control', 'p']))).toBe(true); + }); + + test('false for an unassigned prefix', () => { + 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); + }); +}); + +describe('platform shortcut labels', () => { + test('normalizes Command and Option to platform-neutral modifiers', () => { + expect(normalizeCombo('command+option+n')).toBe('mod+alt+n'); + }); + + test('uses macOS modifier symbols', () => { + expect(formatShortcutForDisplay('mod+ctrl+shift+alt+n', 'Unassigned', 'macos')).toBe( + '⌘ + ⌃ + ⇧ + ⌥ + N', + ); + expect(formatShortcutForDisplay('alt', 'Unassigned', 'macos')).toBe('⌥'); + }); + + test('uses named modifiers on other platforms', () => { + expect(formatShortcutForDisplay('mod+shift+alt+n', 'Unassigned', 'other')).toBe( + 'Ctrl + Shift + Alt + N', + ); + expect(formatShortcutForDisplay('alt', 'Unassigned', 'other')).toBe('Alt'); + }); +}); diff --git a/packages/ui/src/lib/shortcuts/bindings.ts b/packages/ui/src/lib/shortcuts/bindings.ts new file mode 100644 index 00000000..fa45a358 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/bindings.ts @@ -0,0 +1,335 @@ +import type React from 'react'; +import { isDesktopShell } from '@/lib/desktop'; +import { isMacOS } from '@/lib/utils'; + +type ShortcutModifier = 'mod' | 'shift' | 'alt' | 'ctrl'; +type ShortcutDisplayPlatform = 'macos' | 'other'; +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 MODIFIER_LABELS: Record> = { + macos: { + mod: '⌘', + shift: '⇧', + alt: '⌥', + ctrl: '⌃', + }, + other: { + mod: 'Ctrl', + shift: 'Shift', + alt: 'Alt', + ctrl: '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', 'q', 'd', 'h', 'j', 'o', 'u']); +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 }; + }), + }; +} + +function getShortcutDisplayPlatform(): ShortcutDisplayPlatform { + return isMacOS() ? 'macos' : 'other'; +} + +export function formatShortcutForDisplay( + combo: ShortcutCombo, + unassignedLabel = 'Unassigned', + platform = getShortcutDisplayPlatform(), +): 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((chord) => formatChordForDisplay(chord, platform)).join(', '); +} + +function formatChordForDisplay( + parsed: ParsedShortcutChord, + platform: ShortcutDisplayPlatform, +): string { + const modifierLabels = MODIFIER_LABELS[platform]; + const parts = MODIFIER_PRIORITY + .filter((modifier) => parsed.modifiers.has(modifier)) + .map((modifier) => modifierLabels[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; + // Every chord counts: a second chord like "mod+w" is just as capable of + // closing the tab as a first one, and mod+shift+w closes a window. + return parsed.chords.some((chord) => { + if (!chord.modifiers.has('mod')) return false; + if (chord.modifiers.has('alt')) return false; + if (chord.modifiers.has('shift')) { + return chord.key.toLowerCase() === 'w' || chord.key.toLowerCase() === 'q'; + } + return RISKY_BROWSER_SHORTCUT_KEYS.has(chord.key.toLowerCase()); + }); +} + +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())); +} diff --git a/packages/ui/src/lib/shortcuts/config.ts b/packages/ui/src/lib/shortcuts/config.ts new file mode 100644 index 00000000..88cd8e4a --- /dev/null +++ b/packages/ui/src/lib/shortcuts/config.ts @@ -0,0 +1,286 @@ +import type { ShortcutCombo } from './bindings'; + +type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'application'; + +type ShortcutConfig = { + id: string; + defaultBinding: ShortcutCombo; + allowsSequenceFallback?: true; + /** The binding is a bare-modifier chord prefix (completed by another key); + conflict resolution compares its prefix rather than a full combo. */ + prefixStyle?: true; +} & ( + | { customizable: false } + | { + customizable: true; + settingsLabelKey: `settings.openchamber.keyboardShortcuts.action.${string}.label`; + } +); + +const SHORTCUT_GROUPS = { + session: [ + { + id: 'add_selection_to_chat', + defaultBinding: 'mod+l', + allowsSequenceFallback: true, + 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: 'close_session_tab', + defaultBinding: 'alt+w', + customizable: true, + settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.close_session_tab.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: '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', + 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', + // The binding is a bare modifier acting as a chord prefix (completed by + // a digit); conflict resolution must compare its PREFIX, not a combo. + prefixStyle: true, + 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, allowsSequenceFallback: true }, + { 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/dispatcher.test.ts b/packages/ui/src/lib/shortcuts/dispatcher.test.ts new file mode 100644 index 00000000..f41fa88f --- /dev/null +++ b/packages/ui/src/lib/shortcuts/dispatcher.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test } from 'bun:test'; +import { ShortcutDispatcher } from './dispatcher'; +import { ShortcutRegistry } from './registry'; + +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 = 2999; + expect(dispatcher.hasActivePrefix()).toBe(true); + now = 3000; + 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('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('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[] = []; + 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/shortcuts/dispatcher.ts b/packages/ui/src/lib/shortcuts/dispatcher.ts new file mode 100644 index 00000000..f70933d9 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/dispatcher.ts @@ -0,0 +1,170 @@ +import { + eventMatchesShortcut, + normalizeCombo, + parseShortcut, + UNASSIGNED_SHORTCUT, + type ShortcutCombo, +} from './bindings'; +import { type ShortcutHandler, ShortcutRegistry } from './registry'; +import type { ShortcutActionId } from './schema'; +import { isIMECompositionEvent } from '../ime'; + +const SEQUENCE_TIMEOUT_MS = 3000; +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; + // The target the leader chord was pressed on. DOM-agnostic (opaque + // EventTarget): callers use it to decide whether an unmodified completion + // key arriving from an EDITABLE target is a deliberate sequence (same + // target as the arming press) or typing that must not be swallowed. + private prefixTarget: EventTarget | null = null; + private expiresAt = 0; + private prefixSuspensionVersion = 0; + private readonly capturedPrefixEvents = new WeakSet(); + + 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 || isIMECompositionEvent(event) || MODIFIER_KEYS.has(event.key.toLowerCase())) { + return false; + } + if (event.key === 'Escape' && this.hasActivePrefix()) { + return this.handleEscape(); + } + this.hasActivePrefix(); + + const matches = this.getMatches(); + if (this.prefix) { + const pending = this.getPrefixMatches(matches, event); + 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.prefixTarget = event.target; + this.expiresAt = this.now() + this.timeoutMs; + this.prefixSuspensionVersion = this.options.registry.getSuspensionVersion(); + return true; + } + return false; + } + + clear(): void { + this.prefix = undefined; + this.prefixTarget = null; + this.expiresAt = 0; + this.prefixSuspensionVersion = 0; + } + + getActivePrefixTarget(): EventTarget | null { + return this.hasActivePrefix() ? this.prefixTarget : null; + } + + handleBlur(): void { + this.clear(); + } + + handleEscape(): boolean { + 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); + 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); + } + + 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) { + return true; + } + } + 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()) { + const handler = this.options.registry.get(actionId); + if (!handler) continue; + + const binding = normalizeCombo(this.options.getBinding(actionId)); + const parsed = parseShortcut(binding); + 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..b75c3200 --- /dev/null +++ b/packages/ui/src/lib/shortcuts/index.ts @@ -0,0 +1,30 @@ +export { + eventMatchesShortcut, + eventMatchesShortcutPrefix, + formatShortcutForDisplay, + 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, + getShortcutBindingConflicts, + getEffectiveShortcutCombo, + getEffectiveShortcutPrefix, + getShortcutAction, + SHORTCUT_SCHEMA, +} from './schema'; +export type { + CustomizableShortcutAction, + ShortcutBindingConflict, + ShortcutActionId, + ShortcutCategory, +} from './schema'; diff --git a/packages/ui/src/lib/shortcuts/registry.test.ts b/packages/ui/src/lib/shortcuts/registry.test.ts new file mode 100644 index 00000000..4df88b7a --- /dev/null +++ b/packages/ui/src/lib/shortcuts/registry.test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'bun:test'; +import { ShortcutRegistry } from './registry'; + +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); +}); + +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); + expect(registry.isSuspended()).toBe(true); + + resumeFirst(); + resumeFirst(); + expect(registry.get('open_settings')).toBe(undefined); + resumeSecond(); + resumeSecond(); + expect(registry.get('open_settings')).toBe(handler); + expect(registry.isSuspended()).toBe(false); +}); diff --git a/packages/ui/src/lib/shortcuts/registry.ts b/packages/ui/src/lib/shortcuts/registry.ts new file mode 100644 index 00000000..dca7971c --- /dev/null +++ b/packages/ui/src/lib/shortcuts/registry.ts @@ -0,0 +1,71 @@ +import type { ShortcutActionId } from './schema'; + +export type ShortcutHandler = (event: KeyboardEvent) => boolean | void; + +interface RegisteredHandler { + handler: ShortcutHandler; +} + +/** 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 }; + const registered = this.handlers.get(actionId) ?? []; + if (registered.length > 0 && typeof console !== 'undefined' && import.meta.env?.DEV) { + // First registration wins at dispatch; a silent second registration is + // almost always two components fighting over one action. + console.warn(`[shortcuts] duplicate handler registration for "${actionId}" — only the first will dispatch`); + } + registered.push(registration); + this.handlers.set(actionId, registered); + return () => { + const current = this.handlers.get(actionId); + if (!current) return; + const index = current.indexOf(registration); + if (index === -1) return; + current.splice(index, 1); + if (current.length === 0) { + this.handlers.delete(actionId); + } + }; + } + + 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; + } + + isSuspended(): boolean { + return this.suspensionCount > 0; + } + + 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/schema.test.ts b/packages/ui/src/lib/shortcuts/schema.test.ts new file mode 100644 index 00000000..d12499dd --- /dev/null +++ b/packages/ui/src/lib/shortcuts/schema.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from 'bun:test'; +import { + getCustomizableShortcutActions, + getEffectiveShortcutCombo, + getShortcutBindingConflicts, + 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); + const hasValidMetadata = SHORTCUT_SCHEMA.every((action) => { + const chordCount = parseShortcut(action.defaultBinding)?.chords.length; + 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', () => { + 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 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'); + }); + + 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'); + }); + + 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'); + const contextualPrefixConflict = getShortcutBindingConflicts('focus_input', 'mod+l l') + .find((conflict) => conflict.action.id === 'add_selection_to_chat'); + const contextualLeaderConflict = getShortcutBindingConflicts('add_selection_to_chat', 'mod+s') + .find((conflict) => conflict.action.id === 'open_draft_project_picker'); + const blockingPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+p x') + .find((conflict) => conflict.action.id === 'open_command_palette'); + + 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('contextual-prefix'); + expect(internalPrefixConflict?.action.customizable).toBe(false); + expect(contextualPrefixConflict?.kind).toBe('contextual-prefix'); + expect(contextualLeaderConflict?.kind).toBe('contextual-prefix'); + expect(blockingPrefixConflict?.kind).toBe('prefix'); + }); +}); + +describe('shortcut defaults', () => { + // Two actions silently sharing a default binding would race at dispatch + // (registry insertion order decides). Pairs that intentionally share a + // combo because they can never be active in the same runtime must be + // whitelisted here explicitly. + const RUNTIME_EXCLUSIVE_BINDING_PAIRS: ReadonlyArray> = []; + + test('no two actions share a normalized default binding', () => { + const byBinding = new Map(); + for (const action of SHORTCUT_SCHEMA) { + const combo = getEffectiveShortcutCombo(action.id); + if (!combo) continue; + const list = byBinding.get(combo) ?? []; + list.push(action.id); + byBinding.set(combo, list); + } + for (const [combo, ids] of byBinding) { + if (ids.length <= 1) continue; + const whitelisted = RUNTIME_EXCLUSIVE_BINDING_PAIRS.some( + (pair) => ids.every((id) => pair.has(id)), + ); + expect(whitelisted, `default binding "${combo}" shared by ${ids.join(', ')}`).toBe(true); + } + }); + + test('overrides recorded under the flat-file era still resolve', () => { + // The persisted override format is a flat Record and + // must keep resolving through the schema after the module split. + const overrides = { close_session_tab: 'alt+q', open_command_palette: 'mod+shift+k' }; + expect(getEffectiveShortcutCombo('close_session_tab', overrides)).toBe('alt+q'); + expect(getEffectiveShortcutCombo('open_command_palette', overrides)).toBe('mod+shift+k'); + // Unknown ids stay inert rather than throwing. + expect(getEffectiveShortcutCombo('close_session_tab', { ghost_action: 'mod+z', close_session_tab: 'alt+q' } as Record)).toBe('alt+q'); + }); +}); diff --git a/packages/ui/src/lib/shortcuts/schema.ts b/packages/ui/src/lib/shortcuts/schema.ts new file mode 100644 index 00000000..6afe81ec --- /dev/null +++ b/packages/ui/src/lib/shortcuts/schema.ts @@ -0,0 +1,111 @@ +import { + getShortcutConflict, + isValidShortcutCombo, + normalizeCombo, + parseShortcut, + UNASSIGNED_SHORTCUT, + type ShortcutCombo, + type ShortcutConflict, +} from './bindings'; +import { SHORTCUT_SCHEMA } from './config'; + +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 type ShortcutBindingConflictKind = ShortcutConflict | 'contextual-prefix'; +export type ShortcutBindingConflict = { + action: ShortcutAction; + kind: ShortcutBindingConflictKind; +}; + +function allowsContextualPrefix( + action: ShortcutAction, + combo: ShortcutCombo, + candidate: ShortcutAction, + candidateCombo: ShortcutCombo, +): boolean { + const chordCount = parseShortcut(combo)?.chords.length; + const candidateChordCount = parseShortcut(candidateCombo)?.chords.length; + if (chordCount === 1 && candidateChordCount === 2) { + return 'allowsSequenceFallback' in action && action.allowsSequenceFallback; + } + if (chordCount === 2 && candidateChordCount === 1) { + return 'allowsSequenceFallback' in candidate && candidate.allowsSequenceFallback; + } + return false; +} + +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 ''; + if (!action.customizable) return action.defaultBinding; + + 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 ''; + if (!action.customizable) return action.defaultBinding; + + 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; +} + +export function getShortcutBindingConflicts( + actionId: ShortcutActionId, + combo: ShortcutCombo, + overrides?: Record, +): ShortcutBindingConflict[] { + const conflicts: ShortcutBindingConflict[] = []; + const action = getShortcutAction(actionId); + if (!action) return conflicts; + for (const candidate of SHORTCUT_SCHEMA) { + if (candidate.id === actionId) continue; + const candidateCombo = ('prefixStyle' in candidate && candidate.prefixStyle) + ? getEffectiveShortcutPrefix(candidate.id, overrides) + : getEffectiveShortcutCombo(candidate.id, overrides); + const kind = getShortcutConflict(combo, candidateCombo); + if (!kind) continue; + conflicts.push({ + action: candidate, + kind: kind === 'prefix' && allowsContextualPrefix(action, combo, candidate, candidateCombo) + ? 'contextual-prefix' + : kind, + }); + } + return conflicts; +} diff --git a/packages/ui/tests/ego-lite/shortcut-registry.checklist.yaml b/packages/ui/tests/ego-lite/shortcut-registry.checklist.yaml new file mode 100644 index 00000000..f072de87 --- /dev/null +++ b/packages/ui/tests/ego-lite/shortcut-registry.checklist.yaml @@ -0,0 +1,217 @@ +version: 1 +kind: manual-agent-browser-checklist + +metadata: + id: shortcut-registry-ego-lite + title: Shortcut registry and prefix sequence regression + owner: packages/ui + runner: ego-lite + interface: ego-browser + dependencies: [] + documentation: packages/ui/src/lib/shortcuts/DOCUMENTATION.md + +target: + default_url: http://127.0.0.1:9601 + viewport: + width: 1800 + height: 1050 + evidence_directory: ~/Desktop/openchamber-pr-2532-evidence + evidence_prefix: openchamber-pr-2532 + +limitations: + - IME checks use synthetic KeyboardEvent.isComposing and keyCode 229 signals. Repeat them with a real system IME before claiming native IME coverage. + - The Windows profile overrides Chromium user-agent data. It validates browser platform detection and rendered shortcut labels, not native Windows keyboard events or desktop packaging. + - The macOS profile validates the web runtime. Electron, VS Code, hosted mobile, and Capacitor mobile require separate runtime checks. + +platform_profiles: + macos_native: + description: Use the host browser user agent without overrides. + expected_primary_modifier: Command + expected_labels: + - Command symbol U+2318 + - Option symbol U+2325 + setup: + - Open the target URL in a fresh ego-lite tab. + - Confirm navigator.userAgent contains Macintosh or Mac OS X. + - Open Settings, then Shortcuts. + windows_ua_mock: + description: Override Chromium identity before reloading the application. + cdp_command: + method: Network.setUserAgentOverride + params: + userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 + platform: Win32 + userAgentMetadata: + brands: + - brand: Chromium + version: "138" + - brand: Not=A?Brand + version: "24" + fullVersionList: + - brand: Chromium + version: 138.0.0.0 + - brand: Not=A?Brand + version: 24.0.0.0 + fullVersion: 138.0.0.0 + platform: Windows + platformVersion: 10.0.0 + architecture: x86 + model: "" + mobile: false + bitness: "64" + wow64: false + setup: + - Open the target URL in a separate ego-lite tab. + - Apply the CDP command above. + - Reload before evaluating navigator or rendered shortcut labels. + - Confirm navigator.userAgent contains Windows NT and navigator.platform is Win32. + - Open Settings, then Shortcuts. + +checks: + - id: macos-shortcut-labels + priority: critical + profile: macos_native + steps: + - Inspect Session Controls, Panels and Tools, Navigation, and Application. + - Capture the visible Shortcuts settings pane. + assertions: + - Open draft project picker renders as Command + S, P using the macOS Command symbol. + - Open draft worktree picker renders as Command + S, G using the macOS Command symbol. + - Open recent sessions renders as Command + S, L using the macOS Command symbol. + - New Mini Chat window renders both macOS Command and Option symbols. + - No Windows key glyph is used for Mod or Alt. + evidence: + type: screenshot + filename: openchamber-pr-2532-macos-shortcuts.png + last_result: + status: passed + + - id: windows-ua-shortcut-labels + priority: critical + profile: windows_ua_mock + steps: + - Inspect the same shortcut rows used by macos-shortcut-labels. + - Capture the visible Shortcuts settings pane. + assertions: + - Open draft project picker renders as Ctrl + S, P. + - Open draft worktree picker renders as Ctrl + S, G. + - Open recent sessions renders as Ctrl + S, L. + - New Mini Chat window renders as Ctrl + Alt + N. + - No macOS modifier symbols or Windows key glyph are rendered. + evidence: + type: screenshot + filename: openchamber-pr-2532-windows-ua-shortcuts.png + last_result: + status: passed + + - id: recorder-contextual-prefix + priority: critical + profile: macos_native + steps: + - Edit Focus input. + - Record Mod + L as the first chord. + - Verify no conflict message is shown before the 3000 ms settling timeout. + - Record L as the second chord before timeout. + - Capture the settled recorder without saving the override. + assertions: + - The recorder shows two chords and no more than three physical keys per chord. + - A contextual prefix warning names Add selection to chat. + - Confirm remains enabled because the contextual owner yields outside its context. + - The browser-risk warning is visible for the Mod + L leader. + evidence: + type: screenshot + filename: openchamber-pr-2532-recorder-contextual-prefix.png + cleanup: + - Select Cancel so the test does not persist a shortcut override. + last_result: + status: passed + + - id: recorder-single-chord-timeout + priority: high + profile: macos_native + steps: + - Edit Focus input. + - Record Mod + L as the first chord. + - Observe the recorder before 3000 ms. + - Wait at least 3000 ms without pressing a second chord. + assertions: + - No conflict or browser-risk message is visible before settlement. + - Exact-conflict and browser-risk feedback appears after settlement. + cleanup: + - Select Cancel so the test does not persist a shortcut override. + last_result: + status: passed + + - id: selection-toolbar-scope + priority: critical + profile: macos_native + preconditions: + - Open a rendered assistant response containing selectable Markdown text. + steps: + - Select text to open the selection toolbar. + - Trigger an unrelated application shortcut and verify it does not run. + - Dispatch composing Mod + L and verify Add to chat does not run. + - Dispatch composing Escape and verify the toolbar remains open without reaching later global capture listeners. + - Dispatch non-composing Mod + L and verify the selected Markdown reaches the composer once. + - Reopen the toolbar and press Escape. + assertions: + - The visible toolbar suspends the global shortcut registry. + - IME composition is not consumed by the toolbar shortcut scope. + - IME Escape keeps its native default while bypassing global Escape handling. + - Add to chat runs once for the active toolbar only. + - Escape dismisses the toolbar and restores global shortcuts. + evidence: + type: recording + filename: openchamber-pr-2532-shortcut-regression-final.mov + last_result: + status: passed + + - id: draft-picker-sequences + priority: critical + profile: macos_native + preconditions: + - Open a draft session with project and worktree selectors mounted. + steps: + - Trigger Mod + S, P and verify the project picker opens. + - Press Escape once and verify it closes. + - Trigger Mod + S, G and verify the worktree picker opens. + - Press Escape once and verify it closes. + assertions: + - A contextual Mod + S owner yields when its context is inactive. + - Each sequence opens only its target picker. + - One non-IME Escape closes either controlled picker. + evidence: + type: recording + filename: openchamber-pr-2532-shortcut-regression-final.mov + last_result: + status: passed + + - id: dropdown-ime-navigation + priority: critical + profile: macos_native + steps: + - Open the project picker. + - Dispatch Ctrl + N with isComposing true and keyCode 229. + - Dispatch Ctrl + P with isComposing true and keyCode 229. + - Dispatch Escape with isComposing true and keyCode 229. + - Dispatch one non-IME Escape. + assertions: + - Ctrl + N moves active selection forward during composition. + - Ctrl + P moves active selection backward during composition. + - IME Escape remains available to the native input method and does not close the picker. + - Non-IME Escape closes the picker once. + evidence: + type: recording + filename: openchamber-pr-2532-shortcut-regression-final.mov + last_result: + status: passed + +last_run: + date: 2026-08-06 + application_url: http://127.0.0.1:9601 + source: working-tree + browser: ego-lite through ego-browser + overall_status: passed-with-documented-limitations + notes: + - The browser checks passed against the working tree before commit; repository validation is recorded separately in the pull request. + - Keep screenshots and recordings outside the repository and attach them to the pull request. From 94f6b5fd387f9531a69bc387201c0be6b95b3653 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 10:59:04 +0300 Subject: [PATCH 16/49] feat(ui): migrate all shortcut surfaces to the centralized registry --- packages/ui/src/App.tsx | 26 +- .../components/chat/composer/DOCUMENTATION.md | 3 + .../chat/composer/ui/DraftTargetSelectors.tsx | 57 +- .../chat/composer/ui/FocusModeButton.tsx | 17 +- .../chat/message/TextSelectionMenu.tsx | 40 +- .../comments/InlineCommentInput.tsx | 6 +- packages/ui/src/components/layout/Header.tsx | 69 +- .../openchamber/OpenChamberVisualSettings.tsx | 6 +- .../session/DirectoryExplorerDialog.tsx | 5 +- .../session/SessionSwitcherDropdown.tsx | 45 +- .../sidebar/shell/useSwitcherItems.test.ts | 44 + .../session/sidebar/shell/useSwitcherItems.ts | 88 +- .../ui/src/components/ui/dropdown-menu.tsx | 44 +- .../src/components/ui/dropdown-navigation.ts | 2 +- packages/ui/src/components/ui/select.tsx | 49 +- .../ui/src/components/views/FilesView.tsx | 101 +- .../ui/src/components/views/SettingsView.tsx | 21 +- .../ui/src/components/views/TerminalView.tsx | 5 +- .../ui/src/hooks/keyboard-shortcut-dom.ts | 7 + packages/ui/src/hooks/useKeyboardShortcuts.ts | 1051 +++++++---------- .../src/hooks/useMiniChatKeyboardShortcuts.ts | 187 +-- packages/ui/src/lib/addSelectionToChat.ts | 57 + packages/ui/src/lib/shortcuts/schema.test.ts | 12 +- packages/ui/src/lib/utils.ts | 19 - 24 files changed, 1057 insertions(+), 904 deletions(-) create mode 100644 packages/ui/src/components/session/sidebar/shell/useSwitcherItems.test.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index f213c11f..b9d90cc4 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -20,7 +20,7 @@ import { useAgentMemorySync } from '@/hooks/useAgentMemorySync'; 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, @@ -723,26 +723,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/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index f3b03529..33066e13 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -141,6 +141,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 886aad6e..5368d208 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -12,6 +12,7 @@ import React from 'react'; import { Icon } from '@/components/icon/Icon'; import { Input } from '@/components/ui/input'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; +import { shouldDismissDropdown } from '@/components/ui/dropdown-navigation'; import { Select, SelectContent, @@ -26,6 +27,7 @@ import { useI18n } from '@/lib/i18n'; import { matchesRankQuery, rankByQuery } from '@/lib/search/fuzzySearch'; 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'; @@ -106,14 +108,48 @@ 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); + const handlePickerKeyDown = (event: React.KeyboardEvent) => { + if (openPicker === null || !shouldDismissDropdown(event)) return; + event.preventDefault(); + event.stopPropagation(); + setOpenPicker(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} + disableGlobalShortcuts > @@ -145,11 +186,11 @@ export function DraftTargetSelectors(props: DraftTargetProps) { {selectedBranchLabel ?? t('chat.chatInput.branch')} - + {projectRootBranchOption ? ( {t('chat.chatInput.projectRoot')} - + {projectRootBranchOption.label} @@ -168,13 +209,13 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
      {worktreeBranchOptions.map((option) => ( - + {option.pending ? '⏳ ' : ''}{option.label} ))} {selectedDirectory && !selectedBranchIsKnown ? ( - + {selectedBranchLabel} ) : null} 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/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index bcdca931..29718932 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -18,6 +18,7 @@ import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; +import { registerActiveSelectionToolbar } from '@/lib/addSelectionToChat'; import { collectSelectionOverlayRects } from '@/lib/selectionOverlayRects'; interface TextSelectionMenuProps { @@ -106,6 +107,7 @@ export const TextSelectionMenu: React.FC = ({ containerR const openRafRef = React.useRef(null); const mouseUpTimeoutRef = React.useRef(null); const isMenuVisibleRef = React.useRef(false); + const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null); const createSession = useSessionUIStore((state) => state.createSession); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); @@ -156,6 +158,8 @@ export const TextSelectionMenu: React.FC = ({ containerR React.useEffect(() => { return () => { + activeAddToChatCleanupRef.current?.(); + activeAddToChatCleanupRef.current = null; if (openRafRef.current !== null) { window.cancelAnimationFrame(openRafRef.current); openRafRef.current = null; @@ -169,6 +173,8 @@ export const TextSelectionMenu: React.FC = ({ containerR const hideMenu = React.useCallback(() => { pendingSelectionRef.current = null; + activeAddToChatCleanupRef.current?.(); + activeAddToChatCleanupRef.current = null; setCommentRects(null); if (!isMenuVisibleRef.current) { @@ -209,12 +215,30 @@ export const TextSelectionMenu: React.FC = ({ containerR return Math.min(Math.max(anchorX, minX), maxX); }, []); + const addMarkdownToChat = React.useCallback((markdownText: string) => { + const markdownBlock = wrapMarkdownSelectionForChat(markdownText); + setPendingInputText(markdownBlock, 'append'); + + hideMenu(); + + window.getSelection()?.removeAllRanges(); + queueMicrotask(() => { + focusChatInput(); + }); + }, [hideMenu, setPendingInputText]); + const showMenu = React.useCallback(() => { if (!pendingSelectionRef.current) return; const { plainText, markdownText, rect, messageId } = pendingSelectionRef.current; const shouldAnimateIn = !position.show; + activeAddToChatCleanupRef.current?.(); + activeAddToChatCleanupRef.current = registerActiveSelectionToolbar({ + addToChat: () => addMarkdownToChat(markdownText), + dismiss: hideMenu, + }); + // Position menu above the selection const menuX = isMobile ? rect.left + rect.width / 2 @@ -241,7 +265,7 @@ export const TextSelectionMenu: React.FC = ({ containerR openRafRef.current = null; }); } - }, [getDesktopClampedX, isMobile, position.show]); + }, [addMarkdownToChat, getDesktopClampedX, hideMenu, isMobile, position.show]); React.useLayoutEffect(() => { if (!position.show || isMobile || !menuRef.current) { @@ -428,18 +452,8 @@ export const TextSelectionMenu: React.FC = ({ containerR const handleAddToChat = React.useCallback(() => { if (!selectedTextMarkdown) return; - - const markdownBlock = wrapMarkdownSelectionForChat(selectedTextMarkdown); - setPendingInputText(markdownBlock, 'append'); - - hideMenu(); - - // Clear selection - window.getSelection()?.removeAllRanges(); - queueMicrotask(() => { - focusChatInput(); - }); - }, [selectedTextMarkdown, setPendingInputText, hideMenu]); + addMarkdownToChat(selectedTextMarkdown); + }, [addMarkdownToChat, selectedTextMarkdown]); const handleOpenComment = React.useCallback(() => { if (!selectedTextMarkdown) return; diff --git a/packages/ui/src/components/comments/InlineCommentInput.tsx b/packages/ui/src/components/comments/InlineCommentInput.tsx index 8af08eaf..c9abd28d 100644 --- a/packages/ui/src/components/comments/InlineCommentInput.tsx +++ b/packages/ui/src/components/comments/InlineCommentInput.tsx @@ -3,6 +3,7 @@ import { cn } from '@/lib/utils'; import { Icon } from '@/components/icon/Icon'; import { useDeviceInfo } from '@/lib/device'; import { useI18n } from '@/lib/i18n'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; export interface InlineCommentInputProps { initialText?: string; @@ -37,6 +38,7 @@ export function InlineCommentInput({ const { isMobile } = useDeviceInfo(); const [text, setText] = React.useState(initialText); const textareaRef = useRef(null); + const saveShortcut = formatShortcutForDisplay('mod+enter'); void isEditing; const handleTextChange = (value: string) => { @@ -166,7 +168,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 })} className={cn( 'min-w-0 flex-1 resize-none bg-transparent text-sm leading-5 text-[var(--surface-foreground)] outline-none placeholder:text-[var(--surface-mutedForeground)] placeholder:opacity-60', isMobile ? 'py-1.5 text-base leading-6' : 'py-1.5' diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index fd69095a..3c56891d 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -38,7 +38,8 @@ import { WindowsWindowControls } from '@/components/desktop/WindowsWindowControl import { UpdateDialog } from '@/components/ui/UpdateDialog'; import { useDeviceInfo, useTabletStandalonePwaRuntime } from '@/lib/device'; import { cn } from '@/lib/utils'; -import { eventMatchesShortcut, formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo, type ShortcutActionId } from '@/lib/shortcuts'; +import { useKeybinds } from '@/hooks/useKeybind'; import { } from '@/lib/quota/model-families'; @@ -256,7 +257,7 @@ type DesktopServicesMenuProps = { isDesktopServicesOpen: boolean; setIsDesktopServicesOpen: React.Dispatch>; refreshCurrentInstanceLabel: () => Promise; - shortcutLabel: (actionId: string) => string; + shortcutLabel: (actionId: ShortcutActionId) => string; remoteUpdateInfo: UpdateInfo | null; remoteUpdateChecking: boolean; remoteUpdateError: string | null; @@ -1445,7 +1446,7 @@ export const Header: React.FC = () => { } }, [isDesktopApp]); - const shortcutLabel = React.useCallback((actionId: string) => { + const shortcutLabel = React.useCallback((actionId: ShortcutActionId) => { return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); }, [shortcutOverrides]); @@ -1461,51 +1462,27 @@ export const Header: React.FC = () => { }, [isDesktopApp, t]); - 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(); - } + useKeybinds({ + toggle_services_menu: () => { + if (isDesktopServicesOpen) { + setIsDesktopServicesOpen(false); return; } - - // The desktop menu holds one destination now, so this shortcut opens it - // rather than cycling. The binding is kept: it is user-configurable and - // silently dropping it would break existing setups. - const cycleServicesCombo = getEffectiveShortcutCombo('cycle_services_tab', shortcutOverrides); - if (eventMatchesShortcut(e, cycleServicesCombo)) { - e.preventDefault(); - if (servicesTabs.length === 0) return; - setIsDesktopServicesOpen(true); - void refreshCurrentInstanceLabel(); - return; - } - - const toggleContextPlanCombo = getEffectiveShortcutCombo('toggle_context_plan', shortcutOverrides); - if (eventMatchesShortcut(e, toggleContextPlanCombo)) { - e.preventDefault(); - handleOpenContextPlan(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [ - shortcutOverrides, - isDesktopServicesOpen, - servicesTabs, - quotaResults.length, - fetchAllQuotas, - refreshCurrentInstanceLabel, - handleOpenContextPlan, - ]); + setIsDesktopServicesOpen(true); + void refreshCurrentInstanceLabel(); + }, + // The desktop menu holds one destination now, so this shortcut opens it + // rather than cycling. The binding is kept: it is user-configurable and + // silently dropping it would break existing setups. + cycle_services_tab: () => { + if (servicesTabs.length === 0) return false; + setIsDesktopServicesOpen(true); + void refreshCurrentInstanceLabel(); + }, + toggle_context_plan: () => { + handleOpenContextPlan(); + }, + }); const desktopSidebarActions = ( <> diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index f7d203fc..20f973ed 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -62,6 +62,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; @@ -1480,7 +1481,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'), + })} /> )}
    diff --git a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx index e9e75314..1700286e 100644 --- a/packages/ui/src/components/session/DirectoryExplorerDialog.tsx +++ b/packages/ui/src/components/session/DirectoryExplorerDialog.tsx @@ -24,6 +24,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'; import { isFilesystemError, type FilesystemErrorReason, @@ -360,9 +361,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/session/SessionSwitcherDropdown.tsx b/packages/ui/src/components/session/SessionSwitcherDropdown.tsx index 91a874cc..017e6b56 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/shell/useSwitcherItems'; +import { + findSwitcherItemAncestorIds, + useSwitcherItems, + type SwitcherItem, +} from '@/components/session/sidebar/shell/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 { t } = useI18n(); @@ -79,6 +86,9 @@ function SwitcherContent({ onSelect, variant, scopeProjectId }: SwitcherContentP }, [onSelect, openNewSessionDraft]); 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); @@ -91,10 +101,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/shell/useSwitcherItems.ts b/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.ts index 8e0f5e77..8bdc5b31 100644 --- a/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.ts +++ b/packages/ui/src/components/session/sidebar/shell/useSwitcherItems.ts @@ -27,6 +27,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; }; @@ -46,8 +47,69 @@ 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, + isExcluded?: (session: Session) => boolean, +): Session[] => { + const sessionsById = new Map(activeSessions.map((session) => [session.id, session])); + const isEligibleParent = (session: Session): boolean => { + if (session.time?.archived) return false; + if (isExcluded?.(session)) return false; + // SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions. + 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) { + // SAFETY: the SDK Session type omits parentID, but the server includes it on child sessions. + 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); @@ -116,19 +178,17 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, pinnedSessionIds, sessionOrderRanks)); }); - const parents = activeSessions - .filter((session) => !session.time?.archived) + const parents = selectSwitcherParents( + activeSessions, + pinnedSessionIds, + sessionOrderRanks, + scopeProjectId, + currentSessionId, + (session) => findProjectForDirectory(resolveGlobalSessionDirectory(session))?.id ?? null, + maxParents, // btw forks stay hidden until promoted to a full session - .filter((session) => !isBtwSession(session)) - .filter((session) => !isVSCode || !isChatDirectoryPath(resolveGlobalSessionDirectory(session))) - .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); + (session) => isBtwSession(session) || (isVSCode && isChatDirectoryPath(resolveGlobalSessionDirectory(session))), + ); const buildNode = (session: Session): SessionNode => { const childSessions = childrenByParent.get(session.id) ?? []; @@ -158,7 +218,7 @@ export const useSwitcherItems = (enabled: boolean, options: SwitcherItemsOptions }, }; }); - }, [activeSessions, branchesByDirectory, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); + }, [activeSessions, branchesByDirectory, currentSessionId, enabled, findProjectForDirectory, isVSCode, maxParents, pinnedSessionIds, scopeProjectId, sessionOrderRanks, worktreeInfoByPath]); return items; }; diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index e8fd327f..aec5df61 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 { handleDropdownNavigationKey } from "./dropdown-navigation"; import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from "./dropdown-menu.styles"; type AsChildProps = { asChild?: boolean }; @@ -34,11 +36,21 @@ 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 [collisionBoundary, setCollisionBoundary] = React.useState(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false); + const isOpen = open ?? uncontrolledOpen; const portalContextValue = React.useMemo(() => ({ portalContainer, collisionBoundary, @@ -46,9 +58,24 @@ function DropdownMenu({ setCollisionBoundary, }), [collisionBoundary, 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 ( - + ) } @@ -116,11 +143,23 @@ function DropdownMenuContent({ style, children, onCloseAutoFocus, + onKeyDown, ...props }: ContentProps) { const portalContext = React.useContext(DropdownPortalContext); void onCloseAutoFocus + const handleKeyDown: NonNullable['onKeyDown']> = (event) => { + onKeyDown?.(event); + handleDropdownNavigationKey(event, (navigationKey) => { + event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { + key: navigationKey, + bubbles: true, + cancelable: true, + })); + }); + }; + return ( {children} diff --git a/packages/ui/src/components/ui/dropdown-navigation.ts b/packages/ui/src/components/ui/dropdown-navigation.ts index bba31b73..2bd90bf7 100644 --- a/packages/ui/src/components/ui/dropdown-navigation.ts +++ b/packages/ui/src/components/ui/dropdown-navigation.ts @@ -2,7 +2,7 @@ import type React from 'react'; import { isIMECompositionEvent } from '@/lib/ime'; -export function getDropdownNavigationKey(event: Pick): 'ArrowDown' | 'ArrowUp' | null { +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 0fb0ea0e..14a0daa2 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -8,6 +8,8 @@ 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 { handleDropdownNavigationKey } from "./dropdown-navigation"; type AsChildProps = { asChild?: boolean }; type AsChildRenderProps = { @@ -38,15 +40,22 @@ 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 [collisionBoundary, setCollisionBoundary] = React.useState(null); + const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen ?? false); + const isOpen = open ?? uncontrolledOpen; const portalContextValue = React.useMemo(() => ({ portalContainer, collisionBoundary, @@ -63,9 +72,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 ( - + ) } @@ -184,12 +210,24 @@ function SelectContent({ align, collisionAvoidance, constrainToMain = false, + 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); + handleDropdownNavigationKey(event, (navigationKey) => { + event.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { + key: navigationKey, + bubbles: true, + cancelable: true, + })); + }); + }; + return ( ) { +}: React.ComponentProps & { + showSelectedBackground?: boolean; +}) { return ( = ({ 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); @@ -1759,35 +1759,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; @@ -2906,42 +2899,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); @@ -3196,6 +3168,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'; @@ -3225,14 +3198,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 dbec130d..3be50394 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -1,5 +1,9 @@ import React from 'react'; -import { cn, getModifierLabel } from '@/lib/utils'; +import { cn } from '@/lib/utils'; +import { + formatShortcutForDisplay, + getEffectiveShortcutCombo, +} from '@/lib/shortcuts'; import { useUIStore } from '@/stores/useUIStore'; import { useSettingsDirectory } from '@/hooks/useSettingsDirectory'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -187,6 +191,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); @@ -728,7 +733,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) { @@ -1077,7 +1090,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" > @@ -1105,7 +1118,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 21d45de0..8e3e83f7 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; @@ -968,7 +969,7 @@ export const TerminalView: React.FC = ({ visible }) => { onClick={() => handleModifierToggle('ctrl')} disabled={quickKeysDisabled} > - {t('terminalView.quickKeys.controlLabel')} + {formatShortcutForDisplay('ctrl')} {t('terminalView.quickKeys.controlModifierAria')} - + {action.id in shortcutOverrides ? ( + + ) : null} ))}
    diff --git a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts index 256383a1..b0cce183 100644 --- a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.test.ts @@ -4,7 +4,8 @@ import { settleShortcutRecordingState, updateShortcutRecordingState } from './Sh const emptyState = { chords: [], livePreview: null, settled: false }; function keyEvent(key: string, modifiers: Partial> = {}) { - return { key, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers }; + const code = /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` : /^[0-9]$/.test(key) ? `Digit${key}` : key; + return { key, code, repeat: false, isComposing: false, altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, ...modifiers }; } describe('ShortcutRecordingDialog recording state', () => { diff --git a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx index a04fb8a1..a75d2333 100644 --- a/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx +++ b/packages/ui/src/components/sections/openchamber/ShortcutRecordingDialog.tsx @@ -13,6 +13,7 @@ import { getShortcutBindingConflicts, isRiskyBrowserShortcut, keyToShortcutToken, + resolveShortcutEventKey, normalizeCombo, type ShortcutActionId, type ShortcutBindingConflict, @@ -27,6 +28,7 @@ const SECOND_CHORD_TIMEOUT_MS = 3000; interface RecordingKeyboardEvent { altKey: boolean; + code: string; ctrlKey: boolean; isComposing: boolean; key: string; @@ -84,7 +86,7 @@ function keyboardEventToCombo(event: RecordingKeyboardEvent): ShortcutCombo | nu if (MODIFIER_KEYS.has(event.key.toLowerCase())) return null; if (getPhysicalKeyCount(event, true) > MAX_SHORTCUT_KEY_COUNT) return null; - const key = keyToShortcutToken(event.key); + const key = keyToShortcutToken(resolveShortcutEventKey(event)); if (!key) return null; const parts: string[] = []; @@ -200,7 +202,8 @@ export const ShortcutRecordingDialog: React.FC = ( event.preventDefault(); event.stopPropagation(); - if (phase === 'keyup' && action?.id === 'switch_context_surface' && recording.chords.length === 0) { + const isPrefixStyleAction = Boolean(action && 'prefixStyle' in action && action.prefixStyle); + if (phase === 'keyup' && isPrefixStyleAction && recording.chords.length === 0) { const modifierCombo = modifierKeyUpToCombo(event); if (modifierCombo) { setRecording({ chords: [modifierCombo], livePreview: null, settled: true }); @@ -209,6 +212,7 @@ export const ShortcutRecordingDialog: React.FC = ( } const nextRecording = updateShortcutRecordingState(recording, { altKey: event.altKey, + code: event.nativeEvent.code, ctrlKey: event.ctrlKey, isComposing: event.nativeEvent.isComposing, key: event.key, @@ -216,7 +220,7 @@ export const ShortcutRecordingDialog: React.FC = ( repeat: event.repeat, shiftKey: event.shiftKey, }, phase); - setRecording(action?.id === 'switch_context_surface' && nextRecording.chords.length > 1 + setRecording(isPrefixStyleAction && nextRecording.chords.length > 1 ? recording : nextRecording); }; diff --git a/packages/ui/src/components/session/sidebar/shell/useSessionSearchEffects.ts b/packages/ui/src/components/session/sidebar/shell/useSessionSearchEffects.ts index a4543458..439d514e 100644 --- a/packages/ui/src/components/session/sidebar/shell/useSessionSearchEffects.ts +++ b/packages/ui/src/components/session/sidebar/shell/useSessionSearchEffects.ts @@ -26,6 +26,21 @@ export const useSessionSearchEffects = ({ return () => window.cancelAnimationFrame(raf); }, [enabled, isSessionSearchOpen, sessionSearchInputRef]); + // The open_session_list shortcut lands here when the sidebar is visible: + // the session list is already on screen, so the shortcut opens its search. + React.useEffect(() => { + if (!enabled || typeof window === 'undefined') { + return; + } + const handleOpenRequest = () => { + setIsSessionSearchOpen(true); + sessionSearchInputRef.current?.focus(); + sessionSearchInputRef.current?.select(); + }; + window.addEventListener('openchamber:sidebar-session-search', handleOpenRequest); + return () => window.removeEventListener('openchamber:sidebar-session-search', handleOpenRequest); + }, [enabled, setIsSessionSearchOpen, sessionSearchInputRef]); + React.useEffect(() => { if (!enabled || !isSessionSearchOpen || typeof document === 'undefined') { return; diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 77a111e6..3acc62b4 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -37,7 +37,8 @@ import { toast } from '@/components/ui'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import type { Session } from '@opencode-ai/sdk/v2'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; -import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo, shortcutRegistry } from '@/lib/shortcuts'; +import { showOpenCodeStatus } from '@/lib/openCodeStatus'; import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata'; @@ -230,6 +231,25 @@ export const CommandPalette: React.FC = () => { if (currentDirectory) openContextOverview(currentDirectory); }), }, + { + id: 'cycle-theme', + title: t('commandPalette.item.cycleTheme'), + icon: , + shortcutId: 'cycle_theme', + searchText: t('commandPalette.item.cycleTheme'), + onSelect: run(() => { + shortcutRegistry.invoke('cycle_theme'); + }), + }, + { + id: 'open-status', + title: t('commandPalette.item.showOpenCodeStatus'), + icon: , + searchText: t('commandPalette.item.showOpenCodeStatus'), + onSelect: run(() => { + void showOpenCodeStatus(); + }), + }, { id: 'open-settings', title: t('commandPalette.item.openSettings'), @@ -239,6 +259,15 @@ export const CommandPalette: React.FC = () => { onSelect: run(() => setSettingsDialogOpen(true)), }, ]; + list.push({ + id: 'toggle-memory-debug', + title: t('commandPalette.item.toggleMemoryDebug'), + icon: , + searchText: t('commandPalette.item.toggleMemoryDebug'), + onSelect: run(() => { + window.dispatchEvent(new CustomEvent('openchamber:memory-debug-toggle')); + }), + }); if (canUseElectronDesktopIPC()) { list.splice(1, 0, { id: 'new-mini-chat', diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index 6080ac29..f5f776b5 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -10,6 +10,7 @@ import { Icon } from "@/components/icon/Icon"; import { useUIStore } from "@/stores/useUIStore"; import { getEffectiveShortcutCombo, + getEffectiveShortcutPrefix, getShortcutAction, formatShortcutForDisplay, type ShortcutActionId, @@ -156,24 +157,6 @@ export const HelpDialog: React.FC = () => { { categoryKey: "helpDialog.section.panels", items: [ - { - id: 'toggle_right_sidebar', - descriptionKey: 'helpDialog.item.toggleRightSidebar', - icon: "layout-right", - keys: '', - }, - { - id: 'open_right_sidebar_git', - descriptionKey: 'helpDialog.item.openRightSidebarGitTab', - icon: "git-branch", - keys: '', - }, - { - id: 'open_right_sidebar_files', - descriptionKey: 'helpDialog.item.openRightSidebarFilesTab', - icon: "layout-right", - keys: '', - }, { id: 'toggle_terminal', descriptionKey: 'helpDialog.item.toggleTerminalDock', @@ -187,14 +170,13 @@ export const HelpDialog: React.FC = () => { keys: '', }, { - id: 'toggle_context_plan', - descriptionKey: 'helpDialog.item.togglePlanContextPanel', - icon: "time", - keys: '', + keys: [`${formatShortcutForDisplay(getEffectiveShortcutPrefix('switch_context_surface', shortcutOverrides))} + 1...0`], + descriptionKey: "helpDialog.item.switchContextSurface", + icon: "layout-right", }, { - keys: [`${formatShortcutForDisplay('mod')} + 1...0`], - descriptionKey: "helpDialog.item.switchContextSurface", + keys: [`${formatShortcutForDisplay(getEffectiveShortcutPrefix('switch_session_tab', shortcutOverrides))} + 1...9`], + descriptionKey: "helpDialog.item.switchSessionTab", icon: "layout-right", }, ], @@ -214,12 +196,6 @@ export const HelpDialog: React.FC = () => { icon: "stack", keys: '', }, - { - id: 'cycle_services_tab', - descriptionKey: 'helpDialog.item.cycleServicesTab', - icon: "stack", - keys: '', - }, { id: 'open_settings', descriptionKey: "helpDialog.item.openSettings", @@ -258,6 +234,12 @@ export const HelpDialog: React.FC = () => { const descriptionKey = shortcut.descriptionKey ?? (action?.customizable ? action.settingsLabelKey : undefined); if (!descriptionKey) return null; + // This dialog lists what the keyboard can do right now; + // an action without a binding belongs to the command + // palette and Settings, not here. + if (shortcut.id && !getEffectiveShortcutCombo(shortcut.id, shortcutOverrides)) { + return null; + } const displayKeys = shortcut.id ? renderShortcut( shortcut.id, @@ -320,7 +302,7 @@ export const HelpDialog: React.FC = () => { • {t('helpDialog.proTips.recentSessions')}
  • - • {t('helpDialog.proTips.themeCycling')} + • {t('helpDialog.proTips.leaderSequences')}
diff --git a/packages/ui/src/components/ui/dropdown-navigation.ts b/packages/ui/src/components/ui/dropdown-navigation.ts index 2bd90bf7..185e2fc9 100644 --- a/packages/ui/src/components/ui/dropdown-navigation.ts +++ b/packages/ui/src/components/ui/dropdown-navigation.ts @@ -2,16 +2,18 @@ import type React from 'react'; import { isIMECompositionEvent } from '@/lib/ime'; -function getDropdownNavigationKey(event: Pick): 'ArrowDown' | 'ArrowUp' | null { +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'; + // `code` covers non-Latin layouts, where `key` is the layout's own letter. + if (event.key.toLowerCase() === 'n' || event.code === 'KeyN') return 'ArrowDown'; + if (event.key.toLowerCase() === 'p' || event.code === 'KeyP') return 'ArrowUp'; return null; } type DropdownNavigationEvent = Pick< React.KeyboardEvent, | 'altKey' + | 'code' | 'ctrlKey' | 'defaultPrevented' | 'isPropagationStopped' diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index a9da9370..09ef0327 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -1,7 +1,7 @@ import React from 'react'; import { isTerminalEventTarget } from '@/lib/terminalFocus'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs'; +import { activateSessionTabByIndex, closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs'; import { useSelectionStore } from '@/sync/selection-store'; import * as sessionActions from '@/sync/session-actions'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; @@ -11,13 +11,14 @@ import { useKeybinds } from '@/hooks/useKeybind'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { useConfigStore } from '@/stores/useConfigStore'; import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop'; -import { showOpenCodeStatus } from '@/lib/openCodeStatus'; import { eventMatchesShortcut, eventMatchesShortcutPrefix, getEffectiveShortcutCombo, getEffectiveShortcutPrefix, normalizeCombo, + resolveShortcutEventDigit, + resolveShortcutEventKey, ShortcutDispatcher, shortcutRegistry, type ShortcutActionId, @@ -123,8 +124,18 @@ export const useKeyboardShortcuts = () => { }, open_session_list: () => { const state = useUIStore.getState(); - if (state.isMobile) state.setSessionSwitcherOpen(true); - else state.setSessionDropdownOpen(true); + if (state.isMobile) { + state.setSessionSwitcherOpen(true); + return; + } + // The switcher dropdown only mounts while the sidebar is collapsed; + // with the sidebar visible the list is already on screen, so the + // shortcut opens the sidebar's session search instead. + if (state.isSidebarOpen) { + window.dispatchEvent(new CustomEvent('openchamber:sidebar-session-search')); + return; + } + state.setSessionDropdownOpen(true); }, toggle_prompt_navigator: () => { const state = useUIStore.getState(); @@ -146,9 +157,6 @@ export const useKeyboardShortcuts = () => { } state.togglePromptNavigatorPanel(); }, - open_status: () => { - void showOpenCodeStatus(); - }, open_help: () => { useUIStore.getState().toggleHelpDialog(); }, @@ -230,25 +238,6 @@ export const useKeyboardShortcuts = () => { 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(); @@ -482,8 +471,9 @@ export const useKeyboardShortcuts = () => { return; } - const switchSurfaceDigit = event.key.length === 1 && event.key >= '0' && event.key <= '9' - ? (event.key === '0' ? 10 : Number(event.key)) + const rawDigit = resolveShortcutEventDigit(event); + const switchSurfaceDigit = rawDigit !== null + ? (rawDigit === '0' ? 10 : Number(rawDigit)) : null; const switchSurfacePrefix = getEffectiveShortcutPrefix( 'switch_context_surface', @@ -514,13 +504,32 @@ export const useKeyboardShortcuts = () => { } } + const sessionTabDigit = rawDigit !== null && rawDigit !== '0' ? Number(rawDigit) : null; + if ( + sessionTabDigit !== null + && !event.repeat + && !isVSCodeRuntime() + && useUIStore.getState().sessionTabsEnabled + && eventMatchesShortcutPrefix( + event, + getEffectiveShortcutPrefix('switch_session_tab', useUIStore.getState().shortcutOverrides), + heldKeysRef.current, + ) + && activateSessionTabByIndex(sessionTabDigit - 1) + ) { + event.preventDefault(); + return; + } + if (dispatcher.dispatch(event)) event.preventDefault(); }; const handleKeyHoldDown = (event: KeyboardEvent) => { heldKeysRef.current.add(event.key.toLowerCase()); + heldKeysRef.current.add(resolveShortcutEventKey(event).toLowerCase()); }; const handleKeyUp = (event: KeyboardEvent) => { heldKeysRef.current.delete(event.key.toLowerCase()); + heldKeysRef.current.delete(resolveShortcutEventKey(event).toLowerCase()); }; const handleBlur = () => { heldKeysRef.current.clear(); diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index c52e96e8..6f8c5352 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1080,9 +1080,8 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal erweitert umschalten', 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Auswahl zum Chat hinzufügen', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Seitenleiste umschalten', - 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte Seitenleiste umschalten', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Datei-Tab der rechten Seitenleiste öffnen', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Sitzungs-Tab wechseln', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Kontextpanel-Oberfläche wechseln', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung', @@ -1090,9 +1089,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Tastenkürzel öffnen', - 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Plan-Kontextpanel umschalten', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Dienstemenü umschalten', - 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Dienste-Tab durchschalten', 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Thema wechseln', 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent wechseln', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Favorites Modell vorwärts durchschalten', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 15735541..adc8076b 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1684,22 +1684,18 @@ export const dict = { 'helpDialog.item.focusChatInput': 'Chat-Eingabe fokussieren', 'helpDialog.item.togglePromptNavigator': 'Aufforderungs-Navigator umschalten', 'helpDialog.item.abortActiveRun': 'Aktuelle Ausführung abbrechen (Doppeltaste)', - 'helpDialog.item.toggleRightSidebar': 'Rechte Seitenleiste umschalten', - 'helpDialog.item.openRightSidebarGitTab': 'Git-Registerkarte der rechten Seitenleiste öffnen', - 'helpDialog.item.openRightSidebarFilesTab': 'Datei-Registerkarte der rechten Seitenleiste öffnen', 'helpDialog.item.toggleTerminalDock': 'Terminal-Dock umschalten', 'helpDialog.item.toggleTerminalExpanded': 'Terminal erweitert umschalten', - 'helpDialog.item.togglePlanContextPanel': 'Plan-Kontext-Panel umschalten', 'helpDialog.item.cycleTheme': 'Thema wechseln (Hell → Dunkel → System)', + 'helpDialog.item.switchSessionTab': 'Sitzungs-Tab wechseln', 'helpDialog.item.switchContextSurface': 'Kontextpanel-Oberfläche wechseln (Zahlentaste)', 'helpDialog.item.toggleServicesMenu': 'Dienstemenü umschalten', - 'helpDialog.item.cycleServicesTab': 'Dienste-Registerkarte durchgehen', 'helpDialog.item.openSettings': 'Einstellungen öffnen', 'helpDialog.keyCombiner.or': 'oder', 'helpDialog.proTips.title': 'Pro-Tipps:', 'helpDialog.proTips.commandPalette': 'Verwenden Sie die Befehlspalette ({shortcut}), um schnell auf alle Aktionen zuzugreifen', 'helpDialog.proTips.recentSessions': 'Die 5 zuletzt verwendeten Sitzungen erscheinen in der Befehlspalette', - 'helpDialog.proTips.themeCycling': 'Themenwechsel merken sich Ihre Einstellung über Sitzungen hinweg', + 'helpDialog.proTips.leaderSequences': 'Zweistufige Kürzel: erst die Kombination, dann die zweite Taste — Esc bricht ab', 'header.actions.rightSidebarWithShortcut': 'Rechte Seitenleiste ({shortcut})', 'header.actions.toggleRightSidebarAria': 'Rechte Seitenleiste umschalten', 'header.actions.openAppMenu': 'OpenChamber-Menü', @@ -2293,6 +2289,9 @@ export const dict = { 'commandPalette.item.toggleSidebar': 'Seitenleiste umschalten', 'commandPalette.item.showContextUsage': 'Kontextnutzung anzeigen', 'commandPalette.item.toggleTerminal': 'Terminal umschalten', + 'commandPalette.item.cycleTheme': 'Thema wechseln', + 'commandPalette.item.showOpenCodeStatus': 'OpenCode-Status anzeigen', + 'commandPalette.item.toggleMemoryDebug': 'Memory-Debug-Panel umschalten', 'commandPalette.item.openSettings': 'Einstellungen öffnen...', 'commandPalette.session.untitled': 'Unbenannte Sitzung', 'openCodeStatusDialog.title': 'OpenCode-Status', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index f48bdd83..ef9d6265 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1142,9 +1142,8 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Toggle terminal expanded', 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Add selection to chat', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Toggle sidebar', - 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Open Files surface', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Switch session tab', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Switch context panel surface', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session', @@ -1152,9 +1151,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Open keyboard shortcuts', - 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Toggle plan context panel', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Toggle services menu', - 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Cycle services tab', 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Cycle theme', 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Cycle agent', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Cycle favorite model forward', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index c33afc63..686698de 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1858,22 +1858,18 @@ export const dict = { 'helpDialog.item.focusChatInput': 'Focus Chat Input', 'helpDialog.item.togglePromptNavigator': 'Toggle Prompt Navigator', 'helpDialog.item.abortActiveRun': 'Abort active run (double press)', - 'helpDialog.item.toggleRightSidebar': 'Toggle context panel', - 'helpDialog.item.openRightSidebarGitTab': 'Open Git surface', - 'helpDialog.item.openRightSidebarFilesTab': 'Open Files surface', 'helpDialog.item.toggleTerminalDock': 'Toggle Terminal Dock', 'helpDialog.item.toggleTerminalExpanded': 'Toggle Terminal Expanded', - 'helpDialog.item.togglePlanContextPanel': 'Toggle Plan Context Panel', + 'helpDialog.item.switchSessionTab': 'Switch Session Tab', 'helpDialog.item.switchContextSurface': 'Switch Context Panel Surface (number key)', 'helpDialog.item.cycleTheme': 'Cycle Theme (Light → Dark → System)', 'helpDialog.item.toggleServicesMenu': 'Toggle Services Menu', - 'helpDialog.item.cycleServicesTab': 'Cycle Services Tab', 'helpDialog.item.openSettings': 'Open Settings', 'helpDialog.keyCombiner.or': 'or', 'helpDialog.proTips.title': 'Pro Tips:', 'helpDialog.proTips.commandPalette': 'Use Command Palette ({shortcut}) to quickly access all actions', 'helpDialog.proTips.recentSessions': 'The 5 most recent sessions appear in the Command Palette', - 'helpDialog.proTips.themeCycling': 'Theme cycling remembers your preference across sessions', + 'helpDialog.proTips.leaderSequences': 'Two-step shortcuts: press the first combo, then the second key — Esc cancels', 'header.actions.rightSidebarWithShortcut': 'Right sidebar ({shortcut})', 'header.actions.toggleRightSidebarAria': 'Toggle right sidebar', 'header.actions.openAppMenu': 'OpenChamber menu', @@ -2483,6 +2479,9 @@ export const dict = { 'commandPalette.item.toggleSidebar': 'Toggle Sidebar', 'commandPalette.item.showContextUsage': 'Show Context Usage', 'commandPalette.item.toggleTerminal': 'Toggle Terminal', + 'commandPalette.item.cycleTheme': 'Cycle theme', + 'commandPalette.item.showOpenCodeStatus': 'Show OpenCode status', + 'commandPalette.item.toggleMemoryDebug': 'Toggle memory debug panel', 'commandPalette.item.openSettings': 'Open Settings...', 'commandPalette.session.untitled': 'Untitled Session', 'openCodeStatusDialog.title': 'OpenCode Status', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 67ea6135..63f646fb 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1110,9 +1110,8 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir o contraer terminal", "settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Agregar selección al chat", "settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar u ocultar barra lateral", - "settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto', - "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git', - "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superficie de archivos', + "settings.openchamber.keyboardShortcuts.action.switch_session_tab.label": "Cambiar pestaña de sesión", + "settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix": " + 1…9", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Cambiar superficie del panel de contexto", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión", @@ -1120,9 +1119,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atajos de teclado", - "settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Alternar panel de plan de contexto", "settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Mostrar u ocultar menú de servicios", - "settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label": "Cambiar pestaña de servicios", "settings.openchamber.keyboardShortcuts.action.cycle_theme.label": "Cambiar tema", "settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Cambiar agente", "settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Siguiente modelo favorito", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 7999cdb7..031d9b82 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1836,22 +1836,18 @@ export const dict: Record = { "helpDialog.item.focusChatInput": "Enfocar entrada de chat", "helpDialog.item.togglePromptNavigator": "Mostrar u ocultar navegador de prompts", "helpDialog.item.abortActiveRun": "Detener ejecución activa (doble presionar)", - "helpDialog.item.toggleRightSidebar": 'Alternar panel de contexto', - "helpDialog.item.openRightSidebarGitTab": 'Abrir superficie de Git', - "helpDialog.item.openRightSidebarFilesTab": 'Abrir superficie de archivos', "helpDialog.item.toggleTerminalDock": "Mostrar u ocultar dock de terminal", "helpDialog.item.toggleTerminalExpanded": "Expandir o contraer terminal", - "helpDialog.item.togglePlanContextPanel": "Alternar panel de contexto del plan", "helpDialog.item.cycleTheme": "Cambiar tema (Claro → Oscuro → Sistema)", + "helpDialog.item.switchSessionTab": "Cambiar pestaña de sesión", "helpDialog.item.switchContextSurface": "Cambiar superficie del panel de contexto (tecla numérica)", "helpDialog.item.toggleServicesMenu": "Mostrar u ocultar menú de servicios", - "helpDialog.item.cycleServicesTab": "Cambiar pestaña de servicios", "helpDialog.item.openSettings": "Abrir configuración", "helpDialog.keyCombiner.or": "o", "helpDialog.proTips.title": "Consejos:", "helpDialog.proTips.commandPalette": "Usa la paleta de comandos ({shortcut}) para acceder rápidamente a todas las acciones", "helpDialog.proTips.recentSessions": "Las cinco sesiones más recientes aparecen en la paleta de comandos", - "helpDialog.proTips.themeCycling": "El ciclo de tema recuerda tu preferencia entre sesiones", + "helpDialog.proTips.leaderSequences": "Atajos en dos pasos: pulsa la combinación y luego la segunda tecla; Esc cancela", "header.actions.rightSidebarWithShortcut": "Barra lateral derecha ({shortcut})", "header.actions.toggleRightSidebarAria": "Mostrar u ocultar barra lateral derecha", "header.actions.openAppMenu": "Menú de OpenChamber", @@ -2449,6 +2445,9 @@ export const dict: Record = { "commandPalette.item.toggleSidebar": "Mostrar u ocultar barra lateral", "commandPalette.item.showContextUsage": "Mostrar uso del contexto", "commandPalette.item.toggleTerminal": "Mostrar u ocultar terminal", + "commandPalette.item.cycleTheme": "Cambiar tema", + "commandPalette.item.showOpenCodeStatus": "Mostrar estado de OpenCode", + "commandPalette.item.toggleMemoryDebug": "Alternar panel de depuración de memoria", "commandPalette.item.openSettings": "Abrir configuración...", "commandPalette.session.untitled": "Sesión sin título", "openCodeStatusDialog.title": "Estado de OpenCode", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 80d982e6..f4df769b 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1028,9 +1028,8 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal à bascule étendu', 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Ajouter la sélection au chat', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Basculer la barre latérale', - 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Ouvrir la surface Fichiers', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Basculer l’onglet de session', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Basculer la surface du panneau contextuel', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session', @@ -1038,9 +1037,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Ouvrir les raccourcis clavier', - 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Basculer le panneau contextuel du plan', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Basculer le menu des services', - 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Onglet Services vélo', 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Thème du cycle', 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent de cycle', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Faire avancer le modèle favori', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index f16dee7b..982cfd30 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1616,22 +1616,18 @@ export const dict = { 'helpDialog.item.focusChatInput': 'Concentration sur la saisie du chat', 'helpDialog.item.togglePromptNavigator': 'Afficher ou masquer le navigateur de prompts', 'helpDialog.item.abortActiveRun': 'Abandonner l’exécution active (double pression)', - 'helpDialog.item.toggleRightSidebar': 'Afficher/masquer le panneau de contexte', - 'helpDialog.item.openRightSidebarGitTab': 'Ouvrir la surface Git', - 'helpDialog.item.openRightSidebarFilesTab': 'Ouvrir la surface Fichiers', 'helpDialog.item.toggleTerminalDock': 'Basculer la station d\'accueil du terminal', 'helpDialog.item.toggleTerminalExpanded': 'Terminal à bascule étendu', - 'helpDialog.item.togglePlanContextPanel': 'Toggle Panneau contextuel du plan', 'helpDialog.item.cycleTheme': 'Basculer le thème (clair → sombre → système)', + 'helpDialog.item.switchSessionTab': 'Basculer l’onglet de session', 'helpDialog.item.switchContextSurface': 'Basculer la surface du panneau contextuel (touche numérique)', 'helpDialog.item.toggleServicesMenu': 'Basculer le menu des services', - 'helpDialog.item.cycleServicesTab': 'Onglet Services de vélo', 'helpDialog.item.openSettings': 'Ouvrir les paramètres', 'helpDialog.keyCombiner.or': 'ou', 'helpDialog.proTips.title': 'Conseils de pro :', 'helpDialog.proTips.commandPalette': 'Utilisez la palette de commandes ({shortcut}) pour accéder rapidement à toutes les actions', 'helpDialog.proTips.recentSessions': 'Les 5 sessions les plus récentes apparaissent dans la palette de commandes', - 'helpDialog.proTips.themeCycling': 'Le cyclisme thématique mémorise vos préférences au fil des sessions', + 'helpDialog.proTips.leaderSequences': 'Raccourcis en deux temps : appuyez sur la combinaison, puis sur la seconde touche — Échap annule', 'header.actions.rightSidebarWithShortcut': 'Barre latérale droite ({shortcut})', 'header.actions.toggleRightSidebarAria': 'Basculer la barre latérale droite', 'header.actions.openAppMenu': 'Menu de OpenChamber', @@ -2187,6 +2183,9 @@ export const dict = { 'commandPalette.item.toggleSidebar': 'Basculer la barre latérale', 'commandPalette.item.showContextUsage': 'Afficher l\'utilisation du contexte', 'commandPalette.item.toggleTerminal': 'Basculer le terminal', + 'commandPalette.item.cycleTheme': 'Changer de thème', + 'commandPalette.item.showOpenCodeStatus': 'Afficher le statut OpenCode', + 'commandPalette.item.toggleMemoryDebug': 'Basculer le panneau de débogage mémoire', 'commandPalette.item.openSettings': 'Ouvrez les paramètres...', 'commandPalette.session.untitled': 'Session sans titre', 'openCodeStatusDialog.title': 'Statut OpenCode', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 98d45ab8..63e54c6b 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1143,9 +1143,8 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'ターミナル拡大の切替', 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '選択範囲をチャットに追加', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'サイドバーの切替', - 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'ファイルサーフェスを開く', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'セッションタブを切り替え', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'コンテキストパネルのサーフェスを切り替え', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session', @@ -1153,9 +1152,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'キーボードショートカットを開く', - 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '計画コンテキストパネルの切替', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'サービスメニューの切替', - 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'サービスタブを順に切替', 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'テーマを順に切替', 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Agent を順に切替', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'お気に入りモデルを次へ', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 3b511318..d0c55cfc 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1854,22 +1854,18 @@ export const dict: Record = { 'helpDialog.item.focusChatInput': 'チャット入力にフォーカス', 'helpDialog.item.togglePromptNavigator': 'プロンプトナビゲーターの表示切替', 'helpDialog.item.abortActiveRun': 'アクティブな実行を中止(ダブルプレス)', - 'helpDialog.item.toggleRightSidebar': 'コンテキストパネルの表示切替', - 'helpDialog.item.openRightSidebarGitTab': 'Git サーフェスを開く', - 'helpDialog.item.openRightSidebarFilesTab': 'ファイルサーフェスを開く', 'helpDialog.item.toggleTerminalDock': 'ターミナルドックの切り替え', 'helpDialog.item.toggleTerminalExpanded': 'ターミナル展開の切り替え', - 'helpDialog.item.togglePlanContextPanel': '計画コンテキストパネルの切り替え', 'helpDialog.item.cycleTheme': 'テーマ切り替え(ライト→ダーク→システム)', + 'helpDialog.item.switchSessionTab': 'セッションタブを切り替え', 'helpDialog.item.switchContextSurface': 'コンテキストパネルのサーフェスを切り替え(数字キー)', 'helpDialog.item.toggleServicesMenu': 'サービスの切り替え', - 'helpDialog.item.cycleServicesTab': 'サービス変数の切り替え', 'helpDialog.item.openSettings': '設定を開く', 'helpDialog.keyCombiner.or': 'または', 'helpDialog.proTips.title': 'プロのヒント:', 'helpDialog.proTips.commandPalette': 'コマンドパレット({shortcut})を使うとすべての操作にすばやくアクセスできます', 'helpDialog.proTips.recentSessions': '最近の5つのセッションがコマンドパレットに表示されます', - 'helpDialog.proTips.themeCycling': 'テーマの切り替えはセッション間で設定が記憶されます', + 'helpDialog.proTips.leaderSequences': '2段階ショートカット:組み合わせを押してから2つ目のキーを押します(Escで取消)', 'header.actions.rightSidebarWithShortcut': '右サイドバー({shortcut})', 'header.actions.toggleRightSidebarAria': '右サイドバーの切り替え', 'header.actions.openAppMenu': 'OpenChamberメニュー', @@ -2482,6 +2478,9 @@ export const dict: Record = { 'commandPalette.item.toggleSidebar': 'サイドバーの切り替え', 'commandPalette.item.showContextUsage': 'コンテキスト使用量を表示', 'commandPalette.item.toggleTerminal': 'ターミナルの切り替え', + 'commandPalette.item.cycleTheme': 'テーマを順に切替', + 'commandPalette.item.showOpenCodeStatus': 'OpenCode のステータスを表示', + 'commandPalette.item.toggleMemoryDebug': 'メモリデバッグパネルの切替', 'commandPalette.item.openSettings': '設定を開く...', 'commandPalette.session.untitled': '無題のセッション', 'openCodeStatusDialog.title': 'OpenCodeステータス', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 70314bdb..a9ec57e3 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1110,9 +1110,8 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '터미널 확장 토글', 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '선택 내용을 채팅에 추가', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '사이드바 토글', - 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '파일 서피스 열기', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': '세션 탭 전환', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '컨텍스트 패널 서피스 전환', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션', @@ -1120,9 +1119,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창', 'settings.openchamber.keyboardShortcuts.action.open_help.label': '키보드 단축키 열기', - 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '계획 컨텍스트 패널 토글', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': '서비스 메뉴 토글', - 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': '서비스 탭 순환', 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': '테마 순환', 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '에이전트 순환', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '즐겨찾기 모델 앞으로 순환', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 4fef8b87..cba5551d 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1860,22 +1860,18 @@ export const dict: Record = { 'helpDialog.item.focusChatInput': '채팅 입력창으로 포커스 이동', 'helpDialog.item.togglePromptNavigator': '프롬프트 탐색기 표시/숨기기', 'helpDialog.item.abortActiveRun': '활성 실행 중단(두 번 누르기)', - 'helpDialog.item.toggleRightSidebar': '컨텍스트 패널 표시 전환', - 'helpDialog.item.openRightSidebarGitTab': 'Git 서피스 열기', - 'helpDialog.item.openRightSidebarFilesTab': '파일 서피스 열기', 'helpDialog.item.toggleTerminalDock': '터미널 독 전환', 'helpDialog.item.toggleTerminalExpanded': '터미널 펼치기/접기', - 'helpDialog.item.togglePlanContextPanel': '플랜 컨텍스트 패널 전환', 'helpDialog.item.cycleTheme': '테마 순환(라이트 → 다크 → 시스템)', + 'helpDialog.item.switchSessionTab': '세션 탭 전환', 'helpDialog.item.switchContextSurface': '컨텍스트 패널 서피스 전환(숫자 키)', 'helpDialog.item.toggleServicesMenu': '서비스 메뉴 전환', - 'helpDialog.item.cycleServicesTab': '서비스 탭 순환', 'helpDialog.item.openSettings': '설정 열기', 'helpDialog.keyCombiner.or': '또는', 'helpDialog.proTips.title': '팁:', 'helpDialog.proTips.commandPalette': '명령 팔레트({shortcut})로 모든 작업에 빠르게 접근하세요', 'helpDialog.proTips.recentSessions': '최근 세션 5개가 명령 팔레트에 표시됩니다', - 'helpDialog.proTips.themeCycling': '테마 순환은 세션 간에도 선호 설정을 기억합니다', + 'helpDialog.proTips.leaderSequences': '2단계 단축키: 조합을 누른 뒤 두 번째 키를 누르세요 (Esc로 취소)', 'header.actions.rightSidebarWithShortcut': '오른쪽 사이드바 ({shortcut})', 'header.actions.toggleRightSidebarAria': '오른쪽 사이드바 토글', 'header.actions.openAppMenu': 'OpenChamber 메뉴', @@ -2483,6 +2479,9 @@ export const dict: Record = { 'commandPalette.item.toggleSidebar': '토글 사이드바', 'commandPalette.item.showContextUsage': '컨텍스트 사용량 표시', 'commandPalette.item.toggleTerminal': '토글 터미널', + 'commandPalette.item.cycleTheme': '테마 순환', + 'commandPalette.item.showOpenCodeStatus': 'OpenCode 상태 표시', + 'commandPalette.item.toggleMemoryDebug': '메모리 디버그 패널 토글', 'commandPalette.item.openSettings': '설정... 열기', 'commandPalette.session.untitled': '제목 없는 세션', 'openCodeStatusDialog.title': 'OpenCode 상태', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 5a9fccc0..b14793e1 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -818,7 +818,6 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_backward.label': 'Przełącz ulubiony model wstecz', 'settings.openchamber.keyboardShortcuts.action.open_model_selector.label': 'Otwórz wybór modelu', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': 'Przełącz ulubiony model w przód', - 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': 'Przełącz zakładkę usług', 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': 'Przełącz motyw', 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': 'Przełącz agenta', 'settings.openchamber.keyboardShortcuts.action.expand_input.label': 'Rozwiń pole wprowadzania', @@ -831,13 +830,11 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Otwórz paletę poleceń', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Przejdź do linii (edytor plików)', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Otwórz skróty klawiszowe', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': 'Otwórz powierzchnię plików', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': 'Przełącz kartę sesji', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Przełącz powierzchnię panelu kontekstu', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Otwórz powierzchnię Git', 'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Otwórz ustawienia', - 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu', - 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Przełącz panel kontekstu', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Przełącz menu usług', 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Dodaj zaznaczenie do czatu', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Przełącz pasek boczny', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 476aff49..43d81bad 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1452,6 +1452,9 @@ export const dict: Record = { 'commandPalette.item.showSessionSwitcher': 'Pokaż przełącznik sesji', 'commandPalette.item.toggleSidebar': 'Przełącz panel boczny', 'commandPalette.item.toggleTerminal': 'Przełącz terminal', + 'commandPalette.item.cycleTheme': 'Przełącz motyw', + 'commandPalette.item.showOpenCodeStatus': 'Pokaż status OpenCode', + 'commandPalette.item.toggleMemoryDebug': 'Przełącz panel debugowania pamięci', 'commandPalette.session.untitled': 'Nienazwana sesja', 'commandPalette.title': 'Paleta poleceń', 'contextPanel.actions.closePanel': 'Zamknij panel', @@ -2452,7 +2455,6 @@ export const dict: Record = { 'helpDialog.item.createNewSession': 'Utwórz nową sesję', 'helpDialog.item.createNewWorktreeDraft': 'Utwórz nowy szkic drzewa pracy', 'helpDialog.item.cycleAgent': 'Przełącz agenta (w polu czatu)', - 'helpDialog.item.cycleServicesTab': 'Przełącz kartę usług', 'helpDialog.item.cycleTheme': 'Przełącz motyw (Jasny → Ciemny → Systemowy)', 'helpDialog.item.cycleThinkingVariant': 'Przełącz wariant myślenia (skrót globalny)', 'helpDialog.item.focusChatInput': 'Ustaw fokus na polu czatu', @@ -2461,13 +2463,10 @@ export const dict: Record = { 'helpDialog.item.newWindow': 'Nowe okno (tylko desktop)', 'helpDialog.item.openCommandPalette': 'Otwórz paletę poleceń', 'helpDialog.item.openModelSelector': 'Otwórz selektor modeli', - 'helpDialog.item.openRightSidebarFilesTab': 'Otwórz powierzchnię plików', - 'helpDialog.item.openRightSidebarGitTab': 'Otwórz powierzchnię Git', 'helpDialog.item.openSettings': 'Otwórz ustawienia', 'helpDialog.item.showKeyboardShortcuts': 'Pokaż skróty klawiaturowe (to okno)', + 'helpDialog.item.switchSessionTab': 'Przełącz kartę sesji', 'helpDialog.item.switchContextSurface': 'Przełącz powierzchnię panelu kontekstu (klawisz liczbowy)', - 'helpDialog.item.togglePlanContextPanel': 'Przełącz panel kontekstu planu', - 'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu', 'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług', 'helpDialog.item.toggleSessionSidebar': 'Przełącz panel sesji', 'helpDialog.item.addSelectionToChat': 'Dodaj zaznaczenie do czatu', @@ -2476,7 +2475,7 @@ export const dict: Record = { 'helpDialog.keyCombiner.or': 'lub', 'helpDialog.proTips.commandPalette': 'Użyj Palety poleceń ({shortcut}), aby szybko uzyskać dostęp do wszystkich akcji', 'helpDialog.proTips.recentSessions': '5 ostatnich sesji pojawia się w Palecie poleceń', - 'helpDialog.proTips.themeCycling': 'Przełączanie motywów zapamiętuje twoje preferencje między sesjami', + 'helpDialog.proTips.leaderSequences': 'Skróty dwustopniowe: naciśnij kombinację, potem drugi klawisz — Esc anuluje', 'helpDialog.proTips.title': 'Wskazówki:', 'helpDialog.section.interface': 'Interfejs', 'helpDialog.section.navigationCommands': 'Nawigacja i polecenia', 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 f536f6e3..6f5a4319 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1110,9 +1110,8 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir ou recolher terminal", "settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Adicionar seleção ao chat", "settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar ou ocultar barra lateral", - "settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto', - "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git', - "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Abrir superfície de arquivos', + "settings.openchamber.keyboardShortcuts.action.switch_session_tab.label": "Alternar aba de sessão", + "settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix": " + 1…9", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Alternar superfície do painel de contexto", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão", @@ -1120,9 +1119,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atalhos de teclado", - "settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Alternar painel de plano de contexto", "settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Mostrar ou ocultar menu de serviços", - "settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label": "Alternar aba de serviços", "settings.openchamber.keyboardShortcuts.action.cycle_theme.label": "Alternar tema", "settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Alternar agente", "settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Próximo modelo favorito", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 36a06ede..cdc7c27c 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1836,22 +1836,18 @@ export const dict: Record = { "helpDialog.item.focusChatInput": "Focar entrada do chat", "helpDialog.item.togglePromptNavigator": "Mostrar ou ocultar navegador de prompts", "helpDialog.item.abortActiveRun": "Interromper execução ativa (duplo clique)", - "helpDialog.item.toggleRightSidebar": 'Alternar painel de contexto', - "helpDialog.item.openRightSidebarGitTab": 'Abrir superfície do Git', - "helpDialog.item.openRightSidebarFilesTab": 'Abrir superfície de arquivos', "helpDialog.item.toggleTerminalDock": "Mostrar ou ocultar dock de terminal", "helpDialog.item.toggleTerminalExpanded": "Expandir ou recolher o terminal", - "helpDialog.item.togglePlanContextPanel": "Alternar painel de contexto do plano", "helpDialog.item.cycleTheme": "Alternar tema (Claro → Escuro → Sistema)", + "helpDialog.item.switchSessionTab": "Alternar aba de sessão", "helpDialog.item.switchContextSurface": "Alternar superfície do painel de contexto (tecla numérica)", "helpDialog.item.toggleServicesMenu": "Mostrar ou ocultar menu de serviços", - "helpDialog.item.cycleServicesTab": "Alternar aba de serviços", "helpDialog.item.openSettings": "Abrir configurações", "helpDialog.keyCombiner.or": "ou", "helpDialog.proTips.title": "Dicas:", "helpDialog.proTips.commandPalette": "Use a paleta de comandos ({shortcut}) para acessar rapidamente todas as ações", "helpDialog.proTips.recentSessions": "As cinco sessões mais recentes aparecem na paleta de comandos", - "helpDialog.proTips.themeCycling": "A alternância de tema lembra sua preferência entre sessões", + "helpDialog.proTips.leaderSequences": "Atalhos em duas etapas: pressione a combinação e depois a segunda tecla — Esc cancela", "header.actions.rightSidebarWithShortcut": "Barra lateral direita ({shortcut})", "header.actions.toggleRightSidebarAria": "Mostrar ou ocultar barra lateral direita", "header.actions.openAppMenu": "Menu do OpenChamber", @@ -2449,6 +2445,9 @@ export const dict: Record = { "commandPalette.item.toggleSidebar": "Mostrar ou ocultar barra lateral", "commandPalette.item.showContextUsage": "Mostrar uso do contexto", "commandPalette.item.toggleTerminal": "Mostrar ou ocultar terminal", + "commandPalette.item.cycleTheme": "Alternar tema", + "commandPalette.item.showOpenCodeStatus": "Mostrar status do OpenCode", + "commandPalette.item.toggleMemoryDebug": "Alternar painel de depuração de memória", "commandPalette.item.openSettings": "Abrir configurações...", "commandPalette.session.untitled": "Sessão sem título", "openCodeStatusDialog.title": "Status do OpenCode", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 11afb49c..0af768cb 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1110,9 +1110,8 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Розгорнути або згорнути термінал", "settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Додати виділення в чат", "settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Перемкнути бічну панель", - "settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель', - "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git', - "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label": 'Відкрити поверхню файлів', + "settings.openchamber.keyboardShortcuts.action.switch_session_tab.label": "Перемкнути вкладку сесії", + "settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix": " + 1…9", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Перемкнути поверхню панелі контексту", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія", @@ -1120,9 +1119,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Відкрити комбінації клавіш", - "settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Перемкнути контекстну панель плану", "settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Перемкнути меню сервісів", - "settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label": "Перемкнути вкладку сервісів", "settings.openchamber.keyboardShortcuts.action.cycle_theme.label": "Перемкнути тему", "settings.openchamber.keyboardShortcuts.action.cycle_agent.label": "Перемкнути агента", "settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label": "Перемкнути улюблену модель вперед", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 61163aa0..8d17da5a 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1836,22 +1836,18 @@ export const dict: Record = { "helpDialog.item.focusChatInput": "Фокус на полі вводу чату", "helpDialog.item.togglePromptNavigator": "Показати або приховати навігатор промптів", "helpDialog.item.abortActiveRun": "Перервати активний запуск (подвійне натискання)", - "helpDialog.item.toggleRightSidebar": 'Перемкнути контекстну панель', - "helpDialog.item.openRightSidebarGitTab": 'Відкрити поверхню Git', - "helpDialog.item.openRightSidebarFilesTab": 'Відкрити поверхню файлів', "helpDialog.item.toggleTerminalDock": "Перемкнути панель терміналу", "helpDialog.item.toggleTerminalExpanded": "Розгорнути або згорнути термінал", - "helpDialog.item.togglePlanContextPanel": "Перемкнути панель контексту плану", "helpDialog.item.cycleTheme": "Перемкнути тему (Світла → Темна → Системна)", + "helpDialog.item.switchSessionTab": "Перемкнути вкладку сесії", "helpDialog.item.switchContextSurface": "Перемкнути поверхню панелі контексту (цифрова клавіша)", "helpDialog.item.toggleServicesMenu": "Перемкнути меню сервісів", - "helpDialog.item.cycleServicesTab": "Перемкнути вкладку сервісів", "helpDialog.item.openSettings": "Відкрити налаштування", "helpDialog.keyCombiner.or": "або", "helpDialog.proTips.title": "Поради:", "helpDialog.proTips.commandPalette": "Використовуйте палітру команд ({shortcut}), щоб швидко перейти до будь-якої дії", "helpDialog.proTips.recentSessions": "5 останніх сесій відображаються на панелі команд", - "helpDialog.proTips.themeCycling": "Перемикання теми запам’ятовує ваші переваги протягом сесій", + "helpDialog.proTips.leaderSequences": "Двокрокові шорткати: натисни комбінацію, потім другу клавішу — Esc скасовує", "header.actions.rightSidebarWithShortcut": "Права бічна панель ({shortcut})", "header.actions.toggleRightSidebarAria": "Перемкнути праву бічну панель", "header.actions.openAppMenu": "Меню OpenChamber", @@ -2449,6 +2445,9 @@ export const dict: Record = { "commandPalette.item.toggleSidebar": "Перемкнути бічну панель", "commandPalette.item.showContextUsage": "Показати використання контексту", "commandPalette.item.toggleTerminal": "Перемкнути термінал", + "commandPalette.item.cycleTheme": "Перемкнути тему", + "commandPalette.item.showOpenCodeStatus": "Показати статус OpenCode", + "commandPalette.item.toggleMemoryDebug": "Показати/сховати панель memory debug", "commandPalette.item.openSettings": "Відкрити налаштування...", "commandPalette.session.untitled": "Сесія без назви", "openCodeStatusDialog.title": "Статус OpenCode", 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 59714a11..f898b951 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1110,9 +1110,8 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切换终端展开', 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '将选中内容添加到聊天', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切换侧边栏', - 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '打开文件界面', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': '切换会话标签页', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切换上下文面板界面', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话', @@ -1120,9 +1119,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口', 'settings.openchamber.keyboardShortcuts.action.open_help.label': '打开键盘快捷键', - 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '切换上下文面板中的计划', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': '切换服务菜单', - 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': '轮换服务菜单标签', 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': '轮换主题', 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '轮换智能体', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '向前轮换收藏模型', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 6ebc17c6..3e0d8295 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1824,22 +1824,18 @@ export const dict: Record = { 'helpDialog.item.focusChatInput': '聚焦聊天输入框', 'helpDialog.item.togglePromptNavigator': '显示或隐藏提示词导航', 'helpDialog.item.abortActiveRun': '中止当前运行(双击)', - 'helpDialog.item.toggleRightSidebar': '切换上下文面板', - 'helpDialog.item.openRightSidebarGitTab': '打开 Git 界面', - 'helpDialog.item.openRightSidebarFilesTab': '打开文件界面', 'helpDialog.item.toggleTerminalDock': '切换终端停靠栏', 'helpDialog.item.toggleTerminalExpanded': '切换终端展开状态', - 'helpDialog.item.togglePlanContextPanel': '切换计划上下文面板', 'helpDialog.item.cycleTheme': '循环切换主题(浅色 → 深色 → 跟随系统)', + 'helpDialog.item.switchSessionTab': '切换会话标签页', 'helpDialog.item.switchContextSurface': '切换上下文面板界面(数字键)', 'helpDialog.item.toggleServicesMenu': '切换服务菜单', - 'helpDialog.item.cycleServicesTab': '循环服务标签', 'helpDialog.item.openSettings': '打开设置', 'helpDialog.keyCombiner.or': '或', 'helpDialog.proTips.title': '使用提示:', 'helpDialog.proTips.commandPalette': '使用命令面板({shortcut})可快速访问所有操作', 'helpDialog.proTips.recentSessions': '最近 5 个会话会显示在命令面板中', - 'helpDialog.proTips.themeCycling': '主题循环会记住你在各会话中的偏好', + 'helpDialog.proTips.leaderSequences': '两段式快捷键:先按组合键,再按第二个键(Esc 取消)', 'header.actions.rightSidebarWithShortcut': '右侧边栏({shortcut})', 'header.actions.toggleRightSidebarAria': '切换右侧边栏', 'header.actions.openAppMenu': 'OpenChamber 菜单', @@ -2449,6 +2445,9 @@ export const dict: Record = { 'commandPalette.item.toggleSidebar': '切换侧边栏', 'commandPalette.item.showContextUsage': '显示上下文用量', 'commandPalette.item.toggleTerminal': '切换终端', + 'commandPalette.item.cycleTheme': '轮换主题', + 'commandPalette.item.showOpenCodeStatus': '显示 OpenCode 状态', + 'commandPalette.item.toggleMemoryDebug': '切换内存调试面板', 'commandPalette.item.openSettings': '打开设置...', 'commandPalette.session.untitled': '未命名会话', 'openCodeStatusDialog.title': 'OpenCode 状态', 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 af6b2fcb..fe7d3153 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1017,9 +1017,8 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切換終端機展開', 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '將選取內容加入聊天', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切換側邊欄', - 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面', - 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_files.label': '開啟檔案介面', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label': '切換工作階段分頁', + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.suffix': ' + 1…9', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切換上下文面板介面', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建工作階段', @@ -1027,9 +1026,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗', 'settings.openchamber.keyboardShortcuts.action.open_help.label': '開啟鍵盤快速鍵', - 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '切換上下文面板中的計畫', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': '切換服務選單', - 'settings.openchamber.keyboardShortcuts.action.cycle_services_tab.label': '輪換服務選單分頁', 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label': '輪換主題', 'settings.openchamber.keyboardShortcuts.action.cycle_agent.label': '輪換 agent', 'settings.openchamber.keyboardShortcuts.action.cycle_favorite_model_forward.label': '向前輪換收藏模型', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index c3cecc5e..52468dab 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1828,22 +1828,18 @@ export const dict: Record = { 'helpDialog.item.focusChatInput': '聚焦聊天輸入框', 'helpDialog.item.togglePromptNavigator': '顯示或隱藏提示詞導覽', 'helpDialog.item.abortActiveRun': '中止目前執行(連按兩下)', - 'helpDialog.item.toggleRightSidebar': '切換上下文面板', - 'helpDialog.item.openRightSidebarGitTab': '開啟 Git 介面', - 'helpDialog.item.openRightSidebarFilesTab': '開啟檔案介面', 'helpDialog.item.toggleTerminalDock': '切換終端機停靠欄', 'helpDialog.item.toggleTerminalExpanded': '切換終端機展開狀態', - 'helpDialog.item.togglePlanContextPanel': '切換計畫上下文面板', 'helpDialog.item.cycleTheme': '循環切換主題(淺色 → 深色 → 跟隨系統)', + 'helpDialog.item.switchSessionTab': '切換工作階段分頁', 'helpDialog.item.switchContextSurface': '切換上下文面板介面(數字鍵)', 'helpDialog.item.toggleServicesMenu': '切換服務選單', - 'helpDialog.item.cycleServicesTab': '循環服務標籤', 'helpDialog.item.openSettings': '開啟設定', 'helpDialog.keyCombiner.or': '或', 'helpDialog.proTips.title': '使用提示:', 'helpDialog.proTips.commandPalette': '使用命令面板({shortcut})可快速存取所有操作', 'helpDialog.proTips.recentSessions': '最近 5 個會话會顯示在命令面板中', - 'helpDialog.proTips.themeCycling': '主題循環會記住你在各會話中的偏好', + 'helpDialog.proTips.leaderSequences': '兩段式快捷鍵:先按組合鍵,再按第二個鍵(Esc 取消)', 'header.actions.rightSidebarWithShortcut': '右側邊欄({shortcut})', 'header.actions.toggleRightSidebarAria': '切換右側邊欄', 'header.actions.openAppMenu': 'OpenChamber 選單', @@ -2453,6 +2449,9 @@ export const dict: Record = { 'commandPalette.item.toggleSidebar': '切換側邊欄', 'commandPalette.item.showContextUsage': '顯示上下文用量', 'commandPalette.item.toggleTerminal': '切換終端機', + 'commandPalette.item.cycleTheme': '輪換主題', + 'commandPalette.item.showOpenCodeStatus': '顯示 OpenCode 狀態', + 'commandPalette.item.toggleMemoryDebug': '切換記憶體偵錯面板', 'commandPalette.item.openSettings': '開啟設定...', 'commandPalette.session.untitled': '未命名會話', 'openCodeStatusDialog.title': 'OpenCode 狀態', diff --git a/packages/ui/src/lib/sessionTabs.ts b/packages/ui/src/lib/sessionTabs.ts index b3ce2bb6..a1b490b0 100644 --- a/packages/ui/src/lib/sessionTabs.ts +++ b/packages/ui/src/lib/sessionTabs.ts @@ -9,6 +9,23 @@ import { useSessionUIStore } from '@/sync/session-ui-store'; * count as neighbours — the same rule the strip uses for rendering. The * session itself is never touched. */ +/** + * Activate the nth (0-based) header session tab, counting only tabs whose + * session is present in the loaded session list — the same rule the strip + * uses for rendering, so the digit matches what the user sees. + */ +export const activateSessionTabByIndex = (index: number): boolean => { + const { tabIds } = useSessionTabsStore.getState(); + const sessionsById = new Map( + useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const), + ); + const renderable = tabIds.filter((id) => sessionsById.has(id)); + const session = renderable[index] ? sessionsById.get(renderable[index]) : null; + if (!session) return false; + useSessionUIStore.getState().setCurrentSession(session.id, resolveGlobalSessionDirectory(session)); + return true; +}; + export const closeSessionTabAndActivateNeighbour = (sessionId: string): void => { const { tabIds, closeTab } = useSessionTabsStore.getState(); if (!tabIds.includes(sessionId)) return; diff --git a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md index e2d3db84..06a68b4f 100644 --- a/packages/ui/src/lib/shortcuts/DOCUMENTATION.md +++ b/packages/ui/src/lib/shortcuts/DOCUMENTATION.md @@ -25,9 +25,9 @@ Component interaction keys that are not application commands, such as list navig # Binding rules -Bindings remain persisted as `Record`. Each binding has one chord or at most two space-separated chords, such as `mod+s p`. `mod` is the platform-neutral primary modifier (Command on macOS, Control elsewhere), while `alt` is the platform-neutral alternate modifier (Option on macOS, Alt elsewhere); `command`, `cmd`, `meta`, and `option` are accepted input aliases but normalize to those canonical tokens. `normalizeCombo`, `parseShortcut`, `formatShortcutForDisplay`, and `getShortcutConflict` provide the shared parsing and validation behavior. Display formatting uses macOS keyboard symbols (`⌘`, `⌥`, `⌃`, `⇧`) on macOS and named modifiers (`Ctrl`, `Alt`, `Shift`) elsewhere, including tooltip and accessible text consumers. A single chord conflicts with a sequence sharing its first chord; sibling sequences are valid. +Bindings remain persisted as `Record`. Each binding has one chord or at most two space-separated chords, such as `mod+k p`. `mod` is the platform-neutral primary modifier (Command on macOS, Control elsewhere), while `alt` is the platform-neutral alternate modifier (Option on macOS, Alt elsewhere); `command`, `cmd`, `meta`, and `option` are accepted input aliases but normalize to those canonical tokens. `normalizeCombo`, `parseShortcut`, `formatShortcutForDisplay`, and `getShortcutConflict` provide the shared parsing and validation behavior. Display formatting uses macOS keyboard symbols (`⌘`, `⌥`, `⌃`, `⇧`) on macOS and named modifiers (`Ctrl`, `Alt`, `Shift`) elsewhere, including tooltip and accessible text consumers. 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`, `mod+s g`, and `mod+s l` to the draft target pickers and session list. +The default layout follows three modes: single chords for everyday actions, the `mod+k` leader for open/go actions (`mod+k p`, `mod+k g`, `mod+k l`, `mod+k t`, `mod+k n`, `mod+k i`, `mod+k h`), and held digit prefixes — held `mod` + digit switches header session tabs, held `mod+alt` + digit switches context panel surfaces. Every schema action ships with a default binding; palette-only commands (context surfaces, OpenCode status, memory debug) live outside the schema and the palette invokes their owning modules directly. Single-chord handlers still get the first chance at a leader's chord; returning `false` lets the dispatcher arm the sequence. 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. diff --git a/packages/ui/src/lib/shortcuts/bindings.test.ts b/packages/ui/src/lib/shortcuts/bindings.test.ts index be955841..893057c0 100644 --- a/packages/ui/src/lib/shortcuts/bindings.test.ts +++ b/packages/ui/src/lib/shortcuts/bindings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { + eventMatchesShortcut, eventMatchesShortcutPrefix, formatShortcutForDisplay, getEffectiveShortcutPrefix, @@ -9,12 +10,13 @@ import { isShortcutPrefixHeld, normalizeCombo, parseShortcut, + resolveShortcutEventDigit, UNASSIGNED_SHORTCUT, } from './index'; describe('getEffectiveShortcutPrefix', () => { - test('falls back to the action default (bare mod) when unset', () => { - expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod'); + test('falls back to the action default (bare mod+alt) when unset', () => { + expect(getEffectiveShortcutPrefix('switch_context_surface', {})).toBe('mod+alt'); }); test('honors modifier + key overrides', () => { @@ -126,3 +128,31 @@ describe('platform shortcut labels', () => { expect(formatShortcutForDisplay('alt', 'Unassigned', 'other')).toBe('Alt'); }); }); + +describe('layout-independent key matching', () => { + const event = (overrides: Partial): KeyboardEvent => + // SAFETY: the matcher only reads the modifier flags, key, and code + // provided here; a full KeyboardEvent is not constructible in bun tests. + ({ altKey: false, ctrlKey: false, metaKey: false, shiftKey: false, key: '', code: '', ...overrides }) as KeyboardEvent; + + test('a non-Latin layout letter matches through the physical key code', () => { + expect(eventMatchesShortcut(event({ ctrlKey: true, key: 'л', code: 'KeyK' }), 'mod+k')).toBe(true); + expect(eventMatchesShortcut(event({ key: 'з', code: 'KeyP' }), 'p')).toBe(true); + }); + + test('macOS Option symbol substitution matches through the digit code', () => { + expect(eventMatchesShortcut(event({ ctrlKey: true, altKey: true, key: '¡', code: 'Digit1' }), 'mod+alt+1')).toBe(true); + }); + + test('Latin layouts that move keys keep their key-based meaning', () => { + // Dvorak: physical KeyT produces "y"; the binding follows the character. + expect(eventMatchesShortcut(event({ ctrlKey: true, key: 'y', code: 'KeyT' }), 'mod+y')).toBe(true); + expect(eventMatchesShortcut(event({ ctrlKey: true, key: 'y', code: 'KeyT' }), 'mod+t')).toBe(false); + }); + + test('resolveShortcutEventDigit reads the digit from the code under Option', () => { + expect(resolveShortcutEventDigit({ key: '¡', code: 'Digit1' })).toBe('1'); + expect(resolveShortcutEventDigit({ key: '5', code: 'Digit5' })).toBe('5'); + expect(resolveShortcutEventDigit({ key: 'a', code: 'KeyA' })).toBe(null); + }); +}); diff --git a/packages/ui/src/lib/shortcuts/bindings.ts b/packages/ui/src/lib/shortcuts/bindings.ts index fa45a358..1a1b259b 100644 --- a/packages/ui/src/lib/shortcuts/bindings.ts +++ b/packages/ui/src/lib/shortcuts/bindings.ts @@ -247,6 +247,50 @@ export function isRiskyBrowserShortcut(combo: ShortcutCombo): boolean { }); } +const CODE_KEY_MAP = new Map([ + ['Comma', ','], + ['Period', '.'], + ['Slash', '/'], + ['Backquote', '`'], + ['BracketLeft', '['], + ['BracketRight', ']'], + ['Semicolon', ';'], + ['Quote', "'"], + ['Minus', '-'], + ['Equal', '='], +]); + +function keyFromEventCode(code: string): string | null { + if (code.startsWith('Key') && code.length === 4) return code.slice(3).toLowerCase(); + if (code.startsWith('Digit') && code.length === 6) return code.slice(5); + return CODE_KEY_MAP.get(code) ?? null; +} + +/** + * The character a physical key press should match against bindings. `key` + * carries the layout-produced character: Option on macOS substitutes symbols + * ("¡" for ⌥1) and non-Latin layouts substitute their own alphabet ("л" for + * K). Both keep the physical key in `code`, so those two cases fall back to + * it; Latin layouts that MOVE keys (Dvorak, AZERTY) keep their `key`-based + * meaning untouched. + */ +export function resolveShortcutEventKey( + event: Pick, +): string { + const raw = event.key; + if (event.altKey) return keyFromEventCode(event.code) ?? raw; + if (raw.length === 1 && raw.charCodeAt(0) > 127) return keyFromEventCode(event.code) ?? raw; + return raw; +} + +/** The digit a press addresses, layout- and Option-proof via `code`. */ +export function resolveShortcutEventDigit( + event: Pick, +): string | null { + if (event.code.startsWith('Digit') && event.code.length === 6) return event.code.slice(5); + return event.key.length === 1 && event.key >= '0' && event.key <= '9' ? event.key : null; +} + export function eventMatchesShortcut( event: KeyboardEvent | React.KeyboardEvent, combo: ShortcutCombo, @@ -280,16 +324,7 @@ export function eventMatchesShortcut( 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); + return keyToShortcutToken(resolveShortcutEventKey(event)) === keyToShortcutToken(chord.key); } export function isShortcutPrefixHeld(prefixCombo: ShortcutCombo, heldKeys: ReadonlySet): boolean { diff --git a/packages/ui/src/lib/shortcuts/config.ts b/packages/ui/src/lib/shortcuts/config.ts index 0e18660d..0cdf024b 100644 --- a/packages/ui/src/lib/shortcuts/config.ts +++ b/packages/ui/src/lib/shortcuts/config.ts @@ -5,7 +5,6 @@ type ShortcutCategory = 'session' | 'models' | 'panels' | 'navigation' | 'applic type ShortcutConfig = { id: string; defaultBinding: ShortcutCombo; - allowsSequenceFallback?: true; /** The binding is a bare-modifier chord prefix (completed by another key); conflict resolution compares its prefix rather than a full combo. */ prefixStyle?: true; @@ -17,12 +16,18 @@ type ShortcutConfig = { } ); +// Default layout, unified around three modes: +// - Single chords for everyday actions. +// - The mod+k leader for "open/go" actions, second key mnemonic. +// - Held mod + digit switches header session tabs; held mod+alt + digit +// switches context panel surfaces (mod+shift+digit is reserved by macOS +// screenshots). +// Everything else lives only in the command palette, outside this schema. const SHORTCUT_GROUPS = { session: [ { id: 'add_selection_to_chat', defaultBinding: 'mod+l', - allowsSequenceFallback: true, customizable: true, settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label', @@ -35,7 +40,7 @@ const SHORTCUT_GROUPS = { }, { id: 'open_timeline_dialog', - defaultBinding: 'mod+t', + defaultBinding: 'mod+k t', customizable: true, settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_timeline_dialog.label', @@ -54,21 +59,21 @@ const SHORTCUT_GROUPS = { }, { id: 'open_draft_project_picker', - defaultBinding: 'mod+s p', + defaultBinding: 'mod+k p', customizable: true, settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_draft_project_picker.label', }, { id: 'open_draft_worktree_picker', - defaultBinding: 'mod+s g', + defaultBinding: 'mod+k g', customizable: true, settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_draft_worktree_picker.label', }, { id: 'open_session_list', - defaultBinding: 'mod+s l', + defaultBinding: 'mod+k l', customizable: true, settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_session_list.label', @@ -146,65 +151,45 @@ const SHORTCUT_GROUPS = { }, { id: 'toggle_sidebar', - defaultBinding: 'mod+alt+l', + defaultBinding: 'mod+b', customizable: true, settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label', }, { id: 'toggle_prompt_navigator', - defaultBinding: 'mod+alt+p', + defaultBinding: 'mod+k n', 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', + id: 'switch_session_tab', defaultBinding: 'mod', // The binding is a bare modifier acting as a chord prefix (completed by // a digit); conflict resolution must compare its PREFIX, not a combo. prefixStyle: true, customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.switch_session_tab.label', + }, + { + id: 'switch_context_surface', + defaultBinding: 'mod+alt', + prefixStyle: true, + 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', + defaultBinding: 'mod+k i', customizable: true, settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label', }, ], navigation: [ - { id: 'save_file', defaultBinding: 'mod+s', customizable: false, allowsSequenceFallback: true }, + { id: 'save_file', defaultBinding: 'mod+s', customizable: false }, { id: 'find_in_file', defaultBinding: 'mod+f', customizable: false }, { id: 'open_go_to_line', @@ -212,13 +197,6 @@ const SHORTCUT_GROUPS = { 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', - }, ], application: [ { @@ -228,7 +206,6 @@ const SHORTCUT_GROUPS = { settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label', }, - { id: 'open_status', defaultBinding: 'mod+shift+o', customizable: false }, { id: 'open_settings', defaultBinding: 'mod+comma', @@ -237,17 +214,16 @@ const SHORTCUT_GROUPS = { }, { id: 'open_help', - defaultBinding: 'mod+.', + defaultBinding: 'mod+k h', customizable: true, settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.open_help.label', }, { id: 'cycle_theme', - defaultBinding: 'mod+/', + defaultBinding: 'mod+k c', customizable: true, settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.cycle_theme.label', }, - { id: 'toggle_memory_debug', defaultBinding: 'mod+shift+d', customizable: false }, ], } as const satisfies Record; diff --git a/packages/ui/src/lib/shortcuts/index.ts b/packages/ui/src/lib/shortcuts/index.ts index b75c3200..28208074 100644 --- a/packages/ui/src/lib/shortcuts/index.ts +++ b/packages/ui/src/lib/shortcuts/index.ts @@ -8,6 +8,8 @@ export { keyToShortcutToken, normalizeCombo, parseShortcut, + resolveShortcutEventDigit, + resolveShortcutEventKey, UNASSIGNED_SHORTCUT, } from './bindings'; export type { ShortcutCombo } from './bindings'; diff --git a/packages/ui/src/lib/shortcuts/registry.ts b/packages/ui/src/lib/shortcuts/registry.ts index dca7971c..2f6ffb27 100644 --- a/packages/ui/src/lib/shortcuts/registry.ts +++ b/packages/ui/src/lib/shortcuts/registry.ts @@ -39,6 +39,14 @@ export class ShortcutRegistry { return this.handlers.get(actionId)?.[0]?.handler; } + /** Runs an action outside keyboard dispatch (command palette). Bypasses + suspension: the invoking surface, not the keyboard, owns the gesture. */ + invoke(actionId: ShortcutActionId): boolean { + const handler = this.handlers.get(actionId)?.[0]?.handler; + if (!handler) return false; + return handler(new KeyboardEvent('keydown')) !== false; + } + /** Temporarily disables every registered application shortcut. */ suspend(): () => void { this.suspensionCount += 1; diff --git a/packages/ui/src/lib/shortcuts/schema.test.ts b/packages/ui/src/lib/shortcuts/schema.test.ts index fa278128..c82c3077 100644 --- a/packages/ui/src/lib/shortcuts/schema.test.ts +++ b/packages/ui/src/lib/shortcuts/schema.test.ts @@ -49,13 +49,31 @@ describe('shortcut schema', () => { ))).toBe(true); }); - 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'); + test('keeps the mod+k leader for open/go actions', () => { + expect(getShortcutAction('open_draft_project_picker')?.defaultBinding).toBe('mod+k p'); + expect(getShortcutAction('open_draft_worktree_picker')?.defaultBinding).toBe('mod+k g'); + expect(getShortcutAction('open_session_list')?.defaultBinding).toBe('mod+k l'); + expect(getShortcutAction('open_timeline_dialog')?.defaultBinding).toBe('mod+k t'); + expect(getShortcutAction('toggle_prompt_navigator')?.defaultBinding).toBe('mod+k n'); + expect(getShortcutAction('toggle_services_menu')?.defaultBinding).toBe('mod+k i'); + expect(getShortcutAction('open_help')?.defaultBinding).toBe('mod+k h'); + expect(getShortcutAction('cycle_theme')?.defaultBinding).toBe('mod+k c'); expect(getShortcutAction('focus_input')?.category).toBe('session'); }); + test('splits the held digit prefixes between session tabs and surfaces', () => { + expect(getShortcutAction('switch_session_tab')?.defaultBinding).toBe('mod'); + expect(getShortcutAction('switch_context_surface')?.defaultBinding).toBe('mod+alt'); + }); + + test('every action ships with a default binding', () => { + // Palette-only commands live outside this schema entirely; an action in + // the schema without a binding would be dead weight in Settings. + for (const action of SHORTCUT_SCHEMA) { + expect(getEffectiveShortcutCombo(action.id)).not.toBe(''); + } + }); + 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'); @@ -73,10 +91,8 @@ describe('shortcut schema', () => { .find((conflict) => conflict.action.id === 'find_in_file'); const internalPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+s x') .find((conflict) => conflict.action.id === 'save_file'); - const contextualPrefixConflict = getShortcutBindingConflicts('focus_input', 'mod+l l') - .find((conflict) => conflict.action.id === 'add_selection_to_chat'); - const contextualLeaderConflict = getShortcutBindingConflicts('add_selection_to_chat', 'mod+s') - .find((conflict) => conflict.action.id === 'open_draft_project_picker'); + const leaderPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+k') + .find((conflict) => conflict.action.id === 'open_session_list'); const blockingPrefixConflict = getShortcutBindingConflicts('new_chat', 'mod+p x') .find((conflict) => conflict.action.id === 'open_command_palette'); @@ -84,10 +100,9 @@ describe('shortcut schema', () => { expect(customizableConflict?.action.customizable).toBe(true); expect(internalConflict?.kind).toBe('exact'); expect(internalConflict?.action.customizable).toBe(false); - expect(internalPrefixConflict?.kind).toBe('contextual-prefix'); + expect(internalPrefixConflict?.kind).toBe('prefix'); expect(internalPrefixConflict?.action.customizable).toBe(false); - expect(contextualPrefixConflict?.kind).toBe('contextual-prefix'); - expect(contextualLeaderConflict?.kind).toBe('contextual-prefix'); + expect(leaderPrefixConflict?.kind).toBe('prefix'); expect(blockingPrefixConflict?.kind).toBe('prefix'); }); }); diff --git a/packages/ui/src/lib/shortcuts/schema.ts b/packages/ui/src/lib/shortcuts/schema.ts index 6afe81ec..e35977a5 100644 --- a/packages/ui/src/lib/shortcuts/schema.ts +++ b/packages/ui/src/lib/shortcuts/schema.ts @@ -15,29 +15,14 @@ export type ShortcutAction = (typeof SHORTCUT_SCHEMA)[number]; export type ShortcutActionId = ShortcutAction['id']; export type ShortcutCategory = ShortcutAction['category']; export type CustomizableShortcutAction = Extract; +/** 'contextual-prefix' is kept in the union for the recording dialog's + messaging even though no default layout produces it any more. */ export type ShortcutBindingConflictKind = ShortcutConflict | 'contextual-prefix'; export type ShortcutBindingConflict = { action: ShortcutAction; kind: ShortcutBindingConflictKind; }; -function allowsContextualPrefix( - action: ShortcutAction, - combo: ShortcutCombo, - candidate: ShortcutAction, - candidateCombo: ShortcutCombo, -): boolean { - const chordCount = parseShortcut(combo)?.chords.length; - const candidateChordCount = parseShortcut(candidateCombo)?.chords.length; - if (chordCount === 1 && candidateChordCount === 2) { - return 'allowsSequenceFallback' in action && action.allowsSequenceFallback; - } - if (chordCount === 2 && candidateChordCount === 1) { - return 'allowsSequenceFallback' in candidate && candidate.allowsSequenceFallback; - } - return false; -} - export function getShortcutAction(id: string): ShortcutAction | undefined { return SHORTCUT_SCHEMA.find((action) => action.id === id); } @@ -54,7 +39,8 @@ export function getEffectiveShortcutCombo( ): ShortcutCombo { const action = getShortcutAction(actionId); if (!action) return ''; - if (!action.customizable) return action.defaultBinding; + const defaultBinding = action.defaultBinding === UNASSIGNED_SHORTCUT ? '' : action.defaultBinding; + if (!action.customizable) return defaultBinding; const override = overrides?.[actionId]; if (typeof override === 'string') { @@ -63,7 +49,7 @@ export function getEffectiveShortcutCombo( if (isValidShortcutCombo(normalized)) return normalized; } - return action.defaultBinding; + return defaultBinding; } export function getEffectiveShortcutPrefix( @@ -100,12 +86,7 @@ export function getShortcutBindingConflicts( : getEffectiveShortcutCombo(candidate.id, overrides); const kind = getShortcutConflict(combo, candidateCombo); if (!kind) continue; - conflicts.push({ - action: candidate, - kind: kind === 'prefix' && allowsContextualPrefix(action, combo, candidate, candidateCombo) - ? 'contextual-prefix' - : kind, - }); + conflicts.push({ action: candidate, kind }); } return conflicts; } diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index e24e8ead..c377f27b 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -2412,7 +2412,7 @@ export const useUIStore = create()( { name: 'ui-store', storage: createDeferredSafeJSONStorage(), - version: 17, + version: 18, migrate: (persistedState, version) => { if (!persistedState || typeof persistedState !== 'object') { return persistedState; @@ -2433,6 +2433,15 @@ export const useUIStore = create()( delete state.expandedEditorToolbar; } + // v17 -> v18: the default shortcut layout was redesigned around the + // mod+k leader and the held digit prefixes. Old overrides were + // recorded against the previous defaults (e.g. a bare 'mod' surface + // prefix now collides with session tabs), so custom bindings start + // fresh on the new system. + if (version < 18) { + delete state.shortcutOverrides; + } + // v13 -> v14: the separate 'preview' surface merged into 'browser'. // Stored preview tabs keep their URL and become browser tabs; their // id encodes the mode, so it is rebuilt rather than left dangling. diff --git a/packages/ui/tests/ego-lite/shortcut-registry.checklist.yaml b/packages/ui/tests/ego-lite/shortcut-registry.checklist.yaml index f072de87..b2c3b072 100644 --- a/packages/ui/tests/ego-lite/shortcut-registry.checklist.yaml +++ b/packages/ui/tests/ego-lite/shortcut-registry.checklist.yaml @@ -172,12 +172,12 @@ checks: preconditions: - Open a draft session with project and worktree selectors mounted. steps: - - Trigger Mod + S, P and verify the project picker opens. + - Trigger Mod + K, P and verify the project picker opens. - Press Escape once and verify it closes. - - Trigger Mod + S, G and verify the worktree picker opens. + - Trigger Mod + K, G and verify the worktree picker opens. - Press Escape once and verify it closes. assertions: - - A contextual Mod + S owner yields when its context is inactive. + - The Mod + K leader arms without a visible menu and completes on the second key. - Each sequence opens only its target picker. - One non-IME Escape closes either controlled picker. evidence: From f2ec9b1003fd844b22495690b2be87c0cb750410 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 15:46:44 +0300 Subject: [PATCH 20/49] feat(ui): add session history navigation, permission keys, and review shortcuts mod+alt+arrows step through this window's session-open history (or between neighbouring tabs when session tabs are on), mod+k r renames the current session inline, and mod+k a toggles permission auto-accept. Pending permission cards respond to alt+enter / alt+shift+enter / alt+backspace with the keys printed on the buttons. The commit message box commits on mod+enter, alt+arrows step the diff review between changed files, and the command palette gains search-only commands for rare actions so the initial list stays short. --- CHANGELOG.md | 5 + packages/ui/src/components/chat/ChatInput.tsx | 6 ++ .../ui/src/components/chat/PermissionCard.tsx | 34 ++++++ packages/ui/src/components/layout/Header.tsx | 4 + .../ui/src/components/ui/CommandPalette.tsx | 102 +++++++++++++++++- packages/ui/src/components/views/DiffView.tsx | 31 ++++++ .../src/components/views/git/CommitInput.tsx | 8 ++ .../components/views/git/CommitSection.tsx | 3 + packages/ui/src/hooks/useKeyboardShortcuts.ts | 11 +- .../ui/src/lib/i18n/messages/de.settings.ts | 4 + packages/ui/src/lib/i18n/messages/de.ts | 6 ++ .../ui/src/lib/i18n/messages/en.settings.ts | 4 + packages/ui/src/lib/i18n/messages/en.ts | 6 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 4 + packages/ui/src/lib/i18n/messages/es.ts | 6 ++ .../ui/src/lib/i18n/messages/fr.settings.ts | 4 + packages/ui/src/lib/i18n/messages/fr.ts | 6 ++ .../ui/src/lib/i18n/messages/ja.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ja.ts | 6 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 4 + packages/ui/src/lib/i18n/messages/ko.ts | 6 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pl.ts | 6 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 4 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 6 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 4 + packages/ui/src/lib/i18n/messages/uk.ts | 6 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 6 ++ .../src/lib/i18n/messages/zh-TW.settings.ts | 4 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 6 ++ .../src/lib/sessionNavigationHistory.test.ts | 50 +++++++++ .../ui/src/lib/sessionNavigationHistory.ts | 61 +++++++++++ packages/ui/src/lib/sessionTabs.ts | 22 ++++ packages/ui/src/lib/shortcuts/config.ts | 28 +++++ packages/vscode/CHANGELOG.md | 1 + 36 files changed, 474 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/lib/sessionNavigationHistory.test.ts create mode 100644 packages/ui/src/lib/sessionNavigationHistory.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 89e2b34e..9688673e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ All notable changes to this project will be documented in this file. - **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. - **Keyboard shortcuts learned sequences** (thanks @ChangeHow): shortcuts can now be two steps — press Cmd/Ctrl+S, then a letter: P opens the draft's project picker, G its branch picker, L the session list. A held sequence shows no menu but forgives you: Escape or three quiet seconds cancel it, and typing into a text field never triggers one armed elsewhere. Dropdown menus and pickers now also answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and every tooltip and menu label shows the binding you actually have set, not the default. - **Keyboard shortcuts redesigned.** Defaults now follow one model: single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (K then P/G/L — project picker, branch picker, session list; T timeline, N prompt navigator, I services, H shortcut help, C theme), held Cmd/Ctrl+digit for header session tabs and held Cmd/Ctrl+Option+digit for context panel surfaces. Cmd/Ctrl+B now toggles the sidebar; when it's already open, the session-list shortcut jumps into its search. Rare actions moved into the command palette instead of carrying obscure default bindings, and custom bindings recorded under the old layout are reset once. Shortcuts also stopped requiring an English keyboard layout — bindings follow the physical key on non-Latin layouts (and Option-modified digits on macOS), including when recording custom ones. +- **Permission cards answer to the keyboard:** Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons, and the newest pending card is the one that listens. The auto-accept toggle also got a shortcut (Cmd/Ctrl+K, A). +- Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. Cmd/Ctrl+K, R renames the current session right in the header. +- Git: Cmd/Ctrl+Enter in the commit message box commits, like every git client. +- Diff: Alt+Down/Up jumps review to the next or previous changed file, expanding it if collapsed. +- Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme, memory debug) are now found by typing but stay off the first screen, which keeps the initial list scroll-free. - Chat: comment on a reply — select text in a chat message (or in a rendered markdown preview in Files) and choose Comment to attach exactly that quote, with a source line range when it can be located, plus your note to the next message. The selection stays highlighted while you type, and the selection menu was restyled — Add to chat is now Add to input. - Diff: comment like a review — hovering a line shows a + button in the gutter; clicking it, clicking a line, or dragging across lines opens the comment editor for that line or range. The comment editor and saved-comment cards match the chat's comment style. - Composer: hovering or tapping a context chip above the input opens a stacked preview of everything attached, where a comment can be edited in place or an item removed before sending. diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index f088d16d..2cf2578b 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -77,6 +77,7 @@ import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { usePermissionStore } from '@/stores/permissionStore'; import { togglePermissionAutoAccept } from './permissionAutoAccept'; +import { useKeybind } from '@/hooks/useKeybind'; import { extractGitChangedFiles } from './changedFiles'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; @@ -2562,6 +2563,11 @@ const ChatInputComponent: React.FC = ({ t, ]); + useKeybind('toggle_permission_auto_accept', () => { + if (!isPermissionAutoAcceptInteractive) return false; + handlePermissionAutoAcceptToggle(); + }); + React.useEffect(() => { const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId; if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) { diff --git a/packages/ui/src/components/chat/PermissionCard.tsx b/packages/ui/src/components/chat/PermissionCard.tsx index cd63576b..851d70d3 100644 --- a/packages/ui/src/components/chat/PermissionCard.tsx +++ b/packages/ui/src/components/chat/PermissionCard.tsx @@ -10,6 +10,10 @@ import { Icon } from "@/components/icon/Icon"; import { DiffPreview, WritePreview } from './DiffPreview'; import { useI18n } from '@/lib/i18n'; import { getVisiblePermissionPatterns } from './permissionCardPatterns'; +import { formatShortcutForDisplay } from '@/lib/shortcuts'; + +// Newest pending card owns the keyboard; older cards wait their turn. +const activePermissionCardIds: string[] = []; const PERMISSION_BASH_CUSTOM_STYLE: React.CSSProperties = { margin: 0, @@ -126,6 +130,33 @@ export const PermissionCard: React.FC = ({ } }; + const handleResponseRef = React.useRef(handleResponse); + handleResponseRef.current = handleResponse; + + React.useEffect(() => { + if (hasResponded) return; + activePermissionCardIds.push(permission.id); + const handleKeyDown = (event: KeyboardEvent) => { + if (activePermissionCardIds.at(-1) !== permission.id) return; + if (!event.altKey || event.metaKey || event.ctrlKey) return; + const response = event.key === 'Enter' + ? (event.shiftKey ? 'always' as const : 'once' as const) + : event.key === 'Backspace' && !event.shiftKey + ? 'reject' as const + : null; + if (!response) return; + event.preventDefault(); + event.stopPropagation(); + void handleResponseRef.current(response); + }; + window.addEventListener('keydown', handleKeyDown, true); + return () => { + window.removeEventListener('keydown', handleKeyDown, true); + const index = activePermissionCardIds.lastIndexOf(permission.id); + if (index !== -1) activePermissionCardIds.splice(index, 1); + }; + }, [hasResponded, permission.id]); + if (hasResponded) { return null; } @@ -380,6 +411,7 @@ export const PermissionCard: React.FC = ({ > Allow Once + {formatShortcutForDisplay('alt+enter')} {permission.always.length > 0 ? ( @@ -436,6 +468,7 @@ export const PermissionCard: React.FC = ({ > Always Allow + {formatShortcutForDisplay('alt+shift+enter')} )} @@ -459,6 +492,7 @@ export const PermissionCard: React.FC = ({ > Deny + {formatShortcutForDisplay('alt+backspace')} {isResponding && ( diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 09fad963..90573996 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1463,6 +1463,10 @@ export const Header: React.FC = () => { useKeybinds({ + rename_current_session: () => { + if (!currentSessionId || isMobile) return false; + beginHeaderSessionRename(); + }, toggle_services_menu: () => { if (isDesktopServicesOpen) { setIsDesktopServicesOpen(false); diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 3acc62b4..5f2d5c86 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -50,6 +50,7 @@ import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch'; import { truncatePathMiddle } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; +import { copyTextToClipboard } from '@/lib/clipboard'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { buildCommandPaletteFileSearchKey, scoreCommandPaletteFiles } from './commandPaletteFilesState'; @@ -59,6 +60,9 @@ type CommandEntry = { icon: React.ReactNode; shortcutId?: string; searchText: string; + /** Search-only command: reachable by typing, hidden from the initial list + so the first screen stays scroll-free. */ + secondary?: boolean; onSelect: () => void; }; @@ -90,9 +94,14 @@ export const CommandPalette: React.FC = () => { const openContextSurface = useUIStore((s) => s.openContextSurface); const openContextFile = useUIStore((s) => s.openContextFile); const shortcutOverrides = useUIStore((s) => s.shortcutOverrides); + const openMultiRunLauncher = useUIStore((s) => s.openMultiRunLauncher); + const setArchivePageOpen = useUIStore((s) => s.setArchivePageOpen); + const setProjectContextTab = useUIStore((s) => s.setProjectContextTab); const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession); + const currentSessionId = useSessionUIStore((s) => s.currentSessionId); + const togglePinnedSession = useSessionPinnedStore((s) => s.toggle); const activeSessions = useGlobalSessionsStore(React.useCallback( (state) => isCommandPaletteOpen ? state.activeSessions : EMPTY_SESSIONS, @@ -233,6 +242,7 @@ export const CommandPalette: React.FC = () => { }, { id: 'cycle-theme', + secondary: true, title: t('commandPalette.item.cycleTheme'), icon: , shortcutId: 'cycle_theme', @@ -243,6 +253,7 @@ export const CommandPalette: React.FC = () => { }, { id: 'open-status', + secondary: true, title: t('commandPalette.item.showOpenCodeStatus'), icon: , searchText: t('commandPalette.item.showOpenCodeStatus'), @@ -259,8 +270,90 @@ export const CommandPalette: React.FC = () => { onSelect: run(() => setSettingsDialogOpen(true)), }, ]; + list.push( + { + id: 'pin-session', + secondary: true, + title: t('commandPalette.item.pinSession'), + icon: , + searchText: t('commandPalette.item.pinSession'), + onSelect: run(() => { + if (currentSessionId && currentDirectory) { + togglePinnedSession({ directory: currentDirectory, sessionId: currentSessionId }); + } + }), + }, + { + id: 'copy-session-id', + secondary: true, + title: t('commandPalette.item.copySessionId'), + icon: , + searchText: t('commandPalette.item.copySessionId'), + onSelect: run(() => { + if (!currentSessionId) return; + void copyTextToClipboard(currentSessionId) + .then((result) => { + if (result.ok) { + toast.success(t('sessions.sidebar.session.copyId.success')); + return; + } + toast.error(t('sessions.sidebar.session.copyId.error')); + }) + .catch(() => toast.error(t('sessions.sidebar.session.copyId.error'))); + }), + }, + { + id: 'open-multi-run', + secondary: true, + title: t('commandPalette.item.openMultiRun'), + icon: , + searchText: t('commandPalette.item.openMultiRun'), + onSelect: run(() => { + setSessionSwitcherOpen(false); + openMultiRunLauncher(); + }), + }, + { + id: 'open-archive', + secondary: true, + title: t('commandPalette.item.openArchive'), + icon: , + searchText: t('commandPalette.item.openArchive'), + onSelect: run(() => { + setSessionSwitcherOpen(false); + setArchivePageOpen(true); + }), + }, + { + id: 'open-notes', + secondary: true, + title: t('commandPalette.item.openNotes'), + icon: , + searchText: t('commandPalette.item.openNotes'), + onSelect: run(() => { + if (currentDirectory) { + setProjectContextTab('notes'); + openContextSurface(currentDirectory, 'notes'); + } + }), + }, + { + id: 'open-todos', + secondary: true, + title: t('commandPalette.item.openTodos'), + icon: , + searchText: t('commandPalette.item.openTodos'), + onSelect: run(() => { + if (currentDirectory) { + setProjectContextTab('todos'); + openContextSurface(currentDirectory, 'notes'); + } + }), + }, + ); list.push({ id: 'toggle-memory-debug', + secondary: true, title: t('commandPalette.item.toggleMemoryDebug'), icon: , searchText: t('commandPalette.item.toggleMemoryDebug'), @@ -299,6 +392,11 @@ export const CommandPalette: React.FC = () => { setSettingsDialogOpen, activeProject?.id, activeProject?.path, + currentSessionId, + togglePinnedSession, + openMultiRunLauncher, + setArchivePageOpen, + setProjectContextTab, ]); // --------------------------------------------------------------------------- @@ -407,7 +505,9 @@ export const CommandPalette: React.FC = () => { const hasQuery = liveTrimmed.length > 0; const scoredCommands = React.useMemo(() => { - if (!hasQuery) return commands.map((item) => ({ item, score: 0 })); + if (!hasQuery) { + return commands.filter((item) => !item.secondary).map((item) => ({ item, score: 0 })); + } return scoreByFuzzyQuery(commands, liveTrimmed, (c) => c.searchText, { limit: 7, noFuzzy: true, diff --git a/packages/ui/src/components/views/DiffView.tsx b/packages/ui/src/components/views/DiffView.tsx index 28a15721..90e370a4 100644 --- a/packages/ui/src/components/views/DiffView.tsx +++ b/packages/ui/src/components/views/DiffView.tsx @@ -1768,6 +1768,37 @@ export const DiffView: React.FC = ({ scrollToFile(value); }, [cancelPendingScrollAlignment, expandStackedFile, scrollToFile]); + // Step review to the adjacent changed file (alt+arrow): selects, expands + // a collapsed section, and scrolls to it. Window-level because the diff + // surface has no persistent focus target; guarded off editable fields. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (!event.altKey || event.metaKey || event.ctrlKey || event.shiftKey) return; + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return; + const target = event.target; + if (target instanceof HTMLElement && ( + target.isContentEditable + || target.tagName === 'INPUT' + || target.tagName === 'TEXTAREA' + || target.closest('[role="dialog"]') + )) { + return; + } + if (changedFiles.length === 0) return; + const delta = event.key === 'ArrowDown' ? 1 : -1; + const index = displayFile ? changedFiles.findIndex((file) => file.path === displayFile) : -1; + const nextIndex = index === -1 + ? (delta > 0 ? 0 : changedFiles.length - 1) + : index + delta; + const next = changedFiles[nextIndex]; + if (!next) return; + event.preventDefault(); + handleSelectFileAndScroll(next.path); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [changedFiles, displayFile, handleSelectFileAndScroll]); + const handleHeaderLayoutChange = React.useCallback((mode: DiffViewMode) => { const nextLayout: 'inline' | 'side-by-side' = mode === 'side-by-side' ? 'side-by-side' : 'inline'; diff --git a/packages/ui/src/components/views/git/CommitInput.tsx b/packages/ui/src/components/views/git/CommitInput.tsx index a724cb69..304629c7 100644 --- a/packages/ui/src/components/views/git/CommitInput.tsx +++ b/packages/ui/src/components/views/git/CommitInput.tsx @@ -6,6 +6,7 @@ import { useI18n } from '@/lib/i18n'; interface CommitInputProps { value: string; onChange: (value: string) => void; + onSubmit?: () => void; placeholder?: string; disabled?: boolean; hasTouchInput?: boolean; @@ -18,6 +19,7 @@ const MAX_HEIGHT = 200; export const CommitInput: React.FC = ({ value, onChange, + onSubmit, placeholder, disabled = false, hasTouchInput = false, @@ -58,6 +60,12 @@ export const CommitInput: React.FC = ({ ref={textareaRef} value={value} onChange={(e) => onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey) { + e.preventDefault(); + onSubmit?.(); + } + }} placeholder={placeholder ?? t('gitView.commit.messagePlaceholder')} rows={1} disabled={disabled} diff --git a/packages/ui/src/components/views/git/CommitSection.tsx b/packages/ui/src/components/views/git/CommitSection.tsx index 7c7ac7b8..a8cf0b34 100644 --- a/packages/ui/src/components/views/git/CommitSection.tsx +++ b/packages/ui/src/components/views/git/CommitSection.tsx @@ -68,6 +68,9 @@ export const CommitSection: React.FC = ({ { + if (canCommit && !isGeneratingMessage) onCommit(); + }} placeholder={t('gitView.commit.messagePlaceholder')} disabled={commitAction !== null} hasTouchInput={hasTouchInput} diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 09ef0327..d04a1a9e 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -1,7 +1,8 @@ import React from 'react'; import { isTerminalEventTarget } from '@/lib/terminalFocus'; import { useSessionUIStore } from '@/sync/session-ui-store'; -import { activateSessionTabByIndex, closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs'; +import { activateAdjacentSessionTab, activateSessionTabByIndex, closeSessionTabAndActivateNeighbour } from '@/lib/sessionTabs'; +import { navigateSessionHistory } from '@/lib/sessionNavigationHistory'; import { useSelectionStore } from '@/sync/selection-store'; import * as sessionActions from '@/sync/session-actions'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; @@ -169,6 +170,14 @@ export const useKeyboardShortcuts = () => { console.warn('[keyboard-shortcuts] failed to open draft mini chat window', error); }); }, + switch_session_previous: () => { + if (!isVSCodeRuntime() && useUIStore.getState().sessionTabsEnabled && activateAdjacentSessionTab(-1)) return; + return navigateSessionHistory(-1) ? undefined : false; + }, + switch_session_next: () => { + if (!isVSCodeRuntime() && useUIStore.getState().sessionTabsEnabled && activateAdjacentSessionTab(1)) return; + return navigateSessionHistory(1) ? undefined : false; + }, close_session_tab: () => { if (isVSCodeRuntime() || !useUIStore.getState().sessionTabsEnabled) return false; if (currentSessionId) { diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 6f8c5352..cf91d54d 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1085,6 +1085,10 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Kontextpanel-Oberfläche wechseln', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Neue Sitzung', + 'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Vorherige Sitzung', + 'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Nächste Sitzung', + 'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Aktuelle Sitzung umbenennen', + 'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Auto-Genehmigung umschalten', 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Sitzungs-Tab schließen', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Neuer Worktree-Entwurf', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Neues Mini-Chat-Fenster', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index adc8076b..815186a0 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2292,6 +2292,12 @@ export const dict = { 'commandPalette.item.cycleTheme': 'Thema wechseln', 'commandPalette.item.showOpenCodeStatus': 'OpenCode-Status anzeigen', 'commandPalette.item.toggleMemoryDebug': 'Memory-Debug-Panel umschalten', + 'commandPalette.item.pinSession': 'Sitzung anheften oder lösen', + 'commandPalette.item.copySessionId': 'Sitzungs-ID kopieren', + 'commandPalette.item.openMultiRun': 'Multi-Run-Launcher öffnen', + 'commandPalette.item.openArchive': 'Archivierte Sitzungen öffnen', + 'commandPalette.item.openNotes': 'Notizbereich öffnen', + 'commandPalette.item.openTodos': 'To-do-Bereich öffnen', 'commandPalette.item.openSettings': 'Einstellungen öffnen...', 'commandPalette.session.untitled': 'Unbenannte Sitzung', 'openCodeStatusDialog.title': 'OpenCode-Status', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index ef9d6265..8990d2ec 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1147,6 +1147,10 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Switch context panel surface', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session', + 'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Previous session', + 'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Next session', + 'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Rename current session', + 'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Toggle permission auto-accept', 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Close session tab', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 686698de..62ab1973 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2482,6 +2482,12 @@ export const dict = { 'commandPalette.item.cycleTheme': 'Cycle theme', 'commandPalette.item.showOpenCodeStatus': 'Show OpenCode status', 'commandPalette.item.toggleMemoryDebug': 'Toggle memory debug panel', + 'commandPalette.item.pinSession': 'Pin or unpin session', + 'commandPalette.item.copySessionId': 'Copy session ID', + 'commandPalette.item.openMultiRun': 'Open multi-run launcher', + 'commandPalette.item.openArchive': 'Open archived sessions', + 'commandPalette.item.openNotes': 'Open notes surface', + 'commandPalette.item.openTodos': 'Open todos surface', 'commandPalette.item.openSettings': 'Open Settings...', 'commandPalette.session.untitled': 'Untitled Session', 'openCodeStatusDialog.title': 'OpenCode Status', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 63f646fb..6cf7245c 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1115,6 +1115,10 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Cambiar superficie del panel de contexto", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión", + "settings.openchamber.keyboardShortcuts.action.switch_session_previous.label": "Sesión anterior", + "settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Sesión siguiente", + "settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Renombrar sesión actual", + "settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Alternar aprobación automática", "settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Cerrar pestaña de sesión", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 031d9b82..4760f8a1 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2448,6 +2448,12 @@ export const dict: Record = { "commandPalette.item.cycleTheme": "Cambiar tema", "commandPalette.item.showOpenCodeStatus": "Mostrar estado de OpenCode", "commandPalette.item.toggleMemoryDebug": "Alternar panel de depuración de memoria", + "commandPalette.item.pinSession": "Anclar o desanclar sesión", + "commandPalette.item.copySessionId": "Copiar ID de sesión", + "commandPalette.item.openMultiRun": "Abrir lanzador multi-run", + "commandPalette.item.openArchive": "Abrir sesiones archivadas", + "commandPalette.item.openNotes": "Abrir panel de notas", + "commandPalette.item.openTodos": "Abrir panel de tareas", "commandPalette.item.openSettings": "Abrir configuración...", "commandPalette.session.untitled": "Sesión sin título", "openCodeStatusDialog.title": "Estado de OpenCode", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index f4df769b..4e1af53b 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1033,6 +1033,10 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'Basculer la surface du panneau contextuel', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nouvelle session', + 'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Session précédente', + 'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Session suivante', + 'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Renommer la session actuelle', + 'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Basculer l’approbation automatique', 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Fermer l’onglet de session', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nouvelle ébauche d\'worktree', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nouvelle fenêtre de mini-chat', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 982cfd30..34528808 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2186,6 +2186,12 @@ export const dict = { 'commandPalette.item.cycleTheme': 'Changer de thème', 'commandPalette.item.showOpenCodeStatus': 'Afficher le statut OpenCode', 'commandPalette.item.toggleMemoryDebug': 'Basculer le panneau de débogage mémoire', + 'commandPalette.item.pinSession': 'Épingler ou désépingler la session', + 'commandPalette.item.copySessionId': 'Copier l\'ID de session', + 'commandPalette.item.openMultiRun': 'Ouvrir le lanceur multi-run', + 'commandPalette.item.openArchive': 'Ouvrir les sessions archivées', + 'commandPalette.item.openNotes': 'Ouvrir le panneau de notes', + 'commandPalette.item.openTodos': 'Ouvrir le panneau de tâches', 'commandPalette.item.openSettings': 'Ouvrez les paramètres...', 'commandPalette.session.untitled': 'Session sans titre', 'openCodeStatusDialog.title': 'Statut OpenCode', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 63e54c6b..4f664809 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1148,6 +1148,10 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': 'コンテキストパネルのサーフェスを切り替え', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新しい Session', + 'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '前のセッション', + 'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '次のセッション', + 'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '現在のセッション名を変更', + 'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '権限の自動承認を切り替え', 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'セッションタブを閉じる', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新しい Worktree 下書き', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新しいミニチャットウィンドウ', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index d0c55cfc..daedcf79 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2481,6 +2481,12 @@ export const dict: Record = { 'commandPalette.item.cycleTheme': 'テーマを順に切替', 'commandPalette.item.showOpenCodeStatus': 'OpenCode のステータスを表示', 'commandPalette.item.toggleMemoryDebug': 'メモリデバッグパネルの切替', + 'commandPalette.item.pinSession': 'セッションをピン留め/解除', + 'commandPalette.item.copySessionId': 'セッションIDをコピー', + 'commandPalette.item.openMultiRun': 'マルチラン起動画面を開く', + 'commandPalette.item.openArchive': 'アーカイブ済みセッションを開く', + 'commandPalette.item.openNotes': 'ノートパネルを開く', + 'commandPalette.item.openTodos': 'ToDoパネルを開く', 'commandPalette.item.openSettings': '設定を開く...', 'commandPalette.session.untitled': '無題のセッション', 'openCodeStatusDialog.title': 'OpenCodeステータス', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index a9ec57e3..32d73070 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1115,6 +1115,10 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '컨텍스트 패널 서피스 전환', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션', + 'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '이전 세션', + 'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '다음 세션', + 'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '현재 세션 이름 바꾸기', + 'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '권한 자동 승인 전환', 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '세션 탭 닫기', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index cba5551d..da927fe9 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2482,6 +2482,12 @@ export const dict: Record = { 'commandPalette.item.cycleTheme': '테마 순환', 'commandPalette.item.showOpenCodeStatus': 'OpenCode 상태 표시', 'commandPalette.item.toggleMemoryDebug': '메모리 디버그 패널 토글', + 'commandPalette.item.pinSession': '세션 고정 또는 고정 해제', + 'commandPalette.item.copySessionId': '세션 ID 복사', + 'commandPalette.item.openMultiRun': '멀티 런 런처 열기', + 'commandPalette.item.openArchive': '보관된 세션 열기', + 'commandPalette.item.openNotes': '노트 패널 열기', + 'commandPalette.item.openTodos': '할 일 패널 열기', 'commandPalette.item.openSettings': '설정... 열기', 'commandPalette.session.untitled': '제목 없는 세션', 'openCodeStatusDialog.title': 'OpenCode 상태', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index b14793e1..6ca6a1f1 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -824,6 +824,10 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_prompt_navigator.label': 'Przełącz nawigator promptów', 'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Skup pole wprowadzania', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nowa sesja', + 'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': 'Poprzednia sesja', + 'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': 'Następna sesja', + 'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': 'Zmień nazwę bieżącej sesji', + 'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': 'Przełącz automatyczne zatwierdzanie', 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': 'Zamknij kartę sesji', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nowy szkic obszaru roboczego', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nowe okno Mini Chat', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 43d81bad..dcd5e758 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1455,6 +1455,12 @@ export const dict: Record = { 'commandPalette.item.cycleTheme': 'Przełącz motyw', 'commandPalette.item.showOpenCodeStatus': 'Pokaż status OpenCode', 'commandPalette.item.toggleMemoryDebug': 'Przełącz panel debugowania pamięci', + 'commandPalette.item.pinSession': 'Przypnij lub odepnij sesję', + 'commandPalette.item.copySessionId': 'Kopiuj ID sesji', + 'commandPalette.item.openMultiRun': 'Otwórz panel multi-run', + 'commandPalette.item.openArchive': 'Otwórz zarchiwizowane sesje', + 'commandPalette.item.openNotes': 'Otwórz panel notatek', + 'commandPalette.item.openTodos': 'Otwórz panel zadań', 'commandPalette.session.untitled': 'Nienazwana sesja', 'commandPalette.title': 'Paleta poleceń', 'contextPanel.actions.closePanel': 'Zamknij panel', 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 6f5a4319..47ccf4f8 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1115,6 +1115,10 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Alternar superfície do painel de contexto", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão", + "settings.openchamber.keyboardShortcuts.action.switch_session_previous.label": "Sessão anterior", + "settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Próxima sessão", + "settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Renomear sessão atual", + "settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Alternar aprovação automática", "settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Fechar aba da sessão", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index cdc7c27c..4da1628e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2448,6 +2448,12 @@ export const dict: Record = { "commandPalette.item.cycleTheme": "Alternar tema", "commandPalette.item.showOpenCodeStatus": "Mostrar status do OpenCode", "commandPalette.item.toggleMemoryDebug": "Alternar painel de depuração de memória", + "commandPalette.item.pinSession": "Fixar ou desafixar sessão", + "commandPalette.item.copySessionId": "Copiar ID da sessão", + "commandPalette.item.openMultiRun": "Abrir lançador multi-run", + "commandPalette.item.openArchive": "Abrir sessões arquivadas", + "commandPalette.item.openNotes": "Abrir painel de notas", + "commandPalette.item.openTodos": "Abrir painel de tarefas", "commandPalette.item.openSettings": "Abrir configurações...", "commandPalette.session.untitled": "Sessão sem título", "openCodeStatusDialog.title": "Status do OpenCode", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 0af768cb..3e1ac7a9 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1115,6 +1115,10 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.switch_context_surface.label": "Перемкнути поверхню панелі контексту", "settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix": " + 1…0", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія", + "settings.openchamber.keyboardShortcuts.action.switch_session_previous.label": "Попередня сесія", + "settings.openchamber.keyboardShortcuts.action.switch_session_next.label": "Наступна сесія", + "settings.openchamber.keyboardShortcuts.action.rename_current_session.label": "Перейменувати поточну сесію", + "settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label": "Перемкнути авто-дозволи", "settings.openchamber.keyboardShortcuts.action.close_session_tab.label": "Закрити вкладку сесії", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree", "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 8d17da5a..f7c6adcd 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2448,6 +2448,12 @@ export const dict: Record = { "commandPalette.item.cycleTheme": "Перемкнути тему", "commandPalette.item.showOpenCodeStatus": "Показати статус OpenCode", "commandPalette.item.toggleMemoryDebug": "Показати/сховати панель memory debug", + "commandPalette.item.pinSession": "Прикріпити або відкріпити сесію", + "commandPalette.item.copySessionId": "Скопіювати ID сесії", + "commandPalette.item.openMultiRun": "Відкрити лаунчер multi-run", + "commandPalette.item.openArchive": "Відкрити архівовані сесії", + "commandPalette.item.openNotes": "Відкрити панель нотаток", + "commandPalette.item.openTodos": "Відкрити панель завдань", "commandPalette.item.openSettings": "Відкрити налаштування...", "commandPalette.session.untitled": "Сесія без назви", "openCodeStatusDialog.title": "Статус OpenCode", 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 f898b951..8bebdd26 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1115,6 +1115,10 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切换上下文面板界面', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话', + 'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '上一个会话', + 'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '下一个会话', + 'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '重命名当前会话', + 'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '切换权限自动批准', 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '关闭会话标签页', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 3e0d8295..267b2ab7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2448,6 +2448,12 @@ export const dict: Record = { 'commandPalette.item.cycleTheme': '轮换主题', 'commandPalette.item.showOpenCodeStatus': '显示 OpenCode 状态', 'commandPalette.item.toggleMemoryDebug': '切换内存调试面板', + 'commandPalette.item.pinSession': '固定或取消固定会话', + 'commandPalette.item.copySessionId': '复制会话 ID', + 'commandPalette.item.openMultiRun': '打开多任务启动器', + 'commandPalette.item.openArchive': '打开已归档会话', + 'commandPalette.item.openNotes': '打开笔记面板', + 'commandPalette.item.openTodos': '打开待办面板', 'commandPalette.item.openSettings': '打开设置...', 'commandPalette.session.untitled': '未命名会话', 'openCodeStatusDialog.title': 'OpenCode 状态', 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 fe7d3153..c38af529 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1022,6 +1022,10 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.label': '切換上下文面板介面', 'settings.openchamber.keyboardShortcuts.action.switch_context_surface.suffix': ' + 1…0', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建工作階段', + 'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label': '上一個工作階段', + 'settings.openchamber.keyboardShortcuts.action.switch_session_next.label': '下一個工作階段', + 'settings.openchamber.keyboardShortcuts.action.rename_current_session.label': '重新命名目前的工作階段', + 'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label': '切換權限自動核准', 'settings.openchamber.keyboardShortcuts.action.close_session_tab.label': '關閉工作階段分頁', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新增 worktree 草稿', 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 視窗', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 52468dab..f392a509 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2452,6 +2452,12 @@ export const dict: Record = { 'commandPalette.item.cycleTheme': '輪換主題', 'commandPalette.item.showOpenCodeStatus': '顯示 OpenCode 狀態', 'commandPalette.item.toggleMemoryDebug': '切換記憶體偵錯面板', + 'commandPalette.item.pinSession': '釘選或取消釘選會話', + 'commandPalette.item.copySessionId': '複製會話 ID', + 'commandPalette.item.openMultiRun': '開啟多任務啟動器', + 'commandPalette.item.openArchive': '開啟已封存會話', + 'commandPalette.item.openNotes': '開啟筆記面板', + 'commandPalette.item.openTodos': '開啟待辦面板', 'commandPalette.item.openSettings': '開啟設定...', 'commandPalette.session.untitled': '未命名會話', 'openCodeStatusDialog.title': 'OpenCode 狀態', diff --git a/packages/ui/src/lib/sessionNavigationHistory.test.ts b/packages/ui/src/lib/sessionNavigationHistory.test.ts new file mode 100644 index 00000000..b9be6857 --- /dev/null +++ b/packages/ui/src/lib/sessionNavigationHistory.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test'; +import type { Session } from '@opencode-ai/sdk/v2'; +import { navigateSessionHistory } from './sessionNavigationHistory'; +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; + +// SAFETY: the history module only reads a session's id and directory metadata. +const session = (id: string): Session => ({ + id, + title: id, + directory: '/repo', + projectID: 'p1', + version: '1', + time: { created: 1, updated: 1 }, +} as Session); + +describe('sessionNavigationHistory', () => { + test('steps back and forward through the visit order', () => { + useGlobalSessionsStore.setState({ activeSessions: [session('s1'), session('s2'), session('s3')] }); + + useSessionUIStore.setState({ currentSessionId: 's1' }); + useSessionUIStore.setState({ currentSessionId: 's2' }); + useSessionUIStore.setState({ currentSessionId: 's3' }); + + expect(navigateSessionHistory(-1)).toBe(true); + expect(useSessionUIStore.getState().currentSessionId).toBe('s2'); + expect(navigateSessionHistory(-1)).toBe(true); + expect(useSessionUIStore.getState().currentSessionId).toBe('s1'); + expect(navigateSessionHistory(-1)).toBe(false); + + expect(navigateSessionHistory(1)).toBe(true); + expect(useSessionUIStore.getState().currentSessionId).toBe('s2'); + }); + + test('a fresh visit truncates the forward branch', () => { + // Continues from the previous test's state: at s2 with s3 forward. + useSessionUIStore.setState({ currentSessionId: 's1' }); + expect(navigateSessionHistory(1)).toBe(false); + expect(navigateSessionHistory(-1)).toBe(true); + expect(useSessionUIStore.getState().currentSessionId).toBe('s2'); + }); + + test('skips and drops entries whose session no longer exists', () => { + useSessionUIStore.setState({ currentSessionId: 's3' }); + useGlobalSessionsStore.setState({ activeSessions: [session('s1'), session('s3')] }); + // History behind s3 contains s2 (dead) then s1 (alive). + expect(navigateSessionHistory(-1)).toBe(true); + expect(useSessionUIStore.getState().currentSessionId).toBe('s1'); + }); +}); diff --git a/packages/ui/src/lib/sessionNavigationHistory.ts b/packages/ui/src/lib/sessionNavigationHistory.ts new file mode 100644 index 00000000..6f4edf9a --- /dev/null +++ b/packages/ui/src/lib/sessionNavigationHistory.ts @@ -0,0 +1,61 @@ +import { useSessionUIStore } from '@/sync/session-ui-store'; +import { useGlobalSessionsStore, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; + +// Browser-style back/forward over the order sessions were opened in this +// window. A normal session switch truncates the forward part and appends; +// stepping through history moves only the cursor, so back stays back even +// after several presses. In-memory by design: the stack describes this +// window's journey, not durable state. + +const MAX_HISTORY = 100; + +let visitedSessionIds: string[] = []; +let cursor = -1; +let navigating = false; + +const recordVisit = (sessionId: string): void => { + if (visitedSessionIds[cursor] === sessionId) return; + visitedSessionIds = [...visitedSessionIds.slice(0, cursor + 1), sessionId].slice(-MAX_HISTORY); + cursor = visitedSessionIds.length - 1; +}; + +useSessionUIStore.subscribe((state, previousState) => { + if (state.currentSessionId === previousState.currentSessionId) return; + if (!state.currentSessionId || navigating) return; + recordVisit(state.currentSessionId); +}); + +/** + * Steps the current session back (-1) or forward (+1) through this window's + * open history. Entries whose session no longer exists in the loaded list are + * skipped and dropped. Returns false when there is nowhere to go. + */ +export const navigateSessionHistory = (delta: -1 | 1): boolean => { + const sessionsById = new Map( + useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const), + ); + let nextCursor = cursor + delta; + while (nextCursor >= 0 && nextCursor < visitedSessionIds.length) { + const session = sessionsById.get(visitedSessionIds[nextCursor]); + if (session) { + cursor = nextCursor; + navigating = true; + try { + useSessionUIStore.getState().setCurrentSession(session.id, resolveGlobalSessionDirectory(session)); + } finally { + navigating = false; + } + return true; + } + // Drop the dead entry at nextCursor and keep scanning in the same + // direction: a removal shifts later entries one index down, so the next + // forward candidate lands on the same index while a backward scan steps. + visitedSessionIds = [ + ...visitedSessionIds.slice(0, nextCursor), + ...visitedSessionIds.slice(nextCursor + 1), + ]; + if (nextCursor < cursor) cursor -= 1; + if (delta < 0) nextCursor -= 1; + } + return false; +}; diff --git a/packages/ui/src/lib/sessionTabs.ts b/packages/ui/src/lib/sessionTabs.ts index a1b490b0..62d22bfe 100644 --- a/packages/ui/src/lib/sessionTabs.ts +++ b/packages/ui/src/lib/sessionTabs.ts @@ -26,6 +26,28 @@ export const activateSessionTabByIndex = (index: number): boolean => { return true; }; +/** + * Activate the tab one step right (+1) or left (-1) of the current session + * in the rendered strip order, wrapping around the ends. Returns false when + * the current session has no tab or there is nothing to move to. + */ +export const activateAdjacentSessionTab = (delta: -1 | 1): boolean => { + const { tabIds } = useSessionTabsStore.getState(); + const { currentSessionId, setCurrentSession } = useSessionUIStore.getState(); + const sessionsById = new Map( + useGlobalSessionsStore.getState().activeSessions.map((session) => [session.id, session] as const), + ); + const renderable = tabIds.filter((id) => sessionsById.has(id)); + if (!currentSessionId || renderable.length < 2) return false; + const index = renderable.indexOf(currentSessionId); + if (index === -1) return false; + const nextId = renderable[(index + delta + renderable.length) % renderable.length]; + const next = sessionsById.get(nextId); + if (!next) return false; + setCurrentSession(next.id, resolveGlobalSessionDirectory(next)); + return true; +}; + export const closeSessionTabAndActivateNeighbour = (sessionId: string): void => { const { tabIds, closeTab } = useSessionTabsStore.getState(); if (!tabIds.includes(sessionId)) return; diff --git a/packages/ui/src/lib/shortcuts/config.ts b/packages/ui/src/lib/shortcuts/config.ts index 0cdf024b..c9d8dbe3 100644 --- a/packages/ui/src/lib/shortcuts/config.ts +++ b/packages/ui/src/lib/shortcuts/config.ts @@ -51,6 +51,34 @@ const SHORTCUT_GROUPS = { customizable: true, settingsLabelKey: 'settings.openchamber.keyboardShortcuts.action.new_chat.label', }, + { + id: 'switch_session_previous', + defaultBinding: 'mod+alt+arrowleft', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.switch_session_previous.label', + }, + { + id: 'switch_session_next', + defaultBinding: 'mod+alt+arrowright', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.switch_session_next.label', + }, + { + id: 'rename_current_session', + defaultBinding: 'mod+k r', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.rename_current_session.label', + }, + { + id: 'toggle_permission_auto_accept', + defaultBinding: 'mod+k a', + customizable: true, + settingsLabelKey: + 'settings.openchamber.keyboardShortcuts.action.toggle_permission_auto_accept.label', + }, { id: 'close_session_tab', defaultBinding: 'alt+w', diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index d905260d..dbd27b32 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -8,6 +8,7 @@ - Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible. - Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name finds the files inside it. - Search in dropdowns: searchable pickers (agents, models, providers, branches) now put the best matches first, match multi-word queries in any order, and ignore punctuation (so "gpt4o" finds "gpt-4o"). +- Permission cards answer to the keyboard: Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies; the keys are printed on the buttons. - Keyboard: dropdown menus and pickers answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and shortcut labels in tooltips and menus show the binding you actually have set (thanks @ChangeHow). - Chat: OpenCode notices now share one style. - The timeline dialog now fits small windows instead of squeezing the message list to a couple of rows (thanks to @gaojunran). From 2e49e442054f75f3e4498fc0c46ea4b28f4e5357 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 16:18:15 +0300 Subject: [PATCH 21/49] feat(ui): let users hide context rail surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trailing configure button on the rail — outside the sortable list and the digit shortcuts — opens a dialog that toggles each surface. The choice is stored as the hidden set so newly added surfaces appear for everyone, and the rail and the mod+alt+digit switcher share the same visibility filter, so badges and shortcuts always agree. Hidden surfaces keep their data and stay reachable from the command palette. --- .../components/layout/ContextPanelRail.tsx | 25 +++++- .../layout/ContextRailSurfacesDialog.tsx | 78 +++++++++++++++++++ packages/ui/src/hooks/useKeyboardShortcuts.ts | 1 + packages/ui/src/lib/i18n/messages/de.ts | 5 ++ packages/ui/src/lib/i18n/messages/en.ts | 5 ++ packages/ui/src/lib/i18n/messages/es.ts | 5 ++ packages/ui/src/lib/i18n/messages/fr.ts | 5 ++ packages/ui/src/lib/i18n/messages/ja.ts | 5 ++ packages/ui/src/lib/i18n/messages/ko.ts | 5 ++ packages/ui/src/lib/i18n/messages/pl.ts | 5 ++ packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 ++ packages/ui/src/lib/i18n/messages/uk.ts | 5 ++ packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 ++ packages/ui/src/lib/i18n/messages/zh-TW.ts | 5 ++ packages/ui/src/lib/surfaces/DOCUMENTATION.md | 5 +- packages/ui/src/lib/surfaces/registry.ts | 6 ++ packages/ui/src/stores/useUIStore.ts | 27 +++++++ 17 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 packages/ui/src/components/layout/ContextRailSurfacesDialog.tsx diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index 6b3c147a..23308012 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -36,6 +36,7 @@ import { cn } from '@/lib/utils'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitStatus } from '@/stores/useGitStore'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; +import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog'; const RAIL_TOOLTIP_DELAY_MS = 150; // Hold the surface-switch modifier for this long before revealing the order @@ -161,6 +162,7 @@ export const ContextPanelRail: React.FC = () => { const panelState = useUIStore((state) => (directoryKey ? state.contextPanelByDirectory[directoryKey] : undefined)); const workStatusPanelVisible = useUIStore((state) => state.workStatusPanelVisible); const contextRailOrder = useUIStore((state) => state.contextRailOrder); + const contextRailHiddenSurfaces = useUIStore((state) => state.contextRailHiddenSurfaces); const setContextRailOrder = useUIStore((state) => state.setContextRailOrder); const openContextSurface = useUIStore((state) => state.openContextSurface); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); @@ -256,12 +258,15 @@ export const ContextPanelRail: React.FC = () => { const surfaces = React.useMemo(() => { return getVisibleContextRailSurfaces({ railOrder: contextRailOrder, + hiddenSurfaces: contextRailHiddenSurfaces, planModeEnabled, isVSCode: isVSCodeRuntime(), screenWidth, tabs, }); - }, [contextRailOrder, planModeEnabled, screenWidth, tabs]); + }, [contextRailHiddenSurfaces, contextRailOrder, planModeEnabled, screenWidth, tabs]); + + const [isSurfacesDialogOpen, setIsSurfacesDialogOpen] = React.useState(false); const handleDragEnd = React.useCallback((event: DragEndEvent) => { const { active, over } = event; @@ -331,6 +336,24 @@ export const ContextPanelRail: React.FC = () => { })} + {/* Outside the sortable list on purpose: this button takes no digit, + cannot be dragged, and configures the rail rather than living on it. */} + + + + + + {t('contextRail.configure.open')} + + + ); }; diff --git a/packages/ui/src/components/layout/ContextRailSurfacesDialog.tsx b/packages/ui/src/components/layout/ContextRailSurfacesDialog.tsx new file mode 100644 index 00000000..211b465a --- /dev/null +++ b/packages/ui/src/components/layout/ContextRailSurfacesDialog.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useI18n } from '@/lib/i18n'; +import { useUIStore } from '@/stores/useUIStore'; +import { SettingsCheckboxRow } from '@/components/sections/shared/SettingsSection'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { sortContextSurfaces } from '@/lib/surfaces/registry'; + +/** + * Which surfaces the context rail shows. Everything is on by default and the + * choice is stored as the *hidden* set, so a surface added in a later release + * appears for everyone rather than staying invisible to whoever had saved + * settings before it existed. Hidden surfaces also leave the digit shortcuts + * (the rail and the shortcut share one visibility filter). + */ +export const ContextRailSurfacesDialog: React.FC<{ + open: boolean; + onOpenChange: (open: boolean) => void; +}> = ({ open, onOpenChange }) => { + const { t } = useI18n(); + const contextRailOrder = useUIStore((state) => state.contextRailOrder); + const hidden = useUIStore((state) => state.contextRailHiddenSurfaces); + const setSurfaceVisible = useUIStore((state) => state.setContextRailSurfaceVisible); + const setHiddenSurfaces = useUIStore((state) => state.setContextRailHiddenSurfaces); + + // The full registry in the user's rail order — including surfaces a runtime + // filter currently drops, so a choice made on desktop is editable anywhere. + const surfaces = React.useMemo(() => sortContextSurfaces(contextRailOrder), [contextRailOrder]); + + const allVisible = hidden.length === 0; + const noneVisible = surfaces.every((surface) => hidden.includes(surface.id)); + + return ( + + + + {t('contextRail.configure.dialogTitle')} + {t('contextRail.configure.dialogDescription')} + + +
+ {surfaces.map((surface) => ( + setSurfaceVisible(surface.id, checked)} + label={t(surface.labelKey)} + ariaLabel={t(surface.labelKey)} + /> + ))} +
+ + {!allVisible ? ( +
+ {noneVisible ? ( + {t('contextRail.configure.noneWarning')} + ) : } + +
+ ) : null} +
+
+ ); +}; diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index d04a1a9e..0c99f3b0 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -499,6 +499,7 @@ export const useKeyboardShortcuts = () => { const panel = state.contextPanelByDirectory[directory]; const visibleSurfaces = getVisibleContextRailSurfaces({ railOrder: state.contextRailOrder, + hiddenSurfaces: state.contextRailHiddenSurfaces, planModeEnabled: useFeatureFlagsStore.getState().planModeEnabled, isVSCode: isVSCodeRuntime(), screenWidth: window.innerWidth, diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 815186a0..64382772 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2995,6 +2995,11 @@ export const dict = { 'gitView.pr.segment.comments': 'Kommentare', 'gitView.pr.comments.addAll': 'Alle hinzufügen', 'contextPanel.mode.pr': 'PR', + 'contextRail.configure.open': 'Panels konfigurieren', + 'contextRail.configure.dialogTitle': 'Leisten-Panels', + 'contextRail.configure.dialogDescription': 'Wähle, welche Panels die Leiste zeigt. Ausgeblendete Panels behalten ihre Daten und bleiben über die Befehlspalette erreichbar.', + 'contextRail.configure.showAll': 'Alle anzeigen', + 'contextRail.configure.noneWarning': 'Alle Panels sind ausgeblendet.', 'contextRail.aria.rail': 'Kontextleiste', 'contextPanel.editorEmpty.title': 'Kein Kontext ausgewählt', 'contextPanel.editorEmpty.description': 'Wählen Sie etwas aus der Seitenleiste aus, um Kontext anzuzeigen.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 62ab1973..169f4c5f 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1139,6 +1139,11 @@ export const dict = { 'contextPanel.mode.context': 'Context', 'contextPanel.mode.preview': 'Preview', 'contextPanel.mode.browser': 'Browser', + 'contextRail.configure.open': 'Configure panels', + 'contextRail.configure.dialogTitle': 'Rail panels', + 'contextRail.configure.dialogDescription': 'Choose which panels the rail shows. Hidden panels keep their data and stay reachable from the command palette.', + 'contextRail.configure.showAll': 'Show all', + 'contextRail.configure.noneWarning': 'All panels are hidden.', 'contextRail.aria.rail': 'Panel surfaces', 'contextPanel.editorEmpty.title': 'No file open', 'contextPanel.editorEmpty.description': 'Pick a file from the tree to start editing.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 4760f8a1..04bbeaa6 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1140,6 +1140,11 @@ export const dict: Record = { "contextPanel.mode.context": "Contexto", "contextPanel.mode.preview": "Vista previa", "contextPanel.mode.browser": "Navegador", + "contextRail.configure.open": "Configurar paneles", + "contextRail.configure.dialogTitle": "Paneles de la barra", + "contextRail.configure.dialogDescription": "Elige qué paneles muestra la barra. Los paneles ocultos conservan sus datos y siguen accesibles desde la paleta de comandos.", + "contextRail.configure.showAll": "Mostrar todos", + "contextRail.configure.noneWarning": "Todos los paneles están ocultos.", "contextRail.aria.rail": "Superficies del panel", "contextPanel.editorEmpty.title": "Ningún archivo abierto", "contextPanel.editorEmpty.description": "Elige un archivo del árbol para empezar a editar.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 34528808..b35feb84 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -959,6 +959,11 @@ export const dict = { 'contextPanel.mode.context': 'Contexte', 'contextPanel.mode.preview': 'Aperçu', 'contextPanel.mode.browser': 'Navigateur', + 'contextRail.configure.open': 'Configurer les panneaux', + 'contextRail.configure.dialogTitle': 'Panneaux de la barre', + 'contextRail.configure.dialogDescription': 'Choisissez les panneaux affichés par la barre. Les panneaux masqués conservent leurs données et restent accessibles via la palette de commandes.', + 'contextRail.configure.showAll': 'Tout afficher', + 'contextRail.configure.noneWarning': 'Tous les panneaux sont masqués.', 'contextRail.aria.rail': 'Surfaces du panneau', 'contextPanel.editorEmpty.title': 'Aucun fichier ouvert', 'contextPanel.editorEmpty.description': 'Choisissez un fichier dans l’arborescence pour commencer.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index daedcf79..1eeb39e0 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1136,6 +1136,11 @@ export const dict: Record = { 'contextPanel.mode.context': 'コンテキスト', 'contextPanel.mode.preview': 'プレビュー', 'contextPanel.mode.browser': 'ブラウザ', + 'contextRail.configure.open': 'パネルを設定', + 'contextRail.configure.dialogTitle': 'レールのパネル', + 'contextRail.configure.dialogDescription': 'レールに表示するパネルを選択します。非表示のパネルもデータは保持され、コマンドパレットから引き続き開けます。', + 'contextRail.configure.showAll': 'すべて表示', + 'contextRail.configure.noneWarning': 'すべてのパネルが非表示です。', 'contextRail.aria.rail': 'パネルサーフェス', 'contextPanel.editorEmpty.title': 'ファイルが開かれていません', 'contextPanel.editorEmpty.description': 'ツリーからファイルを選んで編集を始めましょう。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index da927fe9..b2c9192a 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1140,6 +1140,11 @@ export const dict: Record = { 'contextPanel.mode.context': '컨텍스트', 'contextPanel.mode.preview': '미리보기', 'contextPanel.mode.browser': '브라우저', + 'contextRail.configure.open': '패널 구성', + 'contextRail.configure.dialogTitle': '레일 패널', + 'contextRail.configure.dialogDescription': '레일에 표시할 패널을 선택하세요. 숨긴 패널의 데이터는 유지되며 명령 팔레트에서 계속 열 수 있습니다.', + 'contextRail.configure.showAll': '모두 표시', + 'contextRail.configure.noneWarning': '모든 패널이 숨겨져 있습니다.', 'contextRail.aria.rail': '패널 서피스', 'contextPanel.editorEmpty.title': '열린 파일 없음', 'contextPanel.editorEmpty.description': '트리에서 파일을 선택해 편집을 시작하세요.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index dcd5e758..25933ab1 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1478,6 +1478,11 @@ export const dict: Record = { 'contextPanel.mode.pr': 'Pull Request', 'contextPanel.mode.preview': 'Podgląd', 'contextPanel.mode.browser': 'Przeglądarka', + 'contextRail.configure.open': 'Konfiguruj panele', + 'contextRail.configure.dialogTitle': 'Panele paska', + 'contextRail.configure.dialogDescription': 'Wybierz, które panele pokazuje pasek. Ukryte panele zachowują dane i pozostają dostępne z palety poleceń.', + 'contextRail.configure.showAll': 'Pokaż wszystkie', + 'contextRail.configure.noneWarning': 'Wszystkie panele są ukryte.', 'contextRail.aria.rail': 'Powierzchnie panelu', 'contextPanel.editorEmpty.title': 'Brak otwartego pliku', 'contextPanel.editorEmpty.description': 'Wybierz plik z drzewa, aby rozpocząć edycję.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 4da1628e..932b3ea3 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1140,6 +1140,11 @@ export const dict: Record = { "contextPanel.mode.context": "Contexto", "contextPanel.mode.preview": "Prévia", "contextPanel.mode.browser": "Navegador", + "contextRail.configure.open": "Configurar painéis", + "contextRail.configure.dialogTitle": "Painéis da barra", + "contextRail.configure.dialogDescription": "Escolha quais painéis a barra mostra. Painéis ocultos mantêm seus dados e continuam acessíveis pela paleta de comandos.", + "contextRail.configure.showAll": "Mostrar todos", + "contextRail.configure.noneWarning": "Todos os painéis estão ocultos.", "contextRail.aria.rail": "Superfícies do painel", "contextPanel.editorEmpty.title": "Nenhum arquivo aberto", "contextPanel.editorEmpty.description": "Escolha um arquivo na árvore para começar a editar.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index f7c6adcd..56f101b1 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1140,6 +1140,11 @@ export const dict: Record = { "contextPanel.mode.context": "Контекст", "contextPanel.mode.preview": "Перегляд", "contextPanel.mode.browser": "Браузер", + "contextRail.configure.open": "Налаштувати панелі", + "contextRail.configure.dialogTitle": "Панелі рейки", + "contextRail.configure.dialogDescription": "Обери, які панелі показує рейка. Приховані панелі зберігають дані й доступні з палітри команд.", + "contextRail.configure.showAll": "Показати всі", + "contextRail.configure.noneWarning": "Усі панелі приховано.", "contextRail.aria.rail": "Поверхні панелі", "contextPanel.editorEmpty.title": "Файл не відкрито", "contextPanel.editorEmpty.description": "Виберіть файл у дереві, щоб почати редагування.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 267b2ab7..2616b032 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1140,6 +1140,11 @@ export const dict: Record = { 'contextPanel.mode.context': '上下文', 'contextPanel.mode.preview': '预览', 'contextPanel.mode.browser': '浏览器', + 'contextRail.configure.open': '配置面板', + 'contextRail.configure.dialogTitle': '侧栏面板', + 'contextRail.configure.dialogDescription': '选择侧栏显示哪些面板。隐藏的面板会保留数据,仍可通过命令面板打开。', + 'contextRail.configure.showAll': '全部显示', + 'contextRail.configure.noneWarning': '所有面板均已隐藏。', 'contextRail.aria.rail': '面板界面', 'contextPanel.editorEmpty.title': '未打开文件', 'contextPanel.editorEmpty.description': '从文件树中选择一个文件开始编辑。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index f392a509..4dc07807 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1152,6 +1152,11 @@ export const dict: Record = { 'contextPanel.mode.context': '上下文', 'contextPanel.mode.preview': '預覽', 'contextPanel.mode.browser': '瀏覽器', + 'contextRail.configure.open': '設定面板', + 'contextRail.configure.dialogTitle': '側欄面板', + 'contextRail.configure.dialogDescription': '選擇側欄顯示哪些面板。隱藏的面板會保留資料,仍可透過命令面板開啟。', + 'contextRail.configure.showAll': '全部顯示', + 'contextRail.configure.noneWarning': '所有面板皆已隱藏。', 'contextRail.aria.rail': '面板介面', 'contextPanel.editorEmpty.title': '未開啟檔案', 'contextPanel.editorEmpty.description': '從檔案樹選擇檔案開始編輯。', diff --git a/packages/ui/src/lib/surfaces/DOCUMENTATION.md b/packages/ui/src/lib/surfaces/DOCUMENTATION.md index 9a584ce5..fd750557 100644 --- a/packages/ui/src/lib/surfaces/DOCUMENTATION.md +++ b/packages/ui/src/lib/surfaces/DOCUMENTATION.md @@ -22,7 +22,10 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by registry's default order and appends any missing surfaces. - `getVisibleContextRailSurfaces` is the single visibility filter shared by the rail and the global surface-switch shortcut (`switch_context_surface` in - `lib/shortcuts.ts`): it drops the plan surface unless plan mode is enabled, + `lib/shortcuts`): it drops surfaces the user hid + (`useUIStore.contextRailHiddenSurfaces`, edited from the rail's trailing + configure button — `ContextRailSurfacesDialog`), drops the plan surface + unless plan mode is enabled, drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, and hides `has-content` surfaces until a tab of their mode exists. Both consumers use it so the digit shown on a rail badge always maps to the same surface the diff --git a/packages/ui/src/lib/surfaces/registry.ts b/packages/ui/src/lib/surfaces/registry.ts index 730389e8..21d2992f 100644 --- a/packages/ui/src/lib/surfaces/registry.ts +++ b/packages/ui/src/lib/surfaces/registry.ts @@ -187,6 +187,9 @@ export const sortContextSurfaces = (railOrder: readonly string[]): ContextSurfac type VisibleRailSurfacesOptions = { railOrder: readonly string[]; + /** Surfaces the user chose to hide from the rail (and from the digit + shortcuts, which share this filter). */ + hiddenSurfaces?: readonly string[]; planModeEnabled: boolean; isVSCode: boolean; screenWidth: number; @@ -203,6 +206,9 @@ type VisibleRailSurfacesOptions = { */ export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOptions): ContextSurfaceDescriptor[] => { return sortContextSurfaces(options.railOrder).filter((surface) => { + if (options.hiddenSurfaces?.includes(surface.id)) { + return false; + } if (surface.id === 'plan' && !options.planModeEnabled) { return false; } diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index c377f27b..b5254f2e 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -607,6 +607,9 @@ interface UIStore { hasManuallyResizedLeftSidebar: boolean; contextPanelByDirectory: Record; contextRailOrder: string[]; + /** Surface ids the user hid from the context rail; stored as the hidden set + so surfaces added later appear for everyone. */ + contextRailHiddenSurfaces: string[]; contextEditorTreeVisible: boolean; contextEditorTreeWidth: number; notesPanelHeight: number; @@ -828,6 +831,8 @@ interface UIStore { setWorkStatusOverlayOpen: (open: boolean) => void; setWorkStatusSectionVisible: (sectionId: string, visible: boolean) => void; setWorkStatusHiddenSections: (sectionIds: string[]) => void; + setContextRailSurfaceVisible: (surfaceId: string, visible: boolean) => void; + setContextRailHiddenSurfaces: (surfaceIds: string[]) => void; setSessionSwitcherOpen: (open: boolean) => void; setSessionDropdownOpen: (open: boolean) => void; setPendingDiffFile: (filePath: string | null, staged?: boolean, scope?: PendingDiffScope | null) => void; @@ -990,6 +995,7 @@ export const useUIStore = create()( hasManuallyResizedLeftSidebar: false, contextPanelByDirectory: {}, contextRailOrder: [], + contextRailHiddenSurfaces: [], contextEditorTreeVisible: true, contextEditorTreeWidth: 240, notesPanelHeight: 112, @@ -1599,6 +1605,23 @@ export const useUIStore = create()( set({ workStatusHiddenSections: [...new Set(sectionIds)] }); }, + setContextRailSurfaceVisible: (surfaceId, visible) => { + set((state) => { + const hidden = state.contextRailHiddenSurfaces; + const isHidden = hidden.includes(surfaceId); + if (visible === !isHidden) return state; + return { + contextRailHiddenSurfaces: visible + ? hidden.filter((entry) => entry !== surfaceId) + : [...hidden, surfaceId], + }; + }); + }, + + setContextRailHiddenSurfaces: (surfaceIds) => { + set({ contextRailHiddenSurfaces: [...new Set(surfaceIds)] }); + }, + setSessionSwitcherOpen: (open) => { if (get().isSessionSwitcherOpen === open) { @@ -2627,6 +2650,9 @@ export const useUIStore = create()( state.autoSaveEnabled = true; } + state.contextRailHiddenSurfaces = Array.isArray(state.contextRailHiddenSurfaces) + ? (state.contextRailHiddenSurfaces as unknown[]).filter((id): id is string => typeof id === 'string' && id.trim() !== '') + : []; state.contextRailOrder = Array.isArray(state.contextRailOrder) ? (state.contextRailOrder as unknown[]).filter((id): id is string => typeof id === 'string' && id.trim() !== '') : []; @@ -2639,6 +2665,7 @@ export const useUIStore = create()( sidebarWidth: state.sidebarWidth, contextPanelByDirectory: state.contextPanelByDirectory, contextRailOrder: state.contextRailOrder, + contextRailHiddenSurfaces: state.contextRailHiddenSurfaces, contextEditorTreeVisible: state.contextEditorTreeVisible, contextEditorTreeWidth: state.contextEditorTreeWidth, notesPanelHeight: state.notesPanelHeight, From 087c148b2eadcc6e8b19b252e97f51839cad3bdd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 16:42:42 +0300 Subject: [PATCH 22/49] fix(chat): rescue stranded viewports and settle navigation jumps Opening a session (or any relayout that shrinks off-screen size estimates) could leave the viewport in a phantom tail below the measured content, with every row out of reach above; a totalSize-change check now detects the fully blank viewport and returns to the real end, and settling a width resize re-asserts the end for a reader who was on it. Prompt-rail and message jumps land on estimated offsets that shift as the target mounts and measures; a short settle loop now re-aligns the target until layout rests, backing off on the first user gesture. --- .../ui/src/components/chat/MessageList.tsx | 67 +++++++++++++++++-- .../ui/src/hooks/useChatTimelineScroll.ts | 43 +++++++++++- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/chat/MessageList.tsx b/packages/ui/src/components/chat/MessageList.tsx index 4197f067..07ce00a3 100644 --- a/packages/ui/src/components/chat/MessageList.tsx +++ b/packages/ui/src/components/chat/MessageList.tsx @@ -1534,6 +1534,54 @@ const MessageList = React.forwardRef(({ return true; }, [allEntries.length]); + // A navigation scroll lands on estimates: an unmounted target teleports + // to its estimated offset, and even a mounted one drifts when neighbours + // finish measuring a frame later. This settle loop re-aligns the target to + // the requested viewport position until the layout stops moving, and backs + // off the moment the user touches the scroll. + const settleNavigationTarget = React.useCallback(( + findElement: () => HTMLElement | null, + desiredOffsetTop: number, + ) => { + const container = resolveScrollContainer(); + if (!container || typeof window === 'undefined') { + return; + } + let frames = 0; + let stable = 0; + let cancelled = false; + const cancelOnUserInput = () => { + cancelled = true; + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + }; + container.addEventListener('touchstart', cancelOnUserInput, { passive: true }); + container.addEventListener('wheel', cancelOnUserInput, { passive: true }); + const step = () => { + if (cancelled) return; + const element = findElement(); + if (element) { + const delta = element.getBoundingClientRect().top + - container.getBoundingClientRect().top + - desiredOffsetTop; + if (Math.abs(delta) > 0.5) { + container.scrollTop += delta; + stable = 0; + } else { + stable += 1; + } + } + frames += 1; + if (stable >= ANCHOR_HOLD_STABLE_FRAMES || frames >= ANCHOR_HOLD_MAX_FRAMES) { + container.removeEventListener('touchstart', cancelOnUserInput); + container.removeEventListener('wheel', cancelOnUserInput); + return; + } + window.requestAnimationFrame(step); + }; + window.requestAnimationFrame(step); + }, [resolveScrollContainer]); + const scrollMessageElementIntoView = React.useCallback((messageId: string, behavior: ScrollBehavior = 'auto') => { const container = resolveScrollContainer(); if (!container) { @@ -1569,14 +1617,19 @@ const MessageList = React.forwardRef(({ if (!container) { return false; } - const turnElement = container.querySelector(`[data-turn-id="${turnId}"]`); + const findTurnElement = () => container.querySelector(`[data-turn-id="${turnId}"]`); + const turnElement = findTurnElement(); if (turnElement) { turnElement.scrollIntoView({ behavior, block: 'start' }); + if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0); return true; } - - return scrollHistoryIndexIntoView(index); + if (!scrollHistoryIndexIntoView(index)) { + return false; + } + if (behavior !== 'smooth') settleNavigationTarget(findTurnElement, 0); + return true; }, scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => { @@ -1586,8 +1639,12 @@ const MessageList = React.forwardRef(({ return false; } - return scrollMessageElementIntoView(messageId, behavior) + const didScroll = scrollMessageElementIntoView(messageId, behavior) || scrollHistoryIndexIntoView(index); + if (didScroll && behavior !== 'smooth') { + settleNavigationTarget(() => findMessageElement(messageId), 50); + } + return didScroll; }, holdViewportAnchor: (anchor) => { @@ -1730,7 +1787,7 @@ const MessageList = React.forwardRef(({ return () => { objectRef.current = null; }; - }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, turnIndexMap, ref]); + }, [findMessageElement, historyEntries.length, messageIndexMap, resolveScrollContainer, scrollHistoryIndexIntoView, scrollMessageElementIntoView, settleNavigationTarget, turnIndexMap, ref]); const anchoredEndSpace = React.useMemo(() => { const resolved = resolveChatListAnchoredEndSpace( diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 161d9eca..544d02af 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -7,6 +7,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { CHAT_LIST_ANCHOR_OFFSET, getAnchoredTurnMetrics, + getRowBottom, resolveTimelineIsAtEnd, type TimelineListMeasurementState, type TimelineScrollMode, @@ -129,6 +130,8 @@ export const useChatTimelineScroll = ({ // True after a real gesture until an explicit opt back in; drives the // overlay scrollbar suppression instead of the anchor's mere existence. const [userOwnsScroll, setUserOwnsScroll] = React.useState(false); + const userOwnsScrollRef = React.useRef(userOwnsScroll); + userOwnsScrollRef.current = userOwnsScroll; const modeRef = React.useRef('following-end'); const isAtEndRef = React.useRef(true); @@ -547,9 +550,12 @@ export const useChatTimelineScroll = ({ // per-frame row re-measure and the pinned viewport shakes. Corrections // stand down for the whole resize and the visible content is held by the // list's size compensation instead. Deliberately NO snap back to the end - // afterwards: a slow drag settles repeatedly, and each snap reads as the - // very jump this suspension removes — geometry changed, staying where the - // reader is beats re-asserting the edge. + // afterwards for a mid-conversation reader: a slow drag settles + // repeatedly, and each snap reads as the very jump this suspension + // removes. A reader who WAS at the end is the exception — after rows + // re-wrap, stale cached sizes can leave a large phantom gap below the + // last row, so re-asserting the end once on settle is what "staying + // where the reader is" means for them. const widthResizingRef = React.useRef(false); React.useEffect(() => { if (!scrollNode || typeof ResizeObserver === 'undefined') return; @@ -569,6 +575,9 @@ export const useChatTimelineScroll = ({ quietTimer = setTimeout(() => { quietTimer = null; widthResizingRef.current = false; + if (isAtEndRef.current && pendingAnchorRef.current === null) { + void listRef.current?.scrollToEnd({ animated: false }); + } }, 350); }); observer.observe(scrollNode); @@ -580,6 +589,34 @@ export const useChatTimelineScroll = ({ const onTimelineDataChange = React.useCallback(() => { if (widthResizingRef.current) return; + + // Stranded-viewport rescue, independent of any follow mode or + // preference: when off-screen size estimates settle smaller than + // estimated, the measured content can end ABOVE the viewport while + // the scroll offset stays at the stale end — the reader faces a blank + // phantom tail with every row out of reach above. That state is never + // intentional, so it is corrected even when auto-follow is off. Only + // a fully blank viewport qualifies; partial visibility is left alone. + if (!userOwnsScrollRef.current) { + const list = listRef.current; + if (list) { + const state = list.getState(); + const lastIndex = state.data.length - 1; + const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null; + if (lastBottom !== null && state.scroll > lastBottom) { + const visibleLength = Math.max( + 0, + state.scrollLength - composerOverlayHeightRef.current - CHAT_LIST_ANCHOR_OFFSET, + ); + void list.scrollToOffset({ + offset: Math.max(0, lastBottom - visibleLength), + animated: false, + }); + return; + } + } + } + if (!streamingAutoFollowEnabledRef.current) return; if (!isLiveFollowActive()) return; From ba3a604e9ea2cb5048b308e25f0efdfdda4dec83 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 16:49:21 +0300 Subject: [PATCH 23/49] fix(chat): offer the scroll pill when growth leaves the reader behind With streaming auto-follow off nothing moves the viewport, so a growing reply slides below the composer without a single scroll event and the at-end transition that shows the pill never fires. Content growth now doubles as the signal: once the measured last row extends past the visible area by the follow re-arm threshold, the end state clears and the pill is scheduled. --- .../ui/src/hooks/useChatTimelineScroll.ts | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/hooks/useChatTimelineScroll.ts b/packages/ui/src/hooks/useChatTimelineScroll.ts index 544d02af..46da713f 100644 --- a/packages/ui/src/hooks/useChatTimelineScroll.ts +++ b/packages/ui/src/hooks/useChatTimelineScroll.ts @@ -9,6 +9,7 @@ import { getAnchoredTurnMetrics, getRowBottom, resolveTimelineIsAtEnd, + TIMELINE_FOLLOW_REARM_THRESHOLD_PX, type TimelineListMeasurementState, type TimelineScrollMode, } from '@/components/chat/lib/scroll/timelineScrollAnchoring'; @@ -617,7 +618,29 @@ export const useChatTimelineScroll = ({ } } - if (!streamingAutoFollowEnabledRef.current) return; + if (!streamingAutoFollowEnabledRef.current) { + // With auto-follow off nothing moves the viewport, so a growing + // reply slides below the visible area without a single scroll + // event — and the at-end transition that offers the pill never + // fires. Content growth is the signal here: once the real last + // row extends past what the composer leaves visible, the reader + // is factually behind and the pill must say so. + const list = listRef.current; + if (list && isAtEndRef.current) { + const state = list.getState(); + const lastIndex = state.data.length - 1; + const lastBottom = lastIndex >= 0 ? getRowBottom(state, lastIndex) : null; + if (lastBottom !== null) { + const visibleBottom = state.scroll + state.scrollLength - composerOverlayHeightRef.current; + if (lastBottom - visibleBottom > TIMELINE_FOLLOW_REARM_THRESHOLD_PX) { + isAtEndRef.current = false; + setIsPinned(false); + scheduleShowScrollButton(); + } + } + } + return; + } if (!isLiveFollowActive()) return; // Since @legendapp/list 3.3.x, maintainScrollAtEnd follows content @@ -674,7 +697,7 @@ export const useChatTimelineScroll = ({ }); }); - }, [isLiveFollowActive]); + }, [isLiveFollowActive, scheduleShowScrollButton]); // The streaming tail grows inside one row without changing the entries // array, so data-change callbacks are silent for the entire stream. The From 27377efaf32236fa222a9e372f45b3f4f01144ac Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 17:10:05 +0300 Subject: [PATCH 24/49] fix(chat): return the typed prompt to the input on any send failure The failure handler restored the text only for a new-session draft; a regular session kept its attachments but lost the prompt to a toast. The restore now runs before any cause-specific branching: an unchanged composer gets the text (and the session draft) back, new typing gets the failed prompt appended instead of clobbered, and a mid-send session switch writes it into the originating session's persisted draft. --- packages/ui/src/components/chat/ChatInput.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 2cf2578b..f028ecd1 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1383,10 +1383,25 @@ const ChatInputComponent: React.FC = ({ console.error('Message send failed:', rawMessage || error); restoreConsumedDrafts(); - const currentInput = composerRef.current?.getValue() ?? messageRef.current; - if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) { - setMessage(inputSnapshot.message); - writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); + // A failed send returns the typed prompt no matter WHY it failed — + // auth, network, server, anything. Losing a long prompt to a toast + // is the one outcome this handler must never produce. + if (inputSnapshot.message) { + if (currentChatDraftIdentityRef.current !== chatDraftIdentity) { + // The user switched sessions mid-send: restore into that + // session's persisted draft, not the visible composer. + writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); + } else { + const currentInput = composerRef.current?.getValue() ?? messageRef.current; + if (!currentInput || currentInput === inputSnapshot.message) { + setMessage(inputSnapshot.message); + writeChatDraft(chatDraftIdentity, inputSnapshot.message, confirmedMentionsRef.current); + } else { + // New typing already lives in the composer; the failed + // prompt joins it instead of clobbering either text. + useInputStore.getState().setPendingInputText(inputSnapshot.message, 'append'); + } + } } const isSoftNetworkError = From 8ea94a119fd6c3bde3e392fc70d9775348da6042 Mon Sep 17 00:00:00 2001 From: Alexandre Reyes Martins Date: Wed, 26 Aug 2026 11:15:49 -0300 Subject: [PATCH 25/49] fix: restore baseline validation (#3142) --- packages/ui/src/apps/runtimeEndpointReset.ts | 2 +- .../ui/src/components/chat/ChatMessage.tsx | 20 -------- .../components/chat/message/MessageBody.tsx | 46 ------------------- packages/ui/src/components/layout/Header.tsx | 2 - .../sidebar/list/sessionCollection.test.ts | 2 +- ...llapsedActivityIndicator.behavior.test.tsx | 2 +- .../web/server/lib/session-goal/runtime.js | 3 +- .../server/lib/session-goal/runtime.test.js | 6 ++- 8 files changed, 9 insertions(+), 74 deletions(-) diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index 22e9705b..cd1f33ce 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -15,7 +15,7 @@ import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { resetStreamingState } from '@/sync/streaming'; -import { useGlobalSessionStatusStore, replaceGlobalSessionStatusById } from '@/sync/global-session-status'; +import { replaceGlobalSessionStatusById } from '@/sync/global-session-status'; import { resetSessionOrdering } from '@/sync/session-ordering'; import { resetSessionActivityTiming } from '@/sync/session-activity-timing'; import { syncDesktopSettings } from '@/lib/persistence'; diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index a4327167..20a86cde 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -457,13 +457,6 @@ const ChatMessage: React.FC = ({ }, [chatRenderMode, isMessageCompleted, isUser, visibleParts]); - const assistantTextParts = React.useMemo(() => { - if (isUser) { - return []; - } - return visibleParts.filter((part) => part.type === 'text'); - }, [isUser, visibleParts]); - const toolParts = React.useMemo(() => { if (isUser) { return []; @@ -545,19 +538,6 @@ const ChatMessage: React.FC = ({ const shouldHideUserMessage = isUser && displayParts.length === 0; - // Message is considered to have an "open step" if info.finish is not yet present - const hasOpenStep = typeof messageFinish !== 'string'; - - const shouldCoordinateRendering = React.useMemo(() => { - if (isUser) { - return false; - } - if (assistantTextParts.length === 0 || toolParts.length === 0) { - return hasOpenStep; - } - return true; - }, [assistantTextParts.length, toolParts.length, hasOpenStep, isUser]); - const themeVariant = currentTheme?.metadata.variant; const isDarkTheme = React.useMemo(() => { if (themeVariant) { diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 35a17a4d..c02d4fc3 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -1343,16 +1343,6 @@ const AssistantMessageBody = React.memo(({ return resolved ? { id: resolved.id, path: resolved.path } : null; }, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]); - const hasTools = toolParts.length > 0; - - const hasPendingTools = React.useMemo(() => { - return toolParts.some((toolPart) => { - const state = (toolPart as Record).state as Record | undefined ?? {}; - const status = state?.status; - return status === 'pending' || status === 'running' || status === 'started'; - }); - }, [toolParts]); - const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => { const state = (toolPart as Record).state as Record | undefined ?? {}; const status = state?.status; @@ -1381,42 +1371,6 @@ const AssistantMessageBody = React.memo(({ return isActiveTool(toolPart) || isToolFinalized(toolPart); }, [isActiveTool, isToolFinalized]); - const allToolsFinalized = React.useMemo(() => { - if (toolParts.length === 0) { - return true; - } - if (hasPendingTools) { - return false; - } - return toolParts.every((toolPart) => isToolFinalized(toolPart)); - }, [toolParts, hasPendingTools, isToolFinalized]); - - const reasoningParts = React.useMemo(() => { - return visibleParts.filter((part) => part.type === 'reasoning'); - }, [visibleParts]); - - const reasoningComplete = React.useMemo(() => { - if (reasoningParts.length === 0) { - return true; - } - return reasoningParts.every((part) => { - const time = (part as Record).time as { end?: number } | undefined; - return typeof time?.end === 'number'; - }); - }, [reasoningParts]); - - // Message is considered to have an "open step" if info.finish is not yet present - const hasOpenStep = typeof messageFinish !== 'string'; - - const shouldHoldForReasoning = - reasoningParts.length > 0 && - hasTools && - (hasPendingTools || hasOpenStep || !allToolsFinalized); - - const shouldHoldTools = awaitingMessageCompletion - || (hasTools && (hasPendingTools || hasOpenStep || !allToolsFinalized)); - const shouldHoldReasoning = awaitingMessageCompletion || shouldHoldForReasoning; - const hasCopyableText = Boolean(hasTextContent) && !awaitingMessageCompletion; const handleForkClick = React.useCallback( diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 90573996..154ba5a5 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -486,8 +486,6 @@ export const Header: React.FC = () => { const pathSegments = activeProject.path.split(/[\\/]/).filter(Boolean); return pathSegments[pathSegments.length - 1] ?? null; }, [activeProject]); - const quotaResults = useQuotaStore((state) => state.results); - const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); const { isMobile } = useDeviceInfo(); diff --git a/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts b/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts index 66c9b12a..38de74e2 100644 --- a/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts +++ b/packages/ui/src/components/session/sidebar/list/sessionCollection.test.ts @@ -4,7 +4,7 @@ import type { Event } from '@opencode-ai/sdk/v2/client'; import React, { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { deriveRecentSessions } from '../recent/activitySections'; -import { applyGlobalSessionStatusEvent, useGlobalSessionStatusStore , replaceGlobalSessionStatusById} from '@/sync/global-session-status'; +import { applyGlobalSessionStatusEvent, replaceGlobalSessionStatusById } from '@/sync/global-session-status'; import { buildSidebarSessionProjection, getDescendantIds, diff --git a/packages/ui/src/components/session/sidebar/sessions/collapsedActivityIndicator.behavior.test.tsx b/packages/ui/src/components/session/sidebar/sessions/collapsedActivityIndicator.behavior.test.tsx index a63095f7..74a7a518 100644 --- a/packages/ui/src/components/session/sidebar/sessions/collapsedActivityIndicator.behavior.test.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/collapsedActivityIndicator.behavior.test.tsx @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test'; import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; import type { Session } from '@opencode-ai/sdk/v2'; -import { useGlobalSessionStatusStore , replaceGlobalSessionStatusById} from '@/sync/global-session-status'; +import { replaceGlobalSessionStatusById } from '@/sync/global-session-status'; import { useNotificationStore } from '@/sync/notification-store'; import { useCollapsedSessionActivityState } from './collapsedActivityState'; import type { SessionNode } from '../types'; diff --git a/packages/web/server/lib/session-goal/runtime.js b/packages/web/server/lib/session-goal/runtime.js index e6916997..f97af98d 100644 --- a/packages/web/server/lib/session-goal/runtime.js +++ b/packages/web/server/lib/session-goal/runtime.js @@ -249,6 +249,7 @@ export const createSessionGoalRuntime = ({ getOpenCodeAuthHeaders, getSmallModelService, emitGoalNotification, + isEnabled = isSessionGoalEnabled, idleQuietMs = IDLE_QUIET_MS, kickoffQuietMs = KICKOFF_QUIET_MS, maxAutoTurns = MAX_AUTO_TURNS, @@ -444,7 +445,7 @@ export const createSessionGoalRuntime = ({ }; const tick = async (sessionId, directory) => { - if (!isSessionGoalEnabled()) return; + if (!isEnabled()) return; const session = await openCodeFetch(`/session/${encodeURIComponent(sessionId)}`, { directory }) .catch((error) => { diff --git a/packages/web/server/lib/session-goal/runtime.test.js b/packages/web/server/lib/session-goal/runtime.test.js index e091c8c4..583e4e37 100644 --- a/packages/web/server/lib/session-goal/runtime.test.js +++ b/packages/web/server/lib/session-goal/runtime.test.js @@ -35,13 +35,14 @@ const startIdleTick = async (fetchImpl) => { buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`, getOpenCodeAuthHeaders: () => ({}), getSmallModelService, + isEnabled: () => true, idleQuietMs: 10, }); runtime.processPayload({ type: 'session.status', properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY }, }); - await vi.advanceTimersByTimeAsync(10); + await vi.runOnlyPendingTimersAsync(); return { runtime, getSmallModelService }; }; @@ -156,6 +157,7 @@ describe('session goal live activity gate', () => { buildOpenCodeUrl: (pathname) => `http://opencode.test${pathname}`, getOpenCodeAuthHeaders: () => ({}), getSmallModelService: async () => service, + isEnabled: () => true, idleQuietMs: 10, }); @@ -163,7 +165,7 @@ describe('session goal live activity gate', () => { type: 'session.status', properties: { sessionID: SESSION_ID, status: { type: 'idle' }, directory: DIRECTORY }, }); - await vi.advanceTimersByTimeAsync(10); + await vi.runOnlyPendingTimersAsync(); expect(service.generateSmallModelText).toHaveBeenCalledOnce(); const patch = requests.find((request) => request.pathname === `/session/${SESSION_ID}` && request.method === 'PATCH'); From 5612849bd791e434c5bf04c1c381617bcebfb7b0 Mon Sep 17 00:00:00 2001 From: Alan Shum Date: Wed, 26 Aug 2026 07:33:31 -0700 Subject: [PATCH 26/49] fix(i18n): correct Ukrainian session grammar (#2984) * fix(i18n): correct Ukrainian session declension * fix(i18n): complete Ukrainian session declension * fix(i18n): correct Ukrainian active session agreement * chore: remove translation fix from changelog --------- Co-authored-by: Iuliia Ivashko --- .../ui/src/lib/i18n/messages/uk.settings.ts | 6 +++--- packages/ui/src/lib/i18n/messages/uk.ts | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 3e1ac7a9..feeafc91 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -206,9 +206,9 @@ export const settingsDict = { "settings.openchamber.tunnel.toast.addManagedRemoteTokenBeforeStarting": "Перед початком додайте токен керованого віддаленого тунелю", "settings.openchamber.tunnel.toast.startFailed": "Не вдалося запустити тунель", "settings.openchamber.tunnel.toast.startedButNoPublicUrl": "Тунель запущено, але публічний URL не повернувся", - "settings.openchamber.tunnel.toast.replacedTunnelSingleSingle": "Попередній тунель замінено: відкликано 1 посилання, анульовано 1 сесія.", + "settings.openchamber.tunnel.toast.replacedTunnelSingleSingle": "Попередній тунель замінено: відкликано 1 посилання, анульовано 1 сесію.", "settings.openchamber.tunnel.toast.replacedTunnelSingleManySessions": "Попередній тунель замінено: відкликано 1 посилання, анульовано сесій: {invalidatedSessionCount}.", - "settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано 1 сесія.", + "settings.openchamber.tunnel.toast.replacedTunnelManyLinksSingleSession": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано 1 сесію.", "settings.openchamber.tunnel.toast.replacedTunnelManyMany": "Попередній тунель замінено: відкликано посилань: {revokedBootstrapCount}, анульовано сесій: {invalidatedSessionCount}.", "settings.openchamber.tunnel.toast.linkReady": "Тунель готовий", "settings.openchamber.tunnel.toast.stopped": "Тунель зупинено", @@ -2145,7 +2145,7 @@ export const settingsDict = { "settings.magicPrompts.page.group.planImprove.title": "Поліпшити план", "settings.magicPrompts.page.group.planImprove.description": "Прихований промпт, який використовується під час надсилання збереженого плану в потік покращення.", "settings.magicPrompts.page.group.planTodo.title": "Планування Todo", - "settings.magicPrompts.page.group.planTodo.description": "Прихований промпт, який використовується під час надсилання завдання до нового сесії планування.", + "settings.magicPrompts.page.group.planTodo.description": "Прихований промпт, який використовується під час надсилання завдання до нової сесії планування.", "settings.magicPrompts.page.group.planImplement.title": "Реалізувати план", "settings.magicPrompts.page.group.planImplement.description": "Прихований промпт, який використовується під час надсилання збереженого плану в потік реалізації.", "settings.magicPrompts.page.group.sessionSummary.title": "Підсумок сесії", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 56f101b1..60bbb406 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -504,12 +504,12 @@ export const dict: Record = { "sessions.sidebar.bulkActions.failedDeletePlural": "Не вдалося видалити сесії {count}", "sessions.sidebar.bulkActions.archivedSingle": "Заархівовано сесію: {count}", "sessions.sidebar.bulkActions.archivedPlural": "Заархівовано сесій: {count}", - "sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесія {count}", + "sessions.sidebar.bulkActions.failedArchiveSingle": "Не вдалося архівувати сесію {count}", "sessions.sidebar.bulkActions.failedArchivePlural": "Не вдалося архівувати сесії {count}", "sessions.sidebar.bulkActions.restore": "Відновити", "sessions.sidebar.bulkActions.restoredSingle": "Відновлено сесію: {count}", "sessions.sidebar.bulkActions.restoredPlural": "Відновлено сесій: {count}", - "sessions.sidebar.bulkActions.failedRestoreSingle": "Не вдалося відновити сесія {count}", + "sessions.sidebar.bulkActions.failedRestoreSingle": "Не вдалося відновити сесію {count}", "sessions.sidebar.bulkActions.failedRestorePlural": "Не вдалося відновити сесії {count}", "sessions.sidebar.folders.none": "Папок ще немає", "sessions.sidebar.folders.newFolderEllipsis": "Нова папка...", @@ -560,9 +560,9 @@ export const dict: Record = { "sessions.sidebar.session.export.dialog.descriptionMany": "Ця сесія має {count} завдань під-агентів. Додати їх до експорту?", "sessions.sidebar.session.export.dialog.includeSubtasks": "Додати завдання під-агентів", "sessions.sidebar.session.export.dialog.confirm": "Експортувати", - "sessions.sidebar.session.status.active": "Сесія активний", + "sessions.sidebar.session.status.active": "Сесія активна", "sessions.sidebar.session.status.unread": "Непрочитані оновлення", - "sessions.sidebar.session.status.pinned": "Закріплений сесія", + "sessions.sidebar.session.status.pinned": "Закріплена сесія", "sessions.sidebar.session.status.movingToWorktree": "Перенесення сесії в новий worktree", "sessions.sidebar.session.status.permissionRequired": "Потрібен дозвіл", "sessions.sidebar.session.status.questionPendingSingle": "1 запитання очікує відповіді", @@ -571,8 +571,8 @@ export const dict: Record = { "sessions.sidebar.session.status.lastTurnDuration": "Останній хід тривав {duration}", "sessions.sidebar.session.subsessions.collapse": "Згорнути підсесії", "sessions.sidebar.session.subsessions.expand": "Розгорнути підсесії", - "sessions.sidebar.dialogs.deleteSession.title": "Видалити сесія?", - "sessions.sidebar.dialogs.archiveSession.title": "Архівувати сесія?", + "sessions.sidebar.dialogs.deleteSession.title": "Видалити сесію?", + "sessions.sidebar.dialogs.archiveSession.title": "Архівувати сесію?", "sessions.sidebar.dialogs.deleteSession.withOneSubtask": "\"{sessionTitle}\" і його підзавдання {count} буде остаточно видалено.", "sessions.sidebar.dialogs.deleteSession.withManySubtasks": "\"{sessionTitle}\" і його підзавдання {count} буде остаточно видалено.", "sessions.sidebar.dialogs.archiveSession.withOneSubtask": "\"{sessionTitle}\" і його підзавдання {count} буде заархівовано.", @@ -581,7 +581,7 @@ export const dict: Record = { "sessions.sidebar.dialogs.archiveSession.single": "\"{sessionTitle}\" буде заархівовано.", "sessions.sidebar.dialogs.neverAsk": "Більше не питати", "sessions.sidebar.dialogs.cancel": "Скасувати", - "sessions.sidebar.dialogs.deleteSession.titleAction": "Видалити сесія", + "sessions.sidebar.dialogs.deleteSession.titleAction": "Видалити сесію", "sessions.sidebar.dialogs.deleteSessions.titleAction": "Видалити сесії", "sessions.sidebar.dialogs.deleteSessions.title": "Видалити сесії?", "sessions.sidebar.dialogs.archiveSessions.title": "Архівувати сесії?", @@ -661,7 +661,7 @@ export const dict: Record = { "sessions.sidebar.folderItem.deleteFolderAria": "Видалити папку {folderName}", "sessions.sidebar.folderItem.emptyFolder": "Порожня папка", "sessions.sidebar.sessionDialogs.ok": "OK", - "sessions.sidebar.sessionDialogs.linkedSessionSingle": "Пов’язаний сесія", + "sessions.sidebar.sessionDialogs.linkedSessionSingle": "Пов’язана сесія", "sessions.sidebar.sessionDialogs.linkedSessionPlural": "Пов’язані сесії", "sessions.sidebar.sessionDialogs.delete.note": "Каталоги worktree залишаються недоторканими. Підсесії, пов’язані з вибраними сесіями, також буде видалено.", "sessions.sidebar.sessionDialogs.directory.errorSelectTitle": "Не вдалося вибрати каталог", @@ -2431,7 +2431,7 @@ export const dict: Record = { "chat.messageBody.subtask.title": "Делеговане завдання", "chat.messageBody.subtask.hidePrompt": "Приховати промпт", "chat.messageBody.subtask.showPrompt": "Показати промпт", - "chat.messageBody.subtask.openSession": "Відкрити сесія підзавдання", + "chat.messageBody.subtask.openSession": "Відкрити сесію підзавдання", "chat.messageBody.shellCommand.title": "Команда оболонки", "chat.messageBody.shellCommand.hideOutput": "Приховати вивід", "chat.messageBody.shellCommand.showOutput": "Показати результат", From f7a006dc6a17f5cb80625dc9f565b85c186ea429 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 18:21:53 +0300 Subject: [PATCH 27/49] feat(auth): detect session expiry live and offer re-login in place Every response already funnels through runtimeFetch, so a classifier there spots 401s, confirms them against /auth/session (a proxied provider 401 must not read as a logout), and flips a small auth-session store. The web and hosted surfaces show a frosted banner under the header whose Log in button hands off to the session gate's existing unlock flow; sends are paused while expired, the session-load error screen explains the auth case and retries itself after login, and returning to a long-idle window revalidates once via visibility/focus. Native mobile feeds the same signal into its connection re-probe instead of showing the banner; VS Code is exempt. --- CHANGELOG.md | 1 + packages/ui/src/apps/MobileApp.tsx | 18 +++ .../src/components/auth/AuthExpiredBanner.tsx | 41 ++++++ .../src/components/auth/SessionAuthGate.tsx | 30 ++++- .../ui/src/components/chat/ChatContainer.tsx | 38 +++++- packages/ui/src/components/chat/ChatInput.tsx | 9 ++ packages/ui/src/lib/i18n/messages/de.ts | 6 +- packages/ui/src/lib/i18n/messages/en.ts | 6 +- packages/ui/src/lib/i18n/messages/es.ts | 6 +- packages/ui/src/lib/i18n/messages/fr.ts | 6 +- packages/ui/src/lib/i18n/messages/ja.ts | 6 +- packages/ui/src/lib/i18n/messages/ko.ts | 6 +- packages/ui/src/lib/i18n/messages/pl.ts | 6 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 6 +- packages/ui/src/lib/i18n/messages/uk.ts | 6 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 6 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 6 +- packages/ui/src/lib/runtime-auth-expiry.ts | 126 ++++++++++++++++++ packages/ui/src/lib/runtime-fetch.ts | 9 ++ 19 files changed, 322 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/components/auth/AuthExpiredBanner.tsx create mode 100644 packages/ui/src/lib/runtime-auth-expiry.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9688673e..bc8d2c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. - **Keyboard shortcuts learned sequences** (thanks @ChangeHow): shortcuts can now be two steps — press Cmd/Ctrl+S, then a letter: P opens the draft's project picker, G its branch picker, L the session list. A held sequence shows no menu but forgives you: Escape or three quiet seconds cancel it, and typing into a text field never triggers one armed elsewhere. Dropdown menus and pickers now also answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and every tooltip and menu label shows the binding you actually have set, not the default. - **Keyboard shortcuts redesigned.** Defaults now follow one model: single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (K then P/G/L — project picker, branch picker, session list; T timeline, N prompt navigator, I services, H shortcut help, C theme), held Cmd/Ctrl+digit for header session tabs and held Cmd/Ctrl+Option+digit for context panel surfaces. Cmd/Ctrl+B now toggles the sidebar; when it's already open, the session-list shortcut jumps into its search. Rare actions moved into the command palette instead of carrying obscure default bindings, and custom bindings recorded under the old layout are reset once. Shortcuts also stopped requiring an English keyboard layout — bindings follow the physical key on non-Latin layouts (and Option-modified digits on macOS), including when recording custom ones. +- **Session expiry is announced, not discovered.** When the OpenChamber login expires (a browser on the LAN, a paired device, a tunnel), a frosted banner appears under the header within seconds — before anything is clicked — saying the session expired, with a Log in button that opens the usual unlock screen. Work on screen stays visible and interactive; sending is paused until login instead of failing into a toast. A 401 is confirmed against the server first, so an expired model-provider key can't fake a logout, and returning to the app after a long absence re-checks the session once. If a conversation failed to load while logged out, it explains that and reloads itself right after login. - **Permission cards answer to the keyboard:** Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons, and the newest pending card is the one that listens. The auto-accept toggle also got a shortcut (Cmd/Ctrl+K, A). - Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. Cmd/Ctrl+K, R renames the current session right in the header. - Git: Cmd/Ctrl+Enter in the commit message box commits, like every git client. diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 4d30dc00..5c4a5079 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -12,6 +12,7 @@ import { SettingsView } from '@/components/views/SettingsView'; import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { TooltipProvider } from '@/components/ui/tooltip'; import { Toaster } from '@/components/ui/sonner'; @@ -772,6 +773,23 @@ export function MobileApp({ apis }: MobileAppProps) { }; }, [isNativeMobileApp, handleNativeResume]); + // A confirmed mid-session auth expiry (classified centrally from live 401 + // traffic) runs the same seq-guarded re-probe the resume path uses: it ends + // in needs-login → the native welcome screen with the auth-expired notice. + // The shared web banner never renders on native (the session gate is not + // mounted here), so this is the only surface reacting to the signal. + React.useEffect(() => { + if (!isNativeMobileApp) return; + return useAuthSessionStore.subscribe((store, previous) => { + if (store.state === 'expired' && previous.state !== 'expired') { + handleNativeResume(); + // The probe ladder owns the outcome from here; the shared store goes + // back to 'ok' so a later expiry can signal again. + useAuthSessionStore.getState().markAuthenticated(); + } + }); + }, [isNativeMobileApp, handleNativeResume]); + React.useEffect(() => { registerRuntimeAPIs(apis); return () => registerRuntimeAPIs(null); diff --git a/packages/ui/src/components/auth/AuthExpiredBanner.tsx b/packages/ui/src/components/auth/AuthExpiredBanner.tsx new file mode 100644 index 00000000..986b7484 --- /dev/null +++ b/packages/ui/src/components/auth/AuthExpiredBanner.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; + +/** + * Non-blocking notice that the OpenChamber session expired mid-work. It never + * takes the screen on its own: work stays visible and interactive, and only + * the explicit "Log in" click hands control to the session gate's full login + * flow (password, passkey, desktop shell — all already there). + */ +export const AuthExpiredBanner: React.FC = () => { + const { t } = useI18n(); + const authState = useAuthSessionStore((store) => store.state); + const markReauthenticating = useAuthSessionStore((store) => store.markReauthenticating); + + if (authState !== 'expired') { + return null; + } + + return ( + // Below the header on purpose: the header row can be a window-drag region + // on desktop, where nothing under the cursor is clickable. +
+
+ + {t('sessionAuth.expired.banner')} + +
+
+ ); +}; diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 553804cc..4b855f1e 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -12,6 +12,8 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { installAuthSessionFocusWatch, useAuthSessionStore } from '@/lib/runtime-auth-expiry'; +import { AuthExpiredBanner } from './AuthExpiredBanner'; import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts'; @@ -557,6 +559,27 @@ export const SessionAuthGate: React.FC = ({ } }, [skipAuth, state]); + // Mid-session expiry: the banner asks for a re-login by flipping the shared + // auth store to 'reauthenticating'; the gate answers with its own status + // check, which lands in the full 'locked' flow on a genuine 401. A + // successful login resolves the store back to 'ok'. + const authSessionState = useAuthSessionStore((store) => store.state); + React.useEffect(() => { + if (!skipAuth) installAuthSessionFocusWatch(); + }, [skipAuth]); + React.useEffect(() => { + if (skipAuth) return; + if (authSessionState === 'reauthenticating') { + void checkStatusRef.current?.(); + } + }, [authSessionState, skipAuth]); + React.useEffect(() => { + if (skipAuth) return; + if (state === 'authenticated' && useAuthSessionStore.getState().state !== 'ok') { + useAuthSessionStore.getState().markAuthenticated(); + } + }, [skipAuth, state]); + React.useEffect(() => { if (state === 'locked' && passwordInputRef.current) { passwordInputRef.current.focus(); @@ -983,5 +1006,10 @@ export const SessionAuthGate: React.FC = ({ ); } - return <>{children}; + return ( + <> + {skipAuth ? null : } + {children} + + ); }; diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 0873eee2..d3c71b23 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -18,6 +18,7 @@ import { StatusRowContainer } from './StatusRowContainer'; import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { PromptNavigatorRail } from './components/PromptNavigatorRail'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { useScrollShadow } from '@/components/ui/useScrollShadow'; import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll'; import { useChatTimelineController } from './hooks/useChatTimelineController'; @@ -645,6 +646,8 @@ export const ChatContainer: React.FC = ({ suspendPartUpdatesForMessageId: streamingMessageId, }); const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES; + const authSessionExpired = useAuthSessionStore((store) => store.state !== 'ok'); + const wasAuthExpiredRef = React.useRef(false); const sessionMessageLoadState = useSessionMessageLoadState( currentSessionId ?? '', effectiveSessionDirectory, @@ -1170,6 +1173,23 @@ export const ChatContainer: React.FC = ({ void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory); }, [currentSessionId, effectiveSessionDirectory, messagesEnabled, sync]); + // A load that failed while the session was expired retries itself the + // moment the re-login lands — the error screen should never outlive its + // cause. + React.useEffect(() => { + if (authSessionExpired) { + wasAuthExpiredRef.current = true; + return; + } + if (wasAuthExpiredRef.current) { + wasAuthExpiredRef.current = false; + if (sessionMessageLoadState.status === 'error') { + retrySessionLoad(); + } + } + }, [authSessionExpired, retrySessionLoad, sessionMessageLoadState.status]); + + React.useEffect(() => { if (!active || !currentSessionId) return; if (lastScrolledSessionKeyRef.current === currentSessionKey) return; @@ -1298,10 +1318,20 @@ export const ChatContainer: React.FC = ({

{t('chat.container.sessionLoadError.title')}

-

{t('chat.container.sessionLoadError.description')}

- +

+ {authSessionExpired + ? t('chat.container.sessionLoadError.authDescription') + : t('chat.container.sessionLoadError.description')} +

+ {authSessionExpired ? ( + + ) : ( + + )} ); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index f028ecd1..8d375160 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -78,6 +78,7 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { usePermissionStore } from '@/stores/permissionStore'; import { togglePermissionAutoAccept } from './permissionAutoAccept'; import { useKeybind } from '@/hooks/useKeybind'; +import { useAuthSessionStore } from '@/lib/runtime-auth-expiry'; import { extractGitChangedFiles } from './changedFiles'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; @@ -965,6 +966,14 @@ const ChatInputComponent: React.FC = ({ const queuedMessageId = options?.queuedMessageId; const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined; const capturedTarget = messageQueueTarget; + // An expired session cannot deliver anything: keep the prompt in the + // composer and point at the login banner instead of burning the send + // on a guaranteed 401. + if (useAuthSessionStore.getState().state !== 'ok') { + toast.error(t('sessionAuth.expired.sendBlocked')); + return; + } + // Snapshot the draft and current-session identity before the first // async gap so a later sidebar selection cannot reroute the send. const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 64382772..591c055b 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2511,6 +2511,9 @@ export const dict = { 'sessionAuth.error.passkeySignInCanceled': 'Passkey-Anmeldung wurde abgebrochen.', 'sessionAuth.error.enterPasswordForPasskey': 'Geben Sie Ihr Passwort ein, um einen Passkey hinzuzufügen.', 'sessionAuth.locked.tunnelTitle': 'Tunnel-Zugriff erforderlich', + 'sessionAuth.expired.banner': 'Deine Sitzung ist abgelaufen — melde dich an, um fortzufahren.', + 'sessionAuth.expired.loginAction': 'Anmelden', + 'sessionAuth.expired.sendBlocked': 'Sitzung abgelaufen — melde dich an, um Nachrichten zu senden.', 'sessionAuth.locked.unlockTitle': 'OpenChamber entsperren', 'sessionAuth.locked.tunnelDescription': 'Öffnen Sie diesen Tunnel über den Einmal-Verbindungslink aus der Desktop-Anwendung.', 'sessionAuth.locked.passwordDescription': 'Diese Sitzung ist passwortgeschützt.', @@ -3091,7 +3094,8 @@ export const dict = { 'chat.commandAutocomplete.command.scheduleTaskDescription': 'Eine geplante Aufgabe erstellen', 'chat.chatInput.toast.scheduleTaskFailed': 'Aufgabe konnte nicht geplant werden', 'chat.container.sessionLoadError.title': 'Sitzung konnte nicht geladen werden', - 'chat.container.sessionLoadError.description': 'Die Sitzung konnte nicht geladen werden.', + 'chat.container.sessionLoadError.description': 'Die Unterhaltung konnte nicht geladen werden — der Server ist womöglich offline oder nicht erreichbar. Nichts ist verloren; versuche es erneut, sobald er wieder da ist.', + 'chat.container.sessionLoadError.authDescription': 'Deine Sitzung ist abgelaufen, daher hat der Server die Anfrage abgelehnt. Melde dich an, dann wird die Unterhaltung geladen.', 'chat.container.sessionLoadError.retry': 'Erneut versuchen', 'sessions.sidebar.group.empty.loadingSessions': 'Sitzungen werden geladen...', 'sessions.sidebar.group.empty.loadFailed': 'Sitzungen konnten nicht geladen werden', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 169f4c5f..53da59bd 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2162,7 +2162,8 @@ export const dict = { 'chat.btw.promoteAria': 'Keep as a separate session', 'chat.btw.toast.promoteFailed': 'Failed to keep the btw session', 'chat.container.sessionLoadError.title': 'Session could not be loaded', - 'chat.container.sessionLoadError.description': 'Check the connection and try loading this session again.', + 'chat.container.sessionLoadError.description': 'The conversation could not be fetched — the server may be offline or unreachable. Nothing is lost; retry once it is back.', + 'chat.container.sessionLoadError.authDescription': 'Your session expired, so the server refused the request. Log in and the conversation will load.', 'chat.container.sessionLoadError.retry': 'Try again', 'sessions.sidebar.group.empty.loadingSessions': 'Loading sessions…', 'sessions.sidebar.group.empty.loadFailed': 'Could not refresh sessions.', @@ -2707,6 +2708,9 @@ export const dict = { 'sessionAuth.error.passkeySignInCanceled': 'Passkey sign-in was canceled.', 'sessionAuth.error.enterPasswordForPasskey': 'Enter your password to add a passkey.', 'sessionAuth.locked.tunnelTitle': 'Tunnel access required', + 'sessionAuth.expired.banner': 'Your session expired — log in to continue.', + 'sessionAuth.expired.loginAction': 'Log in', + 'sessionAuth.expired.sendBlocked': 'Session expired — log in to send messages.', 'sessionAuth.locked.unlockTitle': 'Unlock OpenChamber', 'sessionAuth.locked.tunnelDescription': 'Open this tunnel using the one-time connect link from the desktop app.', 'sessionAuth.locked.passwordDescription': 'This session is password-protected.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 04bbeaa6..9d7f86aa 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2140,7 +2140,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'No se pudo conservar la sesión btw', "chat.container.readOnlySubagentPromptBanner": "Las sesiones de subagentes no pueden recibir prompts.", "chat.container.sessionLoadError.title": "No se pudo cargar la sesión", - "chat.container.sessionLoadError.description": "Comprueba la conexión e intenta cargar esta sesión de nuevo.", + "chat.container.sessionLoadError.description": "No se pudo obtener la conversación: puede que el servidor esté apagado o inaccesible. No se perdió nada; reintenta cuando vuelva.", + "chat.container.sessionLoadError.authDescription": "Tu sesión expiró, por lo que el servidor rechazó la solicitud. Inicia sesión y la conversación se cargará.", "chat.container.sessionLoadError.retry": "Reintentar", "sessions.sidebar.group.empty.loadingSessions": "Cargando sesiones…", "sessions.sidebar.group.empty.loadFailed": "No se pudieron actualizar las sesiones.", @@ -2673,6 +2674,9 @@ export const dict: Record = { "sessionAuth.error.passkeySignInCanceled": "El inicio de sesión con clave de paso se canceló.", "sessionAuth.error.enterPasswordForPasskey": "Introduce tu contraseña para añadir una clave de paso.", "sessionAuth.locked.tunnelTitle": "Se requiere acceso por túnel", + "sessionAuth.expired.banner": "Tu sesión expiró: inicia sesión para continuar.", + "sessionAuth.expired.loginAction": "Iniciar sesión", + "sessionAuth.expired.sendBlocked": "Sesión expirada: inicia sesión para enviar mensajes.", "sessionAuth.locked.unlockTitle": "Desbloquear OpenChamber", "sessionAuth.locked.tunnelDescription": "Abre este túnel usando el enlace de conexión única desde la aplicación de escritorio.", "sessionAuth.locked.passwordDescription": "Esta sesión está protegida con contraseña.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index b35feb84..09e3b596 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1893,7 +1893,8 @@ export const dict = { 'chat.btw.toast.promoteFailed': 'Échec de la conservation de la session btw', 'chat.container.readOnlySubagentPromptBanner': 'Les sessions de sous-agent ne peuvent pas être invitées.', 'chat.container.sessionLoadError.title': 'Impossible de charger la session', - 'chat.container.sessionLoadError.description': 'Vérifiez la connexion et essayez de charger à nouveau cette session.', + 'chat.container.sessionLoadError.description': 'Impossible de récupérer la conversation — le serveur est peut-être hors ligne ou injoignable. Rien n\'est perdu ; réessayez quand il sera de retour.', + 'chat.container.sessionLoadError.authDescription': 'Votre session a expiré, le serveur a donc refusé la requête. Connectez-vous et la conversation se chargera.', 'chat.container.sessionLoadError.retry': 'Réessayer', 'sessions.sidebar.group.empty.loadingSessions': 'Chargement des sessions…', 'sessions.sidebar.group.empty.loadFailed': 'Impossible d’actualiser les sessions.', @@ -2411,6 +2412,9 @@ export const dict = { 'sessionAuth.error.passkeySignInCanceled': 'La connexion par mot de passe a été annulée.', 'sessionAuth.error.enterPasswordForPasskey': 'Entrez votre mot de passe pour ajouter un mot de passe.', 'sessionAuth.locked.tunnelTitle': 'Accès au tunnel requis', + 'sessionAuth.expired.banner': 'Votre session a expiré — connectez-vous pour continuer.', + 'sessionAuth.expired.loginAction': 'Se connecter', + 'sessionAuth.expired.sendBlocked': 'Session expirée — connectez-vous pour envoyer des messages.', 'sessionAuth.locked.unlockTitle': 'Débloquez OpenChamber', 'sessionAuth.locked.tunnelDescription': 'Ouvrez ce tunnel à l\'aide du lien de connexion unique depuis l\'application de bureau.', 'sessionAuth.locked.passwordDescription': 'Cette session est protégée par mot de passe.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 1eeb39e0..0e88eb6d 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2158,7 +2158,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'btwセッションを保持できませんでした', 'chat.container.readOnlySubagentPromptBanner': 'サブエージェントセッションはプロンプトを受け付けません。', 'chat.container.sessionLoadError.title': 'セッションを読み込めませんでした', - 'chat.container.sessionLoadError.description': '接続を確認して、このセッションをもう一度読み込んでください。', + 'chat.container.sessionLoadError.description': '会話を取得できませんでした。サーバーが停止中か到達できない可能性があります。データは失われていません。復旧後に再試行してください。', + 'chat.container.sessionLoadError.authDescription': 'セッションの有効期限が切れたため、サーバーがリクエストを拒否しました。ログインすると会話が読み込まれます。', 'chat.container.sessionLoadError.retry': '再試行', 'sessions.sidebar.group.empty.loadingSessions': 'セッションを読み込んでいます…', 'sessions.sidebar.group.empty.loadFailed': 'セッションを更新できませんでした。', @@ -2706,6 +2707,9 @@ export const dict: Record = { 'sessionAuth.error.passkeySignInCanceled': 'パスキーサインインがキャンセルされました。', 'sessionAuth.error.enterPasswordForPasskey': 'パスキーを追加するためにパスワードを入力してください。', 'sessionAuth.locked.tunnelTitle': 'トンネルアクセスが必要', + 'sessionAuth.expired.banner': 'セッションの有効期限が切れました。続行するにはログインしてください。', + 'sessionAuth.expired.loginAction': 'ログイン', + 'sessionAuth.expired.sendBlocked': 'セッションが切れています。メッセージを送るにはログインしてください。', 'sessionAuth.locked.unlockTitle': 'OpenChamberのロックを解除', 'sessionAuth.locked.tunnelDescription': 'デスクトップアプリのワンタイム接続リンクを使用してこのトンネルを開きます。', 'sessionAuth.locked.passwordDescription': 'このセッションはパスワードで保護されています。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index b2c9192a..ce822479 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2164,7 +2164,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'btw 세션을 유지하지 못했습니다', 'chat.container.readOnlySubagentPromptBanner': '하위 에이전트 세션에는 프롬프트를 보낼 수 없습니다.', 'chat.container.sessionLoadError.title': '세션을 불러올 수 없습니다', - 'chat.container.sessionLoadError.description': '연결을 확인한 후 이 세션을 다시 불러오세요.', + 'chat.container.sessionLoadError.description': '대화를 가져오지 못했습니다. 서버가 꺼져 있거나 연결할 수 없는 상태일 수 있습니다. 데이터는 사라지지 않았으니 복구되면 다시 시도하세요.', + 'chat.container.sessionLoadError.authDescription': '세션이 만료되어 서버가 요청을 거부했습니다. 로그인하면 대화가 로드됩니다.', 'chat.container.sessionLoadError.retry': '다시 시도', 'sessions.sidebar.group.empty.loadingSessions': '세션을 불러오는 중…', 'sessions.sidebar.group.empty.loadFailed': '세션을 새로 고칠 수 없습니다.', @@ -2707,6 +2708,9 @@ export const dict: Record = { 'sessionAuth.error.passkeySignInCanceled': '패스키 로그인이 취소되었습니다.', 'sessionAuth.error.enterPasswordForPasskey': '패스키를 추가하려면 비밀번호를 입력하세요.', 'sessionAuth.locked.tunnelTitle': '터널 접근 필요', + 'sessionAuth.expired.banner': '세션이 만료되었습니다. 계속하려면 로그인하세요.', + 'sessionAuth.expired.loginAction': '로그인', + 'sessionAuth.expired.sendBlocked': '세션이 만료되었습니다. 메시지를 보내려면 로그인하세요.', 'sessionAuth.locked.unlockTitle': 'OpenChamber 잠금 해제', 'sessionAuth.locked.tunnelDescription': '데스크톱 앱의 일회용 연결 링크로 이 터널을 여세요.', 'sessionAuth.locked.passwordDescription': '이 세션은 비밀번호로 보호됩니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 25933ab1..b79dcfd9 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -853,7 +853,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'Nie udało się zachować sesji btw', 'chat.container.readOnlySubagentPromptBanner': 'Sesje podagentów nie mogą otrzymywać promptów.', 'chat.container.sessionLoadError.title': 'Nie udało się wczytać sesji', - 'chat.container.sessionLoadError.description': 'Sprawdź połączenie i spróbuj ponownie wczytać tę sesję.', + 'chat.container.sessionLoadError.description': 'Nie udało się pobrać rozmowy — serwer może być wyłączony lub nieosiągalny. Nic nie przepadło; spróbuj ponownie, gdy wróci.', + 'chat.container.sessionLoadError.authDescription': 'Sesja wygasła, więc serwer odrzucił żądanie. Zaloguj się, a rozmowa się wczyta.', 'chat.container.sessionLoadError.retry': 'Spróbuj ponownie', 'sessions.sidebar.group.empty.loadingSessions': 'Wczytywanie sesji…', 'sessions.sidebar.group.empty.loadFailed': 'Nie udało się odświeżyć sesji.', @@ -2865,6 +2866,9 @@ export const dict: Record = { 'sessionAuth.locked.passwordDescription': 'Ta sesja jest chroniona hasłem.', 'sessionAuth.locked.tunnelDescription': 'Otwórz ten tunel za pomocą jednorazowego linku połączenia z aplikacji desktopowej.', 'sessionAuth.locked.tunnelTitle': 'Wymagany dostęp przez tunel', + 'sessionAuth.expired.banner': 'Sesja wygasła — zaloguj się, aby kontynuować.', + 'sessionAuth.expired.loginAction': 'Zaloguj się', + 'sessionAuth.expired.sendBlocked': 'Sesja wygasła — zaloguj się, aby wysyłać wiadomości.', 'sessionAuth.locked.unlockTitle': 'Odblokuj OpenChamber', 'sessionAuth.password.placeholder': 'Wpisz hasło', 'sessionAuth.toast.passkeyAdded': 'Dodano klucz dostępu', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 932b3ea3..d66e0070 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2140,7 +2140,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'Falha ao manter a sessão btw', "chat.container.readOnlySubagentPromptBanner": "Sessões de subagente não podem receber prompts.", "chat.container.sessionLoadError.title": "Não foi possível carregar a sessão", - "chat.container.sessionLoadError.description": "Verifique a conexão e tente carregar esta sessão novamente.", + "chat.container.sessionLoadError.description": "Não foi possível buscar a conversa — o servidor pode estar desligado ou inacessível. Nada foi perdido; tente novamente quando ele voltar.", + "chat.container.sessionLoadError.authDescription": "Sua sessão expirou, então o servidor recusou a solicitação. Entre e a conversa será carregada.", "chat.container.sessionLoadError.retry": "Tentar novamente", "sessions.sidebar.group.empty.loadingSessions": "Carregando sessões…", "sessions.sidebar.group.empty.loadFailed": "Não foi possível atualizar as sessões.", @@ -2673,6 +2674,9 @@ export const dict: Record = { "sessionAuth.error.passkeySignInCanceled": "O início de sessão com chave de acesso foi cancelado.", "sessionAuth.error.enterPasswordForPasskey": "Digite sua senha para adicionar uma chave de acesso.", "sessionAuth.locked.tunnelTitle": "É necessário acesso por túnel", + "sessionAuth.expired.banner": "Sua sessão expirou — entre para continuar.", + "sessionAuth.expired.loginAction": "Entrar", + "sessionAuth.expired.sendBlocked": "Sessão expirada — entre para enviar mensagens.", "sessionAuth.locked.unlockTitle": "Desbloquear OpenChamber", "sessionAuth.locked.tunnelDescription": "Abra este túnel usando o link de conexão única do aplicativo desktop.", "sessionAuth.locked.passwordDescription": "Esta sessão está protegida com senha.", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 60bbb406..82674b90 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2140,7 +2140,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': 'Не вдалося залишити сесію btw', "chat.container.readOnlySubagentPromptBanner": "Сесії субагентів не можна запитувати.", "chat.container.sessionLoadError.title": "Не вдалося завантажити сесію", - "chat.container.sessionLoadError.description": "Перевірте з’єднання та спробуйте завантажити цю сесію ще раз.", + "chat.container.sessionLoadError.description": "Не вдалося отримати розмову — сервер може бути вимкнений або недосяжний. Нічого не втрачено; спробуй знову, коли він повернеться.", + "chat.container.sessionLoadError.authDescription": "Сесія завершилась, тож сервер відхилив запит. Увійди — і розмова завантажиться.", "chat.container.sessionLoadError.retry": "Спробувати знову", "sessions.sidebar.group.empty.loadingSessions": "Завантаження сесій…", "sessions.sidebar.group.empty.loadFailed": "Не вдалося оновити сесії.", @@ -2673,6 +2674,9 @@ export const dict: Record = { "sessionAuth.error.passkeySignInCanceled": "Вхід за ключем доступу скасовано.", "sessionAuth.error.enterPasswordForPasskey": "Введіть пароль, щоб додати ключ доступу.", "sessionAuth.locked.tunnelTitle": "Потрібен доступ до тунелю", + "sessionAuth.expired.banner": "Сесія завершилась — увійди, щоб продовжити.", + "sessionAuth.expired.loginAction": "Увійти", + "sessionAuth.expired.sendBlocked": "Сесія завершилась — увійди, щоб надсилати повідомлення.", "sessionAuth.locked.unlockTitle": "Розблокувати OpenChamber", "sessionAuth.locked.tunnelDescription": "Відкрийте цей тунель за допомогою одноразового посилання для з’єднання з настільної програми.", "sessionAuth.locked.passwordDescription": "Ця сесія захищена паролем.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2616b032..8121c9c4 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2128,7 +2128,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': '保留 btw 会话失败', 'chat.container.readOnlySubagentPromptBanner': '无法向子智能体会话发送提示。', 'chat.container.sessionLoadError.title': '无法加载会话', - 'chat.container.sessionLoadError.description': '请检查连接,然后重新加载此会话。', + 'chat.container.sessionLoadError.description': '无法获取对话——服务器可能已关闭或无法访问。内容没有丢失;等它恢复后重试即可。', + 'chat.container.sessionLoadError.authDescription': '会话已过期,服务器拒绝了请求。登录后对话即会加载。', 'chat.container.sessionLoadError.retry': '重试', 'sessions.sidebar.group.empty.loadingSessions': '正在加载会话…', 'sessions.sidebar.group.empty.loadFailed': '无法刷新会话。', @@ -2673,6 +2674,9 @@ export const dict: Record = { 'sessionAuth.error.passkeySignInCanceled': 'Passkey 登录已取消。', 'sessionAuth.error.enterPasswordForPasskey': '请输入密码以添加 passkey。', 'sessionAuth.locked.tunnelTitle': '需要隧道访问', + 'sessionAuth.expired.banner': '会话已过期——请登录以继续。', + 'sessionAuth.expired.loginAction': '登录', + 'sessionAuth.expired.sendBlocked': '会话已过期——请登录后再发送消息。', 'sessionAuth.locked.unlockTitle': '解锁 OpenChamber', 'sessionAuth.locked.tunnelDescription': '请使用桌面应用提供的一次性连接链接打开该隧道。', 'sessionAuth.locked.passwordDescription': '此会话受密码保护。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 4dc07807..d6039d9f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2132,7 +2132,8 @@ export const dict: Record = { 'chat.btw.toast.promoteFailed': '保留 btw 工作階段失敗', 'chat.container.readOnlySubagentPromptBanner': '無法向子 Agent 會話傳送提示。', 'chat.container.sessionLoadError.title': '無法載入工作階段', - 'chat.container.sessionLoadError.description': '請檢查連線,然後重新載入此工作階段。', + 'chat.container.sessionLoadError.description': '無法取得對話——伺服器可能已關閉或無法連線。內容沒有遺失;待其恢復後再試即可。', + 'chat.container.sessionLoadError.authDescription': '工作階段已過期,伺服器拒絕了請求。登入後對話即會載入。', 'chat.container.sessionLoadError.retry': '再試一次', 'sessions.sidebar.group.empty.loadingSessions': '正在載入工作階段…', 'sessions.sidebar.group.empty.loadFailed': '無法重新整理工作階段。', @@ -2677,6 +2678,9 @@ export const dict: Record = { 'sessionAuth.error.passkeySignInCanceled': 'Passkey 登入已取消。', 'sessionAuth.error.enterPasswordForPasskey': '請輸入密碼以新增 passkey。', 'sessionAuth.locked.tunnelTitle': '需要 Tunnel 存取', + 'sessionAuth.expired.banner': '工作階段已過期——請登入以繼續。', + 'sessionAuth.expired.loginAction': '登入', + 'sessionAuth.expired.sendBlocked': '工作階段已過期——請登入後再傳送訊息。', 'sessionAuth.locked.unlockTitle': '解鎖 OpenChamber', 'sessionAuth.locked.tunnelDescription': '請使用桌面應用程式提供的一次性連結開啟該 Tunnel。', 'sessionAuth.locked.passwordDescription': '此會話受密碼保護。', diff --git a/packages/ui/src/lib/runtime-auth-expiry.ts b/packages/ui/src/lib/runtime-auth-expiry.ts new file mode 100644 index 00000000..548df47d --- /dev/null +++ b/packages/ui/src/lib/runtime-auth-expiry.ts @@ -0,0 +1,126 @@ +import { create } from 'zustand'; + +// Proactive detection of an expired OpenChamber client session (cookie or +// bearer). There is no polling: every HTTP response already funnels through +// runtimeFetch, and this module only classifies what passes by. A 401 alone +// is NOT proof — OpenCode proxies provider errors through the same routes, so +// a dead Anthropic key also surfaces as 401. Every suspicion is therefore +// confirmed with one debounced GET /auth/session before the state flips. +// +// Consumers: the web/hosted banner (AuthExpiredBanner), the send guard in the +// composer, and the native mobile app, which feeds the signal into its own +// connection orchestration instead of showing the shared banner. + +export type AuthSessionState = 'ok' | 'expired' | 'reauthenticating'; + +interface AuthSessionStore { + state: AuthSessionState; + /** Set only by the confirmed classifier or an explicit auth failure. */ + markExpired: () => void; + markReauthenticating: () => void; + markAuthenticated: () => void; +} + +export const useAuthSessionStore = create((set) => ({ + state: 'ok', + markExpired: () => set((current) => (current.state === 'expired' ? current : { state: 'expired' })), + markReauthenticating: () => set({ state: 'reauthenticating' }), + markAuthenticated: () => set({ state: 'ok' }), +})); + +// One confirm probe per window: parallel 401s from a burst of requests must +// not turn into a probe storm, and a provider-side 401 that keeps repeating +// must not re-probe on every retry. +const CONFIRM_PROBE_MIN_INTERVAL_MS = 15_000; +// Focus revalidation only bothers the server when the tab was away long +// enough for a 12h/7d session to plausibly have died. +const FOCUS_REVALIDATE_MIN_INTERVAL_MS = 5 * 60_000; + +let lastProbeAt = 0; +let probeInFlight = false; + +// Paths where a 401 is part of a normal flow (wrong password on login, a +// pairing redeem, the confirm probe itself) rather than evidence of expiry. +const isExcludedAuthPath = (url: string): boolean => ( + url.includes('/auth/session') || url.includes('/api/client-auth/') +); + +const isClassifiablePath = (url: string): boolean => { + const path = url.startsWith('/') ? url : (() => { + try { + return new URL(url).pathname; + } catch { + return ''; + } + })(); + if (!path.startsWith('/api/') && !path.startsWith('/auth/')) return false; + return !isExcludedAuthPath(path); +}; + +const confirmSessionExpired = async (): Promise => { + if (probeInFlight) return; + probeInFlight = true; + try { + // Deferred import: runtime-fetch classifies through this module, and the + // probe deliberately re-enters it (its /auth/session path is excluded). + const { runtimeFetch } = await import('./runtime-fetch'); + const response = await runtimeFetch('/auth/session', { credentials: 'include' }); + if (response.status === 401) { + useAuthSessionStore.getState().markExpired(); + return; + } + if (response.ok) { + // The suspicious 401 came from deeper in the chain (a provider key, an + // upstream OpenCode instance) — the OpenChamber session is alive. + const { state, markAuthenticated } = useAuthSessionStore.getState(); + if (state === 'expired') markAuthenticated(); + } + } catch { + // Transport failure is connectivity, not authentication; the connection + // status machinery owns that story. + } finally { + probeInFlight = false; + } +}; + +/** + * Called by runtimeFetch for every response. Cheap by design: everything but + * a 401 on a classifiable path returns immediately. + */ +export const observeRuntimeAuthResponse = (url: string, status: number): void => { + if (status !== 401) return; + if (useAuthSessionStore.getState().state === 'expired') return; + if (!isClassifiablePath(url)) return; + const now = Date.now(); + if (now - lastProbeAt < CONFIRM_PROBE_MIN_INTERVAL_MS) return; + lastProbeAt = now; + void confirmSessionExpired(); +}; + +let watchInstalled = false; + +/** + * Revalidates the session when the tab regains visibility after a long + * absence — the "laptop woke up, everything looks alive, first click fails" + * case. One request per wake, nothing periodic. + */ +export const installAuthSessionFocusWatch = (): void => { + // Callers are React effects, so a document always exists here. + if (watchInstalled) return; + watchInstalled = true; + let lastConfirmedAt = Date.now(); + const revalidate = () => { + if (useAuthSessionStore.getState().state !== 'ok') return; + const now = Date.now(); + if (now - lastConfirmedAt < FOCUS_REVALIDATE_MIN_INTERVAL_MS) return; + lastConfirmedAt = now; + lastProbeAt = now; + void confirmSessionExpired(); + }; + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') revalidate(); + }); + // App switches on desktop can refocus the window without a visibility + // change; both signals share one throttle, so a wake costs one request. + window.addEventListener('focus', revalidate); +}; diff --git a/packages/ui/src/lib/runtime-fetch.ts b/packages/ui/src/lib/runtime-fetch.ts index 287d924d..c79fcbbe 100644 --- a/packages/ui/src/lib/runtime-fetch.ts +++ b/packages/ui/src/lib/runtime-fetch.ts @@ -1,6 +1,7 @@ import { getActiveRelayTunnel } from './relay/runtime-tunnel'; import { TUNNEL_PARSE_BASE } from './relay/tunnel-payloads'; import { buildRuntimeAuthHeaders } from './runtime-auth'; +import { observeRuntimeAuthResponse } from './runtime-auth-expiry'; import { getRuntimeUrlResolver, type RuntimeUrlQuery } from './runtime-url'; export interface RuntimeFetchOptions extends RequestInit { @@ -294,6 +295,14 @@ export const runtimeFetch = async (input: string | URL | Request, init: RuntimeF ).toUpperCase(); } + // Session-expiry classification rides on responses that already flow + // through here; only the status is read, never the body. + const rawFetch = doFetch; + doFetch = () => rawFetch().then((response) => { + observeRuntimeAuthResponse(url, response.status); + return response; + }); + // A Request always carries a (possibly default) signal; treat any Request, or // an explicit init.signal, as "has signal" and skip coalescing for safety. const hasSignal = requestInit.signal != null || input instanceof Request; From 098034dc33b4d60add8c26e8fe227308b0da44c6 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 18:36:50 +0300 Subject: [PATCH 28/49] fix(desktop): stop windows from adopting each other's active project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every window shares one server settings document, and every PUT returns the merged whole, so one window's activeProjectId write was adopted by the other on its next unrelated settings save — its sidebar then auto-selected a session in that project and wrote the pointer back, converging both windows onto one session. settings-synced now carries an adoptWorkspace flag: only bootstrap-grade syncs (startup, runtime switch) may adopt the shared workspace pointers; reconcile responses keep the window's own active project while it exists. Notification clicks and session deep links also stopped broadcasting the session switch to every window. --- CHANGELOG.md | 1 + packages/electron/main.mjs | 16 ++++++- .../src/components/auth/SessionAuthGate.tsx | 13 +++++- .../ui/src/contexts/ThemeSystemContext.tsx | 4 +- packages/ui/src/lib/persistence.test.ts | 8 ++-- packages/ui/src/lib/persistence.ts | 25 ++++++++--- packages/ui/src/stores/useOpenInAppsStore.ts | 4 +- .../ui/src/stores/useProjectsStore.test.ts | 33 ++++++++++++++ packages/ui/src/stores/useProjectsStore.ts | 43 ++++++++++++------- 9 files changed, 114 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc8d2c3b..7d0b9ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. - **Keyboard shortcuts learned sequences** (thanks @ChangeHow): shortcuts can now be two steps — press Cmd/Ctrl+S, then a letter: P opens the draft's project picker, G its branch picker, L the session list. A held sequence shows no menu but forgives you: Escape or three quiet seconds cancel it, and typing into a text field never triggers one armed elsewhere. Dropdown menus and pickers now also answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and every tooltip and menu label shows the binding you actually have set, not the default. - **Keyboard shortcuts redesigned.** Defaults now follow one model: single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (K then P/G/L — project picker, branch picker, session list; T timeline, N prompt navigator, I services, H shortcut help, C theme), held Cmd/Ctrl+digit for header session tabs and held Cmd/Ctrl+Option+digit for context panel surfaces. Cmd/Ctrl+B now toggles the sidebar; when it's already open, the session-list shortcut jumps into its search. Rare actions moved into the command palette instead of carrying obscure default bindings, and custom bindings recorded under the old layout are reset once. Shortcuts also stopped requiring an English keyboard layout — bindings follow the physical key on non-Latin layouts (and Option-modified digits on macOS), including when recording custom ones. +- Desktop: two windows on different projects no longer hijack each other — switching sessions in one window could make the other adopt its project and jump to the same session mid-typing (the shared settings file round-tripped the active project between windows). Notification clicks and openchamber:// session links now open in one window instead of switching every window. - **Session expiry is announced, not discovered.** When the OpenChamber login expires (a browser on the LAN, a paired device, a tunnel), a frosted banner appears under the header within seconds — before anything is clicked — saying the session expired, with a Log in button that opens the usual unlock screen. Work on screen stays visible and interactive; sending is paused until login instead of failing into a toast. A 401 is confirmed against the server first, so an expired model-provider key can't fake a logout, and returning to the app after a long absence re-checks the session once. If a conversation failed to load while logged out, it explains that and reloads itself right after login. - **Permission cards answer to the keyboard:** Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons, and the newest pending card is the one that listens. The auto-accept toggle also got a shortcut (Cmd/Ctrl+K, A). - Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. Cmd/Ctrl+K, R renames the current session right in the header. diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 0442f814..4c8dc66b 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1369,7 +1369,7 @@ const maybeShowNativeNotification = (rawInput) => { notification.on('click', () => { focusForegroundWindow(); if (sessionId) { - emitToAllWindows('openchamber:open-session', { sessionId, directory }); + emitToPrimaryWindow('openchamber:open-session', { sessionId, directory }); } release(); }); @@ -1997,6 +1997,18 @@ const emitToAllWindows = (event, detail) => { } }; +// Session navigation must land in ONE window. Broadcasting it makes every +// open window adopt the same session, hijacking whatever the other windows +// were doing. +const emitToPrimaryWindow = (event, detail) => { + const windows = BrowserWindow.getAllWindows().filter((window) => !window.isDestroyed()); + if (windows.length === 0) return; + const target = (state.mainWindow && !state.mainWindow.isDestroyed()) + ? state.mainWindow + : windows.find((window) => window.isFocused()) || windows.find((window) => window.isVisible()) || windows[0]; + emitToWindow(target, event, detail); +}; + const setTaskbarProgress = (value) => { if (process.platform !== 'win32') return; for (const browserWindow of BrowserWindow.getAllWindows()) { @@ -2278,7 +2290,7 @@ const dispatchDeepLink = (link) => { } if (link.type === 'session' && link.value) { - emitToAllWindows('openchamber:open-session', { sessionId: link.value }); + emitToPrimaryWindow('openchamber:open-session', { sessionId: link.value }); return; } if (link.type === 'host' && link.value) { diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 4b855f1e..702d879b 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -353,6 +353,7 @@ export const SessionAuthGate: React.FC = ({ const [activePasskeyAction, setActivePasskeyAction] = React.useState<'auth' | 'register' | null>(null); const passwordInputRef = React.useRef(null); const hasResyncedRef = React.useRef(skipAuth); + const hasBootstrapResyncedRef = React.useRef(skipAuth); React.useEffect(() => { if (typeof window === 'undefined') { @@ -593,10 +594,18 @@ export const SessionAuthGate: React.FC = ({ } if (state === 'authenticated' && !hasResyncedRef.current) { hasResyncedRef.current = true; + // First authentication of this page load is bootstrap: adopt the + // persisted workspace pointers. A re-login after mid-session expiry is + // not — this window already has its own workspace, and the shared + // settings document may carry another window's pointers. + const isBootstrapResync = !hasBootstrapResyncedRef.current; + hasBootstrapResyncedRef.current = true; void (async () => { await initializeAppearancePreferences(); - await syncDesktopSettings(); - await applyPersistedDirectoryPreferences(); + await syncDesktopSettings({ adoptWorkspace: isBootstrapResync }); + if (isBootstrapResync) { + await applyPersistedDirectoryPreferences(); + } })(); } }, [skipAuth, state]); diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index 7e5ef975..cc3a3604 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -11,7 +11,7 @@ import type { DesktopSettings } from '@/lib/desktop'; import { isDesktopLocalOriginActive, isDesktopShell as detectDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { setDesktopWindowTheme } from '@/lib/desktopNative'; import { CSSVariableGenerator } from '@/lib/theme/cssGenerator'; -import { updateDesktopSettings } from '@/lib/persistence'; +import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence'; import { themes, getThemeById, @@ -622,7 +622,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro return; } const handleSettingsSynced = (event: Event) => { - const detail = (event as CustomEvent).detail; + const detail = (event as CustomEvent).detail?.settings; if (!detail) { return; } diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index c698f711..61e5c370 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -558,7 +558,7 @@ describe('updateDesktopSettings', () => { }); const syncedSettings: SettingsPayload[] = []; const handleSettingsSynced = (event: Event) => { - syncedSettings.push((event as CustomEvent).detail); + syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings); }; getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced); @@ -584,7 +584,7 @@ describe('updateDesktopSettings', () => { invalidateSettingsCache(); const syncedSettings: SettingsPayload[] = []; const handleSettingsSynced = (event: Event) => { - syncedSettings.push((event as CustomEvent).detail); + syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings); }; getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced); @@ -616,7 +616,7 @@ describe('updateDesktopSettings', () => { invalidateSettingsCache(); const syncedSettings: SettingsPayload[] = []; const handleSettingsSynced = (event: Event) => { - syncedSettings.push((event as CustomEvent).detail); + syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings); }; getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced); @@ -647,7 +647,7 @@ describe('updateDesktopSettings', () => { invalidateSettingsCache(); const syncedSettings: SettingsPayload[] = []; const handleSettingsSynced = (event: Event) => { - syncedSettings.push((event as CustomEvent).detail); + syncedSettings.push((event as CustomEvent<{ settings: SettingsPayload }>).detail.settings); }; getWindow().addEventListener('openchamber:settings-synced', handleSettingsSynced); diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index e6668fad..09db0255 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -199,11 +199,23 @@ const persistToLocalStorage = (settings: DesktopSettings) => { setOrRemoveLocalStorage('sttLanguage', typeof settings.sttLanguage === 'string' ? settings.sttLanguage : null); }; -const dispatchSettingsSynced = (settings: DesktopSettings): void => { +export interface SettingsSyncedDetail { + settings: DesktopSettings; + /** Whether listeners may adopt cross-window workspace pointers + (activeProjectId / lastDirectory). True only for a bootstrap-grade sync: + the settings document is shared by every window of this server, so a + mid-session reconciliation adopting them would hijack this window's + workspace with another window's choice. */ + adoptWorkspace: boolean; +} + +const dispatchSettingsSynced = (settings: DesktopSettings, adoptWorkspace: boolean): void => { if (typeof window === 'undefined') { return; } - window.dispatchEvent(new CustomEvent('openchamber:settings-synced', { detail: settings })); + window.dispatchEvent(new CustomEvent('openchamber:settings-synced', { + detail: { settings, adoptWorkspace }, + })); }; type SettingsSaveState = 'idle' | 'saving' | 'error'; @@ -1841,7 +1853,8 @@ export const invalidateSettingsCache = (): void => { _settingsCache = null; }; -export const syncDesktopSettings = async (): Promise => { +export const syncDesktopSettings = async (options?: { adoptWorkspace?: boolean }): Promise => { + const adoptWorkspace = options?.adoptWorkspace !== false; if (typeof window === 'undefined') { return; } @@ -1970,7 +1983,7 @@ export const syncDesktopSettings = async (): Promise => { if (!isSettingsRuntimeContextCurrent(context)) return; } - dispatchSettingsSynced(authoritativeSettings); + dispatchSettingsSynced(authoritativeSettings, adoptWorkspace); }; try { @@ -2013,7 +2026,7 @@ async function _flushSettingsUpdate(): Promise { if (updated) { const reconciled = _settingsMutationTracker.reconcile(updated, operation); applyDesktopUiPreferences(reconciled); - dispatchSettingsSynced(reconciled); + dispatchSettingsSynced(reconciled, false); _settingsCache = null; } dispatchSettingsSaveState(updated ? 'saved' : 'error'); @@ -2047,7 +2060,7 @@ async function _flushSettingsUpdate(): Promise { if (updated) { const reconciled = _settingsMutationTracker.reconcile(updated, operation); applyDesktopUiPreferences(reconciled); - dispatchSettingsSynced(reconciled); + dispatchSettingsSynced(reconciled, false); dispatchSettingsSaveState('saved'); // Invalidate GET cache so next read sees the fresh data _settingsCache = null; diff --git a/packages/ui/src/stores/useOpenInAppsStore.ts b/packages/ui/src/stores/useOpenInAppsStore.ts index 3fd4e2dd..149791d9 100644 --- a/packages/ui/src/stores/useOpenInAppsStore.ts +++ b/packages/ui/src/stores/useOpenInAppsStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isDesktopShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop'; import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, getPlatformOpenInApp, type OpenInApp } from '@/lib/openInApps'; -import { updateDesktopSettings } from '@/lib/persistence'; +import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence'; export type OpenInAppOption = OpenInApp & { iconDataUrl?: string; @@ -160,7 +160,7 @@ export const useOpenInAppsStore = create()((set, get) => ({ void loadInstalledApps(); const settingsHandler = (event: Event) => { - const detail = (event as CustomEvent).detail; + const detail = (event as CustomEvent).detail?.settings; const nextId = detail && typeof detail.openInAppId === 'string' && detail.openInAppId.length > 0 diff --git a/packages/ui/src/stores/useProjectsStore.test.ts b/packages/ui/src/stores/useProjectsStore.test.ts index 34c43ea2..6e45dd38 100644 --- a/packages/ui/src/stores/useProjectsStore.test.ts +++ b/packages/ui/src/stores/useProjectsStore.test.ts @@ -18,6 +18,39 @@ describe("useProjectsStore settings synchronization", () => { expect(useProjectsStore.getState().activeProjectId).toBe(null) expect(useProjectsStore.getState().manualProjectOrder).toEqual([]) }) + + test("a reconcile sync never adopts another window's active project", () => { + // Ids are path-derived inside the store's sanitizer, so seed real ones by + // bootstrapping once and reading them back. + const raw = { projects: [{ path: "/repo-a" }, { path: "/repo-b" }] } as DesktopSettings + useProjectsStore.getState().synchronizeFromSettings(raw) + const [first, second] = useProjectsStore.getState().projects + useProjectsStore.setState({ activeProjectId: first.id }) + + // The shared settings document carries window B's pointer; outside a + // bootstrap this window keeps its own. + useProjectsStore.getState().synchronizeFromSettings( + { ...raw, activeProjectId: second.id } as DesktopSettings, + { adoptActiveProject: false }, + ) + expect(useProjectsStore.getState().activeProjectId).toBe(first.id) + + // Unless its own project vanished from the list — then the incoming + // pointer is better than a dangling one. + useProjectsStore.getState().synchronizeFromSettings( + { projects: [{ path: "/repo-b" }], activeProjectId: second.id } as DesktopSettings, + { adoptActiveProject: false }, + ) + expect(useProjectsStore.getState().activeProjectId).toBe(second.id) + + // A bootstrap sync adopts as before. + useProjectsStore.getState().synchronizeFromSettings(raw) + useProjectsStore.setState({ activeProjectId: first.id }) + useProjectsStore.getState().synchronizeFromSettings( + { ...raw, activeProjectId: second.id } as DesktopSettings, + ) + expect(useProjectsStore.getState().activeProjectId).toBe(second.id) + }) }) describe("useProjectsStore selection identity", () => { diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 045ff1e2..6403898c 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -4,7 +4,7 @@ import { opencodeClient } from '@/lib/opencode/client'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import type { ProjectEntry } from '@/lib/api/types'; import type { DesktopSettings } from '@/lib/desktop'; -import { updateDesktopSettings } from '@/lib/persistence'; +import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence'; import { createProjectIdFromPath } from '@/lib/projectId'; import { getDeferredSafeStorage } from './utils/safeStorage'; import { useDirectoryStore } from './useDirectoryStore'; @@ -68,7 +68,7 @@ interface ProjectsStore { reorderProjects: (fromIndex: number, toIndex: number) => void; resetForRuntimeSwitch: () => void; validateProjectPath: (path: string) => ProjectPathValidationResult; - synchronizeFromSettings: (settings: DesktopSettings) => void; + synchronizeFromSettings: (settings: DesktopSettings, options?: { adoptActiveProject?: boolean }) => void; syncVSCodeWorkspaceFolders: (folders: VSCodeWorkspaceFolderConfig[], activePath?: string | null) => ProjectEntry | null; getActiveProject: () => ProjectEntry | null; } @@ -809,7 +809,7 @@ export const useProjectsStore = create()( const payload = (await response.json().catch(() => null)) as { settings?: DesktopSettings } | null; if (payload?.settings) { - get().synchronizeFromSettings(payload.settings); + get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false }); } return { ok: true }; } catch (error) { @@ -838,7 +838,7 @@ export const useProjectsStore = create()( const payload = (await response.json().catch(() => null)) as { settings?: DesktopSettings } | null; if (payload?.settings) { - get().synchronizeFromSettings(payload.settings); + get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false }); } return { ok: true }; } catch (error) { @@ -874,7 +874,7 @@ export const useProjectsStore = create()( } if (payload?.settings) { - get().synchronizeFromSettings(payload.settings); + get().synchronizeFromSettings(payload.settings, { adoptActiveProject: false }); } return { @@ -924,32 +924,43 @@ export const useProjectsStore = create()( set({ projects, activeProjectId: nextActiveProjectId, manualProjectOrder: [] }); }, - synchronizeFromSettings: (settings: DesktopSettings) => { + synchronizeFromSettings: (settings: DesktopSettings, options?: { adoptActiveProject?: boolean }) => { if (isVSCodeProjectsRuntime) { return; } + const adoptActiveProject = options?.adoptActiveProject !== false; const incomingProjects = sanitizeProjects(settings.projects ?? []); const incomingActive = typeof settings.activeProjectId === 'string' && settings.activeProjectId.trim() ? settings.activeProjectId.trim() : null; const current = get(); + const incomingIds = new Set(incomingProjects.map((p) => p.id)); + + // The settings document is shared by every window on this server, so + // outside a bootstrap sync the incoming active pointer is just another + // window's choice — the project LIST still reconciles, but this + // window's active project stays its own while it remains valid. + const nextActive = adoptActiveProject + ? incomingActive + : (current.activeProjectId && incomingIds.has(current.activeProjectId) + ? current.activeProjectId + : incomingActive); const projectsChanged = JSON.stringify(current.projects) !== JSON.stringify(incomingProjects); - const activeChanged = current.activeProjectId !== incomingActive; + const activeChanged = current.activeProjectId !== nextActive; if (!projectsChanged && !activeChanged) { return; } - const incomingIds = new Set(incomingProjects.map((p) => p.id)); const cleanedOrder = get().manualProjectOrder.filter((id) => incomingIds.has(id)); - set({ projects: incomingProjects, activeProjectId: incomingActive, manualProjectOrder: cleanedOrder }); - cacheProjects(incomingProjects, incomingActive); + set({ projects: incomingProjects, activeProjectId: nextActive, manualProjectOrder: cleanedOrder }); + cacheProjects(incomingProjects, nextActive); persistManualProjectOrder(cleanedOrder); - if (incomingActive) { - const activeProject = incomingProjects.find((project) => project.id === incomingActive); + if (activeChanged && nextActive) { + const activeProject = incomingProjects.find((project) => project.id === nextActive); if (activeProject) { opencodeClient.setDirectory(activeProject.path); useDirectoryStore.getState().setDirectory(activeProject.path, { showOverlay: false }); @@ -1005,9 +1016,11 @@ export const useProjectsStore = create()( if (typeof window !== 'undefined') { window.addEventListener('openchamber:settings-synced', (event: Event) => { - const detail = (event as CustomEvent).detail; - if (detail && typeof detail === 'object') { - useProjectsStore.getState().synchronizeFromSettings(detail); + const detail = (event as CustomEvent).detail; + if (detail && typeof detail === 'object' && detail.settings) { + useProjectsStore.getState().synchronizeFromSettings(detail.settings, { + adoptActiveProject: detail.adoptWorkspace, + }); } }); } From edec60faff9a7cd306a3860885ddafabc1367638 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 18:40:34 +0300 Subject: [PATCH 29/49] fix(github): stop fork remotes from claiming the local branch's PR badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-status source candidates were every configured remote, so a checkout carrying contributor forks matched a fork's closed PR whose head merely shared the branch name — a fork's 'main' surfaced on the local main in the git and work-status panels. Only the ranked-first remote (the one the branch pushes to) and its fork network are PR sources now; other remotes remain search targets but their owner:branch heads no longer count. --- CHANGELOG.md | 1 + packages/web/server/lib/github/pr-status.js | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d0b9ced..6b02dcb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file. - **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. - **Keyboard shortcuts learned sequences** (thanks @ChangeHow): shortcuts can now be two steps — press Cmd/Ctrl+S, then a letter: P opens the draft's project picker, G its branch picker, L the session list. A held sequence shows no menu but forgives you: Escape or three quiet seconds cancel it, and typing into a text field never triggers one armed elsewhere. Dropdown menus and pickers now also answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and every tooltip and menu label shows the binding you actually have set, not the default. - **Keyboard shortcuts redesigned.** Defaults now follow one model: single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (K then P/G/L — project picker, branch picker, session list; T timeline, N prompt navigator, I services, H shortcut help, C theme), held Cmd/Ctrl+digit for header session tabs and held Cmd/Ctrl+Option+digit for context panel surfaces. Cmd/Ctrl+B now toggles the sidebar; when it's already open, the session-list shortcut jumps into its search. Rare actions moved into the command palette instead of carrying obscure default bindings, and custom bindings recorded under the old layout are reset once. Shortcuts also stopped requiring an English keyboard layout — bindings follow the physical key on non-Latin layouts (and Option-modified digits on macOS), including when recording custom ones. +- Git: the branch's PR badge no longer picks up a stranger's pull request. With contributor forks added as git remotes, a fork's closed PR whose head branch merely shared a name (a fork's "main") could show up on the local branch in the git panel and the work-status panel; only the repo a branch actually pushes to counts as its PR source now. - Desktop: two windows on different projects no longer hijack each other — switching sessions in one window could make the other adopt its project and jump to the same session mid-typing (the shared settings file round-tripped the active project between windows). Notification clicks and openchamber:// session links now open in one window instead of switching every window. - **Session expiry is announced, not discovered.** When the OpenChamber login expires (a browser on the LAN, a paired device, a tunnel), a frosted banner appears under the header within seconds — before anything is clicked — saying the session expired, with a Log in button that opens the usual unlock screen. Work on screen stays visible and interactive; sending is paused until login instead of failing into a toast. A 401 is confirmed against the server first, so an expired model-provider key can't fake a logout, and returning to the app after a long absence re-checks the session once. If a conversation failed to load while logged out, it explains that and reloads itself right after login. - **Permission cards answer to the keyboard:** Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons, and the newest pending card is the one that listens. The auto-accept toggle also got a shortcut (Cmd/Ctrl+K, A). diff --git a/packages/web/server/lib/github/pr-status.js b/packages/web/server/lib/github/pr-status.js index 8e88122c..50873cc7 100644 --- a/packages/web/server/lib/github/pr-status.js +++ b/packages/web/server/lib/github/pr-status.js @@ -674,7 +674,16 @@ export async function resolveGitHubPrStatus({ octokit, directory, branch, remote }; } - const sourceCandidates = resolvedTargets.slice(); + // Only the repo this branch actually pushes to (the ranked-first remote) + // and its fork network can be the SOURCE of the branch's PRs. Other + // configured remotes — a maintainer's checkout often carries contributor + // forks — are places to look for an open PR, but their `owner:branch` + // heads are unrelated branches that merely share a name; treating them as + // sources made a fork's closed `main` PR show up on the local main. + const primaryRemoteName = resolvedTargets[0]?.remoteName ?? null; + const sourceCandidates = resolvedTargets.filter( + (target) => target.remoteName === primaryRemoteName, + ); // When every consulted repo list was complete, a no-PR result is // authoritative and the expensive Search API fallback is pointless. const coverage = { authoritative: true }; From 9717fc5a54bf1105bd2f81c9affa80f7b3871086 Mon Sep 17 00:00:00 2001 From: Nimo Beeren Date: Wed, 26 Aug 2026 17:46:22 +0200 Subject: [PATCH 30/49] fix(ui): cycle through all thinking variants (#2848) --- packages/ui/src/stores/useConfigStore.test.ts | 16 ++++++++++++++++ packages/ui/src/stores/useConfigStore.ts | 6 +++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index ace3cb21..b3fb527c 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -525,6 +525,22 @@ describe('useConfigStore provider persistence', () => { expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe('high'); }); + test('cycleCurrentVariant wraps through every model variant', () => { + useConfigStore.setState({ + providers: [provider('openai', 'gpt-5.6-sol', { none: {}, low: {}, medium: {}, high: {}, xhigh: {}, max: {} })], + currentProviderId: 'openai', + currentModelId: 'gpt-5.6-sol', + currentVariant: 'high', + directoryScoped: {}, + }); + + const expectedVariants = ['xhigh', 'max', 'none', 'low', 'medium', 'high']; + for (const expectedVariant of expectedVariants) { + useConfigStore.getState().cycleCurrentVariant(); + expect(useConfigStore.getState().currentVariant).toBe(expectedVariant); + } + }); + test('setAgent prefers saved and agent variants before settings default', () => { const sessionId = 'ses_agent_saved_variant'; useSessionUIStore.setState({ currentSessionId: sessionId }); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index fae9ca95..c4884748 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -1904,12 +1904,12 @@ export const useConfigStore = create()( } const index = variantKeys.indexOf(current); - if (index === -1 || index === variantKeys.length - 1) { - get().setCurrentVariant(undefined); + if (index === -1) { + get().setCurrentVariant(variantKeys[0]); return; } - get().setCurrentVariant(variantKeys[index + 1]); + get().setCurrentVariant(variantKeys[(index + 1) % variantKeys.length]); }, setSelectedProvider: (providerId: string) => { From b11bace75bc0ae7ca9bf7a4d4d7b78859c3280e7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 18:48:02 +0300 Subject: [PATCH 31/49] docs: add changelog entries for the rail configurator and chat fixes --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b02dcb5..2cb4bacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ All notable changes to this project will be documented in this file. - **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. - **Keyboard shortcuts learned sequences** (thanks @ChangeHow): shortcuts can now be two steps — press Cmd/Ctrl+S, then a letter: P opens the draft's project picker, G its branch picker, L the session list. A held sequence shows no menu but forgives you: Escape or three quiet seconds cancel it, and typing into a text field never triggers one armed elsewhere. Dropdown menus and pickers now also answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and every tooltip and menu label shows the binding you actually have set, not the default. - **Keyboard shortcuts redesigned.** Defaults now follow one model: single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (K then P/G/L — project picker, branch picker, session list; T timeline, N prompt navigator, I services, H shortcut help, C theme), held Cmd/Ctrl+digit for header session tabs and held Cmd/Ctrl+Option+digit for context panel surfaces. Cmd/Ctrl+B now toggles the sidebar; when it's already open, the session-list shortcut jumps into its search. Rare actions moved into the command palette instead of carrying obscure default bindings, and custom bindings recorded under the old layout are reset once. Shortcuts also stopped requiring an English keyboard layout — bindings follow the physical key on non-Latin layouts (and Option-modified digits on macOS), including when recording custom ones. +- Panels: the context rail got a configure button at its end — a dialog chooses which panels the rail shows. Hidden panels keep their data, stay reachable from the command palette, and leave the Cmd/Ctrl+Alt+digit switcher, so the digits always match the icons you see. +- Chat: a failed send returns your typed prompt to the input — whatever the reason (expired login, network, server error) — instead of losing it to an error toast. If you switched sessions while it was sending, the text lands in that session's draft. +- Chat: opening a session (or resizing panels) could strand the view in a large empty space below the last message; the list now detects that and returns to the real end, and finishing a width resize keeps a reader who was at the bottom at the bottom. +- Chat: prompt-rail and message jumps stopped landing "almost" on the target — the jump now settles onto the exact message once the layout finishes measuring. +- Chat: with streaming auto-follow off, the scroll-to-bottom pill now appears as soon as the reply grows past the visible area, instead of waiting for you to scroll first. - Git: the branch's PR badge no longer picks up a stranger's pull request. With contributor forks added as git remotes, a fork's closed PR whose head branch merely shared a name (a fork's "main") could show up on the local branch in the git panel and the work-status panel; only the repo a branch actually pushes to counts as its PR source now. - Desktop: two windows on different projects no longer hijack each other — switching sessions in one window could make the other adopt its project and jump to the same session mid-typing (the shared settings file round-tripped the active project between windows). Notification clicks and openchamber:// session links now open in one window instead of switching every window. - **Session expiry is announced, not discovered.** When the OpenChamber login expires (a browser on the LAN, a paired device, a tunnel), a frosted banner appears under the header within seconds — before anything is clicked — saying the session expired, with a Log in button that opens the usual unlock screen. Work on screen stays visible and interactive; sending is paused until login instead of failing into a toast. A 401 is confirmed against the server first, so an expired model-provider key can't fake a logout, and returning to the app after a long absence re-checks the session once. If a conversation failed to load while logged out, it explains that and reloads itself right after login. From 3f386248508873741a9b625cef17b578513a8adf Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 18:58:20 +0300 Subject: [PATCH 32/49] docs: reorder and tighten the unreleased changelog --- CHANGELOG.md | 84 +++++++++++++++++++++++----------------------------- 1 file changed, 37 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2cb4bacc..5125d234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,56 +4,46 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- **Chat scrolling rebuilt around your message.** Sending a message parks it near the top of the view and the reply streams into the space below it, so you read from where you asked instead of chasing the bottom. Streamed text arrives a paragraph at a time (code blocks line by line) with a soft fade, and the view glides after it in one continuous motion instead of snapping per line. Scrolling up during a stream immediately hands you the wheel — nothing yanks the view back — and the scroll-to-bottom pill appears on the left, carrying the model's working status while you're away from the live edge. Sending from anywhere mid-conversation jumps you straight to your new message, and opening a session goes straight to the newest message with no scroll animation. -- **Chat context attachments:** everything you attach to a message — diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues and PRs — now shows up in the conversation as a compact context card: a header naming the source, the captured content behind an expander, and your comment below it. Previously most of these arrived as a wall of raw text inside your message. -- **Faster session switching in large workspaces** (thanks @c-w-xiaohei): switching sessions no longer rebuilds the whole sidebar, returning to a recently viewed session restores its rendered messages instead of re-rendering them (file links included), and scrolling long conversations costs less. In a workspace with thousands of loaded sessions, end-to-end switch time dropped by roughly half. -- **Session tabs (opt-in):** the web/desktop header can show your open sessions as browser-style tabs — turn them on in Settings → General → Navigation → Session tabs. Every session you open joins the strip, clicking a tab switches the whole workspace (chat, project, panels), and closing one (its × button, middle-click, or Alt+W — rebindable in Shortcuts) never touches the session itself. Tabs reorder by drag, scroll behind the header buttons when there are many and carry the sidebar's running/unread dot. Each tab has the full session menu — on the "..." button or right-click — plus Close other tabs; renaming works right in the tab. -- **Keyboard shortcuts learned sequences** (thanks @ChangeHow): shortcuts can now be two steps — press Cmd/Ctrl+S, then a letter: P opens the draft's project picker, G its branch picker, L the session list. A held sequence shows no menu but forgives you: Escape or three quiet seconds cancel it, and typing into a text field never triggers one armed elsewhere. Dropdown menus and pickers now also answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and every tooltip and menu label shows the binding you actually have set, not the default. -- **Keyboard shortcuts redesigned.** Defaults now follow one model: single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (K then P/G/L — project picker, branch picker, session list; T timeline, N prompt navigator, I services, H shortcut help, C theme), held Cmd/Ctrl+digit for header session tabs and held Cmd/Ctrl+Option+digit for context panel surfaces. Cmd/Ctrl+B now toggles the sidebar; when it's already open, the session-list shortcut jumps into its search. Rare actions moved into the command palette instead of carrying obscure default bindings, and custom bindings recorded under the old layout are reset once. Shortcuts also stopped requiring an English keyboard layout — bindings follow the physical key on non-Latin layouts (and Option-modified digits on macOS), including when recording custom ones. -- Panels: the context rail got a configure button at its end — a dialog chooses which panels the rail shows. Hidden panels keep their data, stay reachable from the command palette, and leave the Cmd/Ctrl+Alt+digit switcher, so the digits always match the icons you see. -- Chat: a failed send returns your typed prompt to the input — whatever the reason (expired login, network, server error) — instead of losing it to an error toast. If you switched sessions while it was sending, the text lands in that session's draft. -- Chat: opening a session (or resizing panels) could strand the view in a large empty space below the last message; the list now detects that and returns to the real end, and finishing a width resize keeps a reader who was at the bottom at the bottom. -- Chat: prompt-rail and message jumps stopped landing "almost" on the target — the jump now settles onto the exact message once the layout finishes measuring. -- Chat: with streaming auto-follow off, the scroll-to-bottom pill now appears as soon as the reply grows past the visible area, instead of waiting for you to scroll first. -- Git: the branch's PR badge no longer picks up a stranger's pull request. With contributor forks added as git remotes, a fork's closed PR whose head branch merely shared a name (a fork's "main") could show up on the local branch in the git panel and the work-status panel; only the repo a branch actually pushes to counts as its PR source now. -- Desktop: two windows on different projects no longer hijack each other — switching sessions in one window could make the other adopt its project and jump to the same session mid-typing (the shared settings file round-tripped the active project between windows). Notification clicks and openchamber:// session links now open in one window instead of switching every window. -- **Session expiry is announced, not discovered.** When the OpenChamber login expires (a browser on the LAN, a paired device, a tunnel), a frosted banner appears under the header within seconds — before anything is clicked — saying the session expired, with a Log in button that opens the usual unlock screen. Work on screen stays visible and interactive; sending is paused until login instead of failing into a toast. A 401 is confirmed against the server first, so an expired model-provider key can't fake a logout, and returning to the app after a long absence re-checks the session once. If a conversation failed to load while logged out, it explains that and reloads itself right after login. -- **Permission cards answer to the keyboard:** Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons, and the newest pending card is the one that listens. The auto-accept toggle also got a shortcut (Cmd/Ctrl+K, A). -- Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. Cmd/Ctrl+K, R renames the current session right in the header. -- Git: Cmd/Ctrl+Enter in the commit message box commits, like every git client. -- Diff: Alt+Down/Up jumps review to the next or previous changed file, expanding it if collapsed. -- Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme, memory debug) are now found by typing but stay off the first screen, which keeps the initial list scroll-free. -- Chat: comment on a reply — select text in a chat message (or in a rendered markdown preview in Files) and choose Comment to attach exactly that quote, with a source line range when it can be located, plus your note to the next message. The selection stays highlighted while you type, and the selection menu was restyled — Add to chat is now Add to input. -- Diff: comment like a review — hovering a line shows a + button in the gutter; clicking it, clicking a line, or dragging across lines opens the comment editor for that line or range. The comment editor and saved-comment cards match the chat's comment style. -- Composer: hovering or tapping a context chip above the input opens a stacked preview of everything attached, where a comment can be edited in place or an item removed before sending. +- **Chat scrolling rebuilt around your message.** Sending parks your message near the top and the reply streams into the space below it, so you read from where you asked instead of chasing the bottom. Streamed text arrives a paragraph at a time with a soft fade and the view glides after it in one motion; scrolling up immediately hands you the wheel, with the scroll-to-bottom pill carrying the model's working status while you're away. Opening a session lands on the newest message with no animation. +- **Keyboard shortcuts redesigned** (thanks @ChangeHow for the registry): one model everywhere — single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (P project picker, G branch picker, L session list, T timeline, R rename session, N prompt navigator, I services, C theme, H help), held Cmd/Ctrl+digit for session tabs and held Cmd/Ctrl+Option+digit for panel surfaces. Cmd/Ctrl+B toggles the sidebar, rare actions moved into the command palette, and every tooltip shows the binding you actually have set. Shortcuts no longer require an English keyboard layout — bindings follow the physical key, including when recording custom ones. Custom bindings from the old layout reset once. +- **Session expiry is announced, not discovered.** When the OpenChamber login expires (LAN browser, paired device, tunnel), a banner appears under the header within seconds — before anything is clicked — with a Log in button that opens the usual unlock screen. Work stays visible, sending pauses until login, a failed send always returns the typed prompt to the input, and a conversation that failed to load while logged out explains that and reloads itself after login. A 401 is confirmed against the server first, so an expired model-provider key can't fake a logout. +- **Chat context attachments:** everything you attach to a message — diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues and PRs — now shows up as a compact context card: a source header, the captured content behind an expander, and your comment below it, instead of a wall of raw text. +- Session tabs (opt-in): the web/desktop header can show open sessions as browser-style tabs (Settings → General → Navigation). Clicking a tab switches the whole workspace, closing one (×, middle-click, or Alt+W) never touches the session itself, tabs reorder by drag, carry the running/unread dot, and hold the full session menu including rename-in-place. +- Faster session switching in large workspaces (thanks @c-w-xiaohei): the sidebar no longer rebuilds on switch and recently viewed sessions restore their rendered messages; end-to-end switch time roughly halved with thousands of loaded sessions. +- Permission cards answer to the keyboard: Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons. The auto-accept toggle got Cmd/Ctrl+K, A. +- Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. +- Git: Cmd/Ctrl+Enter in the commit message box commits. Diff review moves between changed files with Alt+Down/Up, expanding a collapsed file on arrival. +- Panels: the context rail got a configure button — a dialog chooses which panels the rail shows. Hidden panels keep their data, stay reachable from the command palette, and leave the digit switcher, so digits always match the icons you see. +- Chat: comment on a reply — select text in a chat message (or a rendered markdown preview in Files) and choose Comment to attach exactly that quote, with a source line range when it can be located, plus your note. The selection stays highlighted while you type; Add to chat is now Add to input. +- Diff: comment like a review — hovering a line shows a + in the gutter; clicking or dragging across lines opens the comment editor for that range, styled like the chat's comments. +- Composer: hovering or tapping a context chip opens a stacked preview of everything attached, where a comment can be edited in place or an item removed before sending. - Mobile: the chat comment input overlays the composer exactly and rides the keyboard; Enter makes a new line there, with attach on the button. -- Terminal: terminals no longer vanish or die behind your back. Opening the app in another browser tab, on another device, or after a reload shows the terminals already running on the server instead of an empty list, and terminals sitting in background tabs are no longer closed by the server's idle cleanup while the app is open. -- Search: every searchable picker — branches, projects, agents, models, providers, stashes, SSH hosts, skills, archived sessions — now uses one matcher: best matches come first, multi-word queries match in any order, and punctuation doesn't matter (so "gpt4o" finds "gpt-4o"). Ctrl/Cmd+P also matches the whole file path, not just the file name, and the git branch and gitmoji pickers stopped silently dropping rows a second built-in filter didn't like. Sidebar session search and the Todos/Memory/Plans/Notes filters match the same way. -- Chat: @ file mentions rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible so identical-looking index.md rows are distinguishable. -- Chat: a new "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns the automatic following off entirely — your message still parks at the top on send, but the view never moves on its own afterwards. -- Mobile: narrowing a browser window past phone size switches into the mobile app layout (and back when widened) instead of squeezing the desktop layout. The old/new mobile layout setting is gone — phones always get the mobile layout. -- Chat: streamed code blocks are syntax-highlighted while they stream, and finished messages no longer jump when a code block's line numbers fill in at the end of a reply. -- Chat: finished replies no longer flicker — tool cards stopped re-rendering (and replaying their reveal animation) when they completed, and resizing the window no longer throws the conversation up and down while you're at the bottom. -- Chat: clicking the last item in the prompt rail now always lands on it, and rail jumps teleport instead of a long smooth scroll that could stop halfway. -- Mobile: scrolling during a streaming reply works again — a drag immediately takes over, the scroll-to-bottom pill shows up, and the load-older button no longer throws you to the bottom of the chat. -- Fixed file links in messages being checked twice against the filesystem, and against the wrong project directory on the first pass. +- Terminal: terminals no longer vanish or die behind your back — another tab, another device, or a reload shows the terminals already running on the server, and background-tab terminals survive the server's idle cleanup while the app is open. +- Search: every searchable picker now uses one matcher — best matches first, multi-word queries in any order, punctuation ignored (so "gpt4o" finds "gpt-4o"). Ctrl/Cmd+P also matches the whole file path, and the git branch and gitmoji pickers stopped silently dropping rows. +- Chat: @ file mentions rank files and directories together by match quality, and long paths keep the folder next to the file name visible. +- Chat: a "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns automatic following off entirely; with it off, the scroll-to-bottom pill now appears as soon as the reply grows past the visible area. +- Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme) are found by typing but stay off the first screen. +- Mobile: narrowing a browser window past phone size switches into the mobile layout (and back when widened); the old/new mobile layout setting is gone. +- Chat: a failed send returns your typed prompt to the input — whatever the reason — instead of losing it to an error toast; a mid-send session switch lands it in that session's draft. +- Chat: opening a session or resizing panels could strand the view in a large empty space below the last message; the list now returns to the real end, and a width resize keeps a reader who was at the bottom at the bottom. +- Chat: prompt-rail and message jumps land exactly on the target once the layout finishes measuring, and clicking the last rail item always works. +- Desktop: two windows on different projects no longer hijack each other — one window's session switch could make the other adopt its project mid-typing. Notification clicks and openchamber:// links now open in one window instead of all of them. +- Git: the branch's PR badge no longer picks up a stranger's pull request — with contributor forks added as remotes, a fork's closed PR sharing only the branch name could show up on the local branch. +- Chat: streamed code blocks are syntax-highlighted while streaming, and finished messages no longer jump when line numbers fill in. +- Chat: finished replies no longer flicker — tool cards stopped replaying their reveal animation on completion, and window resizing no longer throws the conversation around at the bottom. +- Mobile: scrolling during a streaming reply works again — a drag immediately takes over, the pill shows up, and load-older no longer throws you to the bottom. +- Fixed file links in messages being checked twice, and against the wrong project directory on the first pass. - Fixed the selected project or session briefly jumping back to a previous choice when settings responses arrived out of order. -- Fixed sessions staying on "loading sessions" forever after the connection to OpenCode went half-open — stalled reads now time out and retry instead of holding bootstrap hostage (thanks @herjarsa). -- Files: previews of files above the editable size cap now show the whole file instead of the first 200k characters, virtualized so opening and scrolling a huge file no longer freezes the app (thanks @gaojunran). -- VSCode: the chat view no longer stays stuck on its loading screen on slow or remote connections (for example code-server behind a reverse proxy) — the connection status is re-sent until the webview is ready to hear it (thanks @VinciYan). -- Terminal: mobile keyboards no longer capitalize the first letter of every command on iOS and Android. -- Desktop: a freshly installed or updated build no longer keeps loading the previous version's interface from cache. -- Devices: re-pairing a phone (or logging in again) keeps the device's existing name in Connected Devices instead of resetting it to "OpenChamber Mobile". -- Relay: paired devices no longer get logged out when the app restarts (for example during an update) while another local OpenChamber process is running — the restarted app keeps serving them instead of a bystander process taking over. +- Fixed sessions staying on "loading sessions" forever after a half-open connection to OpenCode — stalled reads now time out and retry (thanks @herjarsa). +- Files: previews above the editable size cap show the whole file, virtualized so huge files no longer freeze the app (thanks @gaojunran). +- VSCode: the chat view no longer sticks on its loading screen on slow or remote connections (thanks @VinciYan). +- Terminal: mobile keyboards no longer capitalize the first letter of every command. +- Desktop: a freshly installed or updated build no longer loads the previous version's interface from cache. +- Devices: re-pairing a phone keeps the device's existing name instead of resetting it to "OpenChamber Mobile". +- Relay: paired devices no longer get logged out when the app restarts while another local OpenChamber process is running. - Sessions: headers now find archived sessions too, so an archived session's title no longer goes missing. -- Files: the editor toolbar is now always docked under the file tabs; the floating hover toolbar and its setting were removed. -- UI: the chat's top and bottom scroll fades are back, and the first uncached open of a session fades the conversation in instead of popping. -- UI: the timeline dialog now fits small screens instead of squeezing the message list to a couple of rows (thanks to @gaojunran). -- Chat: OpenCode notices now share one style. -- UI: draft target menus stay inside the chat area instead of overlapping the header. -- UI: Linear and Cloudflare tools now show their own icons. -- UI: sidebar item tooltips no longer appear instantly on passing hover. -- UI: the btw panel's shadow is lighter, matching the composer. +- Files: the editor toolbar is always docked under the file tabs; the floating hover toolbar and its setting were removed. +- UI: the chat's scroll fades are back, the first uncached session open fades in, the timeline dialog fits small screens (thanks @gaojunran), OpenCode notices share one style, draft target menus stay inside the chat area, Linear and Cloudflare tools show their own icons, sidebar tooltips no longer appear on passing hover, and the btw panel's shadow matches the composer. ## [1.20.0] - 2026-08-23 From fc21ad5e9c376b48172eaf715d50f700bc7ae392 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 18:59:52 +0300 Subject: [PATCH 33/49] docs: headline session tabs instead of the auth-expiry fix --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5125d234..dfe539bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,8 @@ All notable changes to this project will be documented in this file. - **Chat scrolling rebuilt around your message.** Sending parks your message near the top and the reply streams into the space below it, so you read from where you asked instead of chasing the bottom. Streamed text arrives a paragraph at a time with a soft fade and the view glides after it in one motion; scrolling up immediately hands you the wheel, with the scroll-to-bottom pill carrying the model's working status while you're away. Opening a session lands on the newest message with no animation. - **Keyboard shortcuts redesigned** (thanks @ChangeHow for the registry): one model everywhere — single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (P project picker, G branch picker, L session list, T timeline, R rename session, N prompt navigator, I services, C theme, H help), held Cmd/Ctrl+digit for session tabs and held Cmd/Ctrl+Option+digit for panel surfaces. Cmd/Ctrl+B toggles the sidebar, rare actions moved into the command palette, and every tooltip shows the binding you actually have set. Shortcuts no longer require an English keyboard layout — bindings follow the physical key, including when recording custom ones. Custom bindings from the old layout reset once. -- **Session expiry is announced, not discovered.** When the OpenChamber login expires (LAN browser, paired device, tunnel), a banner appears under the header within seconds — before anything is clicked — with a Log in button that opens the usual unlock screen. Work stays visible, sending pauses until login, a failed send always returns the typed prompt to the input, and a conversation that failed to load while logged out explains that and reloads itself after login. A 401 is confirmed against the server first, so an expired model-provider key can't fake a logout. - **Chat context attachments:** everything you attach to a message — diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues and PRs — now shows up as a compact context card: a source header, the captured content behind an expander, and your comment below it, instead of a wall of raw text. -- Session tabs (opt-in): the web/desktop header can show open sessions as browser-style tabs (Settings → General → Navigation). Clicking a tab switches the whole workspace, closing one (×, middle-click, or Alt+W) never touches the session itself, tabs reorder by drag, carry the running/unread dot, and hold the full session menu including rename-in-place. +- **Session tabs (opt-in):** the web/desktop header can show open sessions as browser-style tabs (Settings → General → Navigation). Clicking a tab switches the whole workspace, closing one (×, middle-click, or Alt+W) never touches the session itself, tabs reorder by drag, carry the running/unread dot, and hold the full session menu including rename-in-place. - Faster session switching in large workspaces (thanks @c-w-xiaohei): the sidebar no longer rebuilds on switch and recently viewed sessions restore their rendered messages; end-to-end switch time roughly halved with thousands of loaded sessions. - Permission cards answer to the keyboard: Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons. The auto-accept toggle got Cmd/Ctrl+K, A. - Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. @@ -24,6 +23,7 @@ All notable changes to this project will be documented in this file. - Chat: a "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns automatic following off entirely; with it off, the scroll-to-bottom pill now appears as soon as the reply grows past the visible area. - Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme) are found by typing but stay off the first screen. - Mobile: narrowing a browser window past phone size switches into the mobile layout (and back when widened); the old/new mobile layout setting is gone. +- Auth: an expired OpenChamber login (LAN browser, paired device, tunnel) is announced within seconds by a banner under the header with a Log in button — instead of being discovered through failing actions. Work stays visible, sending pauses until login, a conversation that failed to load while logged out reloads itself after login, and an expired model-provider key can't fake a logout. - Chat: a failed send returns your typed prompt to the input — whatever the reason — instead of losing it to an error toast; a mid-send session switch lands it in that session's draft. - Chat: opening a session or resizing panels could strand the view in a large empty space below the last message; the list now returns to the real end, and a width resize keeps a reader who was at the bottom at the bottom. - Chat: prompt-rail and message jumps land exactly on the target once the layout finishes measuring, and clicking the last rail item always works. From 4020226984bdb120b370945fc958ad36593d7468 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 19:11:33 +0300 Subject: [PATCH 34/49] docs: tighten changelog bullets and normalize contributor credits --- CHANGELOG.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe539bf..4d7de239 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] -- **Chat scrolling rebuilt around your message.** Sending parks your message near the top and the reply streams into the space below it, so you read from where you asked instead of chasing the bottom. Streamed text arrives a paragraph at a time with a soft fade and the view glides after it in one motion; scrolling up immediately hands you the wheel, with the scroll-to-bottom pill carrying the model's working status while you're away. Opening a session lands on the newest message with no animation. -- **Keyboard shortcuts redesigned** (thanks @ChangeHow for the registry): one model everywhere — single chords for everyday actions, a two-step Cmd/Ctrl+K leader for open/go actions (P project picker, G branch picker, L session list, T timeline, R rename session, N prompt navigator, I services, C theme, H help), held Cmd/Ctrl+digit for session tabs and held Cmd/Ctrl+Option+digit for panel surfaces. Cmd/Ctrl+B toggles the sidebar, rare actions moved into the command palette, and every tooltip shows the binding you actually have set. Shortcuts no longer require an English keyboard layout — bindings follow the physical key, including when recording custom ones. Custom bindings from the old layout reset once. -- **Chat context attachments:** everything you attach to a message — diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues and PRs — now shows up as a compact context card: a source header, the captured content behind an expander, and your comment below it, instead of a wall of raw text. -- **Session tabs (opt-in):** the web/desktop header can show open sessions as browser-style tabs (Settings → General → Navigation). Clicking a tab switches the whole workspace, closing one (×, middle-click, or Alt+W) never touches the session itself, tabs reorder by drag, carry the running/unread dot, and hold the full session menu including rename-in-place. -- Faster session switching in large workspaces (thanks @c-w-xiaohei): the sidebar no longer rebuilds on switch and recently viewed sessions restore their rendered messages; end-to-end switch time roughly halved with thousands of loaded sessions. +- **Chat scrolling rebuilt around your message.** Sending parks your message near the top and the reply streams in below it, gliding smoothly a paragraph at a time. Scrolling up immediately hands you the wheel; the scroll-to-bottom pill carries the model's working status while you're away. +- **Keyboard shortcuts redesigned:** single chords for everyday actions, a Cmd/Ctrl+K leader for two-step open/go actions, held Cmd/Ctrl+digit for session tabs and Cmd/Ctrl+Option+digit for panel surfaces. Shortcuts work on non-English keyboard layouts now, tooltips show the binding you actually have set, and old custom bindings reset once. The full map lives in Settings → Shortcuts (registry contributed by @ChangeHow — thanks!). +- **Chat context attachments:** diff comments, terminal selections, browser annotations, linked issues/PRs and the rest now appear in the conversation as compact context cards instead of walls of raw text. +- **Session tabs (opt-in):** the web/desktop header can show open sessions as browser-style tabs (Settings → General → Navigation). A tab switches the whole workspace; closing one never touches the session itself. +- Sessions: switching is much faster in large workspaces — the sidebar no longer rebuilds on switch and recently viewed sessions restore their rendered messages; end-to-end switch time roughly halved with thousands of loaded sessions (thanks to @c-w-xiaohei). - Permission cards answer to the keyboard: Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons. The auto-accept toggle got Cmd/Ctrl+K, A. - Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. - Git: Cmd/Ctrl+Enter in the commit message box commits. Diff review moves between changed files with Alt+Down/Up, expanding a collapsed file on arrival. @@ -17,13 +17,13 @@ All notable changes to this project will be documented in this file. - Diff: comment like a review — hovering a line shows a + in the gutter; clicking or dragging across lines opens the comment editor for that range, styled like the chat's comments. - Composer: hovering or tapping a context chip opens a stacked preview of everything attached, where a comment can be edited in place or an item removed before sending. - Mobile: the chat comment input overlays the composer exactly and rides the keyboard; Enter makes a new line there, with attach on the button. -- Terminal: terminals no longer vanish or die behind your back — another tab, another device, or a reload shows the terminals already running on the server, and background-tab terminals survive the server's idle cleanup while the app is open. -- Search: every searchable picker now uses one matcher — best matches first, multi-word queries in any order, punctuation ignored (so "gpt4o" finds "gpt-4o"). Ctrl/Cmd+P also matches the whole file path, and the git branch and gitmoji pickers stopped silently dropping rows. +- Terminal: terminals no longer vanish behind your back — every tab and device shows the ones already running on the server, and background tabs survive the idle cleanup. +- Search: every searchable picker uses one matcher now — best matches first, multi-word queries in any order, punctuation ignored ("gpt4o" finds "gpt-4o"). Ctrl/Cmd+P matches whole file paths. - Chat: @ file mentions rank files and directories together by match quality, and long paths keep the folder next to the file name visible. - Chat: a "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns automatic following off entirely; with it off, the scroll-to-bottom pill now appears as soon as the reply grows past the visible area. - Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme) are found by typing but stay off the first screen. - Mobile: narrowing a browser window past phone size switches into the mobile layout (and back when widened); the old/new mobile layout setting is gone. -- Auth: an expired OpenChamber login (LAN browser, paired device, tunnel) is announced within seconds by a banner under the header with a Log in button — instead of being discovered through failing actions. Work stays visible, sending pauses until login, a conversation that failed to load while logged out reloads itself after login, and an expired model-provider key can't fake a logout. +- Auth: an expired OpenChamber login is announced within seconds by a banner with a Log in button, instead of being discovered through failing actions. Sending pauses until login, and a conversation that failed to load reloads itself afterwards. - Chat: a failed send returns your typed prompt to the input — whatever the reason — instead of losing it to an error toast; a mid-send session switch lands it in that session's draft. - Chat: opening a session or resizing panels could strand the view in a large empty space below the last message; the list now returns to the real end, and a width resize keeps a reader who was at the bottom at the bottom. - Chat: prompt-rail and message jumps land exactly on the target once the layout finishes measuring, and clicking the last rail item always works. @@ -34,16 +34,16 @@ All notable changes to this project will be documented in this file. - Mobile: scrolling during a streaming reply works again — a drag immediately takes over, the pill shows up, and load-older no longer throws you to the bottom. - Fixed file links in messages being checked twice, and against the wrong project directory on the first pass. - Fixed the selected project or session briefly jumping back to a previous choice when settings responses arrived out of order. -- Fixed sessions staying on "loading sessions" forever after a half-open connection to OpenCode — stalled reads now time out and retry (thanks @herjarsa). -- Files: previews above the editable size cap show the whole file, virtualized so huge files no longer freeze the app (thanks @gaojunran). -- VSCode: the chat view no longer sticks on its loading screen on slow or remote connections (thanks @VinciYan). +- Fixed sessions staying on "loading sessions" forever after a half-open connection to OpenCode — stalled reads now time out and retry (thanks to @herjarsa). +- Files: previews above the editable size cap show the whole file, virtualized so huge files no longer freeze the app (thanks to @gaojunran). +- VSCode: the chat view no longer sticks on its loading screen on slow or remote connections (thanks to @VinciYan). - Terminal: mobile keyboards no longer capitalize the first letter of every command. - Desktop: a freshly installed or updated build no longer loads the previous version's interface from cache. - Devices: re-pairing a phone keeps the device's existing name instead of resetting it to "OpenChamber Mobile". - Relay: paired devices no longer get logged out when the app restarts while another local OpenChamber process is running. - Sessions: headers now find archived sessions too, so an archived session's title no longer goes missing. - Files: the editor toolbar is always docked under the file tabs; the floating hover toolbar and its setting were removed. -- UI: the chat's scroll fades are back, the first uncached session open fades in, the timeline dialog fits small screens (thanks @gaojunran), OpenCode notices share one style, draft target menus stay inside the chat area, Linear and Cloudflare tools show their own icons, sidebar tooltips no longer appear on passing hover, and the btw panel's shadow matches the composer. +- UI: the chat's scroll fades are back, the first uncached session open fades in, the timeline dialog fits small screens (thanks to @gaojunran), OpenCode notices share one style, draft target menus stay inside the chat area, Linear and Cloudflare tools show their own icons, sidebar tooltips no longer appear on passing hover, and the btw panel's shadow matches the composer. ## [1.20.0] - 2026-08-23 From 067c6caf0c68d4ea7f83aa829b4377c866fc6b49 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 19:17:55 +0300 Subject: [PATCH 35/49] refactor(chat): slim the selection menu down to comment and notes Add to input leaves the desktop menu (mod+L owns it; mobile keeps the button) and the New session action is gone from both variants along with its handler and dead locale keys. --- .../chat/message/TextSelectionMenu.tsx | 62 ------------------- packages/ui/src/lib/i18n/messages/de.ts | 2 - packages/ui/src/lib/i18n/messages/en.ts | 2 - packages/ui/src/lib/i18n/messages/es.ts | 2 - packages/ui/src/lib/i18n/messages/fr.ts | 2 - packages/ui/src/lib/i18n/messages/ja.ts | 2 - packages/ui/src/lib/i18n/messages/ko.ts | 2 - packages/ui/src/lib/i18n/messages/pl.ts | 2 - packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 - packages/ui/src/lib/i18n/messages/uk.ts | 2 - packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 - packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 - 12 files changed, 84 deletions(-) diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index 29718932..f3a8f530 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -108,7 +108,6 @@ export const TextSelectionMenu: React.FC = ({ containerR const mouseUpTimeoutRef = React.useRef(null); const isMenuVisibleRef = React.useRef(false); const activeAddToChatCleanupRef = React.useRef<(() => void) | null>(null); - const createSession = useSessionUIStore((state) => state.createSession); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open); const addContextDraft = useInlineCommentDraftStore((state) => state.addDraft); @@ -487,18 +486,6 @@ export const TextSelectionMenu: React.FC = ({ containerR }); }, [addContextDraft, commentText, currentSessionId, effectiveDirectory, hideMenu, newSessionDraftOpen, selectedMessageId, selectedTextMarkdown]); - const handleCreateNewSession = React.useCallback(async () => { - if (!selectedText) return; - - const session = await createSession(undefined, null, null); - if (session) { - setPendingInputText(selectedText, 'replace'); - } - - hideMenu(); - window.getSelection()?.removeAllRanges(); - }, [selectedText, createSession, setPendingInputText, hideMenu]); - const currentSession = React.useMemo(() => { if (!currentSessionId) { return null; @@ -700,22 +687,6 @@ export const TextSelectionMenu: React.FC = ({ containerR {t('chat.textSelection.actions.addToInput')} - - {!isVSCodeRuntime() ? ( -
- - - -
- - {!isVSCodeRuntime() ? ( <> diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 591c055b..d7f59726 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2014,10 +2014,8 @@ export const dict = { 'chat.textSelection.title.commentOnSelection': 'Auswahl kommentieren', 'chat.textSelection.comment.placeholder': 'Optionalen Kommentar hinzufügen...', 'chat.textSelection.comment.attach': 'Anhängen', - 'chat.textSelection.actions.newSession': 'Neue Sitzung', 'chat.textSelection.actions.addToNotes': 'Zu Notizen hinzufügen', 'chat.textSelection.title.addToCurrentChat': 'Zum aktuellen Chat hinzufügen', - 'chat.textSelection.title.newSessionWithSelection': 'Neue Sitzung mit Auswahl erstellen', 'chat.textSelection.title.saveInsightToNotes': 'Ausgewählten Text zu Notizen speichern', 'chat.messageBody.actions.revertAria': 'Zu dieser Nachricht zurückkehren', 'chat.messageBody.actions.revert': 'Von hier zurückkehren', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 53da59bd..0662db92 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2206,10 +2206,8 @@ export const dict = { 'chat.textSelection.title.commentOnSelection': 'Comment on selection', 'chat.textSelection.comment.placeholder': 'Add an optional comment...', 'chat.textSelection.comment.attach': 'Attach', - 'chat.textSelection.actions.newSession': 'New session', 'chat.textSelection.actions.addToNotes': 'Add to notes', 'chat.textSelection.title.addToCurrentChat': 'Add to current chat', - 'chat.textSelection.title.newSessionWithSelection': 'Create new session with selection', 'chat.textSelection.title.saveInsightToNotes': 'Save selected text to notes', 'chat.messageBody.actions.revertAria': 'Revert to this message', 'chat.messageBody.actions.revert': 'Revert from here', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 9d7f86aa..4949a3e9 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2184,10 +2184,8 @@ export const dict: Record = { "chat.textSelection.title.commentOnSelection": "Comentar la selección", "chat.textSelection.comment.placeholder": "Añade un comentario opcional...", "chat.textSelection.comment.attach": "Adjuntar", - "chat.textSelection.actions.newSession": "Nueva sesión", "chat.textSelection.actions.addToNotes": "Añadir a las notas", "chat.textSelection.title.addToCurrentChat": "Añadir al chat actual", - "chat.textSelection.title.newSessionWithSelection": "Crear nueva sesión con selección", "chat.textSelection.title.saveInsightToNotes": "Guardar texto seleccionado en notas", "chat.messageBody.actions.revertAria": "Volver a este mensaje", "chat.messageBody.actions.revert": "Volver desde aquí", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 09e3b596..573aed79 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1933,10 +1933,8 @@ export const dict = { 'chat.textSelection.title.commentOnSelection': 'Commenter la sélection', 'chat.textSelection.comment.placeholder': 'Ajouter un commentaire facultatif...', 'chat.textSelection.comment.attach': 'Joindre', - 'chat.textSelection.actions.newSession': 'Nouvelle session', 'chat.textSelection.actions.addToNotes': 'Ajouter aux notes', 'chat.textSelection.title.addToCurrentChat': 'Ajouter au chat actuel', - 'chat.textSelection.title.newSessionWithSelection': 'Créer une nouvelle session avec sélection', 'chat.textSelection.title.saveInsightToNotes': 'Enregistrer le texte sélectionné dans les notes', 'chat.messageBody.actions.revertAria': 'Revenir à ce message', 'chat.messageBody.actions.revert': 'Revenir à partir d\'ici', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 0e88eb6d..2b474a39 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2202,10 +2202,8 @@ export const dict: Record = { 'chat.textSelection.title.commentOnSelection': '選択範囲にコメント', 'chat.textSelection.comment.placeholder': '任意のコメントを追加...', 'chat.textSelection.comment.attach': '添付', - 'chat.textSelection.actions.newSession': '新しいセッション', 'chat.textSelection.actions.addToNotes': 'メモに追加', 'chat.textSelection.title.addToCurrentChat': '現在のチャットに追加', - 'chat.textSelection.title.newSessionWithSelection': '選択範囲で新しいセッションを作成', 'chat.textSelection.title.saveInsightToNotes': '選択テキストをメモに保存', 'chat.messageBody.actions.revertAria': 'このメッセージに戻す', 'chat.messageBody.actions.revert': 'ここから元に戻す', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index ce822479..439f8ba6 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2208,10 +2208,8 @@ export const dict: Record = { 'chat.textSelection.title.commentOnSelection': '선택 영역에 댓글 달기', 'chat.textSelection.comment.placeholder': '선택적 댓글 추가...', 'chat.textSelection.comment.attach': '첨부', - 'chat.textSelection.actions.newSession': '새 세션', 'chat.textSelection.actions.addToNotes': '메모에 추가', 'chat.textSelection.title.addToCurrentChat': '현재 채팅에 추가', - 'chat.textSelection.title.newSessionWithSelection': '선택한 내용으로 새 세션 생성', 'chat.textSelection.title.saveInsightToNotes': '선택한 텍스트를 메모에 저장', 'chat.messageBody.actions.revertAria': '이 메시지로 되돌리기', 'chat.messageBody.actions.revert': '여기부터 되돌리기', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index b79dcfd9..76610ba7 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -897,10 +897,8 @@ export const dict: Record = { 'chat.textSelection.title.commentOnSelection': 'Skomentuj zaznaczenie', 'chat.textSelection.comment.placeholder': 'Dodaj opcjonalny komentarz...', 'chat.textSelection.comment.attach': 'Załącz', - 'chat.textSelection.actions.newSession': 'Nowa sesja', 'chat.textSelection.actions.addToNotes': 'Dodaj do notatek', 'chat.textSelection.title.addToCurrentChat': 'Dodaj do obecnego czatu', - 'chat.textSelection.title.newSessionWithSelection': 'Utwórz nową sesję z zaznaczeniem', 'chat.textSelection.title.saveInsightToNotes': 'Zapisz zaznaczony tekst do notatek', 'chat.messageBody.actions.revertAria': 'Cofnij do tej wiadomości', 'chat.messageBody.actions.revert': 'Cofnij od tego miejsca', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index d66e0070..0f63edb1 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2184,10 +2184,8 @@ export const dict: Record = { "chat.textSelection.title.commentOnSelection": "Comentar a seleção", "chat.textSelection.comment.placeholder": "Adicione um comentário opcional...", "chat.textSelection.comment.attach": "Anexar", - "chat.textSelection.actions.newSession": "Nova sessão", "chat.textSelection.actions.addToNotes": "Adicionar às notas", "chat.textSelection.title.addToCurrentChat": "Adicionar ao chat atual", - "chat.textSelection.title.newSessionWithSelection": "Criar nova sessão com seleção", "chat.textSelection.title.saveInsightToNotes": "Salvar texto selecionado em notas", "chat.messageBody.actions.revertAria": "Voltar para esta mensagem", "chat.messageBody.actions.revert": "Voltar daqui", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 82674b90..35f91a31 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2184,10 +2184,8 @@ export const dict: Record = { "chat.textSelection.title.commentOnSelection": "Коментувати виділене", "chat.textSelection.comment.placeholder": "Додайте коментар за бажанням...", "chat.textSelection.comment.attach": "Прикріпити", - "chat.textSelection.actions.newSession": "Нова сесія", "chat.textSelection.actions.addToNotes": "Додати до нотаток", "chat.textSelection.title.addToCurrentChat": "Додати до поточного чату", - "chat.textSelection.title.newSessionWithSelection": "Створити нову сесію із виділенням", "chat.textSelection.title.saveInsightToNotes": "Зберегти вибраний текст у нотатках", "chat.messageBody.actions.revertAria": "Повернутися до цього повідомлення", "chat.messageBody.actions.revert": "Повернутися звідси", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 8121c9c4..7072ef26 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2172,10 +2172,8 @@ export const dict: Record = { 'chat.textSelection.title.commentOnSelection': '评论所选内容', 'chat.textSelection.comment.placeholder': '添加可选评论...', 'chat.textSelection.comment.attach': '附加', - 'chat.textSelection.actions.newSession': '新建会话', 'chat.textSelection.actions.addToNotes': '添加到笔记', 'chat.textSelection.title.addToCurrentChat': '添加到当前聊天', - 'chat.textSelection.title.newSessionWithSelection': '使用选中内容创建新会话', 'chat.textSelection.title.saveInsightToNotes': '将选中文本保存到笔记', 'chat.messageBody.actions.revertAria': '回退到这条消息', 'chat.messageBody.actions.revert': '从此处回退', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index d6039d9f..17861ebd 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2176,10 +2176,8 @@ export const dict: Record = { 'chat.textSelection.title.commentOnSelection': '對所選內容留言', 'chat.textSelection.comment.placeholder': '新增選填留言...', 'chat.textSelection.comment.attach': '附加', - 'chat.textSelection.actions.newSession': '新增會話', 'chat.textSelection.actions.addToNotes': '加入筆記', 'chat.textSelection.title.addToCurrentChat': '加入目前聊天', - 'chat.textSelection.title.newSessionWithSelection': '使用選取內容建立新會話', 'chat.textSelection.title.saveInsightToNotes': '將選取文字儲存到筆記', 'chat.messageBody.actions.revertAria': '收回到這條訊息', 'chat.messageBody.actions.revert': '從此處收回', From c4df01f707551852ea24969a77435280237d651f Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 19:29:04 +0300 Subject: [PATCH 36/49] chore(quota): drop the Command Code usage provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command Code's official API has no usage endpoints; the old usage source was the unofficial studio API reached through a now-archived plugin, so the tile could only ever fail for officially configured users. Removed across server, shared UI, and the VS Code extension; the provider logo fallback stays — it serves the model picker, not usage. --- CHANGELOG.md | 1 + packages/ui/src/lib/quota/providers/index.ts | 1 - packages/ui/src/types/quota.ts | 1 - packages/vscode/src/commandCodeQuota.ts | 66 -------------- packages/vscode/src/quotaProviders.test.ts | 52 ----------- packages/vscode/src/quotaProviders.ts | 16 ---- .../lib/quota/providers/command-code.js | 90 ------------------- .../lib/quota/providers/command-code.test.js | 85 ------------------ .../web/server/lib/quota/providers/index.js | 22 +---- 9 files changed, 5 insertions(+), 329 deletions(-) delete mode 100644 packages/vscode/src/commandCodeQuota.ts delete mode 100644 packages/web/server/lib/quota/providers/command-code.js delete mode 100644 packages/web/server/lib/quota/providers/command-code.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d7de239..d9cd0f87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to this project will be documented in this file. - Chat: a "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns automatic following off entirely; with it off, the scroll-to-bottom pill now appears as soon as the reply grows past the visible area. - Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme) are found by typing but stay off the first screen. - Mobile: narrowing a browser window past phone size switches into the mobile layout (and back when widened); the old/new mobile layout setting is gone. +- Usage: the Command Code tile is gone — their official API exposes no usage data, so the tile could only fail. - Auth: an expired OpenChamber login is announced within seconds by a banner with a Log in button, instead of being discovered through failing actions. Sending pauses until login, and a conversation that failed to load reloads itself afterwards. - Chat: a failed send returns your typed prompt to the input — whatever the reason — instead of losing it to an error toast; a mid-send session switch lands it in that session's draft. - Chat: opening a session or resizing panels could strand the view in a large empty space below the last message; the list now returns to the real end, and a width resize keeps a reader who was at the bottom at the bottom. diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index 4c6067e0..96a4906c 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -8,7 +8,6 @@ export interface QuotaProviderMeta { export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ { id: 'claude', name: 'Claude' }, { id: 'codex', name: 'Codex' }, - { id: 'command-code', name: 'Command Code' }, { id: 'cursor', name: 'Cursor' }, { id: 'github-copilot', name: 'GitHub Copilot' }, { id: 'google', name: 'Google' }, diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index fc00b633..059e4b98 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -1,7 +1,6 @@ export type QuotaProviderId = | 'openai' | 'codex' - | 'command-code' | 'cursor' | 'claude' | 'github-copilot' diff --git a/packages/vscode/src/commandCodeQuota.ts b/packages/vscode/src/commandCodeQuota.ts deleted file mode 100644 index 264e932d..00000000 --- a/packages/vscode/src/commandCodeQuota.ts +++ /dev/null @@ -1,66 +0,0 @@ -type CommandCodeCredits = { - credits?: { monthlyCredits?: number; purchasedCredits?: number; freeCredits?: number }; - windowLimits?: { - fiveHour?: { used?: number; cap?: number; resetAt?: number }; - weekly?: { used?: number; cap?: number; resetAt?: number }; - }; -}; - -type WindowData = { usedPercent: number | null; resetAt: number | null; windowSeconds: number | null; valueLabel: string }; - -const toWindow = (data: WindowData) => ({ - usedPercent: data.usedPercent, - remainingPercent: data.usedPercent === null ? null : Math.max(0, 100 - data.usedPercent), - windowSeconds: data.windowSeconds, - resetAfterSeconds: data.resetAt === null ? null : Math.max(0, Math.floor((data.resetAt - Date.now()) / 1000)), - resetAt: data.resetAt, - resetAtFormatted: null, - resetAfterFormatted: null, - valueLabel: data.valueLabel, -}); - -const isFiniteNumber = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); -const formatCredits = (value: number): string => String(Math.round((value + Number.EPSILON) * 100) / 100); - -const parseCredits = (value: unknown): CommandCodeCredits | null => { - if (!value || typeof value !== 'object') return null; - const payload = value as CommandCodeCredits; - return payload; -}; - -const parseOrgId = (value: unknown): string | null | undefined => { - if (!value || typeof value !== 'object') return undefined; - const org = (value as { org?: { id?: unknown } }).org; - return typeof org?.id === 'string' && org.id.trim() ? org.id.trim() : null; -}; - -const parseCommandCodeCredits = (payload: CommandCodeCredits) => { - const windows: Record> = {}; - for (const [label, value] of [['monthly_credits', payload.credits?.monthlyCredits], ['purchased_credits', payload.credits?.purchasedCredits], ['free_credits', payload.credits?.freeCredits]] as const) { - if (isFiniteNumber(value)) windows[label] = toWindow({ usedPercent: null, resetAt: null, windowSeconds: null, valueLabel: formatCredits(value) }); - } - for (const [label, limit, seconds] of [['5h', payload.windowLimits?.fiveHour, 5 * 60 * 60], ['weekly', payload.windowLimits?.weekly, 7 * 24 * 60 * 60]] as const) { - if (!isFiniteNumber(limit?.used) || !isFiniteNumber(limit.cap) || limit.cap <= 0) continue; - const resetAt = isFiniteNumber(limit.resetAt) ? (limit.resetAt < 1_000_000_000_000 ? limit.resetAt * 1000 : limit.resetAt) : null; - windows[label] = toWindow({ usedPercent: Math.min(100, Math.max(0, limit.used / limit.cap * 100)), resetAt, windowSeconds: seconds, valueLabel: `${formatCredits(limit.used)} / ${formatCredits(limit.cap)}` }); - } - return windows; -}; - -const requestJson = async (path: string, apiKey: string): Promise => { - const response = await fetch(`https://api.commandcode.ai${path}`, { headers: { Accept: 'application/json', Authorization: `Bearer ${apiKey}` }, signal: AbortSignal.timeout(15_000) }); - if (response.status === 401 || response.status === 403) throw new Error('Command Code authentication failed'); - if (!response.ok) throw new Error(`Command Code usage API returned HTTP ${response.status}`); - return response.json().catch(() => null); -}; - -export const fetchCommandCodeUsage = async (apiKey: string) => { - const orgId = parseOrgId(await requestJson('/alpha/whoami', apiKey)); - if (orgId === undefined) throw new Error('Command Code account could not be determined'); - const creditsPath = orgId ? `/alpha/billing/credits?orgId=${encodeURIComponent(orgId)}` : '/alpha/billing/credits'; - const payload = parseCredits(await requestJson(creditsPath, apiKey)); - if (!payload) throw new Error('Command Code usage data could not be parsed'); - const windows = parseCommandCodeCredits(payload); - if (!Object.keys(windows).length) throw new Error('Command Code usage data could not be parsed'); - return windows; -}; diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index ee88916c..0ab8938c 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -17,7 +17,6 @@ const AUTH = JSON.stringify({ crof: { key: 'test-token' }, neuralwatt: { key: 'test-token' }, 'opencode-go': { key: 'test-token' }, - 'command-code': { type: 'oauth', access: 'test-token' }, 'zai-coding-plan': { key: 'test-token' }, deepseek: { key: 'test-token' }, anthropic: { access: 'test-token', refresh: 'test-refresh' }, @@ -104,57 +103,6 @@ describe('OpenCode Go quota provider (VS Code parity)', () => { }); }); -describe('Command Code quota provider (VS Code parity)', () => { - test('uses the OAuth access token and resolves server-backed limits', async () => { - const requests: Array<{ url: string; init?: RequestInit }> = []; - globalThis.fetch = (async (url: string, init?: RequestInit) => { - requests.push({ url, init }); - return mockResponse(url.endsWith('/alpha/whoami') - ? { org: { id: 'org/a' } } - : { credits: { monthlyCredits: 120 }, windowLimits: { fiveHour: { used: 25, cap: 100, resetAt: 1_776_000_000 } } }); - }) as typeof fetch; - - const result = await fetchQuotaForProvider('command-code'); - - assert.equal(result.ok, true); - assert.deepEqual(requests.map(({ url }) => url), [ - 'https://api.commandcode.ai/alpha/whoami', - 'https://api.commandcode.ai/alpha/billing/credits?orgId=org%2Fa', - ]); - assert.equal((requests[0].init?.headers as Record).Authorization, 'Bearer test-token'); - assert.equal(result.usage!.windows['5h']!.usedPercent, 25); - assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '120'); - }); - - test('omits orgId for personal accounts', async () => { - const urls: string[] = []; - globalThis.fetch = (async (url: string) => { - urls.push(url); - return mockResponse(url.endsWith('/alpha/whoami') - ? { user: { id: 'user-1' }, org: null } - : { credits: { monthlyCredits: 120 } }); - }) as typeof fetch; - - const result = await fetchQuotaForProvider('command-code'); - - assert.equal(result.ok, true); - assert.deepEqual(urls, [ - 'https://api.commandcode.ai/alpha/whoami', - 'https://api.commandcode.ai/alpha/billing/credits', - ]); - }); - - test('formats fractional credit values for display', async () => { - globalThis.fetch = (async (url: string) => mockResponse(url.endsWith('/alpha/whoami') - ? { org: null } - : { credits: { monthlyCredits: 69.7947070034 }, windowLimits: { fiveHour: { used: 0.2052929966, cap: 14 } } })) as typeof fetch; - - const result = await fetchQuotaForProvider('command-code'); - - assert.equal(result.usage!.windows.monthly_credits!.valueLabel, '69.79'); - assert.equal(result.usage!.windows['5h']!.valueLabel, '0.21 / 14'); - }); -}); describe('Crof quota provider (VS Code parity)', () => { test('reports credits balance as valueLabel with null percent', async () => { diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 042fc5d4..c70b43b7 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -2,7 +2,6 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fetchOpenCodeGoUsage } from './opencodeGoQuota'; -import { fetchCommandCodeUsage } from './commandCodeQuota'; import { deleteLegacyOpenCodeGoCredential, readCredential } from './quotaCredentials'; import { getProviderAuth, updateProviderAuth } from './opencodeAuth'; @@ -773,9 +772,6 @@ export const listConfiguredQuotaProviders = () => { const configured = new Set(); const openCodeGoAuth = normalizeAuthEntry(getAuthEntry(auth, ['opencode-go'])); if (openCodeGoAuth && (typeof openCodeGoAuth.key === 'string' || typeof openCodeGoAuth.token === 'string')) configured.add('opencode-go'); - const commandCodeAuth = normalizeAuthEntry(getAuthEntry(auth, ['command-code'])); - if (commandCodeAuth && (typeof commandCodeAuth.key === 'string' || typeof commandCodeAuth.access === 'string' || typeof commandCodeAuth.token === 'string')) configured.add('command-code'); - if (process.env.COMMAND_CODE_API_KEY?.trim()) configured.add('command-code'); if (readCredential('ollama-cloud')) configured.add('ollama-cloud'); if (readCredential('cursor')) configured.add('cursor'); @@ -2875,18 +2871,6 @@ const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise { - const entry = normalizeAuthEntry(getAuthEntry(auth, aliases)); - const stored = entry?.key ?? entry?.access ?? entry?.token; - return (typeof stored === 'string' ? stored.trim() : '') || process.env.COMMAND_CODE_API_KEY?.trim() || null; -}; - -const requestJson = async (path, apiKey, fetchImpl) => { - const response = await fetchImpl(`${API_BASE_URL}${path}`, { - headers: { - Accept: 'application/json', - Authorization: `Bearer ${apiKey}`, - 'User-Agent': 'OpenChamber quota provider', - }, - signal: AbortSignal.timeout(15_000), - }); - if (response.status === 401 || response.status === 403) throw new Error('Command Code authentication failed'); - if (!response.ok) throw new Error(`Command Code usage API returned HTTP ${response.status}`); - return response.json().catch(() => null); -}; - -const formatCredits = (value) => String(Math.round((value + Number.EPSILON) * 100) / 100); - -const toBalanceWindow = (value) => toUsageWindow({ - usedPercent: null, - windowSeconds: null, - resetAt: null, - valueLabel: formatCredits(value), -}); - -export const parseCommandCodeCredits = (payload) => { - const root = asObject(payload); - const credits = asObject(root?.credits); - const limits = asObject(root?.windowLimits); - const windows = {}; - - for (const [label, field] of [['monthly_credits', 'monthlyCredits'], ['purchased_credits', 'purchasedCredits'], ['free_credits', 'freeCredits']]) { - const value = toNumber(credits?.[field]); - if (value !== null) windows[label] = toBalanceWindow(value); - } - - for (const [label, field, windowSeconds] of [['5h', 'fiveHour', 5 * 60 * 60], ['weekly', 'weekly', 7 * 24 * 60 * 60]]) { - const limit = asObject(limits?.[field]); - const used = toNumber(limit?.used); - const cap = toNumber(limit?.cap); - if (used === null || cap === null || cap <= 0) continue; - const resetAt = toNumber(limit?.resetAt); - windows[label] = toUsageWindow({ - usedPercent: Math.min(100, Math.max(0, used / cap * 100)), - windowSeconds, - resetAt: resetAt === null ? null : resetAt < 1_000_000_000_000 ? resetAt * 1000 : resetAt, - valueLabel: `${formatCredits(used)} / ${formatCredits(cap)}`, - }); - } - - return windows; -}; - -export const fetchCommandCodeUsage = async (apiKey, fetchImpl = fetch) => { - const identity = asObject(await requestJson('/alpha/whoami', apiKey, fetchImpl)); - const org = asObject(identity?.org); - const orgId = typeof org?.id === 'string' ? org.id.trim() : ''; - const creditsPath = orgId - ? `/alpha/billing/credits?orgId=${encodeURIComponent(orgId)}` - : '/alpha/billing/credits'; - const credits = await requestJson(creditsPath, apiKey, fetchImpl); - const windows = parseCommandCodeCredits(credits); - if (Object.keys(windows).length === 0) throw new Error('Command Code usage data could not be parsed'); - return windows; -}; - -export const isConfigured = () => Boolean(getApiKey()); - -export const fetchQuota = async (auth = readAuthFile()) => { - const apiKey = getApiKey(auth); - if (!apiKey) return buildResult({ providerId, providerName, ok: false, configured: false, error: 'Not configured' }); - try { - return buildResult({ providerId, providerName, ok: true, configured: true, usage: { windows: await fetchCommandCodeUsage(apiKey) } }); - } catch (error) { - return buildResult({ providerId, providerName, ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed' }); - } -}; diff --git a/packages/web/server/lib/quota/providers/command-code.test.js b/packages/web/server/lib/quota/providers/command-code.test.js deleted file mode 100644 index e2dc1dde..00000000 --- a/packages/web/server/lib/quota/providers/command-code.test.js +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; - -import { fetchCommandCodeUsage, fetchQuota, parseCommandCodeCredits } from './command-code.js'; - -const creditsPayload = { - credits: { monthlyCredits: 120, purchasedCredits: 30, freeCredits: 5 }, - windowLimits: { - fiveHour: { used: 25, cap: 100, resetAt: 1_776_000_000 }, - weekly: { used: 70, cap: 200, resetAt: 1_776_604_800 }, - }, -}; - -describe('Command Code quota provider', () => { - it('parses balances and rate-limit windows', () => { - const windows = parseCommandCodeCredits(creditsPayload); - expect(windows.monthly_credits).toMatchObject({ usedPercent: null, valueLabel: '120' }); - expect(windows.purchased_credits).toMatchObject({ usedPercent: null, valueLabel: '30' }); - expect(windows.free_credits).toMatchObject({ usedPercent: null, valueLabel: '5' }); - expect(windows['5h']).toMatchObject({ usedPercent: 25, valueLabel: '25 / 100', resetAt: 1_776_000_000_000 }); - expect(windows.weekly.usedPercent).toBe(35); - }); - - it('formats fractional credit values for display', () => { - const windows = parseCommandCodeCredits({ - credits: { monthlyCredits: 69.7947070034 }, - windowLimits: { fiveHour: { used: 0.2052929966, cap: 14 } }, - }); - expect(windows.monthly_credits.valueLabel).toBe('69.79'); - expect(windows['5h'].valueLabel).toBe('0.21 / 14'); - }); - - it('resolves the organization before fetching credits', async () => { - const requests = []; - const windows = await fetchCommandCodeUsage('secret', async (url, options) => { - requests.push({ url, options }); - return new Response(JSON.stringify(url.endsWith('/alpha/whoami') ? { org: { id: 'org/a' } } : creditsPayload)); - }); - expect(requests.map(({ url }) => url)).toEqual([ - 'https://api.commandcode.ai/alpha/whoami', - 'https://api.commandcode.ai/alpha/billing/credits?orgId=org%2Fa', - ]); - expect(requests[0].options.headers.Authorization).toBe('Bearer secret'); - expect(windows['5h'].usedPercent).toBe(25); - }); - - it('fetches account-scoped credits without orgId for personal accounts', async () => { - const urls = []; - await fetchCommandCodeUsage('secret', async (url) => { - urls.push(url); - return new Response(JSON.stringify(url.endsWith('/alpha/whoami') ? { user: { id: 'user-1' }, org: null } : creditsPayload)); - }); - expect(urls).toEqual([ - 'https://api.commandcode.ai/alpha/whoami', - 'https://api.commandcode.ai/alpha/billing/credits', - ]); - }); - - it('does not expose credentials in authentication errors', async () => { - await expect(fetchCommandCodeUsage('secret', async () => new Response('', { status: 401 }))).rejects.toThrow('authentication failed'); - }); - - it('reads OAuth access credentials from the OpenCode auth file', async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ org: { id: 'org-1' } }))) - .mockResolvedValueOnce(new Response(JSON.stringify(creditsPayload))); - vi.stubGlobal('fetch', fetchMock); - const result = await fetchQuota({ 'command-code': { type: 'oauth', access: 'test-token' } }); - expect(result).toMatchObject({ providerId: 'command-code', ok: true, configured: true }); - expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer test-token'); - vi.unstubAllGlobals(); - }); - - it('recognizes Command Code auth entries under supported provider ID variants', async () => { - for (const providerId of ['commandcode', 'command_code', 'command code']) { - const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(JSON.stringify({ org: { id: 'org-1' } }))) - .mockResolvedValueOnce(new Response(JSON.stringify(creditsPayload))); - vi.stubGlobal('fetch', fetchMock); - - const result = await fetchQuota({ [providerId]: { type: 'oauth', access: 'test-token' } }); - expect(result).toMatchObject({ providerId: 'command-code', ok: true, configured: true }); - vi.unstubAllGlobals(); - } - }); -}); diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 1f97d159..3ae4cc99 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -9,7 +9,6 @@ import { buildResult } from '../utils/index.js'; import * as claude from './claude/index.js'; import * as codex from './codex.js'; -import * as commandCode from './command-code.js'; import * as copilot from './copilot.js'; import * as crof from './crof.js'; import * as cursor from './cursor.js'; @@ -30,12 +29,6 @@ import * as opencodeGo from './opencode-go.js'; import * as xai from './xai.js'; const registry = { - 'command-code': { - providerId: commandCode.providerId, - providerName: commandCode.providerName, - isConfigured: commandCode.isConfigured, - fetchQuota: commandCode.fetchQuota - }, claude: { providerId: claude.providerId, providerName: claude.providerName, @@ -160,12 +153,6 @@ const registry = { const pendingFetches = new Map(); -const normalizeQuotaProviderId = (providerId) => { - if (typeof providerId !== 'string') return providerId; - return ['command-code', 'commandcode', 'command_code', 'command code'].includes(providerId.trim().toLowerCase()) - ? 'command-code' - : providerId; -}; export const listConfiguredQuotaProviders = () => { const configured = []; @@ -210,14 +197,13 @@ const fetchQuotaForProviderUncoalesced = async (providerId) => { }; export const fetchQuotaForProvider = (providerId) => { - const normalizedProviderId = normalizeQuotaProviderId(providerId); - const existing = pendingFetches.get(normalizedProviderId); + const existing = pendingFetches.get(providerId); if (existing) return existing; - const pending = fetchQuotaForProviderUncoalesced(normalizedProviderId).finally(() => { - if (pendingFetches.get(normalizedProviderId) === pending) pendingFetches.delete(normalizedProviderId); + const pending = fetchQuotaForProviderUncoalesced(providerId).finally(() => { + if (pendingFetches.get(providerId) === pending) pendingFetches.delete(providerId); }); - pendingFetches.set(normalizedProviderId, pending); + pendingFetches.set(providerId, pending); return pending; }; From cb18f8c9af258bdeb68a61663c208c1400d2e4b0 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 19:46:23 +0300 Subject: [PATCH 37/49] chore: bump @opencode-ai/sdk to 1.18.23 --- bun.lock | 10 +++++----- package.json | 2 +- packages/ui/package.json | 2 +- packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bun.lock b/bun.lock index 3155488f..b643770c 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -169,7 +169,7 @@ "@dnd-kit/utilities": "^3.2.2", "@legendapp/list": "3.3.8", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.4.0", "@simplewebauthn/browser": "13.3.0", @@ -243,7 +243,7 @@ "version": "1.20.0", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "adm-zip": "^0.6.0", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -270,7 +270,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "@simplewebauthn/server": "13.3.1", "bun-pty": "^0.4.5", "compression": "^1.8.1", @@ -1007,7 +1007,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.21", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-k6iHQ5C8wOPglk+LgFyYnst168cGMQYumgpbVoeXJ+iC1AtvwD5zmjuF8CxMze/y9G1K2bOeO6p9yRvA7eHZLA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.23", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-VouYbL8O2ynLq0atr5fjzCv8YLtFi/zKLQ/uYkCvTJ4CmUdA01XwPn8om+u3TvxnGEfQsBfmThGU141vt3sg5w=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], diff --git a/package.json b/package.json index 6e4cd2b6..fc657a77 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/ui/package.json b/packages/ui/package.json index c9648636..a0b4e7e3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -45,7 +45,7 @@ "@dnd-kit/utilities": "^3.2.2", "@legendapp/list": "3.3.8", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.4.0", "@simplewebauthn/browser": "13.3.0", diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 4fa8976c..238f2356 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -245,7 +245,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "adm-zip": "^0.6.0", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index 186d0cff..b29d5700 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -25,7 +25,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.21", + "@opencode-ai/sdk": "1.18.23", "@simplewebauthn/server": "13.3.1", "bun-pty": "^0.4.5", "compression": "^1.8.1", From 340738a33fcf7b81900e9b3ad7c48c0892b12054 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 19:49:17 +0300 Subject: [PATCH 38/49] chore: update changelog entries for chat shortcut and comment text --- CHANGELOG.md | 3 ++- packages/vscode/CHANGELOG.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9cd0f87..3fb64263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. - **Chat scrolling rebuilt around your message.** Sending parks your message near the top and the reply streams in below it, gliding smoothly a paragraph at a time. Scrolling up immediately hands you the wheel; the scroll-to-bottom pill carries the model's working status while you're away. - **Keyboard shortcuts redesigned:** single chords for everyday actions, a Cmd/Ctrl+K leader for two-step open/go actions, held Cmd/Ctrl+digit for session tabs and Cmd/Ctrl+Option+digit for panel surfaces. Shortcuts work on non-English keyboard layouts now, tooltips show the binding you actually have set, and old custom bindings reset once. The full map lives in Settings → Shortcuts (registry contributed by @ChangeHow — thanks!). +- Chat: Cmd/Ctrl+Shift+T now cycles through every thinking level offered by the selected model instead of skipping levels after reaching the end (thanks to @nimobeeren). - **Chat context attachments:** diff comments, terminal selections, browser annotations, linked issues/PRs and the rest now appear in the conversation as compact context cards instead of walls of raw text. - **Session tabs (opt-in):** the web/desktop header can show open sessions as browser-style tabs (Settings → General → Navigation). A tab switches the whole workspace; closing one never touches the session itself. - Sessions: switching is much faster in large workspaces — the sidebar no longer rebuilds on switch and recently viewed sessions restore their rendered messages; end-to-end switch time roughly halved with thousands of loaded sessions (thanks to @c-w-xiaohei). @@ -13,7 +14,7 @@ All notable changes to this project will be documented in this file. - Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. - Git: Cmd/Ctrl+Enter in the commit message box commits. Diff review moves between changed files with Alt+Down/Up, expanding a collapsed file on arrival. - Panels: the context rail got a configure button — a dialog chooses which panels the rail shows. Hidden panels keep their data, stay reachable from the command palette, and leave the digit switcher, so digits always match the icons you see. -- Chat: comment on a reply — select text in a chat message (or a rendered markdown preview in Files) and choose Comment to attach exactly that quote, with a source line range when it can be located, plus your note. The selection stays highlighted while you type; Add to chat is now Add to input. +- Chat: comment on a reply — select text in a chat message (or a rendered markdown preview in Files) and choose Comment to attach exactly that quote, with a source line range when it can be located, plus your note. The selection stays highlighted while you type. - Diff: comment like a review — hovering a line shows a + in the gutter; clicking or dragging across lines opens the comment editor for that range, styled like the chat's comments. - Composer: hovering or tapping a context chip opens a stacked preview of everything attached, where a comment can be edited in place or an item removed before sending. - Mobile: the chat comment input overlays the composer exactly and rides the keyboard; Enter makes a new line there, with attach on the button. diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index dbd27b32..32e016ec 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -2,7 +2,7 @@ - The chat view no longer stays stuck on its loading screen on slow or remote connections (for example code-server behind a reverse proxy) — the connection status is re-sent until the webview is ready to hear it (thanks @VinciYan). - **Chat context attachments:** diff and file comments, terminal selections, and linked issues/PRs now show in the conversation as compact context cards — source header, captured content behind an expander, your comment below — instead of raw text inside the message. -- **Chat: comment on a reply.** Select text in a chat message and choose Comment to attach that quote with your note to the next message; the selection stays highlighted while you type. Add to chat is now Add to input. +- **Chat: comment on a reply.** Select text in a chat message and choose Comment to attach that quote with your note to the next message; the selection stays highlighted while you type. - Diff: hovering a line shows a + button that opens a comment for the line; clicking a line or dragging across lines opens the editor for that range. The comment editor and saved-comment cards match the chat's comment style. - Composer: hovering a context chip above the input opens a stacked preview of everything attached, where comments can be edited in place or items removed before sending. - Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible. @@ -10,6 +10,7 @@ - Search in dropdowns: searchable pickers (agents, models, providers, branches) now put the best matches first, match multi-word queries in any order, and ignore punctuation (so "gpt4o" finds "gpt-4o"). - Permission cards answer to the keyboard: Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies; the keys are printed on the buttons. - Keyboard: dropdown menus and pickers answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and shortcut labels in tooltips and menus show the binding you actually have set (thanks @ChangeHow). +- Chat: Cmd/Ctrl+Shift+T now cycles through every thinking level offered by the selected model instead of skipping levels after reaching the end (thanks to @nimobeeren). - Chat: OpenCode notices now share one style. - The timeline dialog now fits small windows instead of squeezing the message list to a couple of rows (thanks to @gaojunran). From 717854d06bbe2009e9d13def8eee9c7cafdb1ac2 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 19:50:07 +0300 Subject: [PATCH 39/49] refactor: remove aborted status banner from chat UI Removes the transient aborted banner from the composer status area Simplifies status row rendering to focus on working state and pending changes Cleans up unused abort-status localization strings --- packages/ui/src/components/chat/ChatInput.tsx | 49 +++---------------- .../src/components/chat/ComposerStatusBar.tsx | 15 +----- packages/ui/src/components/chat/StatusRow.tsx | 22 ++------- .../components/chat/StatusRowContainer.tsx | 15 +----- packages/ui/src/lib/i18n/messages/de.ts | 1 - packages/ui/src/lib/i18n/messages/en.ts | 1 - packages/ui/src/lib/i18n/messages/es.ts | 1 - packages/ui/src/lib/i18n/messages/fr.ts | 1 - packages/ui/src/lib/i18n/messages/ja.ts | 1 - packages/ui/src/lib/i18n/messages/ko.ts | 1 - packages/ui/src/lib/i18n/messages/pl.ts | 1 - packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 - packages/ui/src/lib/i18n/messages/uk.ts | 1 - packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 - packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 - 15 files changed, 15 insertions(+), 97 deletions(-) diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 8d375160..1c5a40f1 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -419,7 +419,6 @@ const ChatInputComponent: React.FC = ({ const ensureGitStatus = useGitStore((state) => state.ensureStatus); const fetchGitStatus = useGitStore((state) => state.fetchStatus); const clearGitDiffCache = useGitStore((state) => state.clearDiffCache); - const [showAbortStatus, setShowAbortStatus] = React.useState(false); const setSessionAutoAccept = usePermissionStore((state) => state.setSessionAutoAccept); const [isNarrowComposer, setIsNarrowComposer] = React.useState(false); const [attachmentPreview, setAttachmentPreview] = React.useState({ @@ -697,7 +696,6 @@ const ChatInputComponent: React.FC = ({ attachments, }; }, [resolveInlineFileMention]); - const abortTimeoutRef = React.useRef | null>(null); const prevWasAbortedRef = React.useRef(false); // Issue linking state @@ -1721,29 +1719,15 @@ const ChatInputComponent: React.FC = ({ containerRef: dropZoneRef, }); - const startAbortIndicator = React.useCallback(() => { - if (abortTimeoutRef.current) { - clearTimeout(abortTimeoutRef.current); - abortTimeoutRef.current = null; - } - - setShowAbortStatus(true); - - abortTimeoutRef.current = setTimeout(() => { - setShowAbortStatus(false); - abortTimeoutRef.current = null; - }, 1800); - }, []); const handleAbort = React.useCallback(() => { clearAbortPrompt(); - startAbortIndicator(); // btw mode: the stop button stops the fork's turn, not the main // session's. const abortTarget = isBtwActive && btwSessionId ? btwSessionId : currentSessionId; void abortCurrentOperation(abortTarget || undefined); - }, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive, startAbortIndicator]); + }, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive]); const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => { const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction); @@ -2592,31 +2576,15 @@ const ChatInputComponent: React.FC = ({ handlePermissionAutoAcceptToggle(); }); + // Acknowledging the abort record is what lets the working chip resume for + // the next run; the old "Aborted" banner that used to accompany it is gone. React.useEffect(() => { - const pendingAbortBanner = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId; - if (!prevWasAbortedRef.current && pendingAbortBanner && !showAbortStatus) { - startAbortIndicator(); - if (currentSessionId) { - acknowledgeSessionAbort(currentSessionId); - } + const pendingAbort = Boolean(abortPromptSessionId) && abortPromptSessionId === currentSessionId; + if (!prevWasAbortedRef.current && pendingAbort && currentSessionId) { + acknowledgeSessionAbort(currentSessionId); } - prevWasAbortedRef.current = pendingAbortBanner; - }, [ - abortPromptSessionId, - acknowledgeSessionAbort, - currentSessionId, - showAbortStatus, - startAbortIndicator, - ]); - - React.useEffect(() => { - return () => { - if (abortTimeoutRef.current) { - clearTimeout(abortTimeoutRef.current); - abortTimeoutRef.current = null; - } - }; - }, []); + prevWasAbortedRef.current = pendingAbort; + }, [abortPromptSessionId, acknowledgeSessionAbort, currentSessionId]); return ( <> @@ -2687,7 +2655,6 @@ const ChatInputComponent: React.FC = ({ directory={currentSessionDirectoryForSync ?? currentDirectory} /> = ({ todo }) => { const EMPTY_TODOS: TodoItem[] = []; interface ComposerStatusBarProps { - showAbortStatus?: boolean; showTodos?: boolean; leftAccessory?: React.ReactNode; } export const ComposerStatusBar: React.FC = ({ - showAbortStatus, showTodos = true, leftAccessory, }) => { @@ -186,7 +184,7 @@ export const ComposerStatusBar: React.FC = ({ const hasTodoContent = showTodos && statusSummary.left > 0; const hasLeftAccessory = Boolean(leftAccessory); - const hasContent = Boolean(showAbortStatus) || hasTodoContent || hasLeftAccessory; + const hasContent = hasTodoContent || hasLeftAccessory; const popoverRef = React.useRef(null); React.useEffect(() => { @@ -252,16 +250,7 @@ export const ComposerStatusBar: React.FC = ({
{/* Left: abort status | pending-changes accessory */}
- {showAbortStatus ? ( -
- - -
- ) : leftAccessory ? ( - leftAccessory - ) : null} + {leftAccessory ?? null}
{/* Right: todos dropdown */} diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index a577a3d2..0b1efbb9 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -1,11 +1,9 @@ import React from "react"; import { useSessionUIStore } from '@/sync/session-ui-store'; import { WorkingPlaceholder } from "./message/parts/WorkingPlaceholder"; -import { Icon } from "@/components/icon/Icon"; -import { useI18n } from "@/lib/i18n"; // The floating assistant-status chip that hovers above the composer while the -// agent works ("Claude is working…", abort notice). ONLY that. The composer's +// agent works ("Claude is working…"). ONLY that. The composer's // own bar — pending changes, todos dropdown — is ComposerStatusBar: they used // to share this component, and every restyle of this chip (glass, placement) // silently dragged the composer bar and its dropdown along with it. @@ -17,10 +15,8 @@ interface StatusRowProps { statusText?: string | null; isGenericStatus?: boolean; isWaitingForPermission?: boolean; - wasAborted?: boolean; abortActive?: boolean; retryInfo?: { attempt?: number; next?: number } | null; - showAbortStatus?: boolean; agentName?: string; modelName?: string | null; providerId?: string | null; @@ -31,19 +27,16 @@ export const StatusRow: React.FC = ({ statusText = null, isGenericStatus, isWaitingForPermission, - wasAborted, abortActive, retryInfo, - showAbortStatus, agentName, modelName, providerId, }) => { - const { t } = useI18n(); const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive); - const hasContent = isWorking || Boolean(wasAborted) || Boolean(showAbortStatus); + const shouldRenderPlaceholder = !abortActive; + const hasContent = isWorking; if (!hasContent) { return null; @@ -63,14 +56,7 @@ export const StatusRow: React.FC = ({ a shrink-to-fit wrapper around it always collapsed to zero. */}
- {showAbortStatus ? ( -
- - -
- ) : shouldRenderPlaceholder ? ( + {shouldRenderPlaceholder ? ( { - const currentSessionId = useSessionUIStore((state) => state.currentSessionId); - const abortRecord = useSessionUIStore( - React.useCallback((state) => { - if (!currentSessionId) { - return null; - } - return state.sessionAbortFlags?.get(currentSessionId) ?? null; - }, [currentSessionId]), - ); const { activeModel, working } = useAssistantStatus(); const currentAgentName = useConfigStore((state) => state.currentAgentName); const providers = useConfigStore((state) => state.providers); @@ -35,16 +25,13 @@ export const StatusRowContainer: React.FC = React.memo(() => { return getProviderModelDisplayName(provider, activeModel.modelId) || null; }, [activeModel, providers]); - const wasAborted = Boolean(abortRecord && !abortRecord.acknowledged); - return ( = { "chat.statusRow.tasksTitle": "Tareas", "chat.statusRow.modelStatus": "{model} · {status}", "chat.statusRow.summary.activeLeft": "{active} activas · {left} restantes", - "chat.statusRow.aborted": "Interrumpido", "chat.revertIndicator.redo": "Rehacer", "chat.revertIndicator.redoAria": "Rehacer — restaurar mensajes revertidos", "chat.revertPopover.title": "Revertidos", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 573aed79..147b644f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1826,7 +1826,6 @@ export const dict = { 'chat.statusRow.tasksTitle': 'Tâches', 'chat.statusRow.modelStatus': '{model} · {status}', 'chat.statusRow.summary.activeLeft': '{active} actif · {left} gauche', - 'chat.statusRow.aborted': 'Avorté', 'chat.revertIndicator.redo': 'Refaire', 'chat.revertIndicator.redoAria': 'Rétablir : restaurer les messages annulés', 'chat.revertPopover.title': 'Rétabli', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 2b474a39..9bc2aaee 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2080,7 +2080,6 @@ export const dict: Record = { 'chat.statusRow.tasksTitle': 'タスク', 'chat.statusRow.modelStatus': '{model} · {status}', 'chat.statusRow.summary.activeLeft': '{active}アクティブ · {left}残り', - 'chat.statusRow.aborted': '中止されました', 'chat.revertIndicator.redo': 'やり直し', 'chat.revertIndicator.redoAria': 'やり直し — 元に戻したメッセージを復元', 'chat.revertPopover.title': '元に戻しました', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 439f8ba6..f89cd65f 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2086,7 +2086,6 @@ export const dict: Record = { 'chat.statusRow.tasksTitle': '작업', 'chat.statusRow.modelStatus': '{model} · {status}', 'chat.statusRow.summary.activeLeft': '{active}개 활성 · {left}개 남음', - 'chat.statusRow.aborted': '중단됨', 'chat.revertIndicator.redo': '다시 실행', 'chat.revertIndicator.redoAria': '다시 실행 — 되돌린 메시지 복원', 'chat.revertPopover.title': '되돌림', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 76610ba7..a729a278 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -776,7 +776,6 @@ export const dict: Record = { 'chat.statusRow.tasksTitle': 'Zadania', 'chat.statusRow.modelStatus': '{model} · {status}', 'chat.statusRow.summary.activeLeft': '{active} aktywne · {left} pozostało', - 'chat.statusRow.aborted': 'Przerwane', 'chat.revertIndicator.redo': 'Ponów', 'chat.revertIndicator.redoAria': 'Ponów — przywróć cofnięte wiadomości', 'chat.revertPopover.title': 'Cofnięte', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 0f63edb1..4a8a9f32 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2062,7 +2062,6 @@ export const dict: Record = { "chat.statusRow.tasksTitle": "Tarefas", "chat.statusRow.modelStatus": "{model} · {status}", "chat.statusRow.summary.activeLeft": "{active} ativas · {left} restantes", - "chat.statusRow.aborted": "Interrompido", "chat.revertIndicator.redo": "Refazer", "chat.revertIndicator.redoAria": "Refazer — restaurar mensagens revertidas", "chat.revertPopover.title": "Revertidas", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 35f91a31..b94dfb15 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2062,7 +2062,6 @@ export const dict: Record = { "chat.statusRow.tasksTitle": "завдання", "chat.statusRow.modelStatus": "{model} · {status}", "chat.statusRow.summary.activeLeft": "Активних: {active} · залишилось: {left}", - "chat.statusRow.aborted": "Перервано", "chat.revertIndicator.redo": "Повторити", "chat.revertIndicator.redoAria": "Повторити — відновити відкочені повідомлення", "chat.revertPopover.title": "Відкочено", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 7072ef26..6af651a2 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2050,7 +2050,6 @@ export const dict: Record = { 'chat.statusRow.tasksTitle': '任务', 'chat.statusRow.modelStatus': '{model} · {status}', 'chat.statusRow.summary.activeLeft': '{active} 个活跃 · 剩余 {left} 个', - 'chat.statusRow.aborted': '已中止', 'chat.revertIndicator.redo': '重做', 'chat.revertIndicator.redoAria': '重做 — 恢复已撤回的消息', 'chat.revertPopover.title': '已撤回', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 17861ebd..89b9b996 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2054,7 +2054,6 @@ export const dict: Record = { 'chat.statusRow.tasksTitle': '任務', 'chat.statusRow.modelStatus': '{model} · {status}', 'chat.statusRow.summary.activeLeft': '{active} 個活躍 · 剩餘 {left} 個', - 'chat.statusRow.aborted': '已中止', 'chat.revertIndicator.redo': '重做', 'chat.revertIndicator.redoAria': '重做 — 恢復已收回的訊息', 'chat.revertPopover.title': '已收回', From defd0719b7172d7f90bf7d3b5f9f3d013863dab6 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 19:58:32 +0300 Subject: [PATCH 40/49] docs: clarify changelog highlight ordering guidance --- .agents/skills/changelog-authoring/SKILL.md | 1 + CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.agents/skills/changelog-authoring/SKILL.md b/.agents/skills/changelog-authoring/SKILL.md index 1e5aa674..bfce1eba 100644 --- a/.agents/skills/changelog-authoring/SKILL.md +++ b/.agents/skills/changelog-authoring/SKILL.md @@ -55,6 +55,7 @@ Use `gh pr view --json number,title,body,author,mergedAt` for PR eviden ## Highlights and Ordering - Sort bullets by user impact, not commit order. Breaking changes first, then significant new capabilities or broad user-visible improvements, then smaller features, fixes, and visual polish. +- Keep the opening highlight block contiguous. Place every bold highlight before the first regular bullet; a regular bullet marks the end of the highlight block. - Mark only the strongest highlights with a bold area prefix, such as `- **Chat attachments:** ...`. Usually the first 1–3 bullets; fewer when the release lacks substantial changes, more only when clearly justified. - Treat a change as a highlight only when it introduces a substantial user-facing capability, materially changes a common workflow, or fixes a severe/widespread problem. Do not bold merely because a bullet is first, has a large diff, or was hard to implement. - Keep related platform bullets together only when that does not push a more important change too far down. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fb64263..63196f7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,13 @@ All notable changes to this project will be documented in this file. - **Chat scrolling rebuilt around your message.** Sending parks your message near the top and the reply streams in below it, gliding smoothly a paragraph at a time. Scrolling up immediately hands you the wheel; the scroll-to-bottom pill carries the model's working status while you're away. - **Keyboard shortcuts redesigned:** single chords for everyday actions, a Cmd/Ctrl+K leader for two-step open/go actions, held Cmd/Ctrl+digit for session tabs and Cmd/Ctrl+Option+digit for panel surfaces. Shortcuts work on non-English keyboard layouts now, tooltips show the binding you actually have set, and old custom bindings reset once. The full map lives in Settings → Shortcuts (registry contributed by @ChangeHow — thanks!). -- Chat: Cmd/Ctrl+Shift+T now cycles through every thinking level offered by the selected model instead of skipping levels after reaching the end (thanks to @nimobeeren). - **Chat context attachments:** diff comments, terminal selections, browser annotations, linked issues/PRs and the rest now appear in the conversation as compact context cards instead of walls of raw text. - **Session tabs (opt-in):** the web/desktop header can show open sessions as browser-style tabs (Settings → General → Navigation). A tab switches the whole workspace; closing one never touches the session itself. - Sessions: switching is much faster in large workspaces — the sidebar no longer rebuilds on switch and recently viewed sessions restore their rendered messages; end-to-end switch time roughly halved with thousands of loaded sessions (thanks to @c-w-xiaohei). - Permission cards answer to the keyboard: Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons. The auto-accept toggle got Cmd/Ctrl+K, A. - Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. - Git: Cmd/Ctrl+Enter in the commit message box commits. Diff review moves between changed files with Alt+Down/Up, expanding a collapsed file on arrival. +- Chat: Cmd/Ctrl+Shift+T now cycles through every thinking level offered by the selected model instead of skipping levels after reaching the end (thanks to @nimobeeren). - Panels: the context rail got a configure button — a dialog chooses which panels the rail shows. Hidden panels keep their data, stay reachable from the command palette, and leave the digit switcher, so digits always match the icons you see. - Chat: comment on a reply — select text in a chat message (or a rendered markdown preview in Files) and choose Comment to attach exactly that quote, with a source line range when it can be located, plus your note. The selection stays highlighted while you type. - Diff: comment like a review — hovering a line shows a + in the gutter; clicking or dragging across lines opens the comment editor for that range, styled like the chat's comments. From 057a4447a3231a1523a238858137cc476f7a6384 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=9A=F0=9D=96=91=F0=9D=96=8E?= =?UTF-8?q?=F0=9D=96=8E=F0=9D=96=86?= Date: Wed, 26 Aug 2026 20:12:01 +0300 Subject: [PATCH 41/49] fix(ui): preserve default in thinking cycle (#3153) --- .../ui/src/components/chat/ModelControls.tsx | 69 ++++++++++++----- .../sections/openchamber/DefaultsSettings.tsx | 5 +- packages/ui/src/hooks/useKeyboardShortcuts.ts | 6 +- .../src/hooks/useMiniChatKeyboardShortcuts.ts | 5 +- packages/ui/src/stores/DOCUMENTATION.md | 7 ++ packages/ui/src/stores/useConfigStore.test.ts | 74 ++++++++++++++++++- packages/ui/src/stores/useConfigStore.ts | 72 ++++++++++++------ .../ui/src/sync/__tests__/issue-2039.test.ts | 34 ++++++++- packages/ui/src/sync/session-ui-store.ts | 7 +- 9 files changed, 225 insertions(+), 54 deletions(-) diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 7a953090..4863a164 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -324,7 +324,9 @@ export const ModelControls: React.FC = ({ const providers = useConfigStore((state) => state.providers); const currentProviderId = useConfigStore((state) => state.currentProviderId); const currentModelId = useConfigStore((state) => state.currentModelId); - const currentVariant = useConfigStore((state) => state.currentVariant); + const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant); + const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection); + const currentVariant = currentVariantSelection.override ?? undefined; const currentAgentName = useConfigStore((state) => state.currentAgentName); const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant); const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent); @@ -332,6 +334,7 @@ export const ModelControls: React.FC = ({ const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); const setModel = useConfigStore((state) => state.setModel); const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant); + const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride); const getCurrentModelVariants = useConfigStore((state) => state.getCurrentModelVariants); const setAgent = useConfigStore((state) => state.setAgent); const getCurrentProvider = useConfigStore((state) => state.getCurrentProvider); @@ -693,6 +696,30 @@ export const ModelControls: React.FC = ({ return variants ? Object.keys(variants) : []; }, [providers]); + const resolveInheritedVariantForModel = React.useCallback((providerId: string, modelId: string, agentName?: string | null) => { + const variantOptions = getModelVariantOptions(providerId, modelId); + if (variantOptions.length === 0) return undefined; + + let currentInherited: string | undefined; + if (currentProviderId === providerId && currentModelId === modelId) { + currentInherited = currentVariantSelection.inherited + ?? (currentVariantSelection.override === null || currentVariantSelection.override === undefined + ? effectiveCurrentVariant + : undefined); + } + + const effectiveAgentName = agentName ?? uiAgentName ?? currentAgentName; + const agent = effectiveAgentName ? agents.find((candidate) => candidate.name === effectiveAgentName) : undefined; + const agentVariant = ( + agent?.model?.providerID === providerId + && agent.model.modelID === modelId + ) ? agent.variant : undefined; + const candidates = currentSessionId + ? [agentVariant, settingsDefaultVariant, currentInherited] + : [currentInherited, agentVariant, settingsDefaultVariant]; + return candidates.find((candidate) => candidate !== undefined && variantOptions.includes(candidate)); + }, [agents, currentAgentName, currentModelId, currentProviderId, currentSessionId, currentVariantSelection, effectiveCurrentVariant, getModelVariantOptions, settingsDefaultVariant, uiAgentName]); + const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string) => { const variantOptions = getModelVariantOptions(providerId, modelId); if (variantOptions.length === 0) { @@ -711,10 +738,6 @@ export const ModelControls: React.FC = ({ return currentVariant; } - if (!currentSessionId && settingsDefaultVariant && variantOptions.includes(settingsDefaultVariant)) { - return settingsDefaultVariant; - } - return undefined; }, [ currentAgentName, @@ -724,7 +747,6 @@ export const ModelControls: React.FC = ({ currentVariant, getAgentModelVariantForSession, getModelVariantOptions, - settingsDefaultVariant, uiAgentName, ]); @@ -748,7 +770,10 @@ export const ModelControls: React.FC = ({ } manualVariantSelectionRef.current = true; - setCurrentVariant(variant); + setCurrentVariantOverride( + variant ?? null, + resolveInheritedVariantForModel(providerId, modelId, agentNameOverride), + ); addRecentEffort(providerId, modelId, variant); const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName(); @@ -759,9 +784,11 @@ export const ModelControls: React.FC = ({ addRecentEffort, currentSessionId, getModelVariantOptions, + resolveInheritedVariantForModel, resolveLiveAgentName, saveAgentModelVariantForSession, setCurrentVariant, + setCurrentVariantOverride, ]); const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => { @@ -1121,18 +1148,21 @@ export const ModelControls: React.FC = ({ } if (currentVariant && !availableVariants.includes(currentVariant)) { - setCurrentVariant(undefined); + setCurrentVariantOverride( + null, + resolveInheritedVariantForModel(currentProviderId, currentModelId), + ); return; } // Draft state (no session yet): seed from settings default, but don't override // user selection while drafting. if (!currentSessionId) { - if (!currentVariant && !manualVariantSelectionRef.current) { + if (currentVariantSelection.override === undefined && !manualVariantSelectionRef.current) { const desired = settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant) ? settingsDefaultVariant : undefined; - setCurrentVariant(desired); + setCurrentVariantOverride(desired ?? null, desired); } return; } @@ -1144,13 +1174,14 @@ export const ModelControls: React.FC = ({ currentModelId, ); - const resolvedSaved = savedVariant && availableVariants.includes(savedVariant) - ? savedVariant - : settingsDefaultVariant && availableVariants.includes(settingsDefaultVariant) - ? settingsDefaultVariant - : undefined; - - setCurrentVariant(resolvedSaved); + const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId); + if (savedVariant && availableVariants.includes(savedVariant)) { + setCurrentVariantOverride(savedVariant, inheritedVariant); + } else if (currentVariantSelection.override === null) { + setCurrentVariantOverride(null, inheritedVariant); + } else { + setCurrentVariant(inheritedVariant); + } manualVariantSelectionRef.current = false; }, [ availableVariants, @@ -1160,8 +1191,12 @@ export const ModelControls: React.FC = ({ currentProviderId, currentModelId, currentVariant, + currentVariantSelection.override, + effectiveCurrentVariant, getAgentModelVariantForSession, + resolveInheritedVariantForModel, setCurrentVariant, + setCurrentVariantOverride, settingsDefaultVariant, ]); diff --git a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx index 627964e7..ca4be95c 100644 --- a/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/DefaultsSettings.tsx @@ -42,6 +42,7 @@ export const DefaultsSettings: React.FC = () => { const setModel = useConfigStore((state) => state.setModel); const setAgent = useConfigStore((state) => state.setAgent); const setCurrentVariant = useConfigStore((state) => state.setCurrentVariant); + const setCurrentVariantOverride = useConfigStore((state) => state.setCurrentVariantOverride); const setSettingsDefaultModel = useConfigStore((state) => state.setSettingsDefaultModel); const setSettingsDefaultVariant = useConfigStore((state) => state.setSettingsDefaultVariant); const setSettingsDefaultAgent = useConfigStore((state) => state.setSettingsDefaultAgent); @@ -210,7 +211,7 @@ export const DefaultsSettings: React.FC = () => { setDefaultVariant(newValue); setSettingsDefaultVariant(newValue); if (!chatHasOwnModel) { - setCurrentVariant(newValue); + setCurrentVariantOverride(newValue ?? null, newValue); } try { @@ -219,7 +220,7 @@ export const DefaultsSettings: React.FC = () => { console.warn('Failed to save default variant:', error); } }, - [chatHasOwnModel, setCurrentVariant, setSettingsDefaultVariant] + [chatHasOwnModel, setCurrentVariantOverride, setSettingsDefaultVariant] ); const handleAgentChange = React.useCallback( diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 0c99f3b0..61ad272d 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -273,16 +273,16 @@ export const useKeyboardShortcuts = () => { if (state.isSettingsDialogOpen || hasOverlay) return false; const config = useConfigStore.getState(); if (config.getCurrentModelVariants().length === 0) return false; - config.cycleCurrentVariant(); + const nextVariantOverride = config.cycleCurrentVariant(); const sessionId = useSessionUIStore.getState().currentSessionId; - const { currentVariant, currentAgentName, currentProviderId, currentModelId } = useConfigStore.getState(); + const { currentAgentName, currentProviderId, currentModelId } = useConfigStore.getState(); if (sessionId && currentAgentName && currentProviderId && currentModelId) { useSelectionStore.getState().saveAgentModelVariantForSession( sessionId, currentAgentName, currentProviderId, currentModelId, - currentVariant, + nextVariantOverride, ); } }, diff --git a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts index ead55305..aaa31edf 100644 --- a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts @@ -71,10 +71,9 @@ export const useMiniChatKeyboardShortcuts = () => { const configState = useConfigStore.getState(); if (configState.getCurrentModelVariants().length === 0) return false; - configState.cycleCurrentVariant(); + const nextVariantOverride = configState.cycleCurrentVariant(); const sessionId = useSessionUIStore.getState().currentSessionId; const { - currentVariant, currentAgentName, currentProviderId, currentModelId, @@ -85,7 +84,7 @@ export const useMiniChatKeyboardShortcuts = () => { currentAgentName, currentProviderId, currentModelId, - currentVariant, + nextVariantOverride, ); } }, diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index 3eaf1ffb..ed68eff9 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -213,6 +213,13 @@ Each of them therefore keeps two things: - a flat mirror (`agents`, `commands`, `skills`, `mcpServers`, `providers`) that tracks the **active** project only. +Thinking variants keep the effective value in `currentVariant` so existing send +paths capture a stable configuration. The transient `currentVariantSelection` +distinguishes automatic initialization from a picker or shortcut choosing an +explicit override or `Default`; returning to `Default` restores its inherited +effective value. Only explicit overrides are stored in the per-session +selection store. + Every loader and mutation takes an explicit directory; omitting it means the active project, which is what non-Settings callers pass. A load for another directory writes the map and leaves the mirror alone, so browsing another diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index b3fb527c..1cd6e4d1 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -268,6 +268,7 @@ describe('useConfigStore provider persistence', () => { currentProviderId: '', currentModelId: '', currentVariant: undefined, + currentVariantSelection: { override: undefined, inherited: undefined }, selectedProviderId: '', currentAgentName: undefined, agents: [], @@ -525,20 +526,58 @@ describe('useConfigStore provider persistence', () => { expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe('high'); }); - test('cycleCurrentVariant wraps through every model variant', () => { + test('cycleCurrentVariant reaches Default, low, and medium from inherited high', () => { useConfigStore.setState({ providers: [provider('openai', 'gpt-5.6-sol', { none: {}, low: {}, medium: {}, high: {}, xhigh: {}, max: {} })], currentProviderId: 'openai', currentModelId: 'gpt-5.6-sol', currentVariant: 'high', + currentVariantSelection: { override: undefined, inherited: 'high' }, directoryScoped: {}, }); - const expectedVariants = ['xhigh', 'max', 'none', 'low', 'medium', 'high']; + const expectedVariants = ['xhigh', 'max', undefined, 'none', 'low', 'medium', 'high']; for (const expectedVariant of expectedVariants) { - useConfigStore.getState().cycleCurrentVariant(); - expect(useConfigStore.getState().currentVariant).toBe(expectedVariant); + expect(useConfigStore.getState().cycleCurrentVariant()).toBe(expectedVariant); + expect(useConfigStore.getState().currentVariantSelection.override).toBe(expectedVariant ?? null); } + + useConfigStore.getState().setCurrentVariantOverride('max', 'high'); + expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined); + expect(useConfigStore.getState().currentVariant).toBe('high'); + expect(useConfigStore.getState().currentVariantSelection).toEqual({ override: null, inherited: 'high' }); + }); + + test('cycleCurrentVariant toggles a single variant with Default', () => { + useConfigStore.setState({ + providers: [provider('openai', 'single', { high: {} })], + currentProviderId: 'openai', + currentModelId: 'single', + currentVariant: 'high', + currentVariantSelection: { override: null, inherited: 'high' }, + directoryScoped: {}, + }); + + expect(useConfigStore.getState().cycleCurrentVariant()).toBe('high'); + expect(useConfigStore.getState().currentVariantSelection.override).toBe('high'); + expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined); + expect(useConfigStore.getState().currentVariantSelection.override).toBeNull(); + expect(useConfigStore.getState().currentVariant).toBe('high'); + }); + + test('an unavailable explicit variant cycles back to Default', () => { + useConfigStore.setState({ + providers: [provider('openai', 'changed', { low: {}, high: {} })], + currentProviderId: 'openai', + currentModelId: 'changed', + currentVariant: 'removed', + currentVariantSelection: { override: 'removed', inherited: 'low' }, + directoryScoped: {}, + }); + + expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined); + expect(useConfigStore.getState().currentVariant).toBe('low'); + expect(useConfigStore.getState().currentVariantSelection.override).toBeNull(); }); test('setAgent prefers saved and agent variants before settings default', () => { @@ -716,6 +755,29 @@ describe('useConfigStore provider persistence', () => { expect(state.currentVariant).toBe('high'); }); + test('a fresh session applies the settings thinking level instead of the previous override', () => { + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })], + agents: [testAgent('build')], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentVariant: 'low', + currentVariantSelection: { override: 'low', inherited: 'high' }, + settingsDefaultModel: 'openai/gpt-5.5', + settingsDefaultVariant: 'high', + selectionSource: 'manual', + directoryScoped: {}, + }); + + useConfigStore.getState().applyDefaultModelAgentSelection(); + + const state = useConfigStore.getState(); + expect(state.currentVariant).toBe('high'); + expect(state.currentVariantSelection).toEqual({ override: 'high', inherited: 'high' }); + expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe('high'); + }); + test('a thinking level the project model does not offer is ignored', async () => { useConfigStore.setState({ activeDirectoryKey: DIRECTORY, @@ -1052,6 +1114,8 @@ describe('useConfigStore provider persistence', () => { useConfigStore.setState({ activeDirectoryKey: DIRECTORY, selectionSource: 'manual', + currentVariant: 'high', + currentVariantSelection: { override: 'high', inherited: 'medium' }, opencodeDefaultAgent: 'active-default', opencodeDefaultModel: 'active/model', directoryScoped: { @@ -1073,6 +1137,7 @@ describe('useConfigStore provider persistence', () => { agents: [testAgent('other-agent')], currentProviderId: 'other', currentModelId: 'other-model', + currentVariant: 'low', currentAgentName: 'other-agent', selectedProviderId: 'other', agentModelSelections: {}, @@ -1092,6 +1157,7 @@ describe('useConfigStore provider persistence', () => { expect(state.selectionSource).toBe('auto'); expect(state.opencodeDefaultAgent).toBe('other-default'); expect(state.opencodeDefaultModel).toBe('other/model'); + expect(state.currentVariantSelection).toEqual({ override: undefined, inherited: 'low' }); }); test('sync config without defaults clears stored OpenCode defaults without changing manual selection', () => { diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index c4884748..46ac3379 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -885,6 +885,11 @@ interface DirectoryScopedConfig { selectionSource?: "auto" | "manual"; } +type CurrentVariantSelection = { + override: string | null | undefined; + inherited: string | undefined; +}; + /** * Lift the active directory's cached provider/agent snapshot into the top-level * fields the pickers read (`providers`, `agents`, selections), so a cold start @@ -1006,6 +1011,7 @@ interface ConfigStore { currentProviderId: string; currentModelId: string; currentVariant: string | undefined; + currentVariantSelection: CurrentVariantSelection; currentAgentName: string | undefined; selectedProviderId: string; agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } }; @@ -1098,7 +1104,8 @@ interface ConfigStore { setProvider: (providerId: string) => void; setModel: (modelId: string) => void; setCurrentVariant: (variant: string | undefined) => void; - cycleCurrentVariant: () => void; + setCurrentVariantOverride: (override: string | null | undefined, inherited: string | undefined) => void; + cycleCurrentVariant: () => string | undefined; getCurrentModelVariants: () => string[]; setAgent: (agentName: string | undefined) => void; applyDefaultModelAgentSelection: (options?: { projectDefaultModel?: string; projectDefaultVariant?: string }) => void; @@ -1171,6 +1178,7 @@ export const useConfigStore = create()( currentProviderId: "", currentModelId: "", currentVariant: undefined, + currentVariantSelection: { override: undefined, inherited: undefined }, currentAgentName: undefined, selectedProviderId: "", agentModelSelections: {}, @@ -1437,6 +1445,7 @@ export const useConfigStore = create()( currentProviderId: snapshot.currentProviderId, currentModelId: snapshot.currentModelId, currentVariant: snapshot.currentVariant, + currentVariantSelection: { override: undefined, inherited: snapshot.currentVariant }, currentAgentName: snapshot.currentAgentName, selectedProviderId: snapshot.selectedProviderId, agentModelSelections: snapshot.agentModelSelections, @@ -1453,6 +1462,7 @@ export const useConfigStore = create()( agents: [], currentProviderId: "", currentModelId: "", + currentVariantSelection: { override: undefined, inherited: undefined }, currentAgentName: undefined, selectedProviderId: "", agentModelSelections: {}, @@ -1847,13 +1857,22 @@ export const useConfigStore = create()( }, setCurrentVariant: (variant: string | undefined) => { + get().setCurrentVariantOverride(undefined, variant); + }, + + setCurrentVariantOverride: (override, inherited) => { set((state) => { - if (state.currentVariant === variant) { + const currentVariant = override ?? inherited; + if ( + state.currentVariant === currentVariant + && state.currentVariantSelection.override === override + && state.currentVariantSelection.inherited === inherited + ) { return state; } const directoryKey = state.activeDirectoryKey; - const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { + const baseSnapshot = state.directoryScoped[directoryKey] ?? { providers: state.providers, agents: state.agents, currentProviderId: state.currentProviderId, @@ -1865,18 +1884,17 @@ export const useConfigStore = create()( defaultProviders: state.defaultProviders, }; - const nextSnapshot: DirectoryScopedConfig = { - ...baseSnapshot, - currentVariant: variant, - selectionSource: "manual", - }; - return { - currentVariant: variant, + currentVariant, + currentVariantSelection: { override, inherited }, selectionSource: "manual", directoryScoped: { ...state.directoryScoped, - [directoryKey]: nextSnapshot, + [directoryKey]: { + ...baseSnapshot, + currentVariant, + selectionSource: "manual", + }, }, }; }); @@ -1894,22 +1912,26 @@ export const useConfigStore = create()( cycleCurrentVariant: () => { const variantKeys = get().getCurrentModelVariants(); if (variantKeys.length === 0) { - return; + return undefined; } - const current = get().currentVariant; - if (!current) { - get().setCurrentVariant(variantKeys[0]); - return; + const state = get(); + const currentOverride = state.currentVariantSelection.override; + const inheritedVariant = state.currentVariantSelection.inherited ?? state.currentVariant; + const currentVariant = currentOverride === undefined + ? state.currentVariant + : currentOverride; + let nextOverride: string | null; + + if (currentVariant === null || currentVariant === undefined) { + nextOverride = variantKeys[0]; + } else { + const index = variantKeys.indexOf(currentVariant); + nextOverride = index >= 0 ? (variantKeys[index + 1] ?? null) : null; } - const index = variantKeys.indexOf(current); - if (index === -1) { - get().setCurrentVariant(variantKeys[0]); - return; - } - - get().setCurrentVariant(variantKeys[(index + 1) % variantKeys.length]); + get().setCurrentVariantOverride(nextOverride, inheritedVariant); + return nextOverride ?? undefined; }, setSelectedProvider: (providerId: string) => { @@ -2659,6 +2681,10 @@ export const useConfigStore = create()( nextState.currentProviderId = resolvedProviderId; nextState.currentModelId = resolvedModelId; nextState.currentVariant = resolvedVariant; + nextState.currentVariantSelection = { + override: resolvedVariant, + inherited: resolvedVariant, + }; } return nextState; diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 572d2094..7558bac5 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -4,6 +4,8 @@ import { togglePermissionAutoAccept } from "../../components/chat/permissionAuto const storage = new Map() const createSessionCalls: Array<{ title?: string; directory: string | null; parentID: string | null; metadata?: unknown }> = [] const permissionAutoAcceptCalls: Array<[string, boolean]> = [] +const savedVariantCalls: Array = [] +let configVariantOverride: string | null | undefined // Sync's session→directory index. `createSession` writes it, and directory // resolution reads it as the authoritative source, so the mock has to keep one. const sessionDirectoryRegistry = new Map() @@ -96,6 +98,9 @@ mock.module("@/stores/useConfigStore", () => ({ useConfigStore: { getState: () => ({ currentAgentName: "agent-default", + currentProviderId: "provider", + currentModelId: "model", + currentVariantSelection: { override: configVariantOverride, inherited: "high" }, agents: [], activateDirectory: mock(async () => undefined), applyDefaultModelAgentSelection: mock(() => undefined), @@ -170,7 +175,9 @@ mock.module("../selection-store", () => ({ saveSessionModelSelection: () => undefined, saveSessionAgentSelection: () => undefined, saveAgentModelForSession: () => undefined, - saveAgentModelVariantForSession: () => undefined, + saveAgentModelVariantForSession: (_sessionId: string, _agent: string, _provider: string, _model: string, variant: string | undefined) => { + savedVariantCalls.push(variant) + }, getSessionAgentSelection: () => null, getSessionModelSelection: () => null, getAgentModelForSession: () => null, @@ -348,6 +355,8 @@ describe("issue 2039 draft auto-accept", () => { createSessionCalls.length = 0 sessionDirectoryRegistry.clear() permissionAutoAcceptCalls.length = 0 + savedVariantCalls.length = 0 + configVariantOverride = undefined createdSessionDirectory = undefined useSessionUIStore.setState({ @@ -384,6 +393,29 @@ describe("issue 2039 draft auto-accept", () => { expect(useSessionUIStore.getState().currentSessionId).toBe("ses_issue_2039") }) + test("stores only an explicit draft variant as the session override", async () => { + useSessionUIStore.getState().openNewSessionDraft() + await materializeOpenDraftSession({ + providerID: "provider", + modelID: "model", + agent: "agent-default", + variant: "high", + }) + + expect(savedVariantCalls).toEqual([undefined]) + + configVariantOverride = "high" + useSessionUIStore.getState().openNewSessionDraft() + await materializeOpenDraftSession({ + providerID: "provider", + modelID: "model", + agent: "agent-default", + variant: "high", + }) + + expect(savedVariantCalls).toEqual([undefined, "high"]) + }) + test("does not apply draft auto-accept after the draft is closed", async () => { useSessionUIStore.getState().openNewSessionDraft() useSessionUIStore.getState().setDraftPermissionAutoAcceptEnabled(true) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 769193d3..85aa58be 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -840,13 +840,18 @@ export async function materializeOpenDraftSession(selection: { }) const effectiveDraftAgent = trimmedAgent ?? configState.currentAgentName + const variantOverride = configState.currentProviderId === selection.providerID + && configState.currentModelId === selection.modelID + && configState.currentAgentName === effectiveDraftAgent + ? configState.currentVariantSelection.override ?? undefined + : selection.variant useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID) if (effectiveDraftAgent) { useSelectionStore.getState().saveSessionAgentSelection(created.id, effectiveDraftAgent) useSelectionStore.getState().saveAgentModelForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID) - useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, selection.variant) + useSelectionStore.getState().saveAgentModelVariantForSession(created.id, effectiveDraftAgent, selection.providerID, selection.modelID, variantOverride) } store.initializeNewOpenChamberSession(created.id, configState.agents ?? []) From ea405eafdc6267588baffe907dc901624b9f7948 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 20:24:27 +0300 Subject: [PATCH 42/49] fix(mobile): let the keyboard shrink the page on Android browsers The pre-dvh -webkit-fill-available viewport fix freezes Android Chrome's root at the pre-keyboard height; when interactive-widget=resizes-content shrinks the viewport, the document stays taller than the screen and the clipped composer hides behind the keyboard with no way to scroll to it. dvh-capable browsers now take a dynamic 100dvh instead, and the legacy fallback keeps serving browsers without dvh. --- packages/ui/src/styles/mobile.css | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/ui/src/styles/mobile.css b/packages/ui/src/styles/mobile.css index 91dbafd1..d32e5dcd 100644 --- a/packages/ui/src/styles/mobile.css +++ b/packages/ui/src/styles/mobile.css @@ -142,6 +142,23 @@ min-height: 0; } + /* -webkit-fill-available above is a pre-dvh iOS Safari fix. On Android + Chrome it freezes the root at the pre-keyboard height: when + interactive-widget=resizes-content shrinks the viewport, the document + stays taller than the screen and (with overflow hidden) the composer's + bottom is clipped behind the keyboard with no way to scroll to it. + Every dvh-capable browser gets the dynamic height instead; the legacy + fallback above keeps serving browsers without dvh. */ + @supports (height: 100dvh) { + :root.mobile-pointer:not(.desktop-runtime) { + height: 100dvh; + } + + :root.mobile-pointer:not(.desktop-runtime) .flex.flex-col.h-screen { + height: 100dvh; + } + } + /* Fix main content area */ :root.mobile-pointer:not(.desktop-runtime) .flex-1.overflow-hidden { min-height: 0; @@ -216,6 +233,16 @@ min-height: -webkit-fill-available; } + /* Same Android-keyboard clipping fix as above: dvh-capable browsers + must not keep a frozen -webkit-fill-available minimum. */ + @supports (min-height: 100dvh) { + :root.device-mobile:not(.desktop-runtime) .flex.flex-col.h-screen, + :root.device-tablet:not(.desktop-runtime) .flex.flex-col.h-screen, + :root.mobile-pointer:not(.desktop-runtime) .flex.flex-col.h-screen { + min-height: 100dvh; + } + } + /* Prevent content overlap in iOS */ :root.device-mobile:not(.desktop-runtime) .flex-1.overflow-hidden, :root.device-tablet:not(.desktop-runtime) .flex-1.overflow-hidden, From 11d3c0d51e36bc967af83eeff94ccac6f552feff Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 20:24:27 +0300 Subject: [PATCH 43/49] refactor(chat): drop the Aborted banner from the status surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aborting a run now just ends it quietly: the status chip and the composer bar no longer flash an Aborted notice, and the indicator state machine and its timer are gone from the composer. Acknowledging the session abort record stays — it is what lets the working chip resume on the next run. --- CHANGELOG.md | 2 ++ packages/vscode/CHANGELOG.md | 11 +++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63196f7a..fd021143 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.21.0] - 2026-08-26 + - **Chat scrolling rebuilt around your message.** Sending parks your message near the top and the reply streams in below it, gliding smoothly a paragraph at a time. Scrolling up immediately hands you the wheel; the scroll-to-bottom pill carries the model's working status while you're away. - **Keyboard shortcuts redesigned:** single chords for everyday actions, a Cmd/Ctrl+K leader for two-step open/go actions, held Cmd/Ctrl+digit for session tabs and Cmd/Ctrl+Option+digit for panel surfaces. Shortcuts work on non-English keyboard layouts now, tooltips show the binding you actually have set, and old custom bindings reset once. The full map lives in Settings → Shortcuts (registry contributed by @ChangeHow — thanks!). - **Chat context attachments:** diff comments, terminal selections, browser annotations, linked issues/PRs and the rest now appear in the conversation as compact context cards instead of walls of raw text. diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 32e016ec..521dc115 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,18 +1,17 @@ -## [Unreleased] +## [1.21.0] - 2026-08-26 -- The chat view no longer stays stuck on its loading screen on slow or remote connections (for example code-server behind a reverse proxy) — the connection status is re-sent until the webview is ready to hear it (thanks @VinciYan). - **Chat context attachments:** diff and file comments, terminal selections, and linked issues/PRs now show in the conversation as compact context cards — source header, captured content behind an expander, your comment below — instead of raw text inside the message. - **Chat: comment on a reply.** Select text in a chat message and choose Comment to attach that quote with your note to the next message; the selection stays highlighted while you type. -- Diff: hovering a line shows a + button that opens a comment for the line; clicking a line or dragging across lines opens the editor for that range. The comment editor and saved-comment cards match the chat's comment style. +- Chat: the view no longer stays stuck on its loading screen on slow or remote connections, including code-server behind a reverse proxy (thanks to @VinciYan). - Composer: hovering a context chip above the input opens a stacked preview of everything attached, where comments can be edited in place or items removed before sending. - Chat: @ file mentions now rank files and directories together by how well they match, so the file you typed is at the top instead of below unrelated directories. Multi-word queries match in any order, and long paths keep the folder next to the file name visible. - Search: Ctrl/Cmd+P now matches the whole file path, not just the file name — searching a folder name finds the files inside it. - Search in dropdowns: searchable pickers (agents, models, providers, branches) now put the best matches first, match multi-word queries in any order, and ignore punctuation (so "gpt4o" finds "gpt-4o"). -- Permission cards answer to the keyboard: Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies; the keys are printed on the buttons. -- Keyboard: dropdown menus and pickers answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and shortcut labels in tooltips and menus show the binding you actually have set (thanks @ChangeHow). +- Permissions: cards answer to the keyboard with Alt+Enter to allow once, Alt+Shift+Enter to allow always, and Alt+Backspace to deny; the keys are printed on the buttons. +- Keyboard: dropdown menus and pickers answer Ctrl+N/Ctrl+P for down/up, the session switcher opens focused on your current session, and shortcut labels in tooltips and menus show the binding you actually have set (thanks to @ChangeHow). - Chat: Cmd/Ctrl+Shift+T now cycles through every thinking level offered by the selected model instead of skipping levels after reaching the end (thanks to @nimobeeren). - Chat: OpenCode notices now share one style. -- The timeline dialog now fits small windows instead of squeezing the message list to a couple of rows (thanks to @gaojunran). +- Chat: the timeline dialog now fits small windows instead of squeezing the message list to a couple of rows (thanks to @gaojunran). ## [1.20.0] - 2026-08-23 From 6770bc37178757f1b54cb019025df8a88c778b75 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 20:32:12 +0300 Subject: [PATCH 44/49] fix(browser): keep the panel closed when an agent opens a page The agent's browser.open used to force the context panel open and steal the active surface, which read as panels opening by themselves. Tab upserts now take a reveal option: the agent's opener passes reveal: false, so the tab mounts invisibly (panes are kept alive regardless of visibility, so agent control still works) while the panel and the active tab stay exactly as the user left them. Manual opens are unchanged. --- CHANGELOG.md | 1 + .../ui/src/components/layout/ContextPanel.tsx | 7 ++++-- packages/ui/src/stores/useUIStore.ts | 24 ++++++++++++------- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd021143..f03d79e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ All notable changes to this project will be documented in this file. - Chat: a "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns automatic following off entirely; with it off, the scroll-to-bottom pill now appears as soon as the reply grows past the visible area. - Command palette: rarely used commands (pin session, copy session ID, multi-run launcher, archived sessions, notes, todos, status, theme) are found by typing but stay off the first screen. - Mobile: narrowing a browser window past phone size switches into the mobile layout (and back when widened); the old/new mobile layout setting is gone. +- Browser: an agent opening a page with the browser tool no longer pops the browser panel open (or switches the surface you're on) — the page loads in the background and the rail is where you peek at it. - Usage: the Command Code tile is gone — their official API exposes no usage data, so the tile could only fail. - Auth: an expired OpenChamber login is announced within seconds by a banner with a Log in button, instead of being discovered through failing actions. Sending pauses until login, and a conversation that failed to load reloads itself afterwards. - Chat: a failed send returns your typed prompt to the input — whatever the reason — instead of losing it to an error toast; a mid-send session switch lands it in that session's draft. diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 8c24e134..aa52be5d 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -452,10 +452,13 @@ export const ContextPanel: React.FC = () => { // Lets an agent's browser.open create the tab it needs when none is open yet. // Registered from the panel because opening a tab is panel state, not - // something the browser view itself can do before it exists. + // something the browser view itself can do before it exists. Background on + // purpose: an agent working a page must not pop the panel open (or steal + // the active surface) under the user — the tab mounts invisibly, and the + // rail is where the user opens it when curious. React.useEffect(() => { if (!effectiveDirectory) return; - return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url)); + return registerBrowserOpener((url) => openContextBrowser(effectiveDirectory, url, { reveal: false })); }, [effectiveDirectory, openContextBrowser]); const reorderContextPanelTabs = useUIStore((state) => state.reorderContextPanelTabs); const setSelectedFilePath = useFilesViewTabsStore((state) => state.setSelectedPath); diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index b5254f2e..5eceb243 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -393,7 +393,9 @@ const touchContextPanelState = (prev?: ContextPanelDirectoryState): ContextPanel const upsertContextPanelTab = ( current: ContextPanelDirectoryState, descriptor: ContextPanelTabDescriptor, + options?: { reveal?: boolean }, ): ContextPanelDirectoryState => { + const reveal = options?.reveal !== false; const nextTab = createContextPanelTab(descriptor); // A real file tab replaces the empty editor placeholder ('file' with no // target) that the rail can open before any file is picked. @@ -418,12 +420,18 @@ const upsertContextPanelTab = ( } : tab)); - const activeTabId = nextTab.id; + // A background upsert (an agent working a page) keeps the panel exactly as + // the user left it: closed stays closed, and whatever tab they were on + // stays active. The tab still exists — panes are kept mounted regardless of + // visibility — so agent control and a later manual open both find it. + const activeTabId = reveal + ? nextTab.id + : current.activeTabId ?? nextTab.id; const clampedTabs = clampContextPanelTabs(tabs, CONTEXT_PANEL_MAX_TABS, activeTabId); return { ...current, - isOpen: true, + isOpen: reveal ? true : current.isOpen, tabs: clampedTabs, activeTabId: resolveActiveContextPanelTabID(clampedTabs, activeTabId), touchedAt: Date.now(), @@ -806,14 +814,14 @@ interface UIStore { toggleContextEditorTree: () => void; setContextEditorTreeWidth: (width: number) => void; openContextSurface: (directory: string, mode: ContextPanelMode) => void; - openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor) => void; + openContextPanelTab: (directory: string, tab: ContextPanelTabDescriptor, options?: { reveal?: boolean }) => void; openContextDiff: (directory: string, filePath: string, staged?: boolean, scope?: PendingDiffScope | null) => void; openContextFile: (directory: string, filePath: string) => void; openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void; openContextOverview: (directory: string) => void; openContextPlan: (directory: string) => void; openContextPreview: (directory: string, url: string) => void; - openContextBrowser: (directory: string, url?: string) => void; + openContextBrowser: (directory: string, url?: string, options?: { reveal?: boolean }) => void; openNewContextBrowserTab: (directory: string) => void; setContextPanelTabTargetPath: (directory: string, tabID: string, targetPath: string) => void; setActiveContextPanelTab: (directory: string, tabID: string) => void; @@ -1239,7 +1247,7 @@ export const useUIStore = create()( state.openContextPanelTab(normalizedDirectory, { mode }); }, - openContextPanelTab: (directory, tab) => { + openContextPanelTab: (directory, tab, options) => { const normalizedDirectory = normalizeDirectoryPath((directory || '').trim()); if (!normalizedDirectory) { return; @@ -1250,7 +1258,7 @@ export const useUIStore = create()( const current = touchContextPanelState(prev); const byDirectory = { ...state.contextPanelByDirectory, - [normalizedDirectory]: upsertContextPanelTab(current, tab), + [normalizedDirectory]: upsertContextPanelTab(current, tab, options), }; return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) }; @@ -1351,7 +1359,7 @@ export const useUIStore = create()( label: null, }); }, - openContextBrowser: (directory, url = '') => { + openContextBrowser: (directory, url = '', options) => { const normalizedDirectory = normalizeDirectoryPath((directory || '').trim()); if (!normalizedDirectory || isVSCodeRuntime()) return; const targetUrl = typeof url === 'string' && url.trim().length > 0 ? url.trim() : ''; @@ -1360,7 +1368,7 @@ export const useUIStore = create()( targetPath: targetUrl, dedupeKey: targetUrl || 'browser', label: null, - }); + }, options); }, setContextPanelTabTargetPath: (directory, tabID, targetPath) => { From 25be1985ef0b53df4f462852eaf501801aaa9167 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 20:40:18 +0300 Subject: [PATCH 45/49] chore(ui): remove code orphaned by the shortcut and plan-action cleanups The eslint pass in release:prepare caught what the package-scoped checks did not: Header's handleOpenContextPlan and servicesTabs lost their last callers with the removed shortcuts, the settings-synced listeners no longer need the DesktopSettings import, and the store's openContextPlan action itself went unused once the plan surface was reachable only through the digit switcher and the rail. --- CHANGELOG.md | 2 +- packages/ui/src/components/layout/Header.tsx | 26 ------------------- .../ui/src/contexts/ThemeSystemContext.tsx | 1 - packages/ui/src/stores/useOpenInAppsStore.ts | 2 +- packages/ui/src/stores/useUIStore.ts | 10 ------- 5 files changed, 2 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f03d79e5..d930ec01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ All notable changes to this project will be documented in this file. - **Chat context attachments:** diff comments, terminal selections, browser annotations, linked issues/PRs and the rest now appear in the conversation as compact context cards instead of walls of raw text. - **Session tabs (opt-in):** the web/desktop header can show open sessions as browser-style tabs (Settings → General → Navigation). A tab switches the whole workspace; closing one never touches the session itself. - Sessions: switching is much faster in large workspaces — the sidebar no longer rebuilds on switch and recently viewed sessions restore their rendered messages; end-to-end switch time roughly halved with thousands of loaded sessions (thanks to @c-w-xiaohei). -- Permission cards answer to the keyboard: Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons. The auto-accept toggle got Cmd/Ctrl+K, A. +- Permission: cards answer to the keyboard Alt+Enter allows once, Alt+Shift+Enter allows always, Alt+Backspace denies — the keys are printed on the buttons. The auto-accept toggle got Cmd/Ctrl+K, A. - Sessions: Cmd/Ctrl+Alt+Left/Right steps back and forward through the sessions you opened in this window, browser-history style; with session tabs enabled it moves between neighbouring tabs instead. - Git: Cmd/Ctrl+Enter in the commit message box commits. Diff review moves between changed files with Alt+Down/Up, expanding a collapsed file on arrival. - Chat: Cmd/Ctrl+Shift+T now cycles through every thinking level offered by the selected model instead of skipping levels after reaching the end (thanks to @nimobeeren). diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 154ba5a5..2d48185d 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -434,7 +434,6 @@ export const Header: React.FC = () => { const { t } = useI18n(); const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); const openContextOverview = useUIStore((state) => state.openContextOverview); - const openContextPlan = useUIStore((state) => state.openContextPlan); const closeContextPanel = useUIStore((state) => state.closeContextPanel); const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); const sessionTabsEnabled = useUIStore((state) => state.sessionTabsEnabled); @@ -1263,21 +1262,6 @@ export const Header: React.FC = () => { const isContextPanelActive = activeContextMode === 'context'; - const handleOpenContextPlan = React.useCallback(() => { - const directory = normalize(openDirectory || ''); - if (!directory) { - return; - } - - const panelState = useUIStore.getState().contextPanelByDirectory[directory]; - if (getActiveContextMode(panelState) === 'plan') { - closeContextPanel(directory); - return; - } - - openContextPlan(directory); - }, [closeContextPanel, openContextPlan, openDirectory]); - const desktopHeaderIconButtonClass = DESKTOP_HEADER_ICON_BUTTON_CLASS; // Left padding the header needs to clear the OS window controls (macOS @@ -1448,16 +1432,6 @@ export const Header: React.FC = () => { return formatShortcutForDisplay(getEffectiveShortcutCombo(actionId, shortcutOverrides)); }, [shortcutOverrides]); - // Desktop keeps instances only: quota and MCP now live in the work-status - // panel, which reports them per session rather than per window. The mobile - // menu below is untouched — it has no panel to defer to. - const servicesTabs = React.useMemo(() => { - const base: Array<{ value: 'instance' | 'usage' | 'mcp'; label: string; icon: React.ReactNode }> = []; - if (isDesktopApp) { - base.push({ value: 'instance', label: t('layout.services.instance'), icon: }); - } - return base; - }, [isDesktopApp, t]); useKeybinds({ diff --git a/packages/ui/src/contexts/ThemeSystemContext.tsx b/packages/ui/src/contexts/ThemeSystemContext.tsx index cc3a3604..681e2caf 100644 --- a/packages/ui/src/contexts/ThemeSystemContext.tsx +++ b/packages/ui/src/contexts/ThemeSystemContext.tsx @@ -7,7 +7,6 @@ import React, { } from 'react'; import { flushSync } from 'react-dom'; import type { Theme, ThemeMode } from '@/types/theme'; -import type { DesktopSettings } from '@/lib/desktop'; import { isDesktopLocalOriginActive, isDesktopShell as detectDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { setDesktopWindowTheme } from '@/lib/desktopNative'; import { CSSVariableGenerator } from '@/lib/theme/cssGenerator'; diff --git a/packages/ui/src/stores/useOpenInAppsStore.ts b/packages/ui/src/stores/useOpenInAppsStore.ts index 149791d9..39e17074 100644 --- a/packages/ui/src/stores/useOpenInAppsStore.ts +++ b/packages/ui/src/stores/useOpenInAppsStore.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; -import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isDesktopShell, type DesktopSettings, type InstalledDesktopAppInfo } from '@/lib/desktop'; +import { fetchDesktopInstalledApps, isDesktopLocalOriginActive, isDesktopShell, type InstalledDesktopAppInfo } from '@/lib/desktop'; import { OPEN_IN_APPS, DEFAULT_OPEN_IN_APP_ID, OPEN_IN_ALWAYS_AVAILABLE_APP_IDS, getOpenInAppById, getPlatformOpenInApp, type OpenInApp } from '@/lib/openInApps'; import { type SettingsSyncedDetail, updateDesktopSettings } from '@/lib/persistence'; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index 5eceb243..8ec57362 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -819,7 +819,6 @@ interface UIStore { openContextFile: (directory: string, filePath: string) => void; openContextFileAtLine: (directory: string, filePath: string, line: number, column?: number) => void; openContextOverview: (directory: string) => void; - openContextPlan: (directory: string) => void; openContextPreview: (directory: string, url: string) => void; openContextBrowser: (directory: string, url?: string, options?: { reveal?: boolean }) => void; openNewContextBrowserTab: (directory: string) => void; @@ -1321,15 +1320,6 @@ export const useUIStore = create()( get().openContextPanelTab(normalizedDirectory, { mode: 'context' }); }, - openContextPlan: (directory) => { - const normalizedDirectory = normalizeDirectoryPath((directory || '').trim()); - if (!normalizedDirectory) { - return; - } - - get().openContextPanelTab(normalizedDirectory, { mode: 'plan' }); - }, - openContextPreview: (directory, url) => { const normalizedDirectory = normalizeDirectoryPath((directory || '').trim()); const normalizedUrl = (url || '').trim(); From 11dd8c4eb017d1cf43ab277e21a85bd802671e3d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 23:10:37 +0300 Subject: [PATCH 46/49] fix(mobile): pin the chat composer above the keyboard on Android browsers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat screen relied on the browser's focused-field reveal, which holds on iOS Safari but not on Android, where interactive-widget is also widely ignored — the composer just stayed behind the keyboard. The draft screen's visual-viewport pin now covers the chat screen on Android; iOS chat keeps the native reveal. --- .../composer/state/useMobileViewportPin.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/chat/composer/state/useMobileViewportPin.ts b/packages/ui/src/components/chat/composer/state/useMobileViewportPin.ts index 5c9bd61e..7f5292ac 100644 --- a/packages/ui/src/components/chat/composer/state/useMobileViewportPin.ts +++ b/packages/ui/src/components/chat/composer/state/useMobileViewportPin.ts @@ -17,6 +17,15 @@ import React from 'react'; import { isCapacitorApp } from '@/lib/platform'; import type { ComposerEditorHandle } from '../editor/ComposerEditor'; +// Android mobile browsers are the pan-mode holdouts this pin exists for on +// the CHAT screen too: interactive-widget=resizes-content is ignored by a +// fair share of Android WebView/Chrome builds, and unlike iOS Safari they do +// not reliably reveal the focused field either — the composer just stays +// behind the keyboard. iOS keeps its browser-native reveal on the chat +// screen, so this stays Android-only there. +// Callers are browser-only React effects, so navigator always exists here. +const isAndroidBrowser = (): boolean => /Android/i.test(navigator.userAgent); + export interface MobileViewportPinOptions { isMobile: boolean; /** Composer expanded to fullscreen on mobile. */ @@ -96,12 +105,14 @@ export function useMobileViewportPin(options: MobileViewportPinOptions): void { }; }, [editorRef, formRef, isFullscreen, isMobile]); - // Draft screen with the keyboard up: anchor the normal-height composer to - // the visible bottom. The chat screen does not need this — its own - // focused-field reveal works there. + // Keyboard up: anchor the normal-height composer to the visible bottom. + // Draft screen on every mobile browser; chat screen only on Android, + // where neither viewport resizing nor the focused-field reveal can be + // relied on (iOS chat keeps the browser's own reveal). React.useLayoutEffect(() => { if (!isMobile || isCapacitorApp()) return; - if (!isDraftScreen || isFullscreen || !isFocused) return; + if (isFullscreen || !isFocused) return; + if (!isDraftScreen && !isAndroidBrowser()) return; const vv = window.visualViewport; const form = formRef.current; if (!vv || !form) return; From 4b0fcfaa7bb308788e562520d0075061598926a3 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 23:10:37 +0300 Subject: [PATCH 47/49] fix(desktop): boot relay-paired default hosts without the recovery wall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup path probed a relay host's stored direct URL — often the pairing creator's own loopback — and any failure landed on the Remote Server Unreachable screen before the renderer's relay restore could run. A relay-capable default host now boots to main on the local substrate for any failed direct probe (unreachable, wrong-service, incompatible), skips the 10s second probe, and lets the renderer's existing restore pick direct-or-relay. --- CHANGELOG.md | 2 ++ packages/electron/main.mjs | 25 +++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d930ec01..a743d8b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ All notable changes to this project will be documented in this file. - Mobile: narrowing a browser window past phone size switches into the mobile layout (and back when widened); the old/new mobile layout setting is gone. - Browser: an agent opening a page with the browser tool no longer pops the browser panel open (or switches the surface you're on) — the page loads in the background and the rail is where you peek at it. - Usage: the Command Code tile is gone — their official API exposes no usage data, so the tile could only fail. +- Desktop: a relay-paired default host no longer greets every restart with the "Remote Server Unreachable" screen — the stored direct address (often the pairing machine's own loopback) failing its probe now boots the app normally and connects over the relay, picking the direct route back up automatically when it answers again. +- Mobile: on Android browsers the composer now stays above the keyboard in the chat too — the keyboard could cover it with no way to scroll it into view; the draft screen's viewport pinning now covers the chat screen on Android. - Auth: an expired OpenChamber login is announced within seconds by a banner with a Log in button, instead of being discovered through failing actions. Sending pauses until login, and a conversation that failed to load reloads itself afterwards. - Chat: a failed send returns your typed prompt to the input — whatever the reason — instead of losing it to an error toast; a mid-send session switch lands it in that session's draft. - Chat: opening a session or resizing panels could strand the view in a large empty space below the last message; the list now returns to the real end, and a width resize keeps a reader who was at the bottom at the bottom. diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 4c8dc66b..b2ede00a 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1770,6 +1770,15 @@ const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => : probe?.status === 'wrong-service' ? 'wrong-service' : 'ok'; + // A relay-capable host is not a recovery case just because its stored + // direct URL failed the http probe — that URL is often the pairing + // creator's own loopback (unreachable here, or worse, someone else's + // service). The relay leg is activated in the renderer's relay restore, + // which cannot run from a recovery screen: boot to main on the local + // substrate and let it pick direct-or-relay. + if (status !== 'ok' && sanitizeHostRelayForStorage(host.relay)) { + return { target: 'remote', status: 'ok', hostId: host.id, url: host.apiUrl || host.url, ...availability }; + } return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url, ...availability }; }; @@ -3025,12 +3034,24 @@ const resolveInitialUrl = async () => { } } + const defaultHostRelayCapable = Boolean( + config.defaultHostId + && config.defaultHostId !== LOCAL_HOST_ID + && sanitizeHostRelayForStorage(config.hosts.find((entry) => entry.id === config.defaultHostId)?.relay), + ); if (apiBaseUrl && apiBaseUrl !== localUrl) { remoteProbe = await probeHostWithTimeout(apiBaseUrl, 2_000, clientToken, requestHeaders); - if (remoteProbe.status === 'unreachable') { + if (remoteProbe.status === 'unreachable' && !defaultHostRelayCapable) { remoteProbe = await probeHostWithTimeout(apiBaseUrl, 10_000, clientToken, requestHeaders); } - if (remoteProbe.status === 'unreachable') { + // The renderer's relay restore owns transport selection for relay-capable + // hosts; any failed direct probe falls back to the local substrate. + if (remoteProbe.status !== 'ok' && defaultHostRelayCapable) { + apiBaseUrl = localUrl || ''; + clientToken = localUrl ? readDesktopLocalClientToken() : ''; + requestHeaders = {}; + initialUrl = localUiUrl; + } else if (remoteProbe.status === 'unreachable') { state.unreachableHosts.add(apiBaseUrl); apiBaseUrl = localUrl || ''; clientToken = localUrl ? readDesktopLocalClientToken() : ''; From 8598f3a37c115133e8b4a0240c9bb969f8edb568 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Wed, 26 Aug 2026 23:15:55 +0300 Subject: [PATCH 48/49] release v1.21.0 --- package.json | 2 +- packages/electron/package.json | 2 +- packages/ui/package.json | 2 +- packages/vscode/package.json | 2 +- packages/web/package.json | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index fc657a77..1f2e1fc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.20.0", + "version": "1.21.0", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", diff --git a/packages/electron/package.json b/packages/electron/package.json index b4d03bec..da27bdd8 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.20.0", + "version": "1.21.0", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index a0b4e7e3..f42fae11 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.20.0", + "version": "1.21.0", "private": true, "type": "module", "main": "src/main.tsx", diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 238f2356..fbb32ec1 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.20.0", + "version": "1.21.0", "publisher": "fedaykindev", "private": true, "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index b29d5700..18c97c48 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.20.0", + "version": "1.21.0", "private": false, "type": "module", "main": "./server/index.js", From 2a1c10cb3e783c3096587c01cf93b01881bb0b19 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Thu, 27 Aug 2026 00:17:32 +0300 Subject: [PATCH 49/49] docs: update support link to Patreon --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7241e03a..93fdf3c7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![GitHub stars](https://img.shields.io/github/stars/openchamber/openchamber?style=flat&labelColor=100F0F&color=66800B)](https://github.com/openchamber/openchamber/stargazers) [![GitHub release](https://img.shields.io/github/v/release/openchamber/openchamber?style=flat&labelColor=100F0F&color=205EA6)](https://github.com/openchamber/openchamber/releases/latest) [![Discord](https://img.shields.io/badge/Discord-join.svg?style=flat&labelColor=100F0F&color=8B7EC8&logo=discord&logoColor=FFFCF0)](https://discord.gg/ZYRSdnwwKA) -[![Support the project](https://img.shields.io/badge/Support-Project-black?style=flat&labelColor=100F0F&color=EC8B49&logo=ko-fi&logoColor=FFFCF0)](https://ko-fi.com/G2G41SAWNS) +[![Support the project](https://img.shields.io/badge/Support-Project-black?style=flat&labelColor=100F0F&color=EC8B49&logo=patreon&logoColor=FFFCF0)](https://www.patreon.com/openchamber) ## Run agent work. Keep control. Ship from anywhere.