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
55 changes: 55 additions & 0 deletions apps/app/src/views/RootComposeView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -2383,6 +2385,59 @@ 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) {
return false;
}
if (
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 {
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,
fixedPanelTabsState.secondary.tabs,
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(
Expand Down
46 changes: 46 additions & 0 deletions apps/app/src/views/thread-detail/ThreadDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1152,6 +1153,51 @@ 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) {
return false;
}
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,
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);
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/desktop-window-command-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,11 @@
// 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_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;
69 changes: 68 additions & 1 deletion apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,12 @@ 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_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,
type DesktopBrowserViewManager,
Expand Down Expand Up @@ -464,6 +469,38 @@ function shouldEnableServerDaemonLogsMenu(): boolean {
);
}

// 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<number, NodeJS.Timeout>();

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 {
installApplicationMenu({
createNewWindow() {
Expand All @@ -478,6 +515,25 @@ function refreshApplicationMenu(): void {
null,
);
},
closeWindowOrSideTab(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;
}
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();
},
Expand Down Expand Up @@ -1104,6 +1160,17 @@ function registerDesktopUpdateIpc(): void {
}
nativeTheme.themeSource = parsed.data;
});

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
// well-formed `http(s)` URLs — never `file:`, custom schemes, or junk.
Expand Down
18 changes: 16 additions & 2 deletions apps/desktop/src/menu.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
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";
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: BaseWindow | undefined): void;
createNewWindow(): void;
openServerDaemonLogs(): void;
serverDaemonLogsMenuEnabled: boolean;
Expand Down Expand Up @@ -64,7 +72,13 @@ export function buildApplicationMenuTemplate(
label: "New Window",
},
{ type: "separator" },
{ role: "close" },
{
accelerator: CLOSE_WINDOW_ACCELERATOR,
click(_menuItem, browserWindow) {
args.closeWindowOrSideTab(browserWindow);
},
label: CLOSE_WINDOW_MENU_LABEL,
},
],
},
{
Expand Down
25 changes: 24 additions & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type BbDesktopBrowserSnapshotHandler,
type BbDesktopBrowserStateHandler,
type BbDesktopBrowserUnsubscribe,
type BbDesktopCloseWindowRequestHandler,
type BbDesktopInfo,
type BbDesktopInfoChangeHandler,
type BbDesktopInfoUnsubscribe,
Expand Down Expand Up @@ -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_REQUEST_CHANNEL,
BB_DESKTOP_CLOSE_WINDOW_RESPONSE_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,
Expand Down Expand Up @@ -118,6 +123,8 @@ const browserOpenTabListeners = new Set<BbDesktopBrowserOpenTabHandler>();
const browserScopedOpenTabListeners =
new Set<BbDesktopBrowserScopedOpenTabHandler>();
const browserSnapshotListeners = new Set<BbDesktopBrowserSnapshotHandler>();
const closeWindowRequestListeners =
new Set<BbDesktopCloseWindowRequestHandler>();
const openNewTabListeners = new Set<BbDesktopOpenNewTabHandler>();
const popoutThreadChangedListeners =
new Set<BbDesktopPopoutThreadChangedHandler>();
Expand Down Expand Up @@ -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);
},
Expand All @@ -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;
}
// 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) => {
const parsed = bbDesktopBrowserStateSchema.safeParse(payload);
if (!parsed.success) {
Expand Down
Loading
Loading