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";
77import {
88 app,
99 BrowserWindow,
10 type BrowserWindowConstructorOptions,
1011 clipboard,
1112 dialog,
1213 ipcMain,
......@@ -118,6 +119,15 @@ const DESKTOP_UPDATE_CHANNEL = "latest";
118119const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;
119120const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
120121const 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
122132type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
123133type LinuxDesktopNamedApp = Electron.App & {
......@@ -1649,6 +1659,46 @@ function getInitialWindowBackgroundColor(): string {
16491659 return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
16501660}
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
16521702function createWindow(): BrowserWindow {
16531703 const window = new BrowserWindow({
16541704 width: 1100,
......@@ -1660,8 +1710,7 @@ function createWindow(): BrowserWindow {
16601710 backgroundColor: getInitialWindowBackgroundColor(),
16611711 ...getIconOption(),
16621712 title: APP_DISPLAY_NAME,
1663 titleBarStyle: "hiddenInset",
1664 trafficLightPosition: { x: 16, y: 18 },
1713 ...getWindowTitleBarOptions(),
16651714 webPreferences: {
16661715 preload: Path.join(__dirname, "preload.js"),
16671716 contextIsolation: true,
apps/web/src/components/ChatView.tsx+16-2
......@@ -313,6 +313,7 @@ type ChatViewProps =
313313 environmentId: EnvironmentId;
314314 threadId: ThreadId;
315315 onDiffPanelOpen?: () => void;
316 reserveTitleBarControlInset?: boolean;
316317 routeKind: "server";
317318 draftId?: never;
318319 }
......@@ -320,6 +321,7 @@ type ChatViewProps =
320321 environmentId: EnvironmentId;
321322 threadId: ThreadId;
322323 onDiffPanelOpen?: () => void;
324 reserveTitleBarControlInset?: boolean;
323325 routeKind: "draft";
324326 draftId: DraftId;
325327 };
......@@ -573,7 +575,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra
573575});
574576
575577export 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;
577585 const draftId = routeKind === "draft" ? props.draftId : null;
578586 const routeThreadRef = useMemo(
579587 () => scopeThreadRef(environmentId, threadId),
......@@ -3117,7 +3125,13 @@ export default function ChatView(props: ChatViewProps) {
31173125 <header
31183126 className={cn(
31193127 "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",
31213135 )}
31223136 >
31233137 <ChatHeader
apps/web/src/components/DiffPanelShell.tsx+4-2
......@@ -10,8 +10,10 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar";
1010function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) {
1111 const shouldUseDragRegion = isElectron && mode !== "sheet";
1212 return cn(
13 "flex items-center justify-between gap-2 px-4",
14 shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12",
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
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)]",
1517 );
1618}
1719
apps/web/src/components/NoActiveThreadState.tsx+6-2
......@@ -10,11 +10,15 @@ export function NoActiveThreadState() {
1010 <header
1111 className={cn(
1212 "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",
1416 )}
1517 >
1618 {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>
1822 ) : (
1923 <div className="flex items-center gap-2">
2024 <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({
19741974 );
19751975
19761976 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)]">
19781978 {wordmark}
19791979 </SidebarHeader>
19801980 ) : (
apps/web/src/index.css+2
......@@ -1,6 +1,8 @@
11@import "tailwindcss";
22
33@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
57@theme inline {
68 --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";
99import { isElectron } from "./env";
1010import { getRouter } from "./router";
1111import { APP_DISPLAY_NAME } from "./branding";
12import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay";
1213
1314// Electron loads the app from a file-backed shell, so hash history avoids path resolution issues.
1415const history = isElectron ? createHashHistory() : createBrowserHistory();
1516
1617const router = getRouter(history);
1718
19if (isElectron) {
20 syncDocumentWindowControlsOverlayClass();
21}
22
1823document.title = APP_DISPLAY_NAME;
1924
2025ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+1
......@@ -269,6 +269,7 @@ function ChatThreadRouteView() {
269269 environmentId={threadRef.environmentId}
270270 threadId={threadRef.threadId}
271271 onDiffPanelOpen={markDiffOpened}
272 reserveTitleBarControlInset={!diffOpen}
272273 routeKind="server"
273274 />
274275 </SidebarInset>
apps/web/src/routes/settings.tsx+1-1
......@@ -56,7 +56,7 @@ function SettingsContentLayout() {
5656 )}
5757
5858 {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)]">
6060 <span className="text-xs font-medium tracking-wide text-muted-foreground/70">
6161 Settings
6262 </span>