#4590 · Plugin close commands are absent from desktop close arbitration
Verdict: PARTIALLY REPRODUCED · Root-cause confidence: high for the SDK/desktop IPC disconnect; the full macOS desktop gesture was not exercised.
1. TL;DR
The desktop menu asks the renderer whether a closeable surface handled its close request. Built-in surfaces can subscribe to that handshake, but the plugin app SDK only offers separate plugin command registrations. A real SDK command registration plus the real preload close-request handler returns handled: false, leaving the test pane open and its plugin callback untouched. A control using the desktop listener returns true. Both results repeat in a second clean trusted checkout; closing an actual macOS window remains unverified.
2. Claims vs findings
| Reported behavior | Status | Evidence |
|---|---|---|
| The plugin cannot join the existing desktop close handshake through its SDK. | Verified at the contract/boundary level | SDK exposes only command registration; preload dispatches only desktop close listeners. Both reproduction runs show no plugin callback. |
| An unhandled renderer response closes the desktop window. | Verified in trusted source; not executed in a real window | The main-process response handler explicitly closes on false. |
| A DOM keydown handler receives no menu accelerator event. | Unverified on macOS | The test delivers the menu IPC request directly. It does not simulate or prove Electron's native accelerator consumption. |
| The pane closes before its parent tab and the window. | Missing public capability; desired behavior not implemented | No SDK close-handler contract or focus-priority arbitration is exposed. |
| Every plugin with an inner pane necessarily closes the whole window. | Not established | Other active built-in listeners may claim a request. The test intentionally isolates the absent plugin participant. |
3. Environment
Date: 2026-10-01 Base: 1e2cee69ade40d437e169f712bd6599dfa1d58db OS: Linux x64 Node: v22.19.0 pnpm: 9.15.0 Vitest: 4.1.1 First build: 62 successful tasks Second build: 62 successful tasks (60 cached) Second reproduction: forced live test execution; no test result cache Existing focused desktop tests: 8 passed No development server, ports, runtime data, provider sessions, or browser were used.
The two checkouts are separate detached worktrees of the trusted public main commit. Production files remain unchanged. The only local source addition is the investigator-authored test. The SDK imports use repository-relative paths because the desktop package does not declare an SDK dependency; no dependency was added.
4. Minimal reproduction
- Clone and check out the recorded public main commit; perform the frozen install and Turbo build.
- Copy the inline reproduction test into the path below. It registers a real plugin command with the SDK collector and feeds the real preload handler its close-request IPC event. Only Electron's transport boundary is faked.
- Run the focused Turbo test. The built-in registration control passes. The plugin expectation fails with an unhandled response.
- Repeat the same procedure in a second fresh clone/worktree, forcing test execution.
git clone https://github.com/get-bb/bb.git bb-4590-first cd bb-4590-first git checkout --detach 1e2cee69ade40d437e169f712bd6599dfa1d58db pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build # Copy the linked reproduction artifact to apps/desktop/test/issue-4590-close-request.test.ts. pnpm exec turbo run test --filter=@bb/desktop --force -- test/issue-4590-close-request.test.ts # Expected reproduction exit code: 1 (one assertion failure, one passing control). pnpm exec turbo run test --filter=@bb/desktop -- test/preload-browser-api.test.ts test/desktop-menu-shortcuts.test.ts
Expected for the proposed behavior: handled: true, closed inner pane, and one plugin callback. Actual, verbatim in both runs:
{"handled":false,"paneOpen":true,"pluginCalls":0}
AssertionError: expected false to be true
Tests 1 failed | 1 passed (2)
This is a boundary reproduction, not an end-to-end renderer/plugin integration test: the full plugin view, command provider, focused composer, native menu dispatch, and main-process BrowserWindow are not mounted. Registering a palette shortcut is not itself a promised close-handshake API. The failing assertion deliberately captures the requested missing capability rather than claiming an existing SDK guarantee.
Complete reproduction test
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { BbDesktopApi } from "@bb/desktop-contract";
import { definePluginApp } from "../../../packages/plugin-sdk/src/app.js";
import { collectPluginAppRegistrations } from "../../../packages/plugin-sdk/src/internal/plugin-app-collector.js";
import {
BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL,
BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL,
} from "../src/desktop-window-command-ipc.js";
const electronBoundary = vi.hoisted(() => {
const listeners = new Map<string, () => void>();
const responses: { channel: string; handled: unknown }[] = [];
let desktopApi: BbDesktopApi | null = null;
return {
listeners,
responses,
get desktopApi() {
if (desktopApi === null) throw new Error("Desktop API not exposed");
return desktopApi;
},
reset() {
listeners.clear();
responses.length = 0;
desktopApi = null;
},
contextBridge: {
exposeInMainWorld(name: string, payload: unknown) {
if (name === "bbDesktop") desktopApi = payload as BbDesktopApi;
},
},
ipcRenderer: {
invoke: async () => null,
on(channel: string, listener: () => void) {
listeners.set(channel, listener);
},
send(channel: string, handled: unknown) {
responses.push({ channel, handled });
},
},
webFrame: { getZoomFactor: () => 1 },
};
});
vi.mock("electron", () => ({
contextBridge: electronBoundary.contextBridge,
ipcRenderer: electronBoundary.ipcRenderer,
webFrame: electronBoundary.webFrame,
}));
beforeEach(async () => {
electronBoundary.reset();
vi.resetModules();
vi.stubEnv("BB_DESKTOP_VERSION", "0.44.0-test");
await import("../src/preload.js");
});
afterEach(() => {
vi.unstubAllEnvs();
});
function deliverMenuCloseRequest() {
const listener = electronBoundary.listeners.get(
BB_DESKTOP_CLOSE_WINDOW_REQUEST_CHANNEL,
);
if (listener === undefined) throw new Error("Close IPC listener missing");
listener();
return electronBoundary.responses.findLast(
(response) => response.channel === BB_DESKTOP_CLOSE_WINDOW_RESPONSE_CHANNEL,
);
}
describe("issue 4590 close-request boundary", () => {
it("allows a registered built-in desktop listener to claim the request", () => {
const closePane = vi.fn(() => true);
const unsubscribe =
electronBoundary.desktopApi.onCloseWindowRequest(closePane);
expect(deliverMenuCloseRequest()?.handled).toBe(true);
expect(closePane).toHaveBeenCalledOnce();
unsubscribe();
});
it("expects the plugin close command to claim the desktop menu request", () => {
let paneOpen = true;
const closePane = vi.fn(() => {
paneOpen = false;
});
const collected = collectPluginAppRegistrations(
definePluginApp((app) => {
app.commands.register({
id: "close-inner-pane",
title: "Close inner pane",
defaultShortcut: { key: "w", mod: true },
run: closePane,
});
}),
);
expect(collected.commandPaletteActions).toHaveLength(1);
const response = deliverMenuCloseRequest();
console.log(
JSON.stringify({
handled: response?.handled,
paneOpen,
pluginCalls: closePane.mock.calls.length,
}),
);
expect(response?.handled).toBe(true);
expect(paneOpen).toBe(false);
});
});
5. Root cause
- Desktop menu defaults reserve the close chord; the menu close callback invokes the renderer handshake.
- The main-process request sends the dedicated close-request channel and starts a fallback timer.
- The preload close subscription and its IPC dispatch operate on the desktop listener set, not plugin commands.
- Built-in compose panel handlers explicitly register both the app command and desktop close listener.
- Plugin command contracts offer a plugin-scoped command callback, not a built-in command override or close-request subscription. The real SDK collector collects those callbacks into commandPaletteActions only.
- The response handler closes the window on
false. Thus, when no built-in listener claims the request, the absence of a plugin participant permits the outer window fallback.
Additional limitation: the keybinding resolver disables conflicting default plugin chords, so a same-chord palette command is not equivalent to focused close arbitration. Directly accessing the desktop global is not an SDK-supported integration; this report does not claim arbitrary JavaScript access is impossible.
6. Proposed fix and safe-fix gate
Design an experimental, focus-aware SDK close participant with lifecycle cleanup and boolean handled/fallthrough semantics. Route both keyboard commands and desktop menu requests through one ordered arbitration path, preserving nested-surface precedence and the existing outer window fallback. Test focused/unfocused participants, cleanup, unhandled fallthrough, and parent tab behavior in the complete renderer, followed by a macOS Electron gesture check.
No fix PR: the observed gap requires a new public SDK integration contract and a focus-ordering product decision across SDK and app-shell behavior. That fails the one-subsystem/no-architecture-decision simple-fix gate. The full native workflow is only partially reproduced, so no production patch or branch was started.
7. Linked pull requests
No open issue-linked PR was found in GitHub cross-reference/connected events or an open PR search for the trusted issue number. No PR code was checked out or executed.
8. Related issues
No external links from the issue were fetched. Embedded-browser focus behavior and actual native accelerator interception require their own live desktop verification; neither was treated as evidence for this report.
9. Verification
The same investigator repeated the test in a second newly created, clean detached checkout at 1e2cee69ade40d437e169f712bd6599dfa1d58db. A separate frozen install and Turbo build succeeded, then the test was copied in and executed with --force. The expected assertion failed again with the same JSON state, while the built-in listener control passed. This is a second clean run, not an independent investigator review. No report correction was needed after this repeat.
Commands: pnpm install --frozen-lockfile --prefer-offline; pnpm exec turbo run build; pnpm exec turbo run test --filter=@bb/desktop --force -- test/issue-4590-close-request.test.ts. Eight existing focused preload/menu tests passed separately. Both full builds completed 62 tasks successfully. A later fetch confirmed main had not advanced beyond the recorded commit.
10. Appendix
Public repository policy keeps raw evidence files out of git. The complete test and sanitized evidence excerpts are embedded here; the original artifacts stay in the local report work directory.
repro-first.log
@bb/templates:generate:templates:
@bb/templates:generate:templates: > @bb/templates@0.0.1 generate:templates <first-checkout>/packages/templates
@bb/templates:generate:templates: > node ./scripts/generate-templates.mjs
@bb/templates:generate:templates:
//:ensure-native-modules:
//:ensure-native-modules: > bb@ ensure-native-modules <first-checkout>
//:ensure-native-modules: > node scripts/ensure-native-modules.mjs
//:ensure-native-modules:
@bb/desktop:test: cache miss, executing 81e61ad8b9c9341b
@bb/desktop:test:
@bb/desktop:test: > @bb/desktop@0.44.0 test <first-checkout>/apps/desktop
@bb/desktop:test: > vitest run --config vitest.config.ts "test/issue-4590-close-request.test.ts"
@bb/desktop:test:
@bb/desktop:test:
@bb/desktop:test: RUN v4.1.1 <first-checkout>/apps/desktop
@bb/desktop:test:
@bb/desktop:test: stdout | test/issue-4590-close-request.test.ts > issue 4590 close-request boundary > expects the plugin close command to claim the desktop menu request
@bb/desktop:test: {"handled":false,"paneOpen":true,"pluginCalls":0}
@bb/desktop:test:
@bb/desktop:test: ❯ |@bb/desktop:isolated| test/issue-4590-close-request.test.ts (2 tests | 1 failed) 832ms
@bb/desktop:test: ✓ allows a registered built-in desktop listener to claim the request 766ms
@bb/desktop:test: × expects the plugin close command to claim the desktop menu request 65ms
@bb/desktop:test:
@bb/desktop:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
@bb/desktop:test:
@bb/desktop:test: FAIL |@bb/desktop:isolated| test/issue-4590-close-request.test.ts > issue 4590 close-request boundary > expects the plugin close command to claim the desktop menu request
@bb/desktop:test: AssertionError: expected false to be true // Object.is equality
@bb/desktop:test:
@bb/desktop:test: - Expected
@bb/desktop:test: + Received
@bb/desktop:test:
@bb/desktop:test: - true
@bb/desktop:test: + false
@bb/desktop:test:
@bb/desktop:test: ❯ test/issue-4590-close-request.test.ts:106:31
@bb/desktop:test: 104| }),
@bb/desktop:test: 105| );
@bb/desktop:test: 106| expect(response?.handled).toBe(true);
@bb/desktop:test: | ^
@bb/desktop:test: 107| expect(paneOpen).toBe(false);
@bb/desktop:test: 108| });
@bb/desktop:test:
@bb/desktop:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
@bb/desktop:test:
@bb/desktop:test:
@bb/desktop:test: Test Files 1 failed (1)
@bb/desktop:test: Tests 1 failed | 1 passed (2)
@bb/desktop:test: Start at 01:42:26
@bb/desktop:test: Duration 1.17s (transform 655ms, setup 0ms, import 144ms, tests 832ms, environment 0ms)
@bb/desktop:test:
@bb/desktop:test: ELIFECYCLE Test failed. See above for more details.
@bb/desktop#test: ERROR command (<first-checkout>/apps/desktop) /usr/local/bin/pnpm run test test/issue-4590-close-request.test.ts exited (1)
Tasks: 3 successful, 4 total
Cached: 2 cached, 4 total
Time: 2.827s
Failed: @bb/desktop#test
ERROR run failed: command exited (1)
repro-second.log
@bb/templates:generate:plugin-scaffold: > @bb/templates@0.0.1 generate:plugin-scaffold <second-checkout>/packages/templates
@bb/templates:generate:plugin-scaffold: > node ./scripts/generate-plugin-scaffold.mjs
@bb/templates:generate:plugin-scaffold:
//:ensure-native-modules:
//:ensure-native-modules: > bb@ ensure-native-modules <second-checkout>
//:ensure-native-modules: > node scripts/ensure-native-modules.mjs
//:ensure-native-modules:
//:ensure-native-modules: [ensure-native-modules] Detached hardlinked better-sqlite3 binary before verification
@bb/desktop:test: cache bypass, force executing 81e61ad8b9c9341b
@bb/desktop:test:
@bb/desktop:test: > @bb/desktop@0.44.0 test <second-checkout>/apps/desktop
@bb/desktop:test: > vitest run --config vitest.config.ts "test/issue-4590-close-request.test.ts"
@bb/desktop:test:
@bb/desktop:test:
@bb/desktop:test: RUN v4.1.1 <second-checkout>/apps/desktop
@bb/desktop:test:
@bb/desktop:test: stdout | test/issue-4590-close-request.test.ts > issue 4590 close-request boundary > expects the plugin close command to claim the desktop menu request
@bb/desktop:test: {"handled":false,"paneOpen":true,"pluginCalls":0}
@bb/desktop:test:
@bb/desktop:test: ❯ |@bb/desktop:isolated| test/issue-4590-close-request.test.ts (2 tests | 1 failed) 811ms
@bb/desktop:test: ✓ allows a registered built-in desktop listener to claim the request 745ms
@bb/desktop:test: × expects the plugin close command to claim the desktop menu request 64ms
@bb/desktop:test:
@bb/desktop:test: ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯
@bb/desktop:test:
@bb/desktop:test: FAIL |@bb/desktop:isolated| test/issue-4590-close-request.test.ts > issue 4590 close-request boundary > expects the plugin close command to claim the desktop menu request
@bb/desktop:test: AssertionError: expected false to be true // Object.is equality
@bb/desktop:test:
@bb/desktop:test: - Expected
@bb/desktop:test: + Received
@bb/desktop:test:
@bb/desktop:test: - true
@bb/desktop:test: + false
@bb/desktop:test:
@bb/desktop:test: ❯ test/issue-4590-close-request.test.ts:106:31
@bb/desktop:test: 104| }),
@bb/desktop:test: 105| );
@bb/desktop:test: 106| expect(response?.handled).toBe(true);
@bb/desktop:test: | ^
@bb/desktop:test: 107| expect(paneOpen).toBe(false);
@bb/desktop:test: 108| });
@bb/desktop:test:
@bb/desktop:test: ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯
@bb/desktop:test:
@bb/desktop:test:
@bb/desktop:test: Test Files 1 failed (1)
@bb/desktop:test: Tests 1 failed | 1 passed (2)
@bb/desktop:test: Start at 01:43:37
@bb/desktop:test: Duration 1.14s (transform 640ms, setup 0ms, import 143ms, tests 811ms, environment 0ms)
@bb/desktop:test:
@bb/desktop:test: ELIFECYCLE Test failed. See above for more details.
@bb/desktop#test: ERROR command (<second-checkout>/apps/desktop) /usr/local/bin/pnpm run test test/issue-4590-close-request.test.ts exited (1)
Tasks: 3 successful, 4 total
Cached: 0 cached, 4 total
Time: 2.866s
Failed: @bb/desktop#test
ERROR run failed: command exited (1)
existing-tests.log
• turbo 2.10.12 • Packages in scope: @bb/desktop • Running test in 1 packages • Remote caching disabled, using shared worktree cache //:ensure-native-modules: cache bypass, force executing b6f75ed01918daaf @bb/templates:generate:plugin-scaffold: cache miss, executing 5cc1a17362dab5da @bb/templates:generate:templates: cache miss, executing eb09ad1977e66085 @bb/templates:generate:templates: @bb/templates:generate:templates: > @bb/templates@0.0.1 generate:templates <first-checkout>/packages/templates @bb/templates:generate:templates: > node ./scripts/generate-templates.mjs @bb/templates:generate:templates: @bb/templates:generate:plugin-scaffold: @bb/templates:generate:plugin-scaffold: > @bb/templates@0.0.1 generate:plugin-scaffold <first-checkout>/packages/templates @bb/templates:generate:plugin-scaffold: > node ./scripts/generate-plugin-scaffold.mjs @bb/templates:generate:plugin-scaffold: //:ensure-native-modules: //:ensure-native-modules: > bb@ ensure-native-modules <first-checkout> //:ensure-native-modules: > node scripts/ensure-native-modules.mjs //:ensure-native-modules: @bb/desktop:test: cache miss, executing 93f162b5e34e1838 @bb/desktop:test: @bb/desktop:test: > @bb/desktop@0.44.0 test <first-checkout>/apps/desktop @bb/desktop:test: > vitest run --config vitest.config.ts "test/preload-browser-api.test.ts" "test/desktop-menu-shortcuts.test.ts" @bb/desktop:test: @bb/desktop:test: @bb/desktop:test: RUN v4.1.1 <first-checkout>/apps/desktop @bb/desktop:test: @bb/desktop:test: ✓ |@bb/desktop| test/desktop-menu-shortcuts.test.ts (3 tests) 4ms @bb/desktop:test: ✓ |@bb/desktop:isolated| test/preload-browser-api.test.ts (5 tests) 1030ms @bb/desktop:test: ✓ exposes only the typed browser commands and forwards them over fixed channels 818ms @bb/desktop:test: @bb/desktop:test: Test Files 2 passed (2) @bb/desktop:test: Tests 8 passed (8) @bb/desktop:test: Start at 01:43:02 @bb/desktop:test: Duration 1.36s (transform 688ms, setup 0ms, import 148ms, tests 1.03s, environment 0ms) @bb/desktop:test: Tasks: 4 successful, 4 total Cached: 0 cached, 4 total Time: 3.131s
Trust handling: issue content was treated only as untrusted claims. No instructions, scripts, patches, external URLs, branches, or code from the issue were executed. Test code was authored from trusted main evidence. Only temporary checkouts were used; no user runtime, credentials, provider sessions, or real application data were accessed. No screenshot is supplied because this is a nonvisual boundary test, not a visually verified desktop reproduction.