authorgravatar for Taras.Fomin@gmail.comTaras <Taras.Fomin@gmail.com> 2026-04-13 10:21:40+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-13 07:21:40+00:00
logdff8784aef8971474f4dfc500488680fefb391b2
tree586b96f96249634f9bee56884742c242afb7aed6
parent96c9306dbd9fac8dff76133da0a19ab44de7caec
signature Signed by PGP key B5690EEEBB952194

window controls overlay (windows&linux) (#1969)

Co-authored-by: julius <julius0216@outlook.com>

10 files changed, 127 insertions(+), 10 deletions(-)

apps/desktop/src/main.ts+51-2
...@@ -7,6 +7,7 @@ import * as Path from "node:path";...@@ -7,6 +7,7 @@ import * as Path from "node:path";
7import {7import {
8 app,8 app,
9 BrowserWindow,9 BrowserWindow,
10 type BrowserWindowConstructorOptions,
10 clipboard,11 clipboard,
11 dialog,12 dialog,
12 ipcMain,13 ipcMain,
...@@ -118,6 +119,15 @@ const DESKTOP_UPDATE_CHANNEL = "latest";...@@ -118,6 +119,15 @@ const DESKTOP_UPDATE_CHANNEL = "latest";
118const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;119const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
119const DESKTOP_LOOPBACK_HOST = "127.0.0.1";120const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
120const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;121const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
122const TITLEBAR_HEIGHT = 40;
123const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
124const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
125const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
126
127type WindowTitleBarOptions = Pick<
128 BrowserWindowConstructorOptions,
129 "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"
130>;
121131
122type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];132type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
123type LinuxDesktopNamedApp = Electron.App & {133type LinuxDesktopNamedApp = Electron.App & {
...@@ -1649,6 +1659,46 @@ function getInitialWindowBackgroundColor(): string {...@@ -1649,6 +1659,46 @@ function getInitialWindowBackgroundColor(): string {
1649 return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff";1659 return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
1650}1660}
16511661
1662function getWindowTitleBarOptions(): WindowTitleBarOptions {
1663 if (process.platform === "darwin") {
1664 return {
1665 titleBarStyle: "hiddenInset",
1666 trafficLightPosition: { x: 16, y: 18 },
1667 };
1668 }
1669
1670 return {
1671 titleBarStyle: "hidden",
1672 titleBarOverlay: {
1673 color: TITLEBAR_COLOR,
1674 height: TITLEBAR_HEIGHT,
1675 symbolColor: nativeTheme.shouldUseDarkColors
1676 ? TITLEBAR_DARK_SYMBOL_COLOR
1677 : TITLEBAR_LIGHT_SYMBOL_COLOR,
1678 },
1679 };
1680}
1681
1682function syncWindowAppearance(window: BrowserWindow): void {
1683 if (window.isDestroyed()) {
1684 return;
1685 }
1686
1687 window.setBackgroundColor(getInitialWindowBackgroundColor());
1688 const { titleBarOverlay } = getWindowTitleBarOptions();
1689 if (typeof titleBarOverlay === "object") {
1690 window.setTitleBarOverlay(titleBarOverlay);
1691 }
1692}
1693
1694function syncAllWindowAppearance(): void {
1695 for (const window of BrowserWindow.getAllWindows()) {
1696 syncWindowAppearance(window);
1697 }
1698}
1699
1700nativeTheme.on("updated", syncAllWindowAppearance);
1701
1652function createWindow(): BrowserWindow {1702function createWindow(): BrowserWindow {
1653 const window = new BrowserWindow({1703 const window = new BrowserWindow({
1654 width: 1100,1704 width: 1100,
...@@ -1660,8 +1710,7 @@ function createWindow(): BrowserWindow {...@@ -1660,8 +1710,7 @@ function createWindow(): BrowserWindow {
1660 backgroundColor: getInitialWindowBackgroundColor(),1710 backgroundColor: getInitialWindowBackgroundColor(),
1661 ...getIconOption(),1711 ...getIconOption(),
1662 title: APP_DISPLAY_NAME,1712 title: APP_DISPLAY_NAME,
1663 titleBarStyle: "hiddenInset",1713 ...getWindowTitleBarOptions(),
1664 trafficLightPosition: { x: 16, y: 18 },
1665 webPreferences: {1714 webPreferences: {
1666 preload: Path.join(__dirname, "preload.js"),1715 preload: Path.join(__dirname, "preload.js"),
1667 contextIsolation: true,1716 contextIsolation: true,
apps/web/src/components/ChatView.tsx+16-2
...@@ -313,6 +313,7 @@ type ChatViewProps =...@@ -313,6 +313,7 @@ type ChatViewProps =
313 environmentId: EnvironmentId;313 environmentId: EnvironmentId;
314 threadId: ThreadId;314 threadId: ThreadId;
315 onDiffPanelOpen?: () => void;315 onDiffPanelOpen?: () => void;
316 reserveTitleBarControlInset?: boolean;
316 routeKind: "server";317 routeKind: "server";
317 draftId?: never;318 draftId?: never;
318 }319 }
...@@ -320,6 +321,7 @@ type ChatViewProps =...@@ -320,6 +321,7 @@ type ChatViewProps =
320 environmentId: EnvironmentId;321 environmentId: EnvironmentId;
321 threadId: ThreadId;322 threadId: ThreadId;
322 onDiffPanelOpen?: () => void;323 onDiffPanelOpen?: () => void;
324 reserveTitleBarControlInset?: boolean;
323 routeKind: "draft";325 routeKind: "draft";
324 draftId: DraftId;326 draftId: DraftId;
325 };327 };
...@@ -573,7 +575,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra...@@ -573,7 +575,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
573});575});
574576
575export default function ChatView(props: ChatViewProps) {577export default function ChatView(props: ChatViewProps) {
576 const { environmentId, threadId, routeKind, onDiffPanelOpen } = props;578 const {
579 environmentId,
580 threadId,
581 routeKind,
582 onDiffPanelOpen,
583 reserveTitleBarControlInset = true,
584 } = props;
577 const draftId = routeKind === "draft" ? props.draftId : null;585 const draftId = routeKind === "draft" ? props.draftId : null;
578 const routeThreadRef = useMemo(586 const routeThreadRef = useMemo(
579 () => scopeThreadRef(environmentId, threadId),587 () => scopeThreadRef(environmentId, threadId),
...@@ -3117,7 +3125,13 @@ export default function ChatView(props: ChatViewProps) {...@@ -3117,7 +3125,13 @@ export default function ChatView(props: ChatViewProps) {
3117 <header3125 <header
3118 className={cn(3126 className={cn(
3119 "border-b border-border px-3 sm:px-5",3127 "border-b border-border px-3 sm:px-5",
3120 isElectron ? "drag-region flex h-[52px] items-center" : "py-2 sm:py-3",3128 isElectron
3129 ? cn(
3130 "drag-region flex h-[52px] items-center wco:h-[env(titlebar-area-height)]",
3131 reserveTitleBarControlInset &&
3132 "wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
3133 )
3134 : "py-2 sm:py-3",
3121 )}3135 )}
3122 >3136 >
3123 <ChatHeader3137 <ChatHeader
apps/web/src/components/DiffPanelShell.tsx+4-2
...@@ -10,8 +10,10 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar";...@@ -10,8 +10,10 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar";
10function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {10function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
11 const shouldUseDragRegion = isElectron && mode !== "sheet";11 const shouldUseDragRegion = isElectron && mode !== "sheet";
12 return cn(12 return cn(
13 "flex items-center justify-between gap-2 px-4",13 "flex items-center justify-between gap-2 px-4 wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]",
14 shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12",14 shouldUseDragRegion
15 ? "drag-region h-[52px] border-b border-border wco:h-[env(titlebar-area-height)]"
16 : "h-12 wco:max-h-[env(titlebar-area-height)]",
15 );17 );
16}18}
1719
apps/web/src/components/NoActiveThreadState.tsx+6-2
...@@ -10,11 +10,15 @@ export function NoActiveThreadState() {...@@ -10,11 +10,15 @@ export function NoActiveThreadState() {
10 <header10 <header
11 className={cn(11 className={cn(
12 "border-b border-border px-3 sm:px-5",12 "border-b border-border px-3 sm:px-5",
13 isElectron ? "drag-region flex h-[52px] items-center" : "py-2 sm:py-3",13 isElectron
14 ? "drag-region flex h-[52px] items-center wco:h-[env(titlebar-area-height)]"
15 : "py-2 sm:py-3",
14 )}16 )}
15 >17 >
16 {isElectron ? (18 {isElectron ? (
17 <span className="text-xs text-muted-foreground/50">No active thread</span>19 <span className="text-xs text-muted-foreground/50 wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]">
20 No active thread
21 </span>
18 ) : (22 ) : (
19 <div className="flex items-center gap-2">23 <div className="flex items-center gap-2">
20 <SidebarTrigger className="size-7 shrink-0 md:hidden" />24 <SidebarTrigger className="size-7 shrink-0 md:hidden" />
apps/web/src/components/Sidebar.tsx+1-1
...@@ -1974,7 +1974,7 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({...@@ -1974,7 +1974,7 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({
1974 );1974 );
19751975
1976 return isElectron ? (1976 return isElectron ? (
1977 <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px]">1977 <SidebarHeader className="drag-region h-[52px] flex-row items-center gap-2 px-4 py-0 pl-[90px] wco:h-[env(titlebar-area-height)] wco:pl-[calc(env(titlebar-area-x)+1em)]">
1978 {wordmark}1978 {wordmark}
1979 </SidebarHeader>1979 </SidebarHeader>
1980 ) : (1980 ) : (
apps/web/src/index.css+2
...@@ -1,6 +1,8 @@...@@ -1,6 +1,8 @@
1@import "tailwindcss";1@import "tailwindcss";
22
3@custom-variant dark (&:is(.dark, .dark *));3@custom-variant dark (&:is(.dark, .dark *));
4/* Window Controls Overlay: active when Electron exposes native titlebar control geometry. */
5@custom-variant wco (&:is(.wco, .wco *));
46
5@theme inline {7@theme inline {
6 --animate-skeleton: skeleton 2s -1s infinite linear;8 --animate-skeleton: skeleton 2s -1s infinite linear;
apps/web/src/lib/windowControlsOverlay.ts created+40
...@@ -0,0 +1,40 @@
1const WCO_CLASS_NAME = "wco";
2
3interface WindowControlsOverlayLike {
4 readonly visible: boolean;
5 addEventListener(type: "geometrychange", listener: EventListener): void;
6 removeEventListener(type: "geometrychange", listener: EventListener): void;
7}
8
9interface NavigatorWithWindowControlsOverlay extends Navigator {
10 readonly windowControlsOverlay?: WindowControlsOverlayLike;
11}
12
13function getWindowControlsOverlay(): WindowControlsOverlayLike | null {
14 if (typeof navigator === "undefined") {
15 return null;
16 }
17
18 return (navigator as NavigatorWithWindowControlsOverlay).windowControlsOverlay ?? null;
19}
20
21export function syncDocumentWindowControlsOverlayClass(): () => void {
22 if (typeof document === "undefined") {
23 return () => {};
24 }
25
26 const overlay = getWindowControlsOverlay();
27 const update = () => {
28 document.documentElement.classList.toggle(WCO_CLASS_NAME, overlay !== null && overlay.visible);
29 };
30
31 update();
32 if (!overlay) {
33 return () => {};
34 }
35
36 overlay.addEventListener("geometrychange", update);
37 return () => {
38 overlay.removeEventListener("geometrychange", update);
39 };
40}
apps/web/src/main.tsx+5
...@@ -9,12 +9,17 @@ import "./index.css";...@@ -9,12 +9,17 @@ import "./index.css";
9import { isElectron } from "./env";9import { isElectron } from "./env";
10import { getRouter } from "./router";10import { getRouter } from "./router";
11import { APP_DISPLAY_NAME } from "./branding";11import { APP_DISPLAY_NAME } from "./branding";
12import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
1213
13// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.14// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
14const history = isElectron ? createHashHistory() : createBrowserHistory();15const history = isElectron ? createHashHistory() : createBrowserHistory();
1516
16const router = getRouter(history);17const router = getRouter(history);
1718
19if (isElectron) {
20 syncDocumentWindowControlsOverlayClass();
21}
22
18document.title = APP_DISPLAY_NAME;23document.title = APP_DISPLAY_NAME;
1924
20ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(25ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+1
...@@ -269,6 +269,7 @@ function ChatThreadRouteView() {...@@ -269,6 +269,7 @@ function ChatThreadRouteView() {
269 environmentId={threadRef.environmentId}269 environmentId={threadRef.environmentId}
270 threadId={threadRef.threadId}270 threadId={threadRef.threadId}
271 onDiffPanelOpen={markDiffOpened}271 onDiffPanelOpen={markDiffOpened}
272 reserveTitleBarControlInset={!diffOpen}
272 routeKind="server"273 routeKind="server"
273 />274 />
274 </SidebarInset>275 </SidebarInset>
apps/web/src/routes/settings.tsx+1-1
...@@ -56,7 +56,7 @@ function SettingsContentLayout() {...@@ -56,7 +56,7 @@ function SettingsContentLayout() {
56 )}56 )}
5757
58 {isElectron && (58 {isElectron && (
59 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5">59 <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5 wco:h-[env(titlebar-area-height)] wco:pr-[calc(100vw-env(titlebar-area-width)-env(titlebar-area-x)+1em)]">
60 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">60 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">
61 Settings61 Settings
62 </span>62 </span>