Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/config/notifications.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Ctrl+Shift+,</kbd> (<kbd>⌘+Shift+,</kbd> 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 <kbd>Ctrl+Shift+,</kbd> (<kbd>⌘+Shift+,</kbd> 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.

Expand Down
17 changes: 16 additions & 1 deletion src/browser/components/AgentModePicker/AgentModePicker.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand Down
20 changes: 14 additions & 6 deletions src/browser/components/AgentModePicker/AgentModePicker.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -179,16 +180,23 @@ export const AgentModePicker: React.FC<AgentModePickerProps> = (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();
});
Expand Down
29 changes: 25 additions & 4 deletions src/browser/components/LeftSidebar/LeftSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,6 +17,18 @@ interface LeftSidebarProps {
workspaceRecency: Record<string, number>;
}

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,
Expand All @@ -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
Expand Down
51 changes: 14 additions & 37 deletions src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -684,18 +684,19 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
<Tooltip {...(notificationPopoverOpen ? { open: false } : {})}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
{/* 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. */}
<button
type="button"
onClick={() => setNotifyOnResponse((prev) => !prev)}
className={cn(
"flex h-6 w-6 shrink-0 items-center justify-center rounded",
notifyOnResponse
? "text-foreground"
: "text-muted hover:bg-sidebar-hover hover:text-foreground"
)}
data-testid="notify-on-response-button"
aria-label="Notify on all responses"
aria-pressed={notifyOnResponse}
aria-label="Notifications"
>
{notifyOnResponse ? (
<Bell className="h-3.5 w-3.5" />
Expand All @@ -705,41 +706,17 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
</button>
</PopoverTrigger>
</TooltipTrigger>
{/* 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). */}
<TooltipContent side="bottom" align="end">
<div className="flex flex-col gap-2">
<label className="flex cursor-pointer items-center gap-2">
<Checkbox
checked={notifyOnResponse}
onCheckedChange={(checked) => setNotifyOnResponse(checked === true)}
/>
<span className="text-foreground">
Notify on all responses{" "}
<span className="text-muted-foreground mobile-hide-shortcut-hints">
({formatKeybind(KEYBINDS.TOGGLE_NOTIFICATIONS)})
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2">
<Checkbox
checked={autoEnableNotifications}
onCheckedChange={(checked) => setAutoEnableNotifications(checked === true)}
/>
<span className="text-muted-foreground">
Auto-enable for new workspaces in this project
</span>
</label>
<p className="text-muted-foreground border-separator-light border-t pt-2">
Agents can also notify on specific events.{" "}
<a
href="https://mux.coder.com/config/notifications"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Learn more
</a>
</p>
</div>
Notifications
<br />
Notify on all responses: {notifyOnResponse ? "on" : "off"}
<span className="mobile-hide-shortcut-hints">
{" "}
({formatKeybind(KEYBINDS.TOGGLE_NOTIFICATIONS)})
</span>
</TooltipContent>
</Tooltip>

Expand Down
6 changes: 6 additions & 0 deletions src/browser/hooks/useAIViewKeybinds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 &&
Expand Down
151 changes: 151 additions & 0 deletions src/browser/hooks/useEscapeToDismiss.test.tsx
Original file line number Diff line number Diff line change
@@ -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<APIClient> = { workspace: { interruptStream } };
const onDismiss = mock(() => undefined);
const chatInputAPI: RefObject<ChatInputAPI | null> = { current: null };
const wrapper = ({ children }: { children: ReactNode }) => (
<APIProvider client={createTestApiClient(client)}>{children}</APIProvider>
);
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();
});
});
Loading
Loading