diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index a490ef18b44c..a470edfcd958 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -18,8 +18,10 @@ import { RICH_INPUT_SLOT } from './rich-editor' export type ComposerTarget = 'edit' | 'main' | (string & {}) export type ComposerInsertMode = 'block' | 'inline' -interface FocusDetail { +export interface FocusDetail { target: ComposerTarget + /** Append after focus (type-to-focus / soft `/`). */ + typeChar?: string } interface InsertDetail { @@ -82,8 +84,10 @@ export const markActiveComposer = (target: ComposerTarget) => { * Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */ export const getActiveComposer = (): ComposerTarget => activeTarget -export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') => - dispatch(FOCUS_EVENT, { target: resolve(target) }) +export const requestComposerFocus = ( + target: ComposerTarget | 'active' = 'active', + { typeChar }: { typeChar?: string } = {} +) => dispatch(FOCUS_EVENT, { target: resolve(target), typeChar }) export const requestComposerInsert = ( text: string, @@ -98,8 +102,8 @@ export const requestComposerInsert = ( dispatch(INSERT_EVENT, { mode, target: resolve(target), text: trimmed }) } -export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) => - subscribe(FOCUS_EVENT, ({ target }) => handler(target)) +export const onComposerFocusRequest = (handler: (detail: FocusDetail) => void) => + subscribe(FOCUS_EVENT, handler) export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) => subscribe(INSERT_EVENT, handler) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index b9fa789ad51d..11dc9534df0f 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -158,10 +158,19 @@ export function useComposerDraft({ return undefined } - const offFocus = onComposerFocusRequest(requested => { - if (requested === target) { - setFocusRequestId(id => id + 1) + const offFocus = onComposerFocusRequest(({ target: requested, typeChar }) => { + if (requested !== target) { + return } + + // Type-to-focus appends at end; bare Enter just focuses. + if (typeChar) { + paintDraft(`${draftRef.current}${typeChar}`, true) + + return + } + + setFocusRequestId(id => id + 1) }) const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => { @@ -174,7 +183,7 @@ export function useComposerDraft({ offFocus() offInsert() } - }, [appendExternalText, inputDisabled, target]) + }, [appendExternalText, inputDisabled, paintDraft, target]) const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) => stashSessionDraft(scope, text, attachments) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 4df1edb202c4..e48c81dd2644 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -7,6 +7,11 @@ import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right- import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store' import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions' import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo' +import { + composerFocusKeysAllowed, + isComposerFocusSoftCombo, + typeToFocusChar +} from '@/lib/keybinds/composer-focus-keys' import { $repoStatus } from '@/store/coding-status' import { toggleCommandPalette } from '@/store/command-palette' import { $capture, $comboIndex, endCapture, setBinding } from '@/store/keybinds' @@ -122,7 +127,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { handlersRef.current = { 'keybinds.openPanel': () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`), - 'composer.focus': () => requestComposerFocus('main'), + 'composer.focus': () => requestComposerFocus('active'), 'composer.modelPicker': () => setModelPickerOpen(true), 'composer.voice': requestVoiceToggle, @@ -242,7 +247,15 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { const actionId = $comboIndex.get().get(combo) + // Unbound printable → type-to-focus. Bound chords (shift+n, …) win above. if (!actionId) { + const typeChar = typeToFocusChar(event) + + if (typeChar && composerFocusKeysAllowed(event, 'type')) { + event.preventDefault() + requestComposerFocus('active', { typeChar }) + } + return } @@ -250,6 +263,19 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { return } + // Soft `/` / Enter: gated so dialogs/buttons/terminal keep those keys. + // Rebound chords fall through to the normal handler. + if (actionId === 'composer.focus' && isComposerFocusSoftCombo(combo)) { + if (!composerFocusKeysAllowed(event, combo)) { + return + } + + event.preventDefault() + requestComposerFocus('active', { typeChar: combo === '/' ? '/' : undefined }) + + return + } + // Built-in handlers first (they carry React context); contributed // actions bring their own `run` through the registry. const handler = handlersRef.current[actionId] ?? contributedKeybindHandler(actionId) diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx index dfcc05717dc1..9015e9eee66e 100644 --- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx @@ -180,7 +180,7 @@ export const UserEditComposer: FC = ({ cwd, gateway, sess }, [focusEditor, focusRequestId]) useEffect(() => { - const offFocus = onComposerFocusRequest(target => { + const offFocus = onComposerFocusRequest(({ target }) => { if (target === 'edit') { setFocusRequestId(id => id + 1) } diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 67a57b089bfc..78fe1d34474f 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -54,7 +54,8 @@ const SESSION_SLOT_ACTIONS: KeybindActionMeta[] = Array.from({ length: SESSION_S export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [ // ── Composer ───────────────────────────────────────────────────────────── - { id: 'composer.focus', category: 'composer', defaults: [] }, + // Soft `/` / Enter focus (gated); other printables type-to-focus unbound. + { id: 'composer.focus', category: 'composer', defaults: ['/', 'enter'] }, { id: 'composer.modelPicker', category: 'composer', defaults: [] }, // Voice conversation toggle. Matches the documented `voice.record_key` // (Ctrl+B). On macOS that's literally ⌃B — distinct from the ⌘B sidebar diff --git a/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts b/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts new file mode 100644 index 000000000000..01043a210ce5 --- /dev/null +++ b/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $workspaceIsPage } from '@/app/routes' +import { closeSwitcher, $switcherOpen } from '@/store/session-switcher' + +import { + composerFocusBlockedBySurface, + composerFocusKeysAllowed, + isActivateOnEnterTarget, + typeToFocusChar +} from './composer-focus-keys' + +function keydown(init: KeyboardEventInit & { target?: EventTarget }): KeyboardEvent { + const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init }) + + if (init.target) { + Object.defineProperty(event, 'target', { value: init.target }) + } + + return event +} + +describe('isActivateOnEnterTarget', () => { + it('ignores body / null', () => { + expect(isActivateOnEnterTarget(document.body)).toBe(false) + expect(isActivateOnEnterTarget(null)).toBe(false) + }) + + it('detects buttons and walks to a wrapping activator', () => { + const button = document.createElement('button') + const wrap = document.createElement('div') + wrap.setAttribute('role', 'button') + const child = document.createElement('span') + wrap.append(child) + document.body.append(button, wrap) + + expect(isActivateOnEnterTarget(button)).toBe(true) + expect(isActivateOnEnterTarget(child)).toBe(true) + expect(isActivateOnEnterTarget(document.createElement('div'))).toBe(false) + }) +}) + +describe('composerFocusBlockedBySurface', () => { + beforeEach(() => { + $workspaceIsPage.set(false) + closeSwitcher() + document.body.replaceChildren() + }) + + afterEach(() => { + $workspaceIsPage.set(false) + closeSwitcher() + document.body.replaceChildren() + }) + + it('is clear on empty chat chrome', () => { + expect(composerFocusBlockedBySurface()).toBe(false) + }) + + it('blocks dialogs, the session switcher, and full pages', () => { + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + document.body.append(dialog) + expect(composerFocusBlockedBySurface()).toBe(true) + + document.body.replaceChildren() + $switcherOpen.set(true) + expect(composerFocusBlockedBySurface()).toBe(true) + + closeSwitcher() + $workspaceIsPage.set(true) + expect(composerFocusBlockedBySurface()).toBe(true) + }) + + it('blocks when focus is inside a terminal', () => { + const term = document.createElement('div') + term.setAttribute('data-terminal', '') + const inner = document.createElement('div') + term.append(inner) + document.body.append(term) + Object.defineProperty(document, 'activeElement', { configurable: true, get: () => inner }) + + expect(composerFocusBlockedBySurface()).toBe(true) + + Object.defineProperty(document, 'activeElement', { + configurable: true, + get: () => document.body + }) + }) +}) + +describe('typeToFocusChar', () => { + it('returns printables (case/symbols via event.key)', () => { + expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA' }))).toBe('a') + expect(typeToFocusChar(keydown({ key: 'A', code: 'KeyA', shiftKey: true }))).toBe('A') + expect(typeToFocusChar(keydown({ key: '?', code: 'Slash', shiftKey: true }))).toBe('?') + expect(typeToFocusChar(keydown({ key: ' ', code: 'Space' }))).toBe(' ') + }) + + it('rejects non-printables and modified chords', () => { + expect(typeToFocusChar(keydown({ key: 'Enter', code: 'Enter' }))).toBeNull() + expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA', metaKey: true }))).toBeNull() + expect(typeToFocusChar(keydown({ key: 'a', code: 'KeyA', isComposing: true }))).toBeNull() + }) +}) + +describe('composerFocusKeysAllowed', () => { + beforeEach(() => { + $workspaceIsPage.set(false) + closeSwitcher() + document.body.replaceChildren() + vi.spyOn(document, 'activeElement', 'get').mockReturnValue(document.body) + }) + + afterEach(() => { + $workspaceIsPage.set(false) + closeSwitcher() + document.body.replaceChildren() + vi.restoreAllMocks() + }) + + it('passes rebound chords; allows soft keys on the transcript', () => { + expect(composerFocusKeysAllowed(keydown({ key: 'i', code: 'KeyI', metaKey: true }), 'mod+i')).toBe(true) + expect(composerFocusKeysAllowed(keydown({ key: '/', code: 'Slash', target: document.body }), '/')).toBe(true) + expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: document.body }), 'enter')).toBe( + true + ) + expect(composerFocusKeysAllowed(keydown({ key: 'h', code: 'KeyH', target: document.body }), 'type')).toBe(true) + }) + + it('refuses editables; refuses Enter on buttons but allows / and typing', () => { + const input = document.createElement('input') + const button = document.createElement('button') + document.body.append(input, button) + + expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: input }), 'type')).toBe(false) + expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: button }), 'enter')).toBe(false) + expect(composerFocusKeysAllowed(keydown({ key: '/', code: 'Slash', target: button }), '/')).toBe(true) + expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: button }), 'type')).toBe(true) + }) + + it('refuses when a dialog is open', () => { + const dialog = document.createElement('div') + dialog.setAttribute('role', 'dialog') + document.body.append(dialog) + + expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: document.body }), 'type')).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/keybinds/composer-focus-keys.ts b/apps/desktop/src/lib/keybinds/composer-focus-keys.ts new file mode 100644 index 000000000000..b6569770cc53 --- /dev/null +++ b/apps/desktop/src/lib/keybinds/composer-focus-keys.ts @@ -0,0 +1,84 @@ +/** + * Soft focus / type-to-focus for the chat composer. + * + * On empty chat chrome, Enter focuses the composer; printable keys focus and + * type. Bound shortcuts still win via the keybind index. Surfaces that own + * keys (dialogs, menus, terminal, …) are left alone. + */ + +import { $workspaceIsPage } from '@/app/routes' +import { switcherActive } from '@/store/session-switcher' + +import { isEditableTarget, isFocusWithin } from './combo' + +/** `composer.focus` defaults that need the surface/target gate. */ +export const isComposerFocusSoftCombo = (combo: string) => combo === '/' || combo === 'enter' + +const ENTER_ACTIVATES = [ + 'a[href]', + 'button', + 'summary', + 'input', + 'textarea', + 'select', + '[contenteditable=""]', + '[contenteditable="true"]', + '[role="button"]', + '[role="checkbox"]', + '[role="combobox"]', + '[role="link"]', + '[role="menuitem"]', + '[role="menuitemcheckbox"]', + '[role="menuitemradio"]', + '[role="option"]', + '[role="radio"]', + '[role="switch"]', + '[role="tab"]', + '[role="treeitem"]' +].join(',') + +const BLOCKING_SURFACE = + '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper]' + +/** True when the focused control would normally handle Enter itself. */ +export function isActivateOnEnterTarget(target: EventTarget | null): boolean { + const el = target as HTMLElement | null + + return Boolean(el && el !== document.body && el !== document.documentElement && el.closest(ENTER_ACTIVATES)) +} + +/** Dialogs, menus, terminal, full pages, session switcher — they keep their keys. */ +export function composerFocusBlockedBySurface(): boolean { + return ( + switcherActive() || + $workspaceIsPage.get() || + isFocusWithin('[data-terminal]') || + Boolean(document.querySelector(BLOCKING_SURFACE)) + ) +} + +/** Printable `event.key` for type-to-focus, or null (modifiers / non-printables / IME). */ +export function typeToFocusChar(event: KeyboardEvent): string | null { + if (event.defaultPrevented || event.isComposing || event.metaKey || event.ctrlKey || event.altKey) { + return null + } + + // Length 1 ⇒ letter/digit/punct/space; Enter/Tab/Arrows/Dead/F-keys are longer. + return event.key.length === 1 ? event.key : null +} + +/** + * Whether soft focus / type-to-focus may run. + * `combo` is `/` | `enter` | `'type'` (unbound printable); other chords pass. + */ +export function composerFocusKeysAllowed(event: KeyboardEvent, combo: string): boolean { + if (combo !== 'type' && !isComposerFocusSoftCombo(combo)) { + return true + } + + if (event.defaultPrevented || event.isComposing || isEditableTarget(event.target) || composerFocusBlockedBySurface()) { + return false + } + + return !(combo === 'enter' && isActivateOnEnterTarget(event.target)) +}