From 4f7fee538b07e716ce99138a0ef226bb58a656b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:15:46 +0000 Subject: [PATCH] fix(desktop): keep Escape inside compose instead of exiting the stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While recording or reviewing a compose, Escape both cancelled the compose and exited the stream. The stream's window-level navigation handler skips keys when an input is focused (isTypingTarget), which is why the text path was unaffected, but recording/reviewing have no focused input so Escape leaked through to the exit handler. Fix it at the compose layer rather than teaching the navigation pipe about compose: the compose key handler now consumes (preventDefault + stopPropagation) any key it handles and listens in the capture phase, so it reliably wins over the stream's bubble-phase navigation/action handlers regardless of listener registration order. Pure pause states (hold-space) are untouched, so Escape still exits the stream there. This leaves the typing/task/configuring paths equivalent (their onCancel is the same cancel() the handler invokes) while ⌘+Enter / ⌘+M still fall through to the text editor. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01DapjFX1MhYPZeJv4s56K5L --- .../src/features/compose/compose-overlay.tsx | 37 +++++++++++++------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/js/desktop/src/features/compose/compose-overlay.tsx b/js/desktop/src/features/compose/compose-overlay.tsx index 9144fa2..c4a0bdd 100644 --- a/js/desktop/src/features/compose/compose-overlay.tsx +++ b/js/desktop/src/features/compose/compose-overlay.tsx @@ -595,6 +595,16 @@ export function ComposeOverlay({ const handleKeyDown = (e: KeyboardEvent) => { const currentStep = stepRef.current; + // Consume a key compose handles so it never reaches the stream's + // window-level navigation/action handlers. Without this, e.g. Escape + // while recording or reviewing would both cancel compose and exit the + // stream. Listening in the capture phase (see below) guarantees compose + // sees the key before those handlers regardless of registration order. + const consume = () => { + e.preventDefault(); + e.stopPropagation(); + }; + if ( currentStep === 'typing' || currentStep === 'task' || @@ -602,7 +612,7 @@ export function ComposeOverlay({ currentStep === 'picking' ) { if (e.key === 'Escape') { - e.preventDefault(); + consume(); cancel(); } return; @@ -620,19 +630,19 @@ export function ComposeOverlay({ switch (currentStep) { case 'idle': { if (e.key === '`' && !e.repeat) { - e.preventDefault(); + consume(); handleRecordIntent(); } else if (e.key === 's' || e.key === 'S') { - e.preventDefault(); + consume(); if (!guardIdle()) break; if (!requireDesktop('Screen recording')) break; setRecordingSource('screen'); setStepSync('picking'); } else if (e.key === 't' || e.key === 'T') { - e.preventDefault(); + consume(); handleTextIntent(); } else if (e.key === 'd' || e.key === 'D') { - e.preventDefault(); + consume(); handleTaskIntent(); } break; @@ -641,17 +651,17 @@ export function ComposeOverlay({ case 'recording': { if (e.key === '`' && !e.repeat) { // Second tap stops media recording (toggle mode) - e.preventDefault(); + consume(); handleStopIntent(); } else if ( (e.key === 's' || e.key === 'S') && recordingSourceRef.current === 'screen' ) { // S stops screen recording when main window is focused - e.preventDefault(); + consume(); handleStopIntent(); } else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') { - e.preventDefault(); + consume(); handleCancelIntent(); } break; @@ -659,10 +669,10 @@ export function ComposeOverlay({ case 'reviewing': { if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') { - e.preventDefault(); + consume(); handleCancelIntent(); } else if (e.key === 'Enter') { - e.preventDefault(); + consume(); handleSendIntent(); } break; @@ -689,10 +699,13 @@ export function ComposeOverlay({ } }; - window.addEventListener('keydown', handleKeyDown); + // Capture phase so compose can consume keys before the stream's + // window-level navigation/action handlers (which listen in the bubble + // phase) see them. + window.addEventListener('keydown', handleKeyDown, true); window.addEventListener('keyup', handleKeyUp); return () => { - window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keydown', handleKeyDown, true); window.removeEventListener('keyup', handleKeyUp); }; }, [