From 9a696ab4e13a03fece9e5efefcd1f270fb5bf4f7 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Thu, 2 Jul 2026 16:34:43 -0400 Subject: [PATCH 1/5] Fix OpenCode ACP permission approvals --- .../pending-interaction-validation.ts | 3 +- .../services/pending-interactions.test.ts | 60 +++++++++++++++++++ .../agent-runtime/src/acp/adapter.test.ts | 47 +++++++++++++-- packages/agent-runtime/src/acp/adapter.ts | 46 +++++++------- 4 files changed, 129 insertions(+), 27 deletions(-) diff --git a/apps/server/src/services/interactions/pending-interaction-validation.ts b/apps/server/src/services/interactions/pending-interaction-validation.ts index a785c07e231..442538891a6 100644 --- a/apps/server/src/services/interactions/pending-interaction-validation.ts +++ b/apps/server/src/services/interactions/pending-interaction-validation.ts @@ -311,7 +311,8 @@ export function validatePendingInteractionResolution( } else if ( resolution.decision === "allow_for_session" && (interaction.payload.subject.kind === "command" || - interaction.payload.subject.kind === "file_change") + interaction.payload.subject.kind === "file_change") && + interaction.payload.subject.sessionGrant !== null ) { throw new ApiError( 400, diff --git a/apps/server/test/services/pending-interactions.test.ts b/apps/server/test/services/pending-interactions.test.ts index d00427caad3..b9c306cee8e 100644 --- a/apps/server/test/services/pending-interactions.test.ts +++ b/apps/server/test/services/pending-interactions.test.ts @@ -870,6 +870,66 @@ describe("pending interaction lifecycle", () => { }); }); + it("allows command session approvals when no BB session grant was requested", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host-pending-interaction-command-opaque-session", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + const environment = seedEnvironment(harness.deps, { + hostId: host.id, + projectId: project.id, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + providerId: "acp-opencode", + }); + + const created = registerPendingInteraction( + harness.deps, + harness.deps.pendingInteractions, + { + threadId: thread.id, + turnId: "turn-command-opaque-session", + providerId: "acp-opencode", + providerThreadId: "provider-thread-command-opaque-session", + providerRequestId: "request-command-opaque-session", + payload: createCommandApprovalPayload({ + itemId: "item-command-opaque-session", + reason: "Needs provider-side session approval", + command: "cd backend && pnpm test", + sessionGrant: null, + availableDecisions: ["allow_once", "allow_for_session", "deny"], + }), + }, + ); + if (created.outcome === "rejected") { + throw new Error( + `Expected interaction registration to succeed: ${created.reason}`, + ); + } + + expect( + harness.deps.pendingInteractions.resolvePendingInteraction({ + threadId: thread.id, + interactionId: created.interaction.id, + resolution: createAllowForSessionResolution(null), + }), + ).toEqual( + expect.objectContaining({ + status: "resolving", + resolution: { + decision: "allow_for_session", + grantedPermissions: null, + }, + }), + ); + }); + }); + it("rejects narrowed command session approval grants", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps, { diff --git a/packages/agent-runtime/src/acp/adapter.test.ts b/packages/agent-runtime/src/acp/adapter.test.ts index 7cfb17e154b..0d3b3f1ce60 100644 --- a/packages/agent-runtime/src/acp/adapter.test.ts +++ b/packages/agent-runtime/src/acp/adapter.test.ts @@ -1043,7 +1043,7 @@ describe("acp adapter interactive requests", () => { }); }); - it("decodes non-execute permission requests as permission grants", () => { + it("decodes opaque permission requests as command approvals", () => { const adapter = createAdapter(); const decoded = adapter.decodeInteractiveRequest?.({ id: 8, @@ -1062,16 +1062,55 @@ describe("acp adapter interactive requests", () => { expect(decoded?.payload).toEqual({ kind: "approval", subject: { - kind: "permission_grant", + kind: "command", itemId: "call-2", - toolName: "Fetch docs", - permissions: { network: null, fileSystem: null }, + command: "Fetch docs", + cwd: null, + actions: [{ type: "unknown", command: "Fetch docs" }], + sessionGrant: null, }, reason: null, availableDecisions: ["allow_once", "deny"], }); }); + it("uses the full tool title for OpenCode shell permission requests", () => { + const adapter = createAdapter(); + const command = + 'cd backend && rg -ln "check_module_stub" scripts/ 2>/dev/null'; + const decoded = adapter.decodeInteractiveRequest?.({ + id: 10, + method: "acp/permission/request", + params: { + threadId: "thread-1", + providerThreadId: "sess-1", + turnId: null, + toolCall: { + toolCallId: "call-opencode", + title: command, + }, + options: [ + { optionId: "allow", name: "Allow", kind: "allow_once" }, + { optionId: "always", name: "Always", kind: "allow_always" }, + { optionId: "deny", name: "Deny", kind: "reject_once" }, + ], + }, + }); + expect(decoded?.payload).toEqual({ + kind: "approval", + subject: { + kind: "command", + itemId: "call-opencode", + command, + cwd: null, + actions: [{ type: "unknown", command }], + sessionGrant: null, + }, + reason: null, + availableDecisions: ["allow_once", "allow_for_session", "deny"], + }); + }); + it("encodes resolutions as bare decisions for the bridge", () => { const adapter = createAdapter(); const decoded = adapter.decodeInteractiveRequest?.({ diff --git a/packages/agent-runtime/src/acp/adapter.ts b/packages/agent-runtime/src/acp/adapter.ts index 27e2d1b9a7a..65db75a764d 100644 --- a/packages/agent-runtime/src/acp/adapter.ts +++ b/packages/agent-runtime/src/acp/adapter.ts @@ -452,6 +452,19 @@ function buildAcpApprovalDecisions( return decisions.length > 0 ? decisions : ["deny"]; } +function buildOpaqueAcpPermissionCommand(toolCall: { + command?: string; + title?: string; + kind?: string; +}): string { + return ( + toOptionalString(toolCall.command) ?? + toOptionalString(toolCall.title) ?? + toolCall.kind ?? + "ACP permission request" + ); +} + // --------------------------------------------------------------------------- // Adapter factory // --------------------------------------------------------------------------- @@ -1402,11 +1415,9 @@ export function createAcpProviderAdapter( return null; } const toolCall = parsed.data.toolCall; - const command = - toolCall?.kind === "execute" - ? (toOptionalString(toolCall.command) ?? - toOptionalString(toolCall.title)) - : undefined; + const command = toolCall + ? buildOpaqueAcpPermissionCommand(toolCall) + : "ACP permission request"; return { requestId: request.id, method: request.method, @@ -1415,23 +1426,14 @@ export function createAcpProviderAdapter( turnId: parsed.data.turnId, payload: { kind: "approval", - subject: - toolCall && command - ? { - kind: "command", - itemId: toolCall.toolCallId, - command, - cwd: null, - actions: [{ type: "unknown", command }], - sessionGrant: null, - } - : { - kind: "permission_grant", - itemId: toolCall?.toolCallId ?? "acp-permission", - toolName: - toOptionalString(toolCall?.title) ?? toolCall?.kind ?? null, - permissions: { network: null, fileSystem: null }, - }, + subject: { + kind: "command", + itemId: toolCall?.toolCallId ?? "acp-permission", + command, + cwd: null, + actions: [{ type: "unknown", command }], + sessionGrant: null, + }, reason: null, availableDecisions: buildAcpApprovalDecisions(parsed.data), }, From ebd507b963dc753aa6ebeadc9cf55e32de5579f0 Mon Sep 17 00:00:00 2001 From: David Zhang Date: Thu, 2 Jul 2026 23:26:29 -0400 Subject: [PATCH 2/5] Close side tabs before desktop window --- apps/app/src/views/RootComposeView.tsx | 26 ++++++++++++++ .../views/thread-detail/ThreadDetailView.tsx | 34 +++++++++++++++++++ .../desktop/src/desktop-window-command-ipc.ts | 3 ++ apps/desktop/src/main.ts | 25 +++++++++++++- apps/desktop/src/menu.ts | 11 +++++- apps/desktop/src/preload.ts | 25 +++++++++++++- apps/desktop/test/menu.test.ts | 34 +++++++++++++++++++ apps/desktop/test/preload-browser-api.test.ts | 34 ++++++++++++++++++- packages/desktop-contract/src/info.ts | 10 ++++++ 9 files changed, 198 insertions(+), 4 deletions(-) diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 54c2d9688a9..9e91e2c3a5e 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -126,6 +126,7 @@ import { import { resolveAbsoluteFilePath } from "@/lib/absolute-file-path"; import { getBrowserUrlHost } from "@/lib/browser-url"; import { + getBbDesktopInfo, getDesktopBrowserApi, isDesktopBrowserAvailable, } from "@/lib/bb-desktop"; @@ -174,6 +175,7 @@ import { useThreadFileTabs, type FileSearchSelection, } from "@/components/secondary-panel/useThreadFileTabs"; +import { isSecondaryFileTab } from "@/components/secondary-panel/secondaryPanelTabState"; import { resolveRightPanelFileVisual } from "@/components/secondary-panel/rightPanelFileVisuals"; import { ThreadTerminalPanel } from "@/components/thread/terminal/ThreadTerminalPanel"; import { @@ -2282,6 +2284,30 @@ export function RootComposeView(props: RootComposeViewProps) { openCompactDrawer(); setNewTabFocusRequest((current) => current + 1); }, [openCompactDrawer, openTab]); + const handleCloseWindowRequest = useCallback(() => { + if ( + !isPersistedSecondaryPanelOpen || + activeFixedSecondaryTab === null || + !isSecondaryFileTab(activeFixedSecondaryTab) + ) { + return false; + } + closeTab(activeFixedSecondaryTab.id); + return true; + }, [activeFixedSecondaryTab, closeTab, isPersistedSecondaryPanelOpen]); + useEffect(() => { + if (props.surface !== "page") { + return; + } + const desktopInfo = getBbDesktopInfo(); + if ( + desktopInfo === null || + desktopInfo.onCloseWindowRequest === undefined + ) { + return; + } + return desktopInfo.onCloseWindowRequest(handleCloseWindowRequest); + }, [handleCloseWindowRequest, props.surface]); const handleToggleSecondaryPanel = useCallback(() => { if (isSecondaryPanelOpen) { closeSecondaryPanel(); diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 91ba52ca215..882d7e3e482 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -146,6 +146,7 @@ import { useThreadFileTabs, type FileSearchSelection, } from "@/components/secondary-panel/useThreadFileTabs"; +import { isSecondaryFileTab } from "@/components/secondary-panel/secondaryPanelTabState"; import { useThreadOpenFileSignal } from "@/components/secondary-panel/useThreadOpenFileSignal"; import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link"; import type { SecondaryPanelFileTab } from "@/components/secondary-panel/ThreadSecondaryPanel"; @@ -1075,6 +1076,26 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { openCompactDrawer(); setNewTabFocusRequest((current) => current + 1); }, [openCompactDrawer, openNewTab]); + const handleCloseWindowRequest = useCallback(() => { + if ( + !isPersistedSecondaryPanelOpenForSurface || + activeFixedSecondaryTab === null || + !isSecondaryFileTab(activeFixedSecondaryTab) + ) { + return false; + } + if (activeFixedSecondaryTab.kind === "side-chat") { + closeSideChatTab(activeFixedSecondaryTab.id); + } else { + closeTab(activeFixedSecondaryTab.id); + } + return true; + }, [ + activeFixedSecondaryTab, + closeSideChatTab, + closeTab, + isPersistedSecondaryPanelOpenForSurface, + ]); useEffect(() => { if (props.surface !== "page") { return; @@ -1085,6 +1106,19 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { } return desktopInfo.onOpenNewTab(handleOpenNewTab); }, [handleOpenNewTab, props.surface]); + useEffect(() => { + if (props.surface !== "page") { + return; + } + const desktopInfo = getBbDesktopInfo(); + if ( + desktopInfo === null || + desktopInfo.onCloseWindowRequest === undefined + ) { + return; + } + return desktopInfo.onCloseWindowRequest(handleCloseWindowRequest); + }, [handleCloseWindowRequest, props.surface]); useEffect(() => { if (props.surface !== "page" || getBbDesktopInfo() === null) { return; diff --git a/apps/desktop/src/desktop-window-command-ipc.ts b/apps/desktop/src/desktop-window-command-ipc.ts index a3ae862bdf3..1b0dca7b6fe 100644 --- a/apps/desktop/src/desktop-window-command-ipc.ts +++ b/apps/desktop/src/desktop-window-command-ipc.ts @@ -2,3 +2,6 @@ // by the trusted React renderer. export const BB_DESKTOP_OPEN_NEW_TAB_CHANNEL = "bb-desktop:open-new-tab"; +export const BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL = + "bb-desktop:close-window-request"; +export const BB_DESKTOP_CLOSE_WINDOW_CHANNEL = "bb-desktop:close-window"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index bc81c8d6b39..88f2b5ad5b3 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -88,7 +88,11 @@ import { BB_DESKTOP_SET_THEME_CHANNEL, } from "./desktop-update-ipc.js"; import { BB_DESKTOP_BROWSER_OPEN_TAB_CHANNEL } from "./desktop-browser-ipc.js"; -import { BB_DESKTOP_OPEN_NEW_TAB_CHANNEL } from "./desktop-window-command-ipc.js"; +import { + BB_DESKTOP_CLOSE_WINDOW_CHANNEL, + BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, + BB_DESKTOP_OPEN_NEW_TAB_CHANNEL, +} from "./desktop-window-command-ipc.js"; import { createDesktopBrowserViewManager, type DesktopBrowserViewManager, @@ -464,6 +468,12 @@ function shouldEnableServerDaemonLogsMenu(): boolean { ); } +function isDesktopCloseCommandTarget( + value: unknown, +): value is Pick { + return value instanceof BrowserWindow; +} + function refreshApplicationMenu(): void { installApplicationMenu({ createNewWindow() { @@ -478,6 +488,15 @@ function refreshApplicationMenu(): void { null, ); }, + closeWindowOrSideTab(browserWindow) { + if (!isDesktopCloseCommandTarget(browserWindow)) { + return; + } + browserWindow.webContents.send( + BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, + null, + ); + }, openServerDaemonLogs() { void openServerDaemonLogs(); }, @@ -1104,6 +1123,10 @@ function registerDesktopUpdateIpc(): void { } nativeTheme.themeSource = parsed.data; }); + + ipcMain.on(BB_DESKTOP_CLOSE_WINDOW_CHANNEL, (event) => { + BrowserWindow.fromWebContents(event.sender)?.close(); + }); // The in-app browser tab hands off the current address to the system // browser. The URL originates from a possibly-hostile page, so only open // well-formed `http(s)` URLs — never `file:`, custom schemes, or junk. diff --git a/apps/desktop/src/menu.ts b/apps/desktop/src/menu.ts index a3fda004ab9..c7787111891 100644 --- a/apps/desktop/src/menu.ts +++ b/apps/desktop/src/menu.ts @@ -3,11 +3,14 @@ import { app, Menu, type MenuItemConstructorOptions } from "electron"; export const SERVER_DAEMON_LOGS_MENU_LABEL = "Server & Daemon Logs"; export const OPEN_NEW_TAB_ACCELERATOR = "CmdOrCtrl+T"; export const OPEN_NEW_TAB_MENU_LABEL = "New Tab"; +export const CLOSE_WINDOW_ACCELERATOR = "CmdOrCtrl+W"; +export const CLOSE_WINDOW_MENU_LABEL = "Close Window"; export const TOGGLE_DEVELOPER_TOOLS_MENU_LABEL = "Toggle Developer Tools"; export const TOGGLE_DEVELOPER_TOOLS_ACCELERATOR = "Command+Option+I"; export interface InstallApplicationMenuArgs { openNewTab(): void; + closeWindowOrSideTab(browserWindow: unknown): void; createNewWindow(): void; openServerDaemonLogs(): void; serverDaemonLogsMenuEnabled: boolean; @@ -64,7 +67,13 @@ export function buildApplicationMenuTemplate( label: "New Window", }, { type: "separator" }, - { role: "close" }, + { + accelerator: CLOSE_WINDOW_ACCELERATOR, + click(_menuItem, browserWindow) { + args.closeWindowOrSideTab(browserWindow ?? undefined); + }, + label: CLOSE_WINDOW_MENU_LABEL, + }, ], }, { diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d3eb915afe7..510639a18a7 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -14,6 +14,7 @@ import { type BbDesktopBrowserSnapshotHandler, type BbDesktopBrowserStateHandler, type BbDesktopBrowserUnsubscribe, + type BbDesktopCloseWindowRequestHandler, type BbDesktopInfo, type BbDesktopInfoChangeHandler, type BbDesktopInfoUnsubscribe, @@ -47,7 +48,11 @@ import { BB_DESKTOP_BROWSER_STATE_CHANNEL, BB_DESKTOP_BROWSER_STOP_CHANNEL, } from "./desktop-browser-ipc.js"; -import { BB_DESKTOP_OPEN_NEW_TAB_CHANNEL } from "./desktop-window-command-ipc.js"; +import { + BB_DESKTOP_CLOSE_WINDOW_CHANNEL, + BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, + BB_DESKTOP_OPEN_NEW_TAB_CHANNEL, +} from "./desktop-window-command-ipc.js"; import { BB_DESKTOP_POPOUT_OPEN_IN_MAIN_CHANNEL, BB_DESKTOP_POPOUT_GET_CURRENT_THREAD_CHANNEL, @@ -118,6 +123,8 @@ const browserOpenTabListeners = new Set(); const browserScopedOpenTabListeners = new Set(); const browserSnapshotListeners = new Set(); +const closeWindowRequestListeners = + new Set(); const openNewTabListeners = new Set(); const popoutThreadChangedListeners = new Set(); @@ -261,6 +268,12 @@ const bbDesktopApi: BbDesktopApi = { openNewTabListeners.delete(listener); }; }, + onCloseWindowRequest(listener): BbDesktopInfoUnsubscribe { + closeWindowRequestListeners.add(listener); + return () => { + closeWindowRequestListeners.delete(listener); + }; + }, openExternalUrl(url: string): void { ipcRenderer.send(BB_DESKTOP_OPEN_EXTERNAL_URL_CHANNEL, url); }, @@ -279,6 +292,16 @@ ipcRenderer.on(BB_DESKTOP_OPEN_NEW_TAB_CHANNEL, () => { } }); +ipcRenderer.on(BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, () => { + let handled = false; + for (const listener of closeWindowRequestListeners) { + handled = listener() || handled; + } + if (!handled) { + ipcRenderer.send(BB_DESKTOP_CLOSE_WINDOW_CHANNEL, null); + } +}); + ipcRenderer.on(BB_DESKTOP_BROWSER_STATE_CHANNEL, (_event, payload: unknown) => { const parsed = bbDesktopBrowserStateSchema.safeParse(payload); if (!parsed.success) { diff --git a/apps/desktop/test/menu.test.ts b/apps/desktop/test/menu.test.ts index df266ad4940..59d54698f55 100644 --- a/apps/desktop/test/menu.test.ts +++ b/apps/desktop/test/menu.test.ts @@ -1,6 +1,8 @@ import type { MenuItemConstructorOptions } from "electron"; import { describe, expect, it, vi } from "vitest"; import { + CLOSE_WINDOW_ACCELERATOR, + CLOSE_WINDOW_MENU_LABEL, OPEN_NEW_TAB_ACCELERATOR, OPEN_NEW_TAB_MENU_LABEL, SERVER_DAEMON_LOGS_MENU_LABEL, @@ -47,6 +49,7 @@ function findSubmenuItem( describe("application menu", () => { it("shows a developer tools toggle in the view menu", () => { const template = buildApplicationMenuTemplate({ + closeWindowOrSideTab() {}, createNewWindow() {}, openNewTab() {}, openServerDaemonLogs() {}, @@ -67,6 +70,7 @@ describe("application menu", () => { it("shows a new-tab command in the file menu", () => { const openNewTab = vi.fn(); const template = buildApplicationMenuTemplate({ + closeWindowOrSideTab() {}, createNewWindow() {}, openNewTab, openServerDaemonLogs() {}, @@ -89,8 +93,37 @@ describe("application menu", () => { expect(openNewTab).toHaveBeenCalledOnce(); }); + it("routes the close-window accelerator through the app before closing", () => { + const closeWindowOrSideTab = vi.fn(); + const browserWindow = {}; + const template = buildApplicationMenuTemplate({ + closeWindowOrSideTab, + createNewWindow() {}, + openNewTab() {}, + openServerDaemonLogs() {}, + serverDaemonLogsMenuEnabled: true, + }); + + const menuItem = findSubmenuItem({ + itemLabel: CLOSE_WINDOW_MENU_LABEL, + parentLabel: "File", + template, + }); + + expect(menuItem).not.toBeNull(); + expect(menuItem?.accelerator).toBe(CLOSE_WINDOW_ACCELERATOR); + expect(menuItem?.role).toBeUndefined(); + menuItem?.click?.( + undefined as never, + browserWindow as never, + undefined as never, + ); + expect(closeWindowOrSideTab).toHaveBeenCalledWith(browserWindow); + }); + it("shows an enabled server and daemon logs item for owned runtimes", () => { const template = buildApplicationMenuTemplate({ + closeWindowOrSideTab() {}, createNewWindow() {}, openNewTab() {}, openServerDaemonLogs() {}, @@ -109,6 +142,7 @@ describe("application menu", () => { it("shows a disabled server and daemon logs item for attached runtimes", () => { const template = buildApplicationMenuTemplate({ + closeWindowOrSideTab() {}, createNewWindow() {}, openNewTab() {}, openServerDaemonLogs() {}, diff --git a/apps/desktop/test/preload-browser-api.test.ts b/apps/desktop/test/preload-browser-api.test.ts index ff1f7d06e1c..a9e7a18736c 100644 --- a/apps/desktop/test/preload-browser-api.test.ts +++ b/apps/desktop/test/preload-browser-api.test.ts @@ -38,7 +38,11 @@ import { BB_DESKTOP_BROWSER_STATE_CHANNEL, BB_DESKTOP_BROWSER_STOP_CHANNEL, } from "../src/desktop-browser-ipc.js"; -import { BB_DESKTOP_OPEN_NEW_TAB_CHANNEL } from "../src/desktop-window-command-ipc.js"; +import { + BB_DESKTOP_CLOSE_WINDOW_CHANNEL, + BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, + BB_DESKTOP_OPEN_NEW_TAB_CHANNEL, +} from "../src/desktop-window-command-ipc.js"; const electronMock = vi.hoisted(() => { interface IpcRendererEvent {} @@ -300,6 +304,7 @@ describe("desktop preload browser API", () => { const openTabs: BbDesktopBrowserOpenTabRequest[] = []; const scopedOpenTabs: BbDesktopBrowserScopedOpenTabRequest[] = []; const snapshots: BbDesktopBrowserSnapshot[] = []; + let closeWindowRequestCount = 0; let openNewTabCount = 0; const popoutThreads: BbDesktopPopoutThreadChangedPayload[] = []; const state: BbDesktopBrowserState = { @@ -338,6 +343,10 @@ describe("desktop preload browser API", () => { api.onOpenNewTab?.(() => { openNewTabCount += 1; }); + api.onCloseWindowRequest?.(() => { + closeWindowRequestCount += 1; + return true; + }); api.popout.onThreadChanged((thread) => { popoutThreads.push(thread); }); @@ -378,6 +387,10 @@ describe("desktop preload browser API", () => { channel: BB_DESKTOP_OPEN_NEW_TAB_CHANNEL, payload: null, }); + emitIpcPayload({ + channel: BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, + payload: null, + }); emitIpcPayload({ channel: BB_DESKTOP_POPOUT_THREAD_CHANGED_CHANNEL, payload: { projectId: "proj_a", threadId: "thr_a", extra: true }, @@ -395,10 +408,29 @@ describe("desktop preload browser API", () => { expect(openTabs).toEqual([openTab]); expect(scopedOpenTabs).toEqual([scopedOpenTab]); expect(snapshots).toEqual([snapshot]); + expect(closeWindowRequestCount).toBe(1); expect(openNewTabCount).toBe(1); + expect(electronMock.sendCalls).not.toContainEqual({ + channel: BB_DESKTOP_CLOSE_WINDOW_CHANNEL, + payload: null, + }); expect(popoutThreads).toEqual([ { projectId: "proj_a", threadId: "thr_a" }, null, ]); }); + + it("asks main to close the window when no close-window listener handles the request", async () => { + await loadPreload(); + + emitIpcPayload({ + channel: BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, + payload: null, + }); + + expect(electronMock.sendCalls).toContainEqual({ + channel: BB_DESKTOP_CLOSE_WINDOW_CHANNEL, + payload: null, + }); + }); }); diff --git a/packages/desktop-contract/src/info.ts b/packages/desktop-contract/src/info.ts index 8226db7aae7..bd4c2e96241 100644 --- a/packages/desktop-contract/src/info.ts +++ b/packages/desktop-contract/src/info.ts @@ -21,6 +21,7 @@ export type BbDesktopTheme = z.infer; export type BbDesktopInfoChangeHandler = (info: BbDesktopInfo) => void; export type BbDesktopInfoUnsubscribe = () => void; export type BbDesktopOpenNewTabHandler = () => void; +export type BbDesktopCloseWindowRequestHandler = () => boolean; export interface BbDesktopApi extends BbDesktopInfo { /** @@ -47,6 +48,15 @@ export interface BbDesktopApi extends BbDesktopInfo { onOpenNewTab?( listener: BbDesktopOpenNewTabHandler, ): BbDesktopInfoUnsubscribe; + /** + * Subscribe to native desktop close-window requests. Return true when the + * renderer handled the request, for example by closing an active secondary + * panel tab; returning false preserves the native close-window fallback. + * Optional for desktop shells that predate this command. + */ + onCloseWindowRequest?( + listener: BbDesktopCloseWindowRequestHandler, + ): BbDesktopInfoUnsubscribe; /** * Open a URL in the user's default system browser, leaving the in-app * browser tab. The main process only honors `http(s)` URLs — the address From a9030d17789598b198708642c9dde5527463a4cc Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 2 Jul 2026 22:00:17 -0700 Subject: [PATCH 3/5] Fix review findings for renderer-routed Cmd+W - Close the log viewer (and any non-app-preload window) directly from main instead of sending a close request it can never answer. - Add a main-process fallback timer per close request so a crashed, hung, or still-loading renderer can't make Cmd+W inert; the preload now always answers on a response channel and main owns the close. - Close a focused detached DevTools window when the menu click has no BaseWindow target, matching the old role:"close" behavior. - Gate the renderer close handlers on visible panel state instead of the persisted flag so Cmd+W doesn't silently consume hidden drawer tabs on compact viewports. - Route terminal tabs through the terminal close path so the server-side session is force-closed instead of leaking (and the tab no longer reappears via session sync). - Type the menu contract as BaseWindow | undefined instead of unknown, dropping the bespoke type guard. Co-Authored-By: Claude Fable 5 --- apps/app/src/views/RootComposeView.tsx | 60 +++++++++------- .../views/thread-detail/ThreadDetailView.tsx | 72 ++++++++++--------- .../desktop/src/desktop-window-command-ipc.ts | 7 +- apps/desktop/src/main.ts | 68 ++++++++++++++---- apps/desktop/src/menu.ts | 11 ++- apps/desktop/src/preload.ts | 8 +-- apps/desktop/test/preload-browser-api.test.ts | 14 ++-- 7 files changed, 156 insertions(+), 84 deletions(-) diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 9e91e2c3a5e..e2cf558b641 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -2284,30 +2284,6 @@ export function RootComposeView(props: RootComposeViewProps) { openCompactDrawer(); setNewTabFocusRequest((current) => current + 1); }, [openCompactDrawer, openTab]); - const handleCloseWindowRequest = useCallback(() => { - if ( - !isPersistedSecondaryPanelOpen || - activeFixedSecondaryTab === null || - !isSecondaryFileTab(activeFixedSecondaryTab) - ) { - return false; - } - closeTab(activeFixedSecondaryTab.id); - return true; - }, [activeFixedSecondaryTab, closeTab, isPersistedSecondaryPanelOpen]); - useEffect(() => { - if (props.surface !== "page") { - return; - } - const desktopInfo = getBbDesktopInfo(); - if ( - desktopInfo === null || - desktopInfo.onCloseWindowRequest === undefined - ) { - return; - } - return desktopInfo.onCloseWindowRequest(handleCloseWindowRequest); - }, [handleCloseWindowRequest, props.surface]); const handleToggleSecondaryPanel = useCallback(() => { if (isSecondaryPanelOpen) { closeSecondaryPanel(); @@ -2409,6 +2385,42 @@ export function RootComposeView(props: RootComposeViewProps) { rootPanelTerminalTarget, ], ); + const handleCloseWindowRequest = useCallback(() => { + // Gate on the visible panel state, not the persisted flag: on compact + // viewports the drawer can be dismissed while tabs stay persisted, and + // Cmd+W must not consume hidden tabs. + if ( + !isSecondaryPanelOpen || + activeFixedSecondaryTab === null || + !isSecondaryFileTab(activeFixedSecondaryTab) + ) { + return false; + } + if (activeFixedSecondaryTab.kind === "terminal") { + handleCloseTerminalTab(activeFixedSecondaryTab.terminalId); + } else { + closeTab(activeFixedSecondaryTab.id); + } + return true; + }, [ + activeFixedSecondaryTab, + closeTab, + handleCloseTerminalTab, + isSecondaryPanelOpen, + ]); + useEffect(() => { + if (props.surface !== "page") { + return; + } + const desktopInfo = getBbDesktopInfo(); + if ( + desktopInfo === null || + desktopInfo.onCloseWindowRequest === undefined + ) { + return; + } + return desktopInfo.onCloseWindowRequest(handleCloseWindowRequest); + }, [handleCloseWindowRequest, props.surface]); const fileTabs = (() => { const filenameOf = (path: string) => path.split("/").at(-1) ?? path; const tabs = syncedOrderedSecondaryFileTabs.map( diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 882d7e3e482..988390f9cbb 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -1076,26 +1076,6 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { openCompactDrawer(); setNewTabFocusRequest((current) => current + 1); }, [openCompactDrawer, openNewTab]); - const handleCloseWindowRequest = useCallback(() => { - if ( - !isPersistedSecondaryPanelOpenForSurface || - activeFixedSecondaryTab === null || - !isSecondaryFileTab(activeFixedSecondaryTab) - ) { - return false; - } - if (activeFixedSecondaryTab.kind === "side-chat") { - closeSideChatTab(activeFixedSecondaryTab.id); - } else { - closeTab(activeFixedSecondaryTab.id); - } - return true; - }, [ - activeFixedSecondaryTab, - closeSideChatTab, - closeTab, - isPersistedSecondaryPanelOpenForSurface, - ]); useEffect(() => { if (props.surface !== "page") { return; @@ -1106,19 +1086,6 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { } return desktopInfo.onOpenNewTab(handleOpenNewTab); }, [handleOpenNewTab, props.surface]); - useEffect(() => { - if (props.surface !== "page") { - return; - } - const desktopInfo = getBbDesktopInfo(); - if ( - desktopInfo === null || - desktopInfo.onCloseWindowRequest === undefined - ) { - return; - } - return desktopInfo.onCloseWindowRequest(handleCloseWindowRequest); - }, [handleCloseWindowRequest, props.surface]); useEffect(() => { if (props.surface !== "page" || getBbDesktopInfo() === null) { return; @@ -1186,6 +1153,45 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { }, [closeTerminal, removeFixedTerminalTab, threadId], ); + const handleCloseWindowRequest = useCallback(() => { + // Gate on the visible panel state, not the persisted flag: on compact + // viewports the drawer can be dismissed while tabs stay persisted, and + // Cmd+W must not consume hidden tabs. + if ( + !isSecondaryPanelOpen || + activeFixedSecondaryTab === null || + !isSecondaryFileTab(activeFixedSecondaryTab) + ) { + return false; + } + if (activeFixedSecondaryTab.kind === "terminal") { + handleCloseTerminalTab(activeFixedSecondaryTab.terminalId); + } else if (activeFixedSecondaryTab.kind === "side-chat") { + closeSideChatTab(activeFixedSecondaryTab.id); + } else { + closeTab(activeFixedSecondaryTab.id); + } + return true; + }, [ + activeFixedSecondaryTab, + closeSideChatTab, + closeTab, + handleCloseTerminalTab, + isSecondaryPanelOpen, + ]); + useEffect(() => { + if (props.surface !== "page") { + return; + } + const desktopInfo = getBbDesktopInfo(); + if ( + desktopInfo === null || + desktopInfo.onCloseWindowRequest === undefined + ) { + return; + } + return desktopInfo.onCloseWindowRequest(handleCloseWindowRequest); + }, [handleCloseWindowRequest, props.surface]); const handleChangedFileClick = useCallback( (selection: WorkspaceChangedFileSelection) => { const openTarget = resolveWorkspaceChangedFileOpenTarget(selection); diff --git a/apps/desktop/src/desktop-window-command-ipc.ts b/apps/desktop/src/desktop-window-command-ipc.ts index 1b0dca7b6fe..899b488bad1 100644 --- a/apps/desktop/src/desktop-window-command-ipc.ts +++ b/apps/desktop/src/desktop-window-command-ipc.ts @@ -4,4 +4,9 @@ export const BB_DESKTOP_OPEN_NEW_TAB_CHANNEL = "bb-desktop:open-new-tab"; export const BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL = "bb-desktop:close-window-request"; -export const BB_DESKTOP_CLOSE_WINDOW_CHANNEL = "bb-desktop:close-window"; +export const BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL = + "bb-desktop:close-window-response"; +// How long main waits for the renderer to answer a close request before +// closing the window itself, so a crashed, hung, or still-loading renderer +// cannot make Cmd+W inert. +export const CLOSE_WINDOW_REQUEST_TIMEOUT_MS = 1000; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 88f2b5ad5b3..ba79487741a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -89,9 +89,10 @@ import { } from "./desktop-update-ipc.js"; import { BB_DESKTOP_BROWSER_OPEN_TAB_CHANNEL } from "./desktop-browser-ipc.js"; import { - BB_DESKTOP_CLOSE_WINDOW_CHANNEL, BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, + BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL, BB_DESKTOP_OPEN_NEW_TAB_CHANNEL, + CLOSE_WINDOW_REQUEST_TIMEOUT_MS, } from "./desktop-window-command-ipc.js"; import { createDesktopBrowserViewManager, @@ -468,10 +469,36 @@ function shouldEnableServerDaemonLogsMenu(): boolean { ); } -function isDesktopCloseCommandTarget( - value: unknown, -): value is Pick { - return value instanceof BrowserWindow; +// Close requests routed through the renderer, keyed by webContents id. If the +// renderer never answers (crashed, hung, or still loading), the timer closes +// the window from the main process like the native close role used to. +const pendingCloseWindowRequests = new Map(); + +function requestRendererWindowClose(browserWindow: BrowserWindow): void { + const webContentsId = browserWindow.webContents.id; + const pending = pendingCloseWindowRequests.get(webContentsId); + if (pending !== undefined) { + clearTimeout(pending); + } + pendingCloseWindowRequests.set( + webContentsId, + setTimeout(() => { + pendingCloseWindowRequests.delete(webContentsId); + if (!browserWindow.isDestroyed()) { + browserWindow.close(); + } + }, CLOSE_WINDOW_REQUEST_TIMEOUT_MS), + ); + browserWindow.webContents.send(BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, null); +} + +function closeFocusedDetachedDevTools(): void { + for (const browserWindow of BrowserWindow.getAllWindows()) { + if (browserWindow.webContents.isDevToolsFocused()) { + browserWindow.webContents.closeDevTools(); + return; + } + } } function refreshApplicationMenu(): void { @@ -489,13 +516,23 @@ function refreshApplicationMenu(): void { ); }, closeWindowOrSideTab(browserWindow) { - if (!isDesktopCloseCommandTarget(browserWindow)) { + if (browserWindow === undefined) { + // A focused detached DevTools window is the key window but never + // surfaces as a BaseWindow here; the native close role used to + // close it. + closeFocusedDetachedDevTools(); return; } - browserWindow.webContents.send( - BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, - null, - ); + if ( + !(browserWindow instanceof BrowserWindow) || + browserWindow === logViewerWindow + ) { + // Windows that don't run the app preload can't answer the renderer + // round trip, so close them directly. + browserWindow.close(); + return; + } + requestRendererWindowClose(browserWindow); }, openServerDaemonLogs() { void openServerDaemonLogs(); @@ -1124,8 +1161,15 @@ function registerDesktopUpdateIpc(): void { nativeTheme.themeSource = parsed.data; }); - ipcMain.on(BB_DESKTOP_CLOSE_WINDOW_CHANNEL, (event) => { - BrowserWindow.fromWebContents(event.sender)?.close(); + ipcMain.on(BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL, (event, payload) => { + const pending = pendingCloseWindowRequests.get(event.sender.id); + if (pending !== undefined) { + clearTimeout(pending); + pendingCloseWindowRequests.delete(event.sender.id); + } + if (payload === false) { + BrowserWindow.fromWebContents(event.sender)?.close(); + } }); // The in-app browser tab hands off the current address to the system // browser. The URL originates from a possibly-hostile page, so only open diff --git a/apps/desktop/src/menu.ts b/apps/desktop/src/menu.ts index c7787111891..b95df60f475 100644 --- a/apps/desktop/src/menu.ts +++ b/apps/desktop/src/menu.ts @@ -1,4 +1,9 @@ -import { app, Menu, type MenuItemConstructorOptions } from "electron"; +import { + app, + Menu, + type BaseWindow, + type MenuItemConstructorOptions, +} from "electron"; export const SERVER_DAEMON_LOGS_MENU_LABEL = "Server & Daemon Logs"; export const OPEN_NEW_TAB_ACCELERATOR = "CmdOrCtrl+T"; @@ -10,7 +15,7 @@ export const TOGGLE_DEVELOPER_TOOLS_ACCELERATOR = "Command+Option+I"; export interface InstallApplicationMenuArgs { openNewTab(): void; - closeWindowOrSideTab(browserWindow: unknown): void; + closeWindowOrSideTab(browserWindow: BaseWindow | undefined): void; createNewWindow(): void; openServerDaemonLogs(): void; serverDaemonLogsMenuEnabled: boolean; @@ -70,7 +75,7 @@ export function buildApplicationMenuTemplate( { accelerator: CLOSE_WINDOW_ACCELERATOR, click(_menuItem, browserWindow) { - args.closeWindowOrSideTab(browserWindow ?? undefined); + args.closeWindowOrSideTab(browserWindow); }, label: CLOSE_WINDOW_MENU_LABEL, }, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 510639a18a7..d32fe61f591 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -49,8 +49,8 @@ import { BB_DESKTOP_BROWSER_STOP_CHANNEL, } from "./desktop-browser-ipc.js"; import { - BB_DESKTOP_CLOSE_WINDOW_CHANNEL, BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, + BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL, BB_DESKTOP_OPEN_NEW_TAB_CHANNEL, } from "./desktop-window-command-ipc.js"; import { @@ -297,9 +297,9 @@ ipcRenderer.on(BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, () => { for (const listener of closeWindowRequestListeners) { handled = listener() || handled; } - if (!handled) { - ipcRenderer.send(BB_DESKTOP_CLOSE_WINDOW_CHANNEL, null); - } + // Always answer: main closes the window on `false` and falls back to + // closing it itself if no answer arrives in time. + ipcRenderer.send(BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL, handled); }); ipcRenderer.on(BB_DESKTOP_BROWSER_STATE_CHANNEL, (_event, payload: unknown) => { diff --git a/apps/desktop/test/preload-browser-api.test.ts b/apps/desktop/test/preload-browser-api.test.ts index a9e7a18736c..4c141901931 100644 --- a/apps/desktop/test/preload-browser-api.test.ts +++ b/apps/desktop/test/preload-browser-api.test.ts @@ -39,8 +39,8 @@ import { BB_DESKTOP_BROWSER_STOP_CHANNEL, } from "../src/desktop-browser-ipc.js"; import { - BB_DESKTOP_CLOSE_WINDOW_CHANNEL, BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL, + BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL, BB_DESKTOP_OPEN_NEW_TAB_CHANNEL, } from "../src/desktop-window-command-ipc.js"; @@ -410,9 +410,9 @@ describe("desktop preload browser API", () => { expect(snapshots).toEqual([snapshot]); expect(closeWindowRequestCount).toBe(1); expect(openNewTabCount).toBe(1); - expect(electronMock.sendCalls).not.toContainEqual({ - channel: BB_DESKTOP_CLOSE_WINDOW_CHANNEL, - payload: null, + expect(electronMock.sendCalls).toContainEqual({ + channel: BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL, + payload: true, }); expect(popoutThreads).toEqual([ { projectId: "proj_a", threadId: "thr_a" }, @@ -420,7 +420,7 @@ describe("desktop preload browser API", () => { ]); }); - it("asks main to close the window when no close-window listener handles the request", async () => { + it("answers unhandled close-window requests so main closes the window", async () => { await loadPreload(); emitIpcPayload({ @@ -429,8 +429,8 @@ describe("desktop preload browser API", () => { }); expect(electronMock.sendCalls).toContainEqual({ - channel: BB_DESKTOP_CLOSE_WINDOW_CHANNEL, - payload: null, + channel: BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL, + payload: false, }); }); }); From e36bf7dde4bb756027be7b155c279e24be7bf90e Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 2 Jul 2026 22:04:09 -0700 Subject: [PATCH 4/5] Hide the secondary panel before closing the window on Cmd+W Cmd+W now escalates: close the active closable tab, else hide the visible secondary panel (e.g. when thread-info or git-diff is active), and only close the window once the panel is hidden. Co-Authored-By: Claude Fable 5 --- apps/app/src/views/RootComposeView.tsx | 24 ++++++++++------ .../views/thread-detail/ThreadDetailView.tsx | 28 +++++++++++-------- 2 files changed, 32 insertions(+), 20 deletions(-) diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index e2cf558b641..8a0fbf638fb 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -2389,21 +2389,27 @@ export function RootComposeView(props: RootComposeViewProps) { // Gate on the visible panel state, not the persisted flag: on compact // viewports the drawer can be dismissed while tabs stay persisted, and // Cmd+W must not consume hidden tabs. - if ( - !isSecondaryPanelOpen || - activeFixedSecondaryTab === null || - !isSecondaryFileTab(activeFixedSecondaryTab) - ) { + if (!isSecondaryPanelOpen) { return false; } - if (activeFixedSecondaryTab.kind === "terminal") { - handleCloseTerminalTab(activeFixedSecondaryTab.terminalId); - } else { - closeTab(activeFixedSecondaryTab.id); + if ( + activeFixedSecondaryTab !== null && + isSecondaryFileTab(activeFixedSecondaryTab) + ) { + if (activeFixedSecondaryTab.kind === "terminal") { + handleCloseTerminalTab(activeFixedSecondaryTab.terminalId); + } else { + closeTab(activeFixedSecondaryTab.id); + } + return true; } + // No closable tab is active: hide the panel before letting the next + // Cmd+W close the window. + closeSecondaryPanel(); return true; }, [ activeFixedSecondaryTab, + closeSecondaryPanel, closeTab, handleCloseTerminalTab, isSecondaryPanelOpen, diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 988390f9cbb..e26f9a98e3f 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -1157,23 +1157,29 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { // Gate on the visible panel state, not the persisted flag: on compact // viewports the drawer can be dismissed while tabs stay persisted, and // Cmd+W must not consume hidden tabs. - if ( - !isSecondaryPanelOpen || - activeFixedSecondaryTab === null || - !isSecondaryFileTab(activeFixedSecondaryTab) - ) { + if (!isSecondaryPanelOpen) { return false; } - if (activeFixedSecondaryTab.kind === "terminal") { - handleCloseTerminalTab(activeFixedSecondaryTab.terminalId); - } else if (activeFixedSecondaryTab.kind === "side-chat") { - closeSideChatTab(activeFixedSecondaryTab.id); - } else { - closeTab(activeFixedSecondaryTab.id); + if ( + activeFixedSecondaryTab !== null && + isSecondaryFileTab(activeFixedSecondaryTab) + ) { + if (activeFixedSecondaryTab.kind === "terminal") { + handleCloseTerminalTab(activeFixedSecondaryTab.terminalId); + } else if (activeFixedSecondaryTab.kind === "side-chat") { + closeSideChatTab(activeFixedSecondaryTab.id); + } else { + closeTab(activeFixedSecondaryTab.id); + } + return true; } + // No closable tab is active (e.g. thread-info or git-diff): hide the + // panel before letting the next Cmd+W close the window. + closeSecondaryPanel(); return true; }, [ activeFixedSecondaryTab, + closeSecondaryPanel, closeSideChatTab, closeTab, handleCloseTerminalTab, From 3c8d06799535e28120234d052327fe1bc2a3cda8 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 2 Jul 2026 22:08:32 -0700 Subject: [PATCH 5/5] Hide the root compose panel when Cmd+W hits the lone new-tab placeholder The root compose view reopens a new-tab placeholder whenever the panel would otherwise be empty, so closing the last tab could never reach the hide-panel step: Cmd+W just churned the placeholder. Treat a lone new-tab as "nothing left to close" and hide the panel instead. Co-Authored-By: Claude Fable 5 --- apps/app/src/views/RootComposeView.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx index 8a0fbf638fb..44e049becac 100644 --- a/apps/app/src/views/RootComposeView.tsx +++ b/apps/app/src/views/RootComposeView.tsx @@ -2396,6 +2396,16 @@ export function RootComposeView(props: RootComposeViewProps) { activeFixedSecondaryTab !== null && isSecondaryFileTab(activeFixedSecondaryTab) ) { + // A lone new-tab placeholder respawns on close (an effect reopens one + // whenever the panel would otherwise be empty), so hide the panel + // instead of churning the placeholder. + if ( + activeFixedSecondaryTab.kind === "new-tab" && + fixedPanelTabsState.secondary.tabs.length === 1 + ) { + closeSecondaryPanel(); + return true; + } if (activeFixedSecondaryTab.kind === "terminal") { handleCloseTerminalTab(activeFixedSecondaryTab.terminalId); } else { @@ -2411,6 +2421,7 @@ export function RootComposeView(props: RootComposeViewProps) { activeFixedSecondaryTab, closeSecondaryPanel, closeTab, + fixedPanelTabsState.secondary.tabs, handleCloseTerminalTab, isSecondaryPanelOpen, ]);