diff --git a/docs/shell-design.md b/docs/shell-design.md index d11e323e7e..c9baff127b 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -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, diff --git a/src/app/ShortcutSettings.test.tsx b/src/app/ShortcutSettings.test.tsx index 7dc012d286..3f12cb3c48 100644 --- a/src/app/ShortcutSettings.test.tsx +++ b/src/app/ShortcutSettings.test.tsx @@ -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(); diff --git a/src/app/ShortcutSettings.tsx b/src/app/ShortcutSettings.tsx index 19875fcf61..182a270acd 100644 --- a/src/app/ShortcutSettings.tsx +++ b/src/app/ShortcutSettings.tsx @@ -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 }, diff --git a/src/app/shortcuts.test.tsx b/src/app/shortcuts.test.tsx index d565bcd59e..6ac7a95172 100644 --- a/src/app/shortcuts.test.tsx +++ b/src/app/shortcuts.test.tsx @@ -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([ @@ -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(); } @@ -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( @@ -231,13 +231,29 @@ it("leaves hidden tabs intact and keeps the window open", async () => { , ); 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"], diff --git a/src/app/shortcuts.ts b/src/app/shortcuts.ts index 7ef145dd0c..19763c060c 100644 --- a/src/app/shortcuts.ts +++ b/src/app/shortcuts.ts @@ -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"; @@ -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(); }, }), ...( diff --git a/src/features/conversation/ComposerCompletions.tsx b/src/features/conversation/ComposerCompletions.tsx index 6ab3164d09..64bbc6d30b 100644 --- a/src/features/conversation/ComposerCompletions.tsx +++ b/src/features/conversation/ComposerCompletions.tsx @@ -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(); diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index d755a81291..3aab2e05df 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -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"); @@ -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(""); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 2d33cd503c..168672fc8a 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -3256,6 +3256,7 @@ function Composer({ if ( editing.target && event.key === "Escape" && + !event.shiftKey && !event.defaultPrevented && !event.nativeEvent.isComposing && event.nativeEvent.keyCode !== 229 && diff --git a/src/features/messages/TypingIndicator.test.tsx b/src/features/messages/TypingIndicator.test.tsx index b6711b975c..47562ac118 100644 --- a/src/features/messages/TypingIndicator.test.tsx +++ b/src/features/messages/TypingIndicator.test.tsx @@ -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( +
+ + +
, + ); + 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(); diff --git a/src/features/messages/TypingIndicator.tsx b/src/features/messages/TypingIndicator.tsx index dd4ecd6ccb..bc443e3857 100644 --- a/src/features/messages/TypingIndicator.tsx +++ b/src/features/messages/TypingIndicator.tsx @@ -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 || + event.isComposing || + event.keyCode === 229 + ) return; event.preventDefault(); event.stopPropagation();