diff --git a/apps/desktop/src/app/overlays/overlay-view.tsx b/apps/desktop/src/app/overlays/overlay-view.tsx index 455da2992119..40fce12ec0af 100644 --- a/apps/desktop/src/app/overlays/overlay-view.tsx +++ b/apps/desktop/src/app/overlays/overlay-view.tsx @@ -66,6 +66,13 @@ export function OverlayView({ 'p-[calc(var(--titlebar-height)+0.625rem)]', 'sm:p-[calc(var(--titlebar-height)+0.875rem)]' )} + // Every OverlayView-based overlay (settings, command-center, agents, cron, + // profiles, star map, …) covers the chat while the composer stays mounted + // beneath it. This marker tells `composerFocusBlockedBySurface` to stand + // the global type-to-focus / soft `/` / Enter down, so keystrokes don't + // leak into the hidden composer (and the overlay's own bare-key shortcuts, + // e.g. star map's Space, keep working). + data-overlay-surface="" onClick={event => { if (event.target === event.currentTarget) { closeOverlay() diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx index ddefafa821a0..7e0daf7ef34a 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -296,3 +296,45 @@ describe('ClarifyTool keyboard navigation', () => { expect(request).not.toHaveBeenCalled() }) }) + +describe('ClarifyTool pending marker', () => { + it('marks a live choices card so type-to-focus yields its shortcut keys', () => { + renderLiveClarify() + + // The marker is what `composerFocusBlockedBySurface` keys off of, so the + // global type-to-focus listener stands down and A/B/C… + 1-9 + Enter reach + // the card instead of the composer. + expect(document.querySelector('[data-clarify-choices]')).toBeTruthy() + }) + + it('does not mark a free-text (no-choice) pending card', () => { + $activeSessionId.set('session-1') + $gateway.set({ request: vi.fn().mockResolvedValue({ ok: true }) } as never) + setClarifyRequest({ + choices: null, + question: 'Anything else?', + requestId: 'request-1', + sessionId: 'session-1' + }) + + const args = { question: 'Anything else?' } + renderClarify( + + ) + + // No shortcuts → nothing to protect → composer type-to-focus stays live. + expect(document.querySelector('[data-clarify-choices]')).toBeNull() + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index f9353aadad18..6f1442e2cfed 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -538,7 +538,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { } return ( - + // `data-clarify-choices` marks the panel as owning printable/Enter keys + // while its A/B/C… shortcuts are live, so the global type-to-focus listener + // (`composerFocusBlockedBySurface`) stands down and the letters reach this + // card instead of being redirected into the composer. +
{question} diff --git a/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts b/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts index cab689da0aa6..cebf54950e42 100644 --- a/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts +++ b/apps/desktop/src/lib/keybinds/composer-focus-keys.test.ts @@ -72,6 +72,22 @@ describe('composerFocusBlockedBySurface', () => { expect(composerFocusBlockedBySurface()).toBe(true) }) + it('blocks while an overlay covers the chat (composer sits behind it)', () => { + const overlay = document.createElement('div') + overlay.setAttribute('data-overlay-surface', '') + document.body.append(overlay) + + expect(composerFocusBlockedBySurface()).toBe(true) + }) + + it('blocks while a live clarify choices card owns its letter keys', () => { + const card = document.createElement('div') + card.setAttribute('data-clarify-choices', '') + document.body.append(card) + + expect(composerFocusBlockedBySurface()).toBe(true) + }) + it('blocks when focus is inside a terminal', () => { const term = document.createElement('div') term.setAttribute('data-terminal', '') @@ -146,4 +162,16 @@ describe('composerFocusKeysAllowed', () => { expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: document.body }), 'type')).toBe(false) }) + + it('yields letter + Enter keys to a live clarify choices card', () => { + const card = document.createElement('div') + card.setAttribute('data-clarify-choices', '') + document.body.append(card) + + // The clarify card's own A/B/C… + Enter shortcuts must win over type-to-focus. + expect(composerFocusKeysAllowed(keydown({ key: 'a', code: 'KeyA', target: document.body }), 'type')).toBe(false) + expect(composerFocusKeysAllowed(keydown({ key: 'Enter', code: 'Enter', target: document.body }), 'enter')).toBe( + false + ) + }) }) diff --git a/apps/desktop/src/lib/keybinds/composer-focus-keys.ts b/apps/desktop/src/lib/keybinds/composer-focus-keys.ts index 2e2a9f82fc60..6bba44aa34a1 100644 --- a/apps/desktop/src/lib/keybinds/composer-focus-keys.ts +++ b/apps/desktop/src/lib/keybinds/composer-focus-keys.ts @@ -38,7 +38,7 @@ const ENTER_ACTIVATES = [ ].join(',') const BLOCKING_SURFACE = - '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper]' + '[role="dialog"],[role="alertdialog"],[role="menu"],[role="listbox"],[data-radix-popper-content-wrapper],[data-overlay-surface],[data-clarify-choices]' /** True when the focused control would normally handle Enter itself. */ export function isActivateOnEnterTarget(target: EventTarget | null): boolean { @@ -47,7 +47,13 @@ export function isActivateOnEnterTarget(target: EventTarget | null): boolean { return Boolean(el && el !== document.body && el !== document.documentElement && el.closest(ENTER_ACTIVATES)) } -/** Dialogs, menus, terminal, full pages, session switcher — they keep their keys. */ +/** + * Dialogs, menus, terminal, full pages, session switcher, any open overlay + * (settings / command-center / star map / …), and a live clarify choices card — + * they keep their keys, so type-to-focus / soft `/` / Enter stand down rather + * than stealing keystrokes those surfaces own (or leaking them into the composer + * mounted behind an overlay). + */ export function composerFocusBlockedBySurface(): boolean { return ( switcherActive() ||