diff --git a/src/browser/components/RightSidebar/CodeReview/ImmersiveReviewView.tsx b/src/browser/components/RightSidebar/CodeReview/ImmersiveReviewView.tsx index 3b7b164379..74c3d3d856 100644 --- a/src/browser/components/RightSidebar/CodeReview/ImmersiveReviewView.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ImmersiveReviewView.tsx @@ -27,6 +27,7 @@ import { getFileHunks, } from "@/browser/utils/review/navigation"; import { isEditableElement, KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; +import { stopKeyboardPropagation } from "@/browser/utils/events"; import { buildReadFileScript, processFileContents } from "@/browser/utils/fileExplorer"; import { parseReviewLineRange, @@ -1060,6 +1061,7 @@ export const ImmersiveReviewView: React.FC = (props) = // Esc: return to diff panel (not exit immersive). if (matchesKeybind(e, KEYBINDS.CANCEL)) { e.preventDefault(); + stopKeyboardPropagation(e); setFocusedPanel("diff"); containerRef.current?.focus(); return; @@ -1126,6 +1128,7 @@ export const ImmersiveReviewView: React.FC = (props) = // Esc: exit immersive if (matchesKeybind(e, KEYBINDS.CANCEL)) { e.preventDefault(); + stopKeyboardPropagation(e); onExit(); return; } @@ -1204,8 +1207,10 @@ export const ImmersiveReviewView: React.FC = (props) = } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + // Run in capture phase so immersive Escape handling can swallow the event before + // bubble-phase global stream-interrupt listeners see it. + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); }, [ focusedPanel, allReviews, diff --git a/src/browser/hooks/useAIViewKeybinds.test.tsx b/src/browser/hooks/useAIViewKeybinds.test.tsx index e90a67bccf..5660a51dbe 100644 --- a/src/browser/hooks/useAIViewKeybinds.test.tsx +++ b/src/browser/hooks/useAIViewKeybinds.test.tsx @@ -237,6 +237,58 @@ describe("useAIViewKeybinds", () => { expect(loadOlderHistory.mock.calls.length).toBe(1); }); + test("Escape does not interrupt when immersive review captures Escape", () => { + const interruptStream = mock(() => + Promise.resolve({ success: true as const, data: undefined }) + ); + currentClientMock = { + workspace: { + interruptStream, + }, + }; + + const chatInputAPI: RefObject = { current: null }; + + renderHook(() => + useAIViewKeybinds({ + workspaceId: "ws", + canInterrupt: true, + showRetryBarrier: false, + chatInputAPI, + jumpToBottom: () => undefined, + loadOlderHistory: null, + handleOpenTerminal: () => undefined, + handleOpenInEditor: () => undefined, + aggregator: undefined, + setEditingMessage: () => undefined, + vimEnabled: false, + }) + ); + + const stopImmersiveEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + } + }; + + // Immersive review listens in capture phase so Escape never reaches bubble-phase + // stream interrupt listeners. + window.addEventListener("keydown", stopImmersiveEscape, { capture: true }); + + document.body.dispatchEvent( + new window.KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + }) + ); + + window.removeEventListener("keydown", stopImmersiveEscape, { capture: true }); + + expect(interruptStream.mock.calls.length).toBe(0); + }); + test("Escape does not interrupt when a modal stops propagation (e.g., Settings)", () => { const interruptStream = mock(() => Promise.resolve({ success: true as const, data: undefined })