diff --git a/docs/config/notifications.mdx b/docs/config/notifications.mdx
index 32bdb95ce0a..dc6a1406f6b 100644
--- a/docs/config/notifications.mdx
+++ b/docs/config/notifications.mdx
@@ -9,7 +9,7 @@ Xum can send system notifications to alert you about important events. Notificat
There are two ways to receive notifications:
-1. **Automatic notifications** — Toggle the bell icon in the workspace header to get notified when the agent completes a response. Use Ctrl+Shift+, (⌘+Shift+, on macOS) to toggle quickly.
+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 Ctrl+Shift+, (⌘+Shift+, on macOS) to toggle quickly.
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.
diff --git a/src/browser/components/AgentModePicker/AgentModePicker.test.tsx b/src/browser/components/AgentModePicker/AgentModePicker.test.tsx
index c7ac3395b1f..32aa55886a8 100644
--- a/src/browser/components/AgentModePicker/AgentModePicker.test.tsx
+++ b/src/browser/components/AgentModePicker/AgentModePicker.test.tsx
@@ -1,6 +1,7 @@
import React from "react";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
-import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
+import { act, cleanup, fireEvent, render, waitFor } from "@testing-library/react";
+import { createCustomEvent, CUSTOM_EVENTS } from "@/common/constants/events";
import { installDom } from "../../../../tests/ui/dom";
import { AgentProvider, type AgentContextValue } from "@/browser/contexts/AgentContext";
@@ -145,6 +146,20 @@ describe("AgentModePicker", () => {
expect(view.getByLabelText("Select agent").getAttribute("aria-expanded")).toBe("false");
});
+ test("Escape closes the picker right after the open shortcut", () => {
+ const view = renderPicker();
+ // The hotkey and the palette open the picker through this event. Escape can arrive before
+ // the next animation frame, so the list must already own focus when the open commits.
+ act(() => {
+ window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.OPEN_AGENT_PICKER));
+ });
+ expect(view.getAllByTestId("agent-option").length).toBe(3);
+
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
+
+ expect(view.queryAllByTestId("agent-option").length).toBe(0);
+ });
+
test("uiSelectable false without lock flag does not disable the picker", async () => {
const { getByLabelText, queryAllByTestId } = renderPicker({
initialAgentId: "explore",
diff --git a/src/browser/components/AgentModePicker/AgentModePicker.tsx b/src/browser/components/AgentModePicker/AgentModePicker.tsx
index 4b83a0236c9..a452c3d4030 100644
--- a/src/browser/components/AgentModePicker/AgentModePicker.tsx
+++ b/src/browser/components/AgentModePicker/AgentModePicker.tsx
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
+import { flushSync } from "react-dom";
import { Bot, ChevronDown, Monitor, Route, SquareCode } from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -179,16 +180,23 @@ export const AgentModePicker: React.FC = (props) => {
return;
}
- setIsPickerOpen(true);
- // macOS permissions change outside the app, so re-read them whenever the picker opens.
- computerUse?.refresh();
-
// Pre-select the current agent (or specified) in the list.
const targetId = opts?.highlightAgentId ?? normalizedAgentId;
const currentIndex = options.findIndex((opt) => opt.id === targetId);
- setHighlightedIndex(currentIndex >= 0 ? currentIndex : 0);
+ // Commit the list now so it can take focus before the next key arrives. Escape and the
+ // arrow keys are handled only on the list; when the shortcut opened the picker, the old
+ // frame-deferred focus could run before the list mounted, so focus stayed in the
+ // composer and Escape did nothing (#5676).
+ flushSync(() => {
+ setIsPickerOpen(true);
+ setHighlightedIndex(currentIndex >= 0 ? currentIndex : 0);
+ });
+ dropdownRef.current?.focus();
+ // macOS permissions change outside the app, so re-read them whenever the picker opens.
+ computerUse?.refresh();
- // Focus the dropdown container for keyboard navigation.
+ // Focus again after the frame: the command palette opens the picker while it closes,
+ // and its focus restore moves focus back to the composer after this handler returns.
requestAnimationFrame(() => {
dropdownRef.current?.focus();
});
diff --git a/src/browser/components/LeftSidebar/LeftSidebar.tsx b/src/browser/components/LeftSidebar/LeftSidebar.tsx
index 55e41a26d13..f19547340f3 100644
--- a/src/browser/components/LeftSidebar/LeftSidebar.tsx
+++ b/src/browser/components/LeftSidebar/LeftSidebar.tsx
@@ -1,10 +1,11 @@
-import React from "react";
+import React, { useSyncExternalStore } from "react";
import { cn } from "@/common/lib/utils";
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
import { LEFT_SIDEBAR_COLLAPSED_WIDTH_PX, LEFT_SIDEBAR_DEFAULT_WIDTH_PX } from "@/constants/layout";
import ProjectSidebar from "../ProjectSidebar/ProjectSidebar";
import { TitleBar } from "../TitleBar/TitleBar";
import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar";
+import { useEscapeToDismiss } from "@/browser/hooks/useEscapeToDismiss";
interface LeftSidebarProps {
collapsed: boolean;
@@ -16,6 +17,18 @@ interface LeftSidebarProps {
workspaceRecency: Record;
}
+const MOBILE_OVERLAY_QUERY = "(max-width: 768px)";
+
+function readMobileOverlayQuery(): boolean {
+ return typeof window !== "undefined" && window.matchMedia(MOBILE_OVERLAY_QUERY).matches;
+}
+
+function subscribeToMobileOverlayQuery(onChange: () => void): () => void {
+ const query = window.matchMedia(MOBILE_OVERLAY_QUERY);
+ query.addEventListener("change", onChange);
+ return () => query.removeEventListener("change", onChange);
+}
+
export function LeftSidebar(props: LeftSidebarProps) {
const {
collapsed,
@@ -27,9 +40,17 @@ export function LeftSidebar(props: LeftSidebarProps) {
} = props;
const isDesktop = isDesktopMode();
// Match the CSS gate for the mobile "overlay" sidebar (width-only, any pointer
- // type); we don't show a drag handle in that mode since CSS pins the width.
- const isMobileOverlay =
- typeof window !== "undefined" && window.matchMedia("(max-width: 768px)").matches;
+ // type); we don't show a drag handle in that mode since CSS pins the width. Subscribed, so a
+ // window resized into the overlay width also gets the drawer's Escape handling.
+ const isMobileOverlay = useSyncExternalStore(
+ subscribeToMobileOverlayQuery,
+ readMobileOverlayQuery,
+ () => false
+ );
+
+ // The drawer covers the page like a modal, so Escape closes it the way a backdrop tap does
+ // (#5685), unless a popover, menu or dialog inside it owns Escape.
+ useEscapeToDismiss(!collapsed && isMobileOverlay, onToggleCollapsed);
const handleBeforeOpenSettings = () => {
// Keep settings navigation escapable on narrow viewports by dismissing the
diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx
index 759e2d37452..151e1130b50 100644
--- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx
+++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx
@@ -684,9 +684,11 @@ export const WorkspaceMenuBar: React.FC = ({
+ {/* A click only opens the settings: it used to also flip "Notify on all
+ responses", so nobody could look at the settings without changing them
+ (#5691). The checkbox and the shortcut toggle it. */}
+ {/* A plain label: the settings live only in the popover. When the tooltip repeated
+ them, Radix reopened it on the refocused bell after Escape closed the popover,
+ and a second copy of the settings appeared (#5691). */}
-
-
-
-
- Agents can also notify on specific events.{" "}
-
- Learn more
-
-
-
+ Notifications
+
+ Notify on all responses: {notifyOnResponse ? "on" : "off"}
+
+ {" "}
+ ({formatKeybind(KEYBINDS.TOGGLE_NOTIFICATIONS)})
+
diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts
index d61d386d7cc..984c1fb94e6 100644
--- a/src/browser/hooks/useAIViewKeybinds.ts
+++ b/src/browser/hooks/useAIViewKeybinds.ts
@@ -14,6 +14,7 @@ import type { StreamingMessageAggregator } from "@/browser/utils/messages/Stream
import { isCompactingStream, cancelCompaction } from "@/browser/utils/compaction/handler";
import { stopStream } from "@/browser/utils/stopStream";
import { useAPI } from "@/browser/contexts/API";
+import { isEscapeDismissOverlayOpen } from "@/browser/hooks/useEscapeToDismiss";
import type { EditingMessageState } from "@/browser/utils/chatEditing";
interface UseAIViewKeybindsParams {
@@ -91,6 +92,11 @@ export function useAIViewKeybinds({
return;
}
+ // An open overlay (tutorial, narrow-screen drawer) takes this Escape to close itself.
+ if (interruptKeybind === KEYBINDS.INTERRUPT_STREAM_NORMAL && isEscapeDismissOverlayOpen()) {
+ return;
+ }
+
// Normal mode uses Escape; skip when typing in inputs unless explicitly opted in.
if (
interruptKeybind === KEYBINDS.INTERRUPT_STREAM_NORMAL &&
diff --git a/src/browser/hooks/useEscapeToDismiss.test.tsx b/src/browser/hooks/useEscapeToDismiss.test.tsx
new file mode 100644
index 00000000000..d7b96aee83c
--- /dev/null
+++ b/src/browser/hooks/useEscapeToDismiss.test.tsx
@@ -0,0 +1,151 @@
+import type { ReactNode, RefObject } from "react";
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
+import { cleanup, renderHook, waitFor } from "@testing-library/react";
+import { GlobalWindow } from "happy-dom";
+import type { ChatInputAPI } from "@/browser/features/ChatInput";
+import { APIProvider, type APIClient } from "@/browser/contexts/API";
+import { createTestApiClient, type TestApiOverrides } from "@/browser/testUtils";
+import { useAIViewKeybinds } from "./useAIViewKeybinds";
+import { useEscapeToDismiss } from "./useEscapeToDismiss";
+
+let originalWindow: typeof globalThis.window;
+let originalDocument: typeof globalThis.document;
+let originalHTMLElement: unknown;
+
+// The app mounts the stream-interrupt listener (ChatPane) before an overlay opens, so this
+// harness registers it first too: the order decides which window listener sees Escape first.
+function renderWithStreamInterrupt(overlayOpen: boolean) {
+ const interruptStream = mock(() => Promise.resolve({ success: true as const, data: undefined }));
+ const client: TestApiOverrides = { workspace: { interruptStream } };
+ const onDismiss = mock(() => undefined);
+ const chatInputAPI: RefObject = { current: null };
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+ renderHook(
+ () => {
+ useAIViewKeybinds({
+ workspaceId: "ws",
+ canInterrupt: true,
+ showRetryBarrier: false,
+ chatInputAPI,
+ jumpToBottom: () => undefined,
+ loadOlderHistory: null,
+ handleOpenTerminal: () => undefined,
+ handleOpenInEditor: () => undefined,
+ aggregator: undefined,
+ setEditingMessage: () => undefined,
+ vimEnabled: false,
+ });
+ useEscapeToDismiss(overlayOpen, onDismiss);
+ },
+ { wrapper }
+ );
+ return { interruptStream, onDismiss };
+}
+
+function pressEscape(target: EventTarget, init: KeyboardEventInit = {}): KeyboardEvent {
+ const event = new window.KeyboardEvent("keydown", {
+ key: "Escape",
+ bubbles: true,
+ cancelable: true,
+ ...init,
+ });
+ target.dispatchEvent(event);
+ return event;
+}
+
+function addComposer(): HTMLTextAreaElement {
+ const composer = document.createElement("textarea");
+ document.body.appendChild(composer);
+ composer.focus();
+ return composer;
+}
+
+describe("useEscapeToDismiss", () => {
+ beforeEach(() => {
+ originalWindow = globalThis.window;
+ originalDocument = globalThis.document;
+ originalHTMLElement = (globalThis as unknown as { HTMLElement: unknown }).HTMLElement;
+ const domWindow = new GlobalWindow() as unknown as Window & typeof globalThis;
+ globalThis.window = domWindow;
+ globalThis.document = domWindow.document;
+ // The keybind helpers check `target instanceof HTMLElement`.
+ (globalThis as unknown as { HTMLElement: unknown }).HTMLElement = domWindow.HTMLElement;
+ });
+
+ afterEach(() => {
+ cleanup();
+ globalThis.window = originalWindow;
+ globalThis.document = originalDocument;
+ (globalThis as unknown as { HTMLElement: unknown }).HTMLElement = originalHTMLElement;
+ });
+
+ test("with the overlay closed, Escape interrupts the stream exactly as before", async () => {
+ const { interruptStream, onDismiss } = renderWithStreamInterrupt(false);
+
+ // The composer ignores Escape for the interrupt unless it opts in, and nothing claims it.
+ const fromComposer = pressEscape(addComposer());
+ expect(fromComposer.defaultPrevented).toBe(false);
+ expect(interruptStream).not.toHaveBeenCalled();
+
+ // Outside an editable element, Escape still interrupts.
+ pressEscape(document.body);
+ await waitFor(() => expect(interruptStream).toHaveBeenCalledTimes(1));
+ expect(onDismiss).not.toHaveBeenCalled();
+ });
+
+ test("with the overlay open, Escape from the composer closes it and interrupts nothing", () => {
+ const { interruptStream, onDismiss } = renderWithStreamInterrupt(true);
+
+ const event = pressEscape(addComposer());
+
+ expect(onDismiss).toHaveBeenCalledTimes(1);
+ expect(event.defaultPrevented).toBe(true);
+ expect(interruptStream).not.toHaveBeenCalled();
+ });
+
+ test("with the overlay open, Escape from targets the interrupt accepts closes it and interrupts nothing", async () => {
+ const { interruptStream, onDismiss } = renderWithStreamInterrupt(true);
+ const optedIn = addComposer();
+ optedIn.setAttribute("data-escape-interrupts-stream", "");
+
+ pressEscape(document.body);
+ pressEscape(optedIn);
+
+ expect(onDismiss).toHaveBeenCalledTimes(2);
+ // stopStream runs asynchronously, so give a wrongly started interrupt time to arrive.
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ expect(interruptStream).not.toHaveBeenCalled();
+ });
+
+ test("leaves Escape to whatever already handled it", () => {
+ const { onDismiss } = renderWithStreamInterrupt(true);
+ const composer = addComposer();
+
+ // An open popover, menu or edit mode: they call preventDefault or stop propagation.
+ const claim = (e: Event) => e.preventDefault();
+ document.addEventListener("keydown", claim);
+ pressEscape(composer);
+ document.removeEventListener("keydown", claim);
+ const stop = (e: Event) => e.stopPropagation();
+ document.addEventListener("keydown", stop);
+ pressEscape(composer);
+ document.removeEventListener("keydown", stop);
+
+ // IME composition, modified Escape, a terminal, and an open modal dialog.
+ pressEscape(composer, { isComposing: true });
+ pressEscape(composer, { ctrlKey: true, shiftKey: true });
+ const terminal = document.createElement("div");
+ terminal.setAttribute("data-terminal-container", "");
+ document.body.appendChild(terminal);
+ pressEscape(terminal);
+ const modal = document.createElement("div");
+ modal.setAttribute("role", "dialog");
+ modal.setAttribute("aria-modal", "true");
+ document.body.appendChild(modal);
+ pressEscape(composer);
+
+ expect(onDismiss).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/browser/hooks/useEscapeToDismiss.ts b/src/browser/hooks/useEscapeToDismiss.ts
new file mode 100644
index 00000000000..de67cf3c4e1
--- /dev/null
+++ b/src/browser/hooks/useEscapeToDismiss.ts
@@ -0,0 +1,68 @@
+import { useEffect, useLayoutEffect, useRef } from "react";
+import {
+ isDesktopViewportFocused,
+ isDialogOpen,
+ isTerminalFocused,
+ KEYBINDS,
+ matchesKeybind,
+} from "@/browser/utils/ui/keybinds";
+
+/**
+ * Close an overlay (the narrow-screen sidebar drawer) on Escape, but only when nothing else
+ * claimed the key.
+ *
+ * The overlay does not own focus, so Escape usually arrives from somewhere else, often the
+ * composer. A capture-phase listener took Escape from every popover, menu, dialog and edit mode
+ * (#5670 tried one for the tutorial and reverted it). This one runs in the window's bubble phase,
+ * after the open layers had their turn:
+ * - Radix popovers, menus and dialogs stop Escape in the document capture phase.
+ * - React handlers that call stopKeyboardPropagation stop it at the React root.
+ * - Document listeners (composer suggestions, the send-mode menu) call preventDefault.
+ * The stream interrupt (useAIViewKeybinds) listens in the same phase and mounts first, so it
+ * would see Escape before this listener. It asks isEscapeDismissOverlayOpen() instead and yields
+ * while an overlay is open: one Escape never does both. When no overlay is open, no listener
+ * exists and Escape behaves exactly as it does without this hook.
+ *
+ * Only one overlay uses this hook. Before a second one does, decide which of them answers
+ * Escape: listeners run in registration order, not in visual order.
+ */
+let openOverlayCount = 0;
+
+/** True while an overlay that closes on Escape is open (see useEscapeToDismiss). */
+export function isEscapeDismissOverlayOpen(): boolean {
+ return openOverlayCount > 0;
+}
+
+export function useEscapeToDismiss(enabled: boolean, onDismiss: () => void): void {
+ // A ref keeps one subscription per open overlay. Re-subscribing on every render would move this
+ // listener behind later window listeners and change which one sees Escape first.
+ const onDismissRef = useRef(onDismiss);
+ useLayoutEffect(() => {
+ onDismissRef.current = onDismiss;
+ }, [onDismiss]);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ // KEYBINDS.CANCEL is bare Escape: modified Escape belongs to other shortcuts.
+ if (!matchesKeybind(e, KEYBINDS.CANCEL)) return;
+ // Something closer to the focus already handled Escape, or an IME is composing text.
+ if (e.defaultPrevented || e.isComposing) return;
+ // Terminals and remote desktops own their keyboard, Escape included.
+ if (isTerminalFocused(e.target) || isDesktopViewportFocused(e.target)) return;
+ // A modal above the overlay closes first.
+ if (isDialogOpen()) return;
+
+ e.preventDefault();
+ onDismissRef.current();
+ };
+
+ openOverlayCount += 1;
+ window.addEventListener("keydown", handleKeyDown);
+ return () => {
+ openOverlayCount -= 1;
+ window.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [enabled]);
+}
diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts
index a945ee47b13..34a40158644 100644
--- a/src/node/services/agentSkills/builtInSkillContent.generated.ts
+++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts
@@ -4768,7 +4768,7 @@ export const BUILTIN_SKILL_FILES: Record> = {
"",
"There are two ways to receive notifications:",
"",
- "1. **Automatic notifications** — Toggle the bell icon in the workspace header to get notified when the agent completes a response. Use Ctrl+Shift+, (⌘+Shift+, on macOS) to toggle quickly.",
+ "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 Ctrl+Shift+, (⌘+Shift+, on macOS) to toggle quickly.",
"",
"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.",
"",
diff --git a/tests/bugbash/repros/creationFormForgetsName.e2e.ts b/tests/bugbash/repros/creationFormForgetsName.e2e.ts
index 5cdad6ac428..6fb1c415c79 100644
--- a/tests/bugbash/repros/creationFormForgetsName.e2e.ts
+++ b/tests/bugbash/repros/creationFormForgetsName.e2e.ts
@@ -18,8 +18,8 @@ test(
await name.fill("n3-repro");
await screen.getByRole("textbox", "Message").fill("create from the default form");
await screen.getByRole("button", "Send message").tap();
- // The new workspace opens; its menu bar shows the notifications toggle.
- await expect(screen.getByRole("button", "Notify on all responses")).toBeVisible({
+ // The new workspace opens; its menu bar shows the notifications button.
+ await expect(screen.getByRole("button", "Notifications")).toBeVisible({
timeout: 30_000,
});
diff --git a/tests/bugbash/repros/escapeClosesOverlays.e2e.ts b/tests/bugbash/repros/escapeClosesOverlays.e2e.ts
new file mode 100644
index 00000000000..56e6d624b49
--- /dev/null
+++ b/tests/bugbash/repros/escapeClosesOverlays.e2e.ts
@@ -0,0 +1,85 @@
+// #5676, #5685, #5691: Escape closes the agent picker, the narrow-screen sidebar drawer and the
+// notifications popover (once). Each test fails on the old code.
+import { test } from "@e2e-dev/web";
+import { expect } from "e2e";
+import {
+ armLayerProbe,
+ expectNotifyOnAllResponses,
+ NOTIFICATIONS_POPOVER,
+ openPlayground,
+ waitForLayerEscapeReady,
+ WORKSPACE_TITLE,
+} from "./helpers";
+
+test(
+ "Escape closes the agent picker opened with Ctrl+Shift+A",
+ { tags: ["bugbash", "5676"] },
+ async ({ app, screen, browser }) => {
+ await browser.setViewport({ width: 1440, height: 900 });
+ await openPlayground(app, screen, browser);
+ const picker = screen.getByRole("button", "Select agent");
+ // The old frame-deferred focus missed on every other open, so try twice.
+ for (let attempt = 0; attempt < 2; attempt++) {
+ await screen.getByRole("textbox", "Message").tap();
+ await browser.keyboard.press("Control+Shift+A");
+ await expect(picker).toBeExpanded();
+ await browser.keyboard.press("Escape");
+ await expect(picker).not.toBeExpanded();
+ }
+ }
+);
+
+test(
+ "Escape closes the narrow-screen sidebar drawer",
+ { tags: ["bugbash", "5685"] },
+ async ({ app, screen, browser }) => {
+ await browser.setViewport({ width: 390, height: 844 });
+ await openPlayground(app, screen, browser);
+ const drawerBackdrop = browser.locator(".mobile-overlay");
+ const openMenu = screen.getByRole("button", "Open sidebar menu");
+ if (await openMenu.isVisible()) await openMenu.tap();
+ await expect(drawerBackdrop).toHaveCount(1);
+ await expect(
+ screen.getByRole("navigation", "Projects").getByText(WORKSPACE_TITLE)
+ ).toBeVisible();
+
+ await browser.keyboard.press("Escape");
+ await expect(drawerBackdrop).toHaveCount(0);
+ }
+);
+
+test(
+ "A click on the notifications bell does not change the setting",
+ { tags: ["bugbash", "5691"] },
+ async ({ app, screen, browser }) => {
+ await browser.setViewport({ width: 1440, height: 900 });
+ await openPlayground(app, screen, browser);
+ // The helper clicks the bell, reads the checkbox and closes the popover. The click used to
+ // turn the setting on as well.
+ await expectNotifyOnAllResponses(screen, browser, false);
+ await expectNotifyOnAllResponses(screen, browser, false);
+ }
+);
+
+test(
+ "One Escape closes the notifications popover without a second copy of the settings",
+ { tags: ["bugbash", "5691"] },
+ async ({ app, screen, browser }) => {
+ await browser.setViewport({ width: 1440, height: 900 });
+ await openPlayground(app, screen, browser);
+ const bell = screen.getByRole("button", "Notifications");
+ await armLayerProbe(browser, NOTIFICATIONS_POPOVER);
+ await bell.tap();
+ await expect(screen.getByRole("checkbox", /^Notify on all responses/)).toBeVisible();
+ await waitForLayerEscapeReady(browser);
+ await browser.keyboard.press("Escape");
+ // Radix returns focus to the bell, and its tooltip opens on that focus. The tooltip used to
+ // repeat the settings, checkbox included.
+ await expect(bell).toBeFocused();
+ const layer = browser.locator("[data-radix-popper-content-wrapper]");
+ await expect(layer).toBeVisible();
+ await expect(
+ browser.locator("[data-radix-popper-content-wrapper] [role=checkbox]")
+ ).toHaveCount(0);
+ }
+);
diff --git a/tests/bugbash/repros/helpers.ts b/tests/bugbash/repros/helpers.ts
index ae0d3e8e5fd..5968212fd63 100644
--- a/tests/bugbash/repros/helpers.ts
+++ b/tests/bugbash/repros/helpers.ts
@@ -53,6 +53,159 @@ export async function sendMessageForEdit(
return edit;
}
+/**
+ * Selector of the notifications popover's content: the element that Radix registers as a
+ * DismissableLayer.
+ */
+export const NOTIFICATIONS_POPOVER = "[data-radix-popper-content-wrapper] [role=dialog]";
+
+interface LayerProbe {
+ arm(selector: string): void;
+ state(): string;
+}
+
+/**
+ * Runs in the page before the app loads (`addInitScript`). It tells the tests when a Radix
+ * DismissableLayer (popover, menu, dialog) is ready to take Escape.
+ *
+ * Why the tests need it: a layer ignores Escape for a short time after it opens. Radix 1.1
+ * registers the layer in an effect (`context.layers.add(node)`), then sends
+ * `dismissableLayer.update`. Every layer re-renders on that event (`force({})`) and only then
+ * computes its true `index`. Its Escape listener reads the handler that the last committed render
+ * created, and a passive effect (`useCallbackRef`) installs that handler. Until that re-render has
+ * committed and its passive effects have run, `index` is -1 and Escape does nothing. CI's runner
+ * sends Escape fast enough to land in that window. A person cannot.
+ *
+ * The probe proves each step instead of guessing a delay:
+ * 1. `arm(selector)` is called before the open.
+ * 2. On `dismissableLayer.update`, the probe checks that `context.layers` now holds the element
+ * and records the layer's force state (its second `useState`).
+ * 3. React calls `onPostCommitFiberRoot` of the DevTools hook after a commit's passive effects
+ * have run. When the force state differs from the recorded one there, a render after the
+ * registration has committed and its handler is installed: the state is "ready".
+ * The probe reads React internals (fiber, hook list) and checks their shape. If Radix or React
+ * changes them, `state()` reports an error, and the test fails loudly instead of racing.
+ */
+function layerProbeInit(): void {
+ interface Hook {
+ memoizedState: unknown;
+ next: Hook | null;
+ }
+ interface Fiber {
+ type: { displayName?: string } | null;
+ return: Fiber | null;
+ alternate: Fiber | null;
+ memoizedState: Hook | null;
+ dependencies: { firstContext: { memoizedValue: unknown } | null } | null;
+ }
+ interface Armed {
+ selector: string;
+ // Force state at registration. `undefined` until the layer has registered.
+ registeredForceState?: unknown;
+ ready: boolean;
+ error?: string;
+ }
+ let armed: Armed | null = null;
+
+ const fiberOf = (el: Element): Fiber | null => {
+ const key = Object.keys(el).find((k) => k.startsWith("__reactFiber$"));
+ return key ? ((el as unknown as Record)[key] ?? null) : null;
+ };
+ const layerFiberOf = (el: Element): Fiber | null => {
+ let fiber = fiberOf(el);
+ while (fiber && fiber.type?.displayName !== "DismissableLayer") fiber = fiber.return;
+ return fiber;
+ };
+ const forceState = (fiber: Fiber | null): unknown => fiber?.memoizedState?.next?.memoizedState;
+
+ document.addEventListener("dismissableLayer.update", () => {
+ if (!armed || armed.ready || armed.error || armed.registeredForceState !== undefined) return;
+ const el = document.querySelector(armed.selector);
+ if (!el) return;
+ const found = layerFiberOf(el);
+ if (!found) {
+ armed.error = "probe: no DismissableLayer fiber above the element (Radix changed?)";
+ return;
+ }
+ // React keeps two fibers per component. Hook 1 is `node`: the fiber that has rendered with
+ // `node` set to this element is the newer one. Before that render the layer cannot have
+ // registered, so an update event then comes from another layer.
+ const fiber = [found, found.alternate].find((f) => f?.memoizedState?.memoizedState === el);
+ if (!fiber) return;
+ const context = fiber.dependencies?.firstContext?.memoizedValue as
+ | { layers?: unknown }
+ | undefined;
+ // Hook 2 is the force state, an object.
+ const force = forceState(fiber);
+ if (!(context?.layers instanceof Set) || typeof force !== "object" || force === null) {
+ armed.error = "probe: DismissableLayer context or force state changed shape (Radix changed?)";
+ return;
+ }
+ // This event can come from another layer; only this element's registration counts.
+ if (!context.layers.has(el)) return;
+ armed.registeredForceState = force;
+ });
+
+ (
+ window as unknown as { __REACT_DEVTOOLS_GLOBAL_HOOK__: unknown }
+ ).__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
+ supportsFiber: true,
+ renderers: new Map(),
+ inject: () => 1,
+ checkDCE: () => undefined,
+ onScheduleFiberRoot: () => undefined,
+ onCommitFiberRoot: () => undefined,
+ onCommitFiberUnmount: () => undefined,
+ onPostCommitFiberRoot: () => {
+ if (!armed || armed.ready || armed.registeredForceState === undefined) return;
+ const el = document.querySelector(armed.selector);
+ const fiber = el ? layerFiberOf(el) : null;
+ if (!fiber) return;
+ // React keeps two fibers per component, and only one is committed. The other still holds
+ // the older state, so a change on either one means a newer render committed.
+ const before = armed.registeredForceState;
+ if (forceState(fiber) !== before || forceState(fiber.alternate) !== before) {
+ armed.ready = true;
+ }
+ },
+ };
+
+ const probe: LayerProbe = {
+ arm: (selector) => {
+ armed = { selector, ready: false };
+ },
+ state: () => {
+ if (!armed) return "not armed: call armLayerProbe before the open";
+ if (armed.error) return armed.error;
+ if (armed.ready) return "ready";
+ return armed.registeredForceState === undefined ? "not registered" : "registered";
+ },
+ };
+ (window as unknown as { __layerProbe: LayerProbe }).__layerProbe = probe;
+}
+
+/** Call before the next open of the layer that `selector` matches. */
+export async function armLayerProbe(browser: Browser, selector: string): Promise {
+ await browser.evaluate((s: string) => {
+ (window as unknown as { __layerProbe: LayerProbe }).__layerProbe.arm(s);
+ return null;
+ }, selector);
+}
+
+/**
+ * Waits until the armed layer takes Escape (see layerProbeInit). The caller then presses Escape
+ * once, as a person does.
+ */
+export async function waitForLayerEscapeReady(browser: Browser): Promise {
+ await expect
+ .poll(() =>
+ browser.evaluate(() =>
+ (window as unknown as { __layerProbe: LayerProbe }).__layerProbe.state()
+ )
+ )
+ .toBe("ready");
+}
+
/** Opens the app with tutorials off and selects the seeded workspace. */
export async function openPlayground(
app: { open(path?: string): Promise },
@@ -60,6 +213,7 @@ export async function openPlayground(
browser: Browser
): Promise {
await disableTutorials(browser);
+ await browser.addInitScript(layerProbeInit);
await app.open();
// A fresh context starts with the project collapsed in the sidebar.
const expand = screen.getByRole("button", "Expand project demo-app");
@@ -72,7 +226,30 @@ export async function openPlayground(
}
if (await expand.isVisible()) await expand.tap();
await screen.getByText(WORKSPACE_TITLE).first().tap();
- await expect(screen.getByRole("button", "Notify on all responses")).toBeVisible({
+ await expect(screen.getByRole("button", "Notifications")).toBeVisible({
timeout: 15_000,
});
}
+
+/**
+ * Asserts the "Notify on all responses" setting: opens the bell's settings popover, reads the
+ * checkbox, and closes it with Escape. A click on the bell only opens the popover (#5691).
+ */
+export async function expectNotifyOnAllResponses(
+ screen: Screen,
+ browser: Browser,
+ checked: boolean
+): Promise {
+ await armLayerProbe(browser, NOTIFICATIONS_POPOVER);
+ await screen.getByRole("button", "Notifications").tap();
+ const setting = screen.getByRole("checkbox", /^Notify on all responses/);
+ await expect(setting).toBeVisible();
+ if (checked) {
+ await expect(setting).toBeChecked();
+ } else {
+ await expect(setting).not.toBeChecked();
+ }
+ await waitForLayerEscapeReady(browser);
+ await browser.keyboard.press("Escape");
+ await expect(setting).toBeHidden();
+}
diff --git a/tests/bugbash/repros/knownFailureDetailsShortcuts.e2e.ts b/tests/bugbash/repros/knownFailureDetailsShortcuts.e2e.ts
index b0d7ae5c165..b91b496da3d 100644
--- a/tests/bugbash/repros/knownFailureDetailsShortcuts.e2e.ts
+++ b/tests/bugbash/repros/knownFailureDetailsShortcuts.e2e.ts
@@ -2,7 +2,7 @@
// details button, which stops every key, so global shortcuts do nothing. Fails until #5671 is fixed.
import { test } from "@e2e-dev/web";
import { expect } from "e2e";
-import { openPlayground } from "./helpers";
+import { expectNotifyOnAllResponses, openPlayground } from "./helpers";
test(
"global shortcuts still work after the Workspace details popover closes",
@@ -12,9 +12,8 @@ test(
// desktop keyboard flow.
await browser.setViewport({ width: 1440, height: 900 });
await openPlayground(app, screen, browser);
- const bell = screen.getByRole("button", "Notify on all responses");
- await expect(bell).toHaveAttribute("aria-pressed", "false");
-
+ // A fresh workspace starts with "Notify on all responses" off. The check opens the bell's
+ // popover, so it runs only at the end: it would move focus away from the details button.
await screen.getByRole("textbox", "Message").tap();
await browser.keyboard.press("Control+Shift+D");
const details = screen.getByRole("dialog");
@@ -26,6 +25,6 @@ test(
// Any global shortcut shows it; this one toggles notifications (see notificationsShortcut).
await browser.keyboard.press("Control+Shift+Comma");
- await expect(bell).toHaveAttribute("aria-pressed", "true");
+ await expectNotifyOnAllResponses(screen, browser, true);
}
);
diff --git a/tests/bugbash/repros/notificationsShortcut.e2e.ts b/tests/bugbash/repros/notificationsShortcut.e2e.ts
index 246d2c7dedb..7735bdbea85 100644
--- a/tests/bugbash/repros/notificationsShortcut.e2e.ts
+++ b/tests/bugbash/repros/notificationsShortcut.e2e.ts
@@ -1,22 +1,22 @@
// N5 (#5670): Ctrl+Shift+N was bound to both "New scratch chat" and "Toggle notifications".
// Notifications moved to Ctrl/Cmd+Shift+Comma; this repro fails if the shortcut stops toggling it.
import { test } from "@e2e-dev/web";
-import { expect } from "e2e";
-import { openPlayground } from "./helpers";
+import { expectNotifyOnAllResponses, openPlayground } from "./helpers";
test(
"Ctrl+Shift+Comma toggles notifications in a workspace",
{ tags: ["bugbash", "N5"] },
async ({ app, screen, browser }) => {
await openPlayground(app, screen, browser);
- const bell = screen.getByRole("button", "Notify on all responses");
- await expect(bell).toHaveAttribute("aria-pressed", "false");
+ await expectNotifyOnAllResponses(screen, browser, false);
- await screen.getByRole("textbox", "Message").tap();
+ const composer = screen.getByRole("textbox", "Message");
+ await composer.tap();
await browser.keyboard.press("Control+Shift+Comma");
- await expect(bell).toHaveAttribute("aria-pressed", "true");
+ await expectNotifyOnAllResponses(screen, browser, true);
+ await composer.tap();
await browser.keyboard.press("Control+Shift+Comma");
- await expect(bell).toHaveAttribute("aria-pressed", "false");
+ await expectNotifyOnAllResponses(screen, browser, false);
}
);
diff --git a/tests/ui/compaction/compaction.test.ts b/tests/ui/compaction/compaction.test.ts
index ee82e66b844..248501971b2 100644
--- a/tests/ui/compaction/compaction.test.ts
+++ b/tests/ui/compaction/compaction.test.ts
@@ -447,12 +447,12 @@ describe("Auto-follow-up and compaction notification behavior (mock AI router)",
globalThis as { Notification: unknown }
).Notification;
- // Enable notifications via UI (click bell button in workspace header)
- const notifyButton = app.view.container.querySelector(
- '[data-testid="notify-on-response-button"]'
- );
- if (!notifyButton) throw new Error("Notify button not found");
- fireEvent.click(notifyButton);
+ // Enable notifications with the toggle shortcut. The bell only opens the settings popover,
+ // and Radix popover content does not render in happy-dom.
+ if (!app.view.container.querySelector('[data-testid="notify-on-response-button"]')) {
+ throw new Error("Notify button not found");
+ }
+ fireEvent.keyDown(window, { key: ",", code: "Comma", ctrlKey: true, shiftKey: true });
// Send seed message and wait for notification
await app.chat.send(seedMessage);