diff --git a/electron/main.mjs b/electron/main.mjs index 81e861575c..487a50383e 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -22,6 +22,7 @@ import { migrateWorkspaceCredentials, workspaceCredentialEnv } from "./workspace import { activateExistingWindow } from "./single-instance.mjs"; import { pollServerIdentity } from "./server-boot-probe.mjs"; import { packageUrlFromCommandLine, packageUrlFromDeepLink } from "./package-link.mjs"; +import { windowChromeOptions } from "./window-chrome.mjs"; import { defaultSaveName, withSavableFile } from "./save-file.mjs"; import { ensureManagedComposioCredentials, @@ -38,7 +39,7 @@ import { withoutManagedCompanionTunnelAccess, } from "./managed-companion-tunnel.mjs"; import { createSecureCredentialState } from "./secure-credential-state.mjs"; -import { skinChrome, isKnownSkin } from "./skin-overlay.cjs"; +import { isKnownSkin } from "./skin-overlay.cjs"; import { readSecureCredentials } from "./secure-credentials.mjs"; import { createControlPlaneClient } from "./control-plane-client.mjs"; import { @@ -73,9 +74,6 @@ let desktopWorkspaceManager = null; let desktopWorkspaceOwner = null; let pendingPackageInstallUrl = packageUrlFromCommandLine(process.argv); let mainWindow = null; -// The active skin id, mirrored from the renderer so a window's native -// caption-button overlay (issue #454) starts and stays on the right colours. -let currentSkin = "midnight"; let unreadCount = 0; let unreadOverlayIcon = null; @@ -1004,8 +1002,6 @@ ipcMain.on("desktop:unread-count", (event, value) => { }); function createWindow() { - const isMac = process.platform === "darwin"; - const waitsForSkinSync = process.platform === "win32"; const primary = screen.getPrimaryDisplay(); const displays = [primary, ...screen.getAllDisplays().filter((display) => display.id !== primary.id)]; const restored = resolveWindowState(readWindowState(), displays.map((display) => display.workArea)); @@ -1013,50 +1009,16 @@ function createWindow() { ...restored.bounds, minWidth: 900, minHeight: 600, - // The renderer restores its persisted skin before mounting React and - // mirrors it over desktop:skin. Keep Windows hidden until that handshake - // recolors the native caption-button overlay, otherwise a saved light - // skin still flashes the Midnight-black block on every cold start. - show: !waitsForSkinSync, icon: APP_ICON, backgroundColor: "#070707", autoHideMenuBar: process.platform !== "darwin", - // macOS keeps inset traffic lights, Windows keeps its custom overlay, - // and Linux uses the native desktop title bar and window controls. - ...(isMac - ? { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } } - : process.platform === "win32" - ? { - titleBarStyle: "hidden", - // height MUST match the ChatView/GroupView header strip (px-5 py-3 - // around a 36px control row = 60). Windows draws the caption buttons - // to fill the overlay, so anything shorter leaves a dead band under - // them and anything taller overhangs the header. - // color/symbolColor follow the active skin (issue #454): the - // caption buttons live in this native overlay, and a light skin - // with a Midnight-black overlay is the "black block in the - // top-right corner". height stays 60 — see the note above. - titleBarOverlay: { ...skinChrome(currentSkin), height: 60 }, - } - : {}), + ...windowChromeOptions(process.platform), webPreferences: { contextIsolation: true, preload: path.join(__dirname, "preload.cjs"), }, }); mainWindow = win; - if (waitsForSkinSync) { - // A broken renderer or preload must not strand the app as an invisible - // process. Normal startup shows from desktop:skin almost immediately; - // this is only the bounded recovery path. - const skinSyncFallback = setTimeout(() => { - if (!win.isDestroyed() && !win.isVisible()) win.show(); - }, 5_000); - skinSyncFallback.unref?.(); - const clearSkinSyncFallback = () => clearTimeout(skinSyncFallback); - win.once("show", clearSkinSyncFallback); - win.once("closed", clearSkinSyncFallback); - } installWindowStatePersistence(win); applyUnreadBadge(win); if (restored.maximized) win.maximize(); @@ -1322,27 +1284,11 @@ ipcMain.handle("desktop:save-file", async (event, rawPath) => { }); }); -// The renderer owns the skin (it lives in localStorage and stamps -// [data-skin] before first paint); it tells the main process so the one -// surface CSS cannot reach — the Windows caption-button overlay — matches. -// Persisted in-process so a window opened later starts on the right colours. -ipcMain.handle("desktop:skin", (event, skin) => { +// The renderer owns the skin. Native Windows/Linux chrome is intentionally +// outside that surface; acknowledge the renderer handshake without creating +// a frameless caption overlay that can cover page controls. +ipcMain.handle("desktop:skin", (_event, skin) => { if (!isKnownSkin(skin)) return false; - currentSkin = skin; - // The caption-button overlay is a Windows-only surface (createWindow only - // configures titleBarOverlay there); on macOS/Linux the renderer's CSS is - // the whole story and there is nothing native to recolour. - if (process.platform === "win32") { - const sender = BrowserWindow.fromWebContents(event.sender); - try { - sender?.setTitleBarOverlay({ ...skinChrome(skin), height: 60 }); - // The first Windows window starts hidden so a persisted light skin is - // already applied when native chrome becomes visible. - if (sender && !sender.isVisible()) sender.show(); - } catch { - // a window created without an overlay throws; safe to ignore - } - } return true; }); diff --git a/electron/window-chrome.mjs b/electron/window-chrome.mjs new file mode 100644 index 0000000000..d714b48a17 --- /dev/null +++ b/electron/window-chrome.mjs @@ -0,0 +1,12 @@ +/** + * Keep custom inset chrome only where the platform owns a stable inset model. + * Windows' titleBarOverlay sits on top of renderer content, so every new page + * must otherwise remember to reserve its width. Native Windows/Linux chrome + * keeps caption controls outside the app layout and cannot cover actions. + */ +export function windowChromeOptions(platform) { + if (platform === "darwin") { + return { titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 16 } }; + } + return {}; +} diff --git a/electron/window-chrome.test.mjs b/electron/window-chrome.test.mjs new file mode 100644 index 0000000000..509fb0e216 --- /dev/null +++ b/electron/window-chrome.test.mjs @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; + +import { windowChromeOptions } from "./window-chrome.mjs"; + +describe("window chrome", () => { + it("uses inset traffic lights on macOS", () => { + expect(windowChromeOptions("darwin")).toEqual({ + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 16 }, + }); + }); + + it("keeps Windows controls in the native title bar, outside app content", () => { + expect(windowChromeOptions("win32")).toEqual({}); + }); + + it("keeps Linux window chrome native", () => { + expect(windowChromeOptions("linux")).toEqual({}); + }); +}); diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index bb694776a0..b596147a3c 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1005,14 +1005,6 @@ export function ChatView({ bot }: { bot: Bot }) { }); }; - // on Windows the frameless window's min/max/close overlay sits at the - // top-right: the header becomes the drag strip and clears room for it - const isWin = window.ogb?.platform === "win32"; - // SAFETY: Electron supports this nonstandard CSS property, which React's type declarations omit. - const drag = isWin ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined; - // SAFETY: Electron supports this nonstandard CSS property, which React's type declarations omit. - const noDrag = isWin ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined; - return (
{/* Call mode covers the thread while the bot is on the line */} @@ -1025,11 +1017,9 @@ export function ChatView({ bot }: { bot: Bot }) { "@container/chathead flex items-center justify-between px-5 py-3", // Room for the drawer button, which overlays this corner below md. "pl-11 md:pl-5", - isWin && "pr-[148px]", )} - style={drag} > -
+
-
+