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
68 changes: 7 additions & 61 deletions electron/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -1004,59 +1002,23 @@ 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));
const win = new BrowserWindow({
...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();
Expand Down Expand Up @@ -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;
});

Expand Down
12 changes: 12 additions & 0 deletions electron/window-chrome.mjs
Original file line number Diff line number Diff line change
@@ -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 {};
}
20 changes: 20 additions & 0 deletions electron/window-chrome.test.mjs
Original file line number Diff line number Diff line change
@@ -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({});
});
});
14 changes: 2 additions & 12 deletions src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<main className="relative flex h-full min-w-0 flex-1 flex-col bg-app">
{/* Call mode covers the thread while the bot is on the line */}
Expand All @@ -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}
>
<div className="flex min-w-0 items-center gap-2.5 rounded-lg px-1.5 py-1" style={noDrag}>
<div className="flex min-w-0 items-center gap-2.5 rounded-lg px-1.5 py-1">
<button
onClick={() => dispatch({ type: "toggleSettings", open: true })}
className="flex size-10 shrink-0 items-center justify-center rounded-lg hover:bg-raised/50"
Expand Down Expand Up @@ -1059,7 +1049,7 @@ export function ChatView({ bot }: { bot: Bot }) {
)}
{bot.busy && <Loader2 size={14} className="animate-spin text-ink-secondary" />}
</div>
<div className="flex shrink-0 items-center gap-2" style={noDrag}>
<div className="flex shrink-0 items-center gap-2">
<button
onClick={() => setFindOpen((open) => !open)}
aria-label="Find in conversation"
Expand Down
10 changes: 2 additions & 8 deletions src/components/GroupView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -989,10 +989,6 @@ export function GroupView({ group }: { group: Group }) {
</span>
));

const isWin = window.ogb?.platform === "win32";
const drag = isWin ? ({ WebkitAppRegion: "drag" } as React.CSSProperties) : undefined;
const noDrag = isWin ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperties) : undefined;

return (
<main className="relative flex h-full min-w-0 flex-1 flex-col bg-app">
<GroupCallOverlay group={group} members={members} />
Expand All @@ -1005,15 +1001,13 @@ export function GroupView({ group }: { group: Group }) {
"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}
>
<div className="flex min-w-0 items-center gap-2" style={noDrag}>
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[15px] font-semibold text-ink">{group.name}</span>
{!setupPending && !group.dm && <GroupTaskPicker group={group} />}
</div>
<div className="flex items-center gap-1.5" style={noDrag}>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setFindOpen((open) => !open)}
Expand Down