History

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

Taras committed 7 months ago

Commit dff87841 parent 96c930610 files · +127 −10Browse files

Unified · Split

10 files
  1. apps/desktop/src/main.ts +51 −2
  2. apps/web/src/components/ChatView.tsx +16 −2
  3. apps/web/src/components/DiffPanelShell.tsx +4 −2
  4. apps/web/src/components/NoActiveThreadState.tsx +6 −2
  5. apps/web/src/components/Sidebar.tsx +1 −1
  6. apps/web/src/index.css +2 −0
  7. apps/web/src/lib/windowControlsOverlay.ts +40 −0
  8. apps/web/src/main.tsx +5 −0
  9. apps/web/src/routes/_chat.$environmentId.$threadId.tsx +1 −0
  10. apps/web/src/routes/settings.tsx +1 −1
apps/desktop/src/main.ts+51−2
@@ -7,6 +7,7 @@ import * as Path from "node:path"; · 6 unmodified lines
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"; · 105 unmodified lines
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>;
121 131
122type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];132type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
123type LinuxDesktopNamedApp = Electron.App & {133type LinuxDesktopNamedApp = Electron.App & {
@@ -1649,6 +1659,46 @@ function getInitialWindowBackgroundColor(): string { · 1525 unmodified lines
1649 return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff";1659 return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
1650}1660}
1651 1661
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 { · 5 unmodified lines
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 = · 312 unmodified lines
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 = · 1 unmodified line
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 · 247 unmodified lines
573});575});
574 576
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) { · 2537 unmodified lines
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"; · 9 unmodified lines
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}
17 19
apps/web/src/components/NoActiveThreadState.tsx+6−2
@@ -10,11 +10,15 @@ export function NoActiveThreadState() { · 9 unmodified lines
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({ · 1973 unmodified lines
1974 );1974 );
1975 1975
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−0
@@ -1,6 +1,8 @@
1@import "tailwindcss";1@import "tailwindcss";
2 2
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 *));
4 6
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+40−0
@@ -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−0
@@ -9,12 +9,17 @@ import "./index.css"; · 8 unmodified lines
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";
12 13
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();
15 16
16const router = getRouter(history);17const router = getRouter(history);
17 18
19if (isElectron) {
20 syncDocumentWindowControlsOverlayClass();
21}
22
18document.title = APP_DISPLAY_NAME;23document.title = APP_DISPLAY_NAME;
19 24
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−0
@@ -269,6 +269,7 @@ function ChatThreadRouteView() { · 268 unmodified lines
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() { · 55 unmodified lines
56 )}56 )}
57 57
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>