Skip to content

Commit fa30fa4

Browse files
authored
🤖 fix: Escape closes the agent picker, sidebar drawer and notifications popover (#5736)
## Summary Escape now closes three layers that ignored it: the agent picker opened with Ctrl+Shift+A, the narrow-screen sidebar drawer, and the notifications popover (which used to reopen as a duplicate tooltip). A click on the notifications bell now only opens its settings and no longer changes them. Fixes #5676 Fixes #5685 Fixes #5691 Refs #5701 (dropped from this PR, see Scope below) ## Background All of them came from the agent bug bash in #5670. - #5676: `openPicker` focused the list in a `requestAnimationFrame`. When the shortcut opened the picker, that callback could run before the list mounted, so focus stayed in the composer. Escape is handled only on the list, so nothing closed it. On main it failed on every second open. - #5685: the drawer closes only through its backdrop. It leaves focus where it was (usually the composer). - #5691: the bell button opened a Popover and also had a Tooltip with the same checkboxes. After Escape, Radix returned focus to the bell and the Tooltip opened on that focus, so a second copy of the settings appeared. The bell's `onClick` also toggled "Notify on all responses". ## Implementation - **Agent picker:** the open commits with `flushSync` and focuses the list right away. The frame-deferred focus stays, because the command palette restores focus to the composer after its action runs. - **New `useEscapeToDismiss(enabled, onDismiss)` hook** for the drawer. It listens in the window's bubble phase, so open layers handle Escape first. Radix popovers, menus and dialogs stop Escape in the document capture phase. React handlers stop it with `stopKeyboardPropagation`. Document listeners call `preventDefault`. The hook also skips IME composition, modified Escape, terminals, remote desktops and open modal dialogs. #5670 tried a capture-phase listener and reverted it, because that one took Escape from every popover, dialog and edit mode. - **Stream interrupt is unchanged when no overlay is open.** The interrupt listener (`useAIViewKeybinds`) is in the same phase and mounts first. It now yields Escape while an overlay is open (`isEscapeDismissOverlayOpen()`), so one Escape never closes an overlay and also stops a stream. When no overlay is open, the hook has no listener, so Escape behaves exactly as on main. Tests cover both cases: a plain composer, `body`, and a composer that opts in with `data-escape-interrupts-stream`. ## Scope The tutorial's Escape (#5701) is not in this PR. Three review rounds each found a new ordering case between the tutorial and the layers under it (the drawer, opened before or after the tutorial, and a picker opened by shortcut beneath the tutorial). Each fix needed more ordering mechanism, so I removed the tutorial part instead of growing it. #5701 stays open. A fix there needs a decision about which layer owns Escape while a tutorial covers the app. Known limitation: when a tutorial shows over the open drawer, Escape closes the drawer behind the tutorial. On main, Escape does nothing in that case. - **Drawer width:** `LeftSidebar` now subscribes to its `(max-width: 768px)` media query. It used to read the query once per render, so a window narrowed into the drawer width on the new-workspace screen did not get Escape. - **Notifications bell (#5691, Option A approved by the coordinator):** the click only opens the popover. The checkbox and the shortcut toggle the setting. The tooltip is a plain label ("Notifications", the current state, and the shortcut on desktop). The button's accessible name is now "Notifications", the same as the tooltip. `aria-pressed` is gone because the button no longer toggles anything. The docs page says to check the box. ## Validation Pre-fix failures (unit tests on main): ``` AgentModePicker > Escape closes the picker right after the open shortcut Expected: 0 Received: 3 ``` New bug-bash repros in `tests/bugbash/repros/escapeClosesOverlays.e2e.ts` (4 tests, web and phone targets). I proved each one by restoring the old code in a scratch build. Each failed on its own assertion and passed after the restore: | Repro | Old code result | |---|---| | picker (#5676) | `ASSERTION_FAILED: expect.not.toBeExpanded` | | drawer (#5685) | `ASSERTION_FAILED: expect.toHaveCount` | | bell click (#5691) | `ASSERTION_FAILED: expect.not.toBeChecked` | | bell Escape (#5691) | `ASSERTION_FAILED: expect.toHaveCount` | The repros that read the bell's `aria-pressed` (`notificationsShortcut`, `knownFailureDetailsShortcuts`, `creationFormForgetsName`) now read the popover's checkbox through a shared helper. `knownFailureDetailsShortcuts` keeps its `known-failure` tag for #5671, which a later PR fixes. It reads the setting only at the end, because opening the bell popover would move focus off the details button. It still fails on its own assertion (`expect.toBeChecked`), and `make test-bugbash-known-failures` fails the same tests as on main. `tests/ui/compaction` enables notifications with the shortcut, because Radix popover content does not render in happy-dom. Mutation check: with the interrupt's overlay check removed, `useEscapeToDismiss.test.tsx` fails. ### Escape timing in the repros A Radix popover ignores Escape for a short time after it opens. This window exists on main, too. The CI run on 387b350 sent Escape inside it, so `Test / E2E (linux 1/4)` failed. The repros now press Escape exactly once, after an in-page probe in `tests/bugbash/repros/helpers.ts` reports that the layer takes Escape. The probe needs no sleep, no retry and no product change. Why Radix ignores the early Escape (`@radix-ui/react-dismissable-layer` 1.1.11): the layer registers in an effect (`context.layers.add(node)`), then sends `dismissableLayer.update`. It finds its own `index` only on the re-render that this event forces (`force({})`). Its Escape listener calls the handler from the last committed render, and a passive effect installs that handler. Until then `index` is -1, and Escape does nothing. The probe proves each step: 1. The test arms the probe before the open. 2. On `dismissableLayer.update`, the probe checks that `context.layers` holds the popover element, and records the layer's force state. 3. A stand-in React DevTools hook gets `onPostCommitFiberRoot`, which React calls after a commit's passive effects have run. The probe reports "ready" the first time the force state has changed there. If Radix or React changes the internals that the probe reads, the wait fails with a named error and does not race. `notificationsShortcut.e2e.ts` failed in CI for the same reason, although this change does not edit it. It calls `expectNotifyOnAllResponses` three times, and CI failed in that helper (`helpers.ts:98`, `expect(setting).toBeHidden()` right after its Escape). The helper now waits for the probe before its Escape. Ctrl+Shift+Comma opens no layer, so the shortcut presses need no wait. ### Screenshots Drawer at 390 px, Escape. Before: ![Drawer still open after Escape](https://github.com/user-attachments/assets/07b263d0-b4bf-4052-81a6-b200646fb3e3) After (the drawer was open before Escape as in this shot, then closed): ![Drawer open before Escape](https://github.com/user-attachments/assets/7adfd9f4-6c05-4361-8a29-2577d812b3eb) ![Drawer closed after Escape](https://github.com/user-attachments/assets/240336d5-5991-4dcf-abc4-d77a1bbf1a8b) Bell after Escape. Before, a second copy of the settings: ![Duplicate settings tooltip](https://github.com/user-attachments/assets/ffe018c7-07b0-4080-8bc8-8ea4363a1e35) After, a plain label: ![Plain label tooltip](https://github.com/user-attachments/assets/3e68ac8c-4b17-4179-96fd-f4c4ad3d9473) Bell popover at 390 px: ![Notifications popover on a phone width](https://github.com/user-attachments/assets/0e466a22-f694-4297-bdc6-936b3b195cfc) ## Risks Low. Escape routing changes only while the narrow drawer is open. During that time Escape closes the overlay instead of interrupting a stream. Users who click the bell to toggle notifications now need the checkbox or the shortcut. --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high -->
1 parent 16e33ab commit fa30fa4

15 files changed

Lines changed: 578 additions & 71 deletions

‎docs/config/notifications.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Xum can send system notifications to alert you about important events. Notificat
99

1010
There are two ways to receive notifications:
1111

12-
1. **Automatic notifications** — Toggle the bell icon in the workspace header to get notified when the agent completes a response. Use <kbd>Ctrl+Shift+,</kbd> (<kbd>⌘+Shift+,</kbd> on macOS) to toggle quickly.
12+
1. **Automatic notifications** — Click the bell icon in the workspace header and check **Notify on all responses** to get notified when the agent completes a response. Use <kbd>Ctrl+Shift+,</kbd> (<kbd>⌘+Shift+,</kbd> on macOS) to toggle quickly.
1313

1414
2. **Agent-triggered notifications** — The `notify` tool lets agents send notifications for specific events. You control when agents use this through prompts or scoped instructions.
1515

‎src/browser/components/AgentModePicker/AgentModePicker.test.tsx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from "react";
22
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3-
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
3+
import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
4+
import { createCustomEvent, CUSTOM_EVENTS } from "@/common/constants/events";
45
import { installDom } from "../../../../tests/ui/dom";
56

67
import { AgentProvider, type AgentContextValue } from "@/browser/contexts/AgentContext";
@@ -145,6 +146,20 @@ describe("AgentModePicker", () => {
145146
expect(view.getByLabelText("Select agent").getAttribute("aria-expanded")).toBe("false");
146147
});
147148

149+
test("Escape closes the picker right after the open shortcut", () => {
150+
const view = renderPicker();
151+
// The hotkey and the palette open the picker through this event. Escape can arrive before
152+
// the next animation frame, so the list must already own focus when the open commits.
153+
act(() => {
154+
window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.OPEN_AGENT_PICKER));
155+
});
156+
expect(view.getAllByTestId("agent-option").length).toBe(3);
157+
158+
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
159+
160+
expect(view.queryAllByTestId("agent-option").length).toBe(0);
161+
});
162+
148163
test("uiSelectable false without lock flag does not disable the picker", async () => {
149164
const { getByLabelText, queryAllByTestId } = renderPicker({
150165
initialAgentId: "explore",

‎src/browser/components/AgentModePicker/AgentModePicker.tsx‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
2+
import { flushSync } from "react-dom";
23
import { Bot, ChevronDown, Monitor, Route, SquareCode } from "lucide-react";
34
import type { LucideIcon } from "lucide-react";
45

@@ -179,16 +180,23 @@ export const AgentModePicker: React.FC<AgentModePickerProps> = (props) => {
179180
return;
180181
}
181182

182-
setIsPickerOpen(true);
183-
// macOS permissions change outside the app, so re-read them whenever the picker opens.
184-
computerUse?.refresh();
185-
186183
// Pre-select the current agent (or specified) in the list.
187184
const targetId = opts?.highlightAgentId ?? normalizedAgentId;
188185
const currentIndex = options.findIndex((opt) => opt.id === targetId);
189-
setHighlightedIndex(currentIndex >= 0 ? currentIndex : 0);
186+
// Commit the list now so it can take focus before the next key arrives. Escape and the
187+
// arrow keys are handled only on the list; when the shortcut opened the picker, the old
188+
// frame-deferred focus could run before the list mounted, so focus stayed in the
189+
// composer and Escape did nothing (#5676).
190+
flushSync(() => {
191+
setIsPickerOpen(true);
192+
setHighlightedIndex(currentIndex >= 0 ? currentIndex : 0);
193+
});
194+
dropdownRef.current?.focus();
195+
// macOS permissions change outside the app, so re-read them whenever the picker opens.
196+
computerUse?.refresh();
190197

191-
// Focus the dropdown container for keyboard navigation.
198+
// Focus again after the frame: the command palette opens the picker while it closes,
199+
// and its focus restore moves focus back to the composer after this handler returns.
192200
requestAnimationFrame(() => {
193201
dropdownRef.current?.focus();
194202
});

‎src/browser/components/LeftSidebar/LeftSidebar.tsx‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import React from "react";
1+
import React, { useSyncExternalStore } from "react";
22
import { cn } from "@/common/lib/utils";
33
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
44
import { LEFT_SIDEBAR_COLLAPSED_WIDTH_PX, LEFT_SIDEBAR_DEFAULT_WIDTH_PX } from "@/constants/layout";
55
import ProjectSidebar from "../ProjectSidebar/ProjectSidebar";
66
import { TitleBar } from "../TitleBar/TitleBar";
77
import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar";
8+
import { useEscapeToDismiss } from "@/browser/hooks/useEscapeToDismiss";
89

910
interface LeftSidebarProps {
1011
collapsed: boolean;
@@ -16,6 +17,18 @@ interface LeftSidebarProps {
1617
workspaceRecency: Record<string, number>;
1718
}
1819

20+
const MOBILE_OVERLAY_QUERY = "(max-width: 768px)";
21+
22+
function readMobileOverlayQuery(): boolean {
23+
return typeof window !== "undefined" && window.matchMedia(MOBILE_OVERLAY_QUERY).matches;
24+
}
25+
26+
function subscribeToMobileOverlayQuery(onChange: () => void): () => void {
27+
const query = window.matchMedia(MOBILE_OVERLAY_QUERY);
28+
query.addEventListener("change", onChange);
29+
return () => query.removeEventListener("change", onChange);
30+
}
31+
1932
export function LeftSidebar(props: LeftSidebarProps) {
2033
const {
2134
collapsed,
@@ -27,9 +40,17 @@ export function LeftSidebar(props: LeftSidebarProps) {
2740
} = props;
2841
const isDesktop = isDesktopMode();
2942
// Match the CSS gate for the mobile "overlay" sidebar (width-only, any pointer
30-
// type); we don't show a drag handle in that mode since CSS pins the width.
31-
const isMobileOverlay =
32-
typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches;
43+
// type); we don't show a drag handle in that mode since CSS pins the width. Subscribed, so a
44+
// window resized into the overlay width also gets the drawer's Escape handling.
45+
const isMobileOverlay = useSyncExternalStore(
46+
subscribeToMobileOverlayQuery,
47+
readMobileOverlayQuery,
48+
() => false
49+
);
50+
51+
// The drawer covers the page like a modal, so Escape closes it the way a backdrop tap does
52+
// (#5685), unless a popover, menu or dialog inside it owns Escape.
53+
useEscapeToDismiss(!collapsed && isMobileOverlay, onToggleCollapsed);
3354

3455
const handleBeforeOpenSettings = () => {
3556
// Keep settings navigation escapable on narrow viewports by dismissing the

‎src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx‎

Lines changed: 14 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -684,18 +684,19 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
684684
<Tooltip {...(notificationPopoverOpen ? { open: false } : {})}>
685685
<TooltipTrigger asChild>
686686
<PopoverTrigger asChild>
687+
{/* A click only opens the settings: it used to also flip "Notify on all
688+
responses", so nobody could look at the settings without changing them
689+
(#5691). The checkbox and the shortcut toggle it. */}
687690
<button
688691
type="button"
689-
onClick={() => setNotifyOnResponse((prev) => !prev)}
690692
className={cn(
691693
"flex h-6 w-6 shrink-0 items-center justify-center rounded",
692694
notifyOnResponse
693695
? "text-foreground"
694696
: "text-muted hover:bg-sidebar-hover hover:text-foreground"
695697
)}
696698
data-testid="notify-on-response-button"
697-
aria-label="Notify on all responses"
698-
aria-pressed={notifyOnResponse}
699+
aria-label="Notifications"
699700
>
700701
{notifyOnResponse ? (
701702
<Bell className="h-3.5 w-3.5" />
@@ -705,41 +706,17 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
705706
</button>
706707
</PopoverTrigger>
707708
</TooltipTrigger>
709+
{/* A plain label: the settings live only in the popover. When the tooltip repeated
710+
them, Radix reopened it on the refocused bell after Escape closed the popover,
711+
and a second copy of the settings appeared (#5691). */}
708712
<TooltipContent side="bottom" align="end">
709-
<div className="flex flex-col gap-2">
710-
<label className="flex cursor-pointer items-center gap-2">
711-
<Checkbox
712-
checked={notifyOnResponse}
713-
onCheckedChange={(checked) => setNotifyOnResponse(checked === true)}
714-
/>
715-
<span className="text-foreground">
716-
Notify on all responses{" "}
717-
<span className="text-muted-foreground mobile-hide-shortcut-hints">
718-
({formatKeybind(KEYBINDS.TOGGLE_NOTIFICATIONS)})
719-
</span>
720-
</span>
721-
</label>
722-
<label className="flex cursor-pointer items-start gap-2">
723-
<Checkbox
724-
checked={autoEnableNotifications}
725-
onCheckedChange={(checked) => setAutoEnableNotifications(checked === true)}
726-
/>
727-
<span className="text-muted-foreground">
728-
Auto-enable for new workspaces in this project
729-
</span>
730-
</label>
731-
<p className="text-muted-foreground border-separator-light border-t pt-2">
732-
Agents can also notify on specific events.{" "}
733-
<a
734-
href="https://mux.coder.com/config/notifications"
735-
target="_blank"
736-
rel="noopener noreferrer"
737-
className="text-accent hover:underline"
738-
>
739-
Learn more
740-
</a>
741-
</p>
742-
</div>
713+
Notifications
714+
<br />
715+
Notify on all responses: {notifyOnResponse ? "on" : "off"}
716+
<span className="mobile-hide-shortcut-hints">
717+
{" "}
718+
({formatKeybind(KEYBINDS.TOGGLE_NOTIFICATIONS)})
719+
</span>
743720
</TooltipContent>
744721
</Tooltip>
745722

‎src/browser/hooks/useAIViewKeybinds.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { StreamingMessageAggregator } from "@/browser/utils/messages/Stream
1414
import { isCompactingStream, cancelCompaction } from "@/browser/utils/compaction/handler";
1515
import { stopStream } from "@/browser/utils/stopStream";
1616
import { useAPI } from "@/browser/contexts/API";
17+
import { isEscapeDismissOverlayOpen } from "@/browser/hooks/useEscapeToDismiss";
1718
import type { EditingMessageState } from "@/browser/utils/chatEditing";
1819

1920
interface UseAIViewKeybindsParams {
@@ -91,6 +92,11 @@ export function useAIViewKeybinds({
9192
return;
9293
}
9394

95+
// An open overlay (tutorial, narrow-screen drawer) takes this Escape to close itself.
96+
if (interruptKeybind === KEYBINDS.INTERRUPT_STREAM_NORMAL && isEscapeDismissOverlayOpen()) {
97+
return;
98+
}
99+
94100
// Normal mode uses Escape; skip when typing in inputs unless explicitly opted in.
95101
if (
96102
interruptKeybind === KEYBINDS.INTERRUPT_STREAM_NORMAL &&
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { ReactNode, RefObject } from "react";
2+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
3+
import { cleanup, renderHook, waitFor } from "@testing-library/react";
4+
import { GlobalWindow } from "happy-dom";
5+
import type { ChatInputAPI } from "@/browser/features/ChatInput";
6+
import { APIProvider, type APIClient } from "@/browser/contexts/API";
7+
import { createTestApiClient, type TestApiOverrides } from "@/browser/testUtils";
8+
import { useAIViewKeybinds } from "./useAIViewKeybinds";
9+
import { useEscapeToDismiss } from "./useEscapeToDismiss";
10+
11+
let originalWindow: typeof globalThis.window;
12+
let originalDocument: typeof globalThis.document;
13+
let originalHTMLElement: unknown;
14+
15+
// The app mounts the stream-interrupt listener (ChatPane) before an overlay opens, so this
16+
// harness registers it first too: the order decides which window listener sees Escape first.
17+
function renderWithStreamInterrupt(overlayOpen: boolean) {
18+
const interruptStream = mock(() => Promise.resolve({ success: true as const, data: undefined }));
19+
const client: TestApiOverrides<APIClient> = { workspace: { interruptStream } };
20+
const onDismiss = mock(() => undefined);
21+
const chatInputAPI: RefObject<ChatInputAPI | null> = { current: null };
22+
const wrapper = ({ children }: { children: ReactNode }) => (
23+
<APIProvider client={createTestApiClient(client)}>{children}</APIProvider>
24+
);
25+
renderHook(
26+
() => {
27+
useAIViewKeybinds({
28+
workspaceId: "ws",
29+
canInterrupt: true,
30+
showRetryBarrier: false,
31+
chatInputAPI,
32+
jumpToBottom: () => undefined,
33+
loadOlderHistory: null,
34+
handleOpenTerminal: () => undefined,
35+
handleOpenInEditor: () => undefined,
36+
aggregator: undefined,
37+
setEditingMessage: () => undefined,
38+
vimEnabled: false,
39+
});
40+
useEscapeToDismiss(overlayOpen, onDismiss);
41+
},
42+
{ wrapper }
43+
);
44+
return { interruptStream, onDismiss };
45+
}
46+
47+
function pressEscape(target: EventTarget, init: KeyboardEventInit = {}): KeyboardEvent {
48+
const event = new window.KeyboardEvent("keydown", {
49+
key: "Escape",
50+
bubbles: true,
51+
cancelable: true,
52+
...init,
53+
});
54+
target.dispatchEvent(event);
55+
return event;
56+
}
57+
58+
function addComposer(): HTMLTextAreaElement {
59+
const composer = document.createElement("textarea");
60+
document.body.appendChild(composer);
61+
composer.focus();
62+
return composer;
63+
}
64+
65+
describe("useEscapeToDismiss", () => {
66+
beforeEach(() => {
67+
originalWindow = globalThis.window;
68+
originalDocument = globalThis.document;
69+
originalHTMLElement = (globalThis as unknown as { HTMLElement: unknown }).HTMLElement;
70+
const domWindow = new GlobalWindow() as unknown as Window & typeof globalThis;
71+
globalThis.window = domWindow;
72+
globalThis.document = domWindow.document;
73+
// The keybind helpers check `target instanceof HTMLElement`.
74+
(globalThis as unknown as { HTMLElement: unknown }).HTMLElement = domWindow.HTMLElement;
75+
});
76+
77+
afterEach(() => {
78+
cleanup();
79+
globalThis.window = originalWindow;
80+
globalThis.document = originalDocument;
81+
(globalThis as unknown as { HTMLElement: unknown }).HTMLElement = originalHTMLElement;
82+
});
83+
84+
test("with the overlay closed, Escape interrupts the stream exactly as before", async () => {
85+
const { interruptStream, onDismiss } = renderWithStreamInterrupt(false);
86+
87+
// The composer ignores Escape for the interrupt unless it opts in, and nothing claims it.
88+
const fromComposer = pressEscape(addComposer());
89+
expect(fromComposer.defaultPrevented).toBe(false);
90+
expect(interruptStream).not.toHaveBeenCalled();
91+
92+
// Outside an editable element, Escape still interrupts.
93+
pressEscape(document.body);
94+
await waitFor(() => expect(interruptStream).toHaveBeenCalledTimes(1));
95+
expect(onDismiss).not.toHaveBeenCalled();
96+
});
97+
98+
test("with the overlay open, Escape from the composer closes it and interrupts nothing", () => {
99+
const { interruptStream, onDismiss } = renderWithStreamInterrupt(true);
100+
101+
const event = pressEscape(addComposer());
102+
103+
expect(onDismiss).toHaveBeenCalledTimes(1);
104+
expect(event.defaultPrevented).toBe(true);
105+
expect(interruptStream).not.toHaveBeenCalled();
106+
});
107+
108+
test("with the overlay open, Escape from targets the interrupt accepts closes it and interrupts nothing", async () => {
109+
const { interruptStream, onDismiss } = renderWithStreamInterrupt(true);
110+
const optedIn = addComposer();
111+
optedIn.setAttribute("data-escape-interrupts-stream", "");
112+
113+
pressEscape(document.body);
114+
pressEscape(optedIn);
115+
116+
expect(onDismiss).toHaveBeenCalledTimes(2);
117+
// stopStream runs asynchronously, so give a wrongly started interrupt time to arrive.
118+
await new Promise((resolve) => setTimeout(resolve, 20));
119+
expect(interruptStream).not.toHaveBeenCalled();
120+
});
121+
122+
test("leaves Escape to whatever already handled it", () => {
123+
const { onDismiss } = renderWithStreamInterrupt(true);
124+
const composer = addComposer();
125+
126+
// An open popover, menu or edit mode: they call preventDefault or stop propagation.
127+
const claim = (e: Event) => e.preventDefault();
128+
document.addEventListener("keydown", claim);
129+
pressEscape(composer);
130+
document.removeEventListener("keydown", claim);
131+
const stop = (e: Event) => e.stopPropagation();
132+
document.addEventListener("keydown", stop);
133+
pressEscape(composer);
134+
document.removeEventListener("keydown", stop);
135+
136+
// IME composition, modified Escape, a terminal, and an open modal dialog.
137+
pressEscape(composer, { isComposing: true });
138+
pressEscape(composer, { ctrlKey: true, shiftKey: true });
139+
const terminal = document.createElement("div");
140+
terminal.setAttribute("data-terminal-container", "");
141+
document.body.appendChild(terminal);
142+
pressEscape(terminal);
143+
const modal = document.createElement("div");
144+
modal.setAttribute("role", "dialog");
145+
modal.setAttribute("aria-modal", "true");
146+
document.body.appendChild(modal);
147+
pressEscape(composer);
148+
149+
expect(onDismiss).not.toHaveBeenCalled();
150+
});
151+
});

0 commit comments

Comments
 (0)