Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions apps/desktop/src/app/chat/composer/focus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
export const requestComposerFocus = (
target: ComposerTarget | 'active' = 'active',
{ typeChar }: { typeChar?: string } = {}
) => dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target), typeChar })

export const requestComposerInsert = (
text: string,
Expand All @@ -98,8 +102,8 @@ export const requestComposerInsert = (
dispatch<InsertDetail>(INSERT_EVENT, { mode, target: resolve(target), text: trimmed })
}

export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<FocusDetail>(FOCUS_EVENT, ({ target }) => handler(target))
export const onComposerFocusRequest = (handler: (detail: FocusDetail) => void) =>
subscribe<FocusDetail>(FOCUS_EVENT, handler)

export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
subscribe<InsertDetail>(INSERT_EVENT, handler)
Expand Down
17 changes: 13 additions & 4 deletions apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,19 @@
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 }) => {
Expand All @@ -174,7 +183,7 @@
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)
Expand Down Expand Up @@ -262,7 +271,7 @@
unsubscribe()
window.clearTimeout(draftPersistTimerRef.current)
}
}, [composerRuntime, queueEditRef])

Check warning on line 274 in apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / Typecheck & Test (apps/desktop)

React Hook useEffect has a missing dependency: 'stashAt'. Either include it or remove the dependency array

const insertText = (text: string) => {
const base = draftRef.current
Expand Down Expand Up @@ -355,7 +364,7 @@
window.removeEventListener('pagehide', flushPendingDraftPersist)
flushPendingDraftPersist()
}
}, [syncDraftFromEditor])

Check warning on line 367 in apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / Typecheck & Test (apps/desktop)

React Hook useEffect has a missing dependency: 'stashAt'. Either include it or remove the dependency array

return {
activeQueueSessionKeyRef,
Expand Down
28 changes: 27 additions & 1 deletion apps/desktop/src/app/hooks/use-keybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,

Expand Down Expand Up @@ -242,14 +247,35 @@ 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
}

if (isEditableTarget(event.target) && !comboAllowedInInput(combo)) {
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
}, [focusEditor, focusRequestId])

useEffect(() => {
const offFocus = onComposerFocusRequest(target => {
const offFocus = onComposerFocusRequest(({ target }) => {
if (target === 'edit') {
setFocusRequestId(id => id + 1)
}
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/lib/keybinds/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 149 additions & 0 deletions apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
84 changes: 84 additions & 0 deletions apps/desktop/src/lib/keybinds/composer-focus-keys.ts
Original file line number Diff line number Diff line change
@@ -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))
}
Loading