Merge pull request #68918 from NousResearch/bb/composer-focus-keys

feat(desktop): type-to-focus the composer from empty chat chrome
This commit is contained in:
brooklyn! 2026-07-21 16:33:45 -05:00 committed by GitHub
commit 81f4095bc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 285 additions and 12 deletions

View file

@ -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<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,
@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)
}

View file

@ -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

View file

@ -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)
})
})

View file

@ -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))
}