| author | |
| committer | |
| log | dff8784aef8971474f4dfc500488680fefb391b2 |
| tree | 586b96f96249634f9bee56884742c242afb7aed6 |
| parent | 96c9306dbd9fac8dff76133da0a19ab44de7caec |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 7 | import { |
| 8 | 8 | app, |
| 9 | 9 | BrowserWindow, |
| 10 | type BrowserWindowConstructorOptions, | |
| 10 | 11 | clipboard, |
| 11 | 12 | dialog, |
| 12 | 13 | ipcMain, |
| ... | ... | @@ -118,6 +119,15 @@ const DESKTOP_UPDATE_CHANNEL = "latest"; |
| 118 | 119 | const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; |
| 119 | 120 | const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; |
| 120 | 121 | const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; |
| 122 | const TITLEBAR_HEIGHT = 40; | |
| 123 | const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux | |
| 124 | const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; | |
| 125 | const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; | |
| 126 | ||
| 127 | type WindowTitleBarOptions = Pick< | |
| 128 | BrowserWindowConstructorOptions, | |
| 129 | "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition" | |
| 130 | >; | |
| 121 | 131 | |
| 122 | 132 | type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; |
| 123 | 133 | type LinuxDesktopNamedApp = Electron.App & { |
| ... | ... | @@ -1649,6 +1659,46 @@ function getInitialWindowBackgroundColor(): string { |
| 1649 | 1659 | return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; |
| 1650 | 1660 | } |
| 1651 | 1661 | |
| 1662 | function 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 | ||
| 1682 | function 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 | ||
| 1694 | function syncAllWindowAppearance(): void { | |
| 1695 | for (const window of BrowserWindow.getAllWindows()) { | |
| 1696 | syncWindowAppearance(window); | |
| 1697 | } | |
| 1698 | } | |
| 1699 | ||
| 1700 | nativeTheme.on("updated", syncAllWindowAppearance); | |
| 1701 | ||
| 1652 | 1702 | function createWindow(): BrowserWindow { |
| 1653 | 1703 | const window = new BrowserWindow({ |
| 1654 | 1704 | width: 1100, |
| ... | ... | @@ -1660,8 +1710,7 @@ function createWindow(): BrowserWindow { |
| 1660 | 1710 | backgroundColor: getInitialWindowBackgroundColor(), |
| 1661 | 1711 | ...getIconOption(), |
| 1662 | 1712 | title: APP_DISPLAY_NAME, |
| 1663 | titleBarStyle: "hiddenInset", | |
| 1664 | trafficLightPosition: { x: 16, y: 18 }, | |
| 1713 | ...getWindowTitleBarOptions(), | |
| 1665 | 1714 | webPreferences: { |
| 1666 | 1715 | preload: Path.join(__dirname, "preload.js"), |
| 1667 | 1716 | contextIsolation: true, |
apps/web/src/components/ChatView.tsx+16-2| ... | ... | @@ -313,6 +313,7 @@ type ChatViewProps = |
| 313 | 313 | environmentId: EnvironmentId; |
| 314 | 314 | threadId: ThreadId; |
| 315 | 315 | onDiffPanelOpen?: () => void; |
| 316 | reserveTitleBarControlInset?: boolean; | |
| 316 | 317 | routeKind: "server"; |
| 317 | 318 | draftId?: never; |
| 318 | 319 | } |
| ... | ... | @@ -320,6 +321,7 @@ type ChatViewProps = |
| 320 | 321 | environmentId: EnvironmentId; |
| 321 | 322 | threadId: ThreadId; |
| 322 | 323 | onDiffPanelOpen?: () => void; |
| 324 | reserveTitleBarControlInset?: boolean; | |
| 323 | 325 | routeKind: "draft"; |
| 324 | 326 | draftId: DraftId; |
| 325 | 327 | }; |
| ... | ... | @@ -573,7 +575,13 @@ const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDra |
| 573 | 575 | }); |
| 574 | 576 | |
| 575 | 577 | export 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 | 585 | const draftId = routeKind === "draft" ? props.draftId : null; |
| 578 | 586 | const routeThreadRef = useMemo( |
| 579 | 587 | () => scopeThreadRef(environmentId, threadId), |
| ... | ... | @@ -3117,7 +3125,13 @@ export default function ChatView(props: ChatViewProps) { |
| 3117 | 3125 | <header |
| 3118 | 3126 | className={cn( |
| 3119 | 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 | 3137 | <ChatHeader |
apps/web/src/components/DiffPanelShell.tsx+4-2| ... | ... | @@ -10,8 +10,10 @@ export type DiffPanelMode = "inline" | "sheet" | "sidebar"; |
| 10 | 10 | function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { |
| 11 | 11 | const shouldUseDragRegion = isElectron && mode !== "sheet"; |
| 12 | 12 | 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)]", | |
| 15 | 17 | ); |
| 16 | 18 | } |
| 17 | 19 |
apps/web/src/components/NoActiveThreadState.tsx+6-2| ... | ... | @@ -10,11 +10,15 @@ export function NoActiveThreadState() { |
| 10 | 10 | <header |
| 11 | 11 | className={cn( |
| 12 | 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 | 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 | 23 | <div className="flex items-center gap-2"> |
| 20 | 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 | 1974 | ); |
| 1975 | 1975 | |
| 1976 | 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 | 1978 | {wordmark} |
| 1979 | 1979 | </SidebarHeader> |
| 1980 | 1980 | ) : ( |
apps/web/src/index.css+2| ... | ... | @@ -1,6 +1,8 @@ |
| 1 | 1 | @import "tailwindcss"; |
| 2 | 2 | |
| 3 | 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 | 7 | @theme inline { |
| 6 | 8 | --animate-skeleton: skeleton 2s -1s infinite linear; |
apps/web/src/lib/windowControlsOverlay.ts created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | const WCO_CLASS_NAME = "wco"; | |
| 2 | ||
| 3 | interface WindowControlsOverlayLike { | |
| 4 | readonly visible: boolean; | |
| 5 | addEventListener(type: "geometrychange", listener: EventListener): void; | |
| 6 | removeEventListener(type: "geometrychange", listener: EventListener): void; | |
| 7 | } | |
| 8 | ||
| 9 | interface NavigatorWithWindowControlsOverlay extends Navigator { | |
| 10 | readonly windowControlsOverlay?: WindowControlsOverlayLike; | |
| 11 | } | |
| 12 | ||
| 13 | function getWindowControlsOverlay(): WindowControlsOverlayLike | null { | |
| 14 | if (typeof navigator === "undefined") { | |
| 15 | return null; | |
| 16 | } | |
| 17 | ||
| 18 | return (navigator as NavigatorWithWindowControlsOverlay).windowControlsOverlay ?? null; | |
| 19 | } | |
| 20 | ||
| 21 | export 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 | 9 | import { isElectron } from "./env"; |
| 10 | 10 | import { getRouter } from "./router"; |
| 11 | 11 | import { APP_DISPLAY_NAME } from "./branding"; |
| 12 | import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; | |
| 12 | 13 | |
| 13 | 14 | // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. |
| 14 | 15 | const history = isElectron ? createHashHistory() : createBrowserHistory(); |
| 15 | 16 | |
| 16 | 17 | const router = getRouter(history); |
| 17 | 18 | |
| 19 | if (isElectron) { | |
| 20 | syncDocumentWindowControlsOverlayClass(); | |
| 21 | } | |
| 22 | ||
| 18 | 23 | document.title = APP_DISPLAY_NAME; |
| 19 | 24 | |
| 20 | 25 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( |
apps/web/src/routes/_chat.$environmentId.$threadId.tsx+1| ... | ... | @@ -269,6 +269,7 @@ function ChatThreadRouteView() { |
| 269 | 269 | environmentId={threadRef.environmentId} |
| 270 | 270 | threadId={threadRef.threadId} |
| 271 | 271 | onDiffPanelOpen={markDiffOpened} |
| 272 | reserveTitleBarControlInset={!diffOpen} | |
| 272 | 273 | routeKind="server" |
| 273 | 274 | /> |
| 274 | 275 | </SidebarInset> |
apps/web/src/routes/settings.tsx+1-1| ... | ... | @@ -56,7 +56,7 @@ function SettingsContentLayout() { |
| 56 | 56 | )} |
| 57 | 57 | |
| 58 | 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 | 60 | <span className="text-xs font-medium tracking-wide text-muted-foreground/70"> |
| 61 | 61 | Settings |
| 62 | 62 | </span> |