refactor(desktop): extract global Esc-to-cancel into useComposerEscCancel
Moves the chat-focused Esc-cancel listener (the latest-handler ref + the register-once window keydown effect) out of ChatBar into hooks/use-composer-esc-cancel.ts, verbatim. Encapsulating the latest-closure ref inside its own hook is the first of the plan's "delete the latest-closure refs" cleanups: it's no longer a loose ref in the 1.4k-line component, just an implementation detail of a focused side-effect hook keyed on busy/awaitingInput/ onCancel.
This commit is contained in:
parent
5fdc2acedc
commit
e009ba57ac
2 changed files with 57 additions and 39 deletions
|
|
@ -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)
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue