diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts new file mode 100644 index 000000000..37b3625a4 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-esc-cancel.ts @@ -0,0 +1,54 @@ +import { useEffect, useRef } from 'react' + +import { triggerHaptic } from '@/lib/haptics' + +interface UseComposerEscCancelOptions { + awaitingInput: boolean + busy: boolean + onCancel: () => unknown +} + +/** + * Global Esc-to-cancel: stop the in-flight turn when the CHAT (not the composer + * input, which has its own handler) has focus — clicking into the transcript and + * hitting Esc stops the run, matching the Stop button. A latest-handler ref keeps + * the window listener registered exactly once while still reading fresh + * busy/awaitingInput/onCancel each press. + */ +export function useComposerEscCancel({ awaitingInput, busy, onCancel }: UseComposerEscCancelOptions) { + // Intentional only: we bail if (a) the composer/another field already handled + // Esc (defaultPrevented), (b) focus is in any input/textarea/contenteditable + // (you're typing, not stopping), or (c) a dialog/popover is open — Esc must + // close that overlay, never double as canceling the stream behind it. + const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {}) + + escCancelRef.current = (event: globalThis.KeyboardEvent) => { + // `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret + // prompt, which owns Esc (or is meant to persist) — never cancel the stream + // out from under it. + if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) { + return + } + + const active = document.activeElement as HTMLElement | null + + if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { + return + } + + if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) { + return + } + + event.preventDefault() + triggerHaptic('cancel') + void Promise.resolve(onCancel()) + } + + useEffect(() => { + const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event) + window.addEventListener('keydown', onKeyDown) + + return () => window.removeEventListener('keydown', onKeyDown) + }, []) +} diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 648a50ebf..37ab11043 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -64,6 +64,7 @@ import { useAtCompletions } from './hooks/use-at-completions' import { useComposerBranch } from './hooks/use-composer-branch' import { useComposerDraft } from './hooks/use-composer-draft' import { useComposerDrop } from './hooks/use-composer-drop' +import { useComposerEscCancel } from './hooks/use-composer-esc-cancel' import { useComposerMetrics } from './hooks/use-composer-metrics' import { useComposerQueue } from './hooks/use-composer-queue' import { useComposerSubmit } from './hooks/use-composer-submit' @@ -971,45 +972,8 @@ export function ChatBar({ const { handleBranchOff, handleConvertBranch, handleListBranches, handleSwitchBranch, openInWorktree } = useComposerBranch({ clearDraft, cwd, draftRef }) - // Esc cancels the in-flight turn when the CHAT has focus — not just the - // composer input (which has its own handler above). Clicking into the - // transcript and hitting Esc now stops the run, matching the Stop button. - // Intentional only: we bail if (a) the composer/another field already - // handled Esc (defaultPrevented), (b) focus is in any input/textarea/ - // contenteditable (you're typing, not stopping), or (c) a dialog/popover is - // open — Esc must close that overlay, never double as canceling the stream - // behind it. A latest-handler ref keeps the listener registered once. - const escCancelRef = useRef<(event: globalThis.KeyboardEvent) => void>(() => {}) - - escCancelRef.current = (event: globalThis.KeyboardEvent) => { - // `awaitingInput`: the turn is parked on a clarify / approval / sudo / secret - // prompt, which owns Esc (or is meant to persist) — never cancel the stream - // out from under it. - if (event.key !== 'Escape' || event.defaultPrevented || !busy || awaitingInput) { - return - } - - const active = document.activeElement as HTMLElement | null - - if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { - return - } - - if (document.querySelector('[role="dialog"],[role="alertdialog"],[data-radix-popper-content-wrapper]')) { - return - } - - event.preventDefault() - triggerHaptic('cancel') - void Promise.resolve(onCancel()) - } - - useEffect(() => { - const onKeyDown = (event: globalThis.KeyboardEvent) => escCancelRef.current(event) - window.addEventListener('keydown', onKeyDown) - - return () => window.removeEventListener('keydown', onKeyDown) - }, []) + // Global Esc-to-cancel when the chat (not the composer input) has focus. + useComposerEscCancel({ awaitingInput, busy, onCancel }) const submitUrl = () => { const url = urlValue.trim()