fix(desktop): keep esc inside compose instead of exiting the stream (#294)

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.


Claude-Session: https://claude.ai/code/session_01DapjFX1MhYPZeJv4s56K5L

Co-authored-by: Claude <noreply@anthropic.com>
This commit was merged in pull request #294.
This commit is contained in:
Arjun Patel
2026-06-21 08:32:58 -07:00
committed by GitHub
parent bfe53b46cf
commit c6ce1bdc65
@@ -595,6 +595,16 @@ export function ComposeOverlay({
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const currentStep = stepRef.current; 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 ( if (
currentStep === 'typing' || currentStep === 'typing' ||
currentStep === 'task' || currentStep === 'task' ||
@@ -602,7 +612,7 @@ export function ComposeOverlay({
currentStep === 'picking' currentStep === 'picking'
) { ) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); consume();
cancel(); cancel();
} }
return; return;
@@ -620,19 +630,19 @@ export function ComposeOverlay({
switch (currentStep) { switch (currentStep) {
case 'idle': { case 'idle': {
if (e.key === '`' && !e.repeat) { if (e.key === '`' && !e.repeat) {
e.preventDefault(); consume();
handleRecordIntent(); handleRecordIntent();
} else if (e.key === 's' || e.key === 'S') { } else if (e.key === 's' || e.key === 'S') {
e.preventDefault(); consume();
if (!guardIdle()) break; if (!guardIdle()) break;
if (!requireDesktop('Screen recording')) break; if (!requireDesktop('Screen recording')) break;
setRecordingSource('screen'); setRecordingSource('screen');
setStepSync('picking'); setStepSync('picking');
} else if (e.key === 't' || e.key === 'T') { } else if (e.key === 't' || e.key === 'T') {
e.preventDefault(); consume();
handleTextIntent(); handleTextIntent();
} else if (e.key === 'd' || e.key === 'D') { } else if (e.key === 'd' || e.key === 'D') {
e.preventDefault(); consume();
handleTaskIntent(); handleTaskIntent();
} }
break; break;
@@ -641,17 +651,17 @@ export function ComposeOverlay({
case 'recording': { case 'recording': {
if (e.key === '`' && !e.repeat) { if (e.key === '`' && !e.repeat) {
// Second tap stops media recording (toggle mode) // Second tap stops media recording (toggle mode)
e.preventDefault(); consume();
handleStopIntent(); handleStopIntent();
} else if ( } else if (
(e.key === 's' || e.key === 'S') && (e.key === 's' || e.key === 'S') &&
recordingSourceRef.current === 'screen' recordingSourceRef.current === 'screen'
) { ) {
// S stops screen recording when main window is focused // S stops screen recording when main window is focused
e.preventDefault(); consume();
handleStopIntent(); handleStopIntent();
} else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') { } else if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
e.preventDefault(); consume();
handleCancelIntent(); handleCancelIntent();
} }
break; break;
@@ -659,10 +669,10 @@ export function ComposeOverlay({
case 'reviewing': { case 'reviewing': {
if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') { if (e.key === 'q' || e.key === 'Q' || e.key === 'Escape') {
e.preventDefault(); consume();
handleCancelIntent(); handleCancelIntent();
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
e.preventDefault(); consume();
handleSendIntent(); handleSendIntent();
} }
break; 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); window.addEventListener('keyup', handleKeyUp);
return () => { return () => {
window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('keyup', handleKeyUp); window.removeEventListener('keyup', handleKeyUp);
}; };
}, [ }, [