diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ef30d3b4bb..fa0ad50da4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -7,6 +7,7 @@ import * as Path from "node:path"; import { app, BrowserWindow, + type BrowserWindowConstructorOptions, clipboard, dialog, ipcMain, @@ -118,6 +119,15 @@ const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; +const TITLEBAR_HEIGHT = 40; +const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux +const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; +const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; + +type WindowTitleBarOptions = Pick< + BrowserWindowConstructorOptions, + "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" +>; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -1649,6 +1659,46 @@ function getInitialWindowBackgroundColor(): string { return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; } +function getWindowTitleBarOptions(): WindowTitleBarOptions { + if (process.platform === "darwin") { + return { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 18 }, + }; + } + + return { + titleBarStyle: "hidden", + titleBarOverlay: { + color: TITLEBAR_COLOR, + height: TITLEBAR_HEIGHT, + symbolColor: nativeTheme.shouldUseDarkColors + ? TITLEBAR_DARK_SYMBOL_COLOR + : TITLEBAR_LIGHT_SYMBOL_COLOR, + }, + }; +} + +function syncWindowAppearance(window: BrowserWindow): void { + if (window.isDestroyed()) { + return; + } + + window.setBackgroundColor(getInitialWindowBackgroundColor()); + const { titleBarOverlay } = getWindowTitleBarOptions(); + if (typeof titleBarOverlay === "object") { + window.setTitleBarOverlay(titleBarOverlay); + } +} + +function syncAllWindowAppearance(): void { + for (const window of BrowserWindow.getAllWindows()) { + syncWindowAppearance(window); + } +} + +nativeTheme.on("updated", syncAllWindowAppearance); + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, @@ -1660,8 +1710,7 @@ function createWindow(): BrowserWindow { backgroundColor: getInitialWindowBackgroundColor(), ...getIconOption(), title: APP_DISPLAY_NAME, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, + ...getWindowTitleBarOptions(), webPreferences: { preload: Path.join(__dirname, "preload.js"), contextIsolation: true, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7a5a2875cc..4d7b03e21a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -313,6 +313,7 @@ type ChatViewProps = environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; + reserveTitleBarControlInset?: boolean; routeKind: "server"; draftId?: never; } @@ -320,6 +321,7 @@ type ChatViewProps = environmentId: EnvironmentId; threadId: ThreadId; onDiffPanelOpen?: () => void; + reserveTitleBarControlInset?: boolean; routeKind: "draft"; draftId: DraftId; }; @@ -573,7 +575,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra }); export default function ChatView(props: ChatViewProps) { - const { environmentId, threadId, routeKind, onDiffPanelOpen } = props; + const { + environmentId, + threadId, + routeKind, + onDiffPanelOpen, + reserveTitleBarControlInset = true, + } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( () => scopeThreadRef(environmentId, threadId), @@ -3294,7 +3302,13 @@ export default function ChatView(props: ChatViewProps) {
{isElectron ? ( - No active thread + + No active thread + ) : (
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 03ae979017..2495429efd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1974,7 +1974,7 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ ); return isElectron ? ( - + {wordmark} ) : ( diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 71602ee05e..1259d70495 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,6 +1,8 @@ @import "tailwindcss"; @custom-variant dark (&:is(.dark, .dark *)); +/* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */ +@custom-variant wco (&:is(.wco, .wco *)); @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; diff --git a/apps/web/src/lib/windowControlsOverlay.ts b/apps/web/src/lib/windowControlsOverlay.ts new file mode 100644 index 0000000000..8f9ae786b0 --- /dev/null +++ b/apps/web/src/lib/windowControlsOverlay.ts @@ -0,0 +1,40 @@ +const WCO_CLASS_NAME = "wco"; + +interface WindowControlsOverlayLike { + readonly visible: boolean; + addEventListener(type: "geometrychange", listener: EventListener): void; + removeEventListener(type: "geometrychange", listener: EventListener): void; +} + +interface NavigatorWithWindowControlsOverlay extends Navigator { + readonly windowControlsOverlay?: WindowControlsOverlayLike; +} + +function getWindowControlsOverlay(): WindowControlsOverlayLike | null { + if (typeof navigator === "undefined") { + return null; + } + + return (navigator as NavigatorWithWindowControlsOverlay).windowControlsOverlay ?? null; +} + +export function syncDocumentWindowControlsOverlayClass(): () => void { + if (typeof document === "undefined") { + return () => {}; + } + + const overlay = getWindowControlsOverlay(); + const update = () => { + document.documentElement.classList.toggle(WCO_CLASS_NAME, overlay !== null && overlay.visible); + }; + + update(); + if (!overlay) { + return () => {}; + } + + overlay.addEventListener("geometrychange", update); + return () => { + overlay.removeEventListener("geometrychange", update); + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fda5913c97..68a7dfaa93 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,12 +9,17 @@ import "./index.css"; import { isElectron } from "./env"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; +import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); +if (isElectron) { + syncDocumentWindowControlsOverlayClass(); +} + document.title = APP_DISPLAY_NAME; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index b62fd6b9c6..fa3f59b93f 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -269,6 +269,7 @@ function ChatThreadRouteView() { environmentId={threadRef.environmentId} threadId={threadRef.threadId} onDiffPanelOpen={markDiffOpened} + reserveTitleBarControlInset={!diffOpen} routeKind="server" /> diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 543b182c37..0905542363 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -56,7 +56,7 @@ function SettingsContentLayout() { )} {isElectron && ( -
+
Settings