Skip to content
Closed
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/shell-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ closable tab in the focused pane. If focus is in the permanent main conversation
it closes the selected tab in the visible side pane instead. A selected closable
main-pane tab takes priority over that fallback. Hidden tabs remain untouched;
With nothing to close, Cmd+W on macOS closes the window while keeping Buzz and
running agents alive; Ctrl+W on Windows/Linux does nothing.
running agents alive; Ctrl+W on Windows/Linux closes the window, as Alt+F4 does.
The macOS native Close menu uses the same action, including when an embedded
browser owns focus (embedded browsing is currently macOS-only). On macOS,
Cmd+Shift+W and the native **Close All Tabs** menu item close all tabs in the current channel,
Expand Down
2 changes: 1 addition & 1 deletion src/app/ShortcutSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1056,7 +1056,7 @@ it("shows desktop Close as reserved and read-only while other actions remain edi
plugins={catalog([])}
/>,
);
const closeRow = within(row("Close tab"));
const closeRow = within(row("Close tab or window"));
expect(closeRow.getByText("Reserved by Buzz")).toBeVisible();
expect(closeRow.getByText("Control W")).toBeInTheDocument();
expect(closeRow.queryByRole("button")).not.toBeInTheDocument();
Expand Down
2 changes: 1 addition & 1 deletion src/app/ShortcutSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ const CLIPBOARD_CHORDS: readonly KeyBinding[] = [
{ key: "x", mod: true },
{ key: "a", mod: true },
];
/** Switch tab, close tab and quit: the desktop shell owns these, so they stay fixed. */
/** Switch tab, close and quit: the desktop shell owns these, so they stay fixed. */
const DESKTOP_CHORDS: readonly KeyBinding[] = [
{ key: "q", mod: true },
{ key: "w", mod: true },
Expand Down
24 changes: 20 additions & 4 deletions src/app/shortcuts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ function press(
}

it.each(["Win32"])(
"%s closes tabs from the composer, never the window; ignores held repeats",
"%s closes tabs from the composer, not the window until the next press; ignores held repeats",
async (platform) => {
const h = harness(platform);
vi.spyOn(HTMLElement.prototype, "getClientRects").mockReturnValue([
Expand Down Expand Up @@ -146,7 +146,7 @@ it.each(["Win32"])(
press(document.body, { repeat: true });
expect(close).not.toHaveBeenCalled();
press();
expect(close).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledOnce();
} finally {
await h.dispose();
}
Expand Down Expand Up @@ -222,7 +222,7 @@ it("does nothing behind a modal, with or without a tab consumer", async () => {
}
});

it("leaves hidden tabs intact and keeps the window open", async () => {
it("leaves hidden tabs intact and falls back to window close", async () => {
const h = harness();
try {
render(
Expand All @@ -231,13 +231,29 @@ it("leaves hidden tabs intact and keeps the window open", async () => {
</div>,
);
press();
expect(close).not.toHaveBeenCalled();
expect(close).toHaveBeenCalledOnce();
expect(screen.getAllByRole("tab", { hidden: true })).toHaveLength(2);
} finally {
await h.dispose();
}
});

it("reports a failed window close without retrying", async () => {
const h = harness();
const failure = new Error("Window unavailable");
close.mockRejectedValueOnce(failure);
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
press();
await vi.waitFor(() =>
expect(error).toHaveBeenCalledWith("Shortcut failed: close-tab", failure),
);
expect(close).toHaveBeenCalledOnce();
} finally {
await h.dispose();
}
});

it.each([
[false, "Win32"],
[true, "MacIntel"],
Expand Down
13 changes: 8 additions & 5 deletions src/app/shortcuts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isTauri } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { isApplePlatform } from "../features/shortcuts/format";
import type { ShortcutsService } from "../features/shortcuts/service";
import type { Appearance } from "../shared/theme/service";
Expand Down Expand Up @@ -159,18 +160,20 @@ export function registerAppShortcuts(
remove.push(
shortcuts.registerHost({
id: "close-tab",
title: "Close tab",
title: "Close tab or window",
binding: { key: "w", mod: true },
order: HOST_SHORTCUT_ORDER.close,
allowInEditable: true,
// xterm hands keys to this dispatcher before writing PTY bytes. Keep
// Ctrl+W as shell word deletion in both the terminal tab and drawer.
when: () => !document.activeElement?.closest(".xterm"),
// Only ever closes a tab: with nothing to close, the window stays.
run: () => {
window.dispatchEvent(
new Event("buzz:close-active-tab", { cancelable: true }),
);
if (
window.dispatchEvent(
new Event("buzz:close-active-tab", { cancelable: true }),
)
)
return getCurrentWindow().close();
},
}),
...(
Expand Down
2 changes: 1 addition & 1 deletion src/features/conversation/ComposerCompletions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ function OwnedCompletion({
) => {
if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229)
return false;
if (event.key === "Escape") {
if (event.key === "Escape" && !event.shiftKey) {
event.preventDefault();
event.stopPropagation();
current.current.editor.invalidate();
Expand Down
6 changes: 6 additions & 0 deletions src/features/messages/MessageComposer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1564,6 +1564,10 @@ it("still closes the popup when the query closes, on Escape, and on a Space that
expect(h.rows()).toBeNull();
h.fill("@hone");
expect(h.rows()).toEqual(["Honey hone"]);
expect(fireEvent.keyDown(input, { key: "Escape", shiftKey: true })).toBe(
true,
);
expect(h.rows()).toEqual(["Honey hone"]);
fireEvent.keyDown(input, { key: "Escape" });
expect(h.rows()).toBeNull();
h.fill("@honey");
Expand Down Expand Up @@ -2753,6 +2757,8 @@ it("defers a failed upload recovery until an empty active message edit ends", as
expect(editor).toHaveAccessibleName("Edit message");
await userEvent.clear(editor);
expect(editor).toHaveValue("");
fireEvent.keyDown(editor, { key: "Escape", shiftKey: true });
expect(editor).toHaveAccessibleName("Edit message");
await act(async () => h.uploadCalls[0]?.result.reject(new Error("offline")));
expect(editor).toHaveAccessibleName("Edit message");
expect(editor).toHaveValue("");
Expand Down
1 change: 1 addition & 0 deletions src/features/messages/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3256,6 +3256,7 @@ function Composer({
if (
editing.target &&
event.key === "Escape" &&
!event.shiftKey &&
!event.defaultPrevented &&
!event.nativeEvent.isComposing &&
event.nativeEvent.keyCode !== 229 &&
Expand Down
22 changes: 22 additions & 0 deletions src/features/messages/TypingIndicator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,28 @@ it.each(["hover", "keyboard"])(
},
);

it("lets Shift+Escape reach the owning panel while the popup is open", async () => {
const f = fixture(),
user = userEvent.setup(),
panelKey = vi.fn();
f.update([entry()]);
render(
<section aria-label="Thread" onKeyDown={panelKey}>
<input aria-label="Composer" />
<TypingIndicator session={f.session} channelId="channel" />
</section>,
);
const input = screen.getByRole("textbox");
input.focus();
await user.hover(
screen.getByRole("button", { name: "Activity: Agent working" }),
);
await screen.findByRole("dialog");
panelKey.mockClear();
fireEvent.keyDown(input, { key: "Escape", shiftKey: true });
expect(panelKey).toHaveBeenCalledOnce();
});

it("hands navigation a retained trigger instead of the disposable popup action", async () => {
const f = fixture(),
user = userEvent.setup();
Expand Down
7 changes: 6 additions & 1 deletion src/features/messages/TypingIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,12 @@ function AgentChooser({
// Hover leaves focus in the composer. Consume Escape before its panel's
// React handler; Base UI's document-bubble dismissal would arrive too late.
const dismiss = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.isComposing || event.keyCode === 229)
if (
event.key !== "Escape" ||
event.shiftKey ||

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow Shift+Escape through Base UI's popover dismissal

When the activity popup is open and the user presses Shift+Escape, returning here only bypasses this capture listener; Base UI still handles Escape during document bubbling and stops propagation before ShortcutsService's window-level listener, so “Mark all as read” never runs. The added test sees the intermediate React section handler, which runs before Base UI, but does not exercise the actual shortcut boundary; handle the escape-key change event by allowing propagation for Shift+Escape.

AGENTS.md reference: AGENTS.md:L166-L169

Useful? React with 👍 / 👎.

event.isComposing ||
event.keyCode === 229
)
return;
event.preventDefault();
event.stopPropagation();
Expand Down
Loading