From 4cc5bbf3bd582fb5475309000d58f7b66048fbd7 Mon Sep 17 00:00:00 2001 From: rot <96458554+rotlove@users.noreply.github.com> Date: Tue, 7 Apr 2026 01:40:05 +0300 Subject: [PATCH 1/7] feat(desktop): add windows custom title bar and window controls bridge --- apps/desktop/src/main.ts | 80 +++++++++++++++- apps/desktop/src/preload.ts | 20 ++++ apps/web/src/components/ChatView.tsx | 11 ++- apps/web/src/components/DesktopTitleBar.tsx | 51 ++++++++++ .../components/DesktopWindowControls.test.tsx | 38 ++++++++ .../src/components/DesktopWindowControls.tsx | 95 +++++++++++++++++++ apps/web/src/components/DiffPanelShell.tsx | 16 +++- apps/web/src/components/Sidebar.tsx | 15 ++- apps/web/src/env.ts | 5 + apps/web/src/routes/_chat.index.tsx | 7 +- apps/web/src/routes/settings.tsx | 22 ++++- apps/web/src/wsNativeApi.test.ts | 5 + packages/contracts/src/ipc.ts | 9 ++ 13 files changed, 359 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/DesktopTitleBar.tsx create mode 100644 apps/web/src/components/DesktopWindowControls.test.tsx create mode 100644 apps/web/src/components/DesktopWindowControls.tsx diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3d61571df9..f1f8bba3db 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -19,6 +19,7 @@ import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; import type { DesktopTheme, + DesktopWindowState, DesktopUpdateActionResult, DesktopUpdateCheckResult, DesktopUpdateState, @@ -60,6 +61,11 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const MINIMIZE_WINDOW_CHANNEL = "desktop:window-minimize"; +const TOGGLE_MAXIMIZE_WINDOW_CHANNEL = "desktop:window-toggle-maximize"; +const CLOSE_WINDOW_CHANNEL = "desktop:window-close"; +const GET_WINDOW_STATE_CHANNEL = "desktop:window-state-get"; +const WINDOW_STATE_CHANNEL = "desktop:window-state"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SCHEME = "t3"; @@ -773,6 +779,29 @@ function emitUpdateState(): void { } } +function resolveEventWindow(event: Electron.IpcMainInvokeEvent): BrowserWindow | null { + const window = BrowserWindow.fromWebContents(event.sender); + if (window && !window.isDestroyed()) { + return window; + } + if (mainWindow && !mainWindow.isDestroyed()) { + return mainWindow; + } + const fallback = BrowserWindow.getAllWindows().find((entry) => !entry.isDestroyed()); + return fallback ?? null; +} + +function toWindowState(window: BrowserWindow): DesktopWindowState { + return { + maximized: window.isMaximized(), + }; +} + +function emitWindowState(window: BrowserWindow): void { + if (window.isDestroyed()) return; + window.webContents.send(WINDOW_STATE_CHANNEL, toWindowState(window)); +} + function setUpdateState(patch: Partial): void { updateState = { ...updateState, ...patch }; emitUpdateState(); @@ -1172,6 +1201,45 @@ function registerIpcHandlers(): void { event.returnValue = backendWsUrl; }); + ipcMain.removeHandler(MINIMIZE_WINDOW_CHANNEL); + ipcMain.handle(MINIMIZE_WINDOW_CHANNEL, async (event) => { + const window = resolveEventWindow(event); + if (!window) return; + window.minimize(); + }); + + ipcMain.removeHandler(TOGGLE_MAXIMIZE_WINDOW_CHANNEL); + ipcMain.handle(TOGGLE_MAXIMIZE_WINDOW_CHANNEL, async (event) => { + const window = resolveEventWindow(event); + if (!window) { + return { maximized: false } satisfies DesktopWindowState; + } + if (window.isMaximized()) { + window.unmaximize(); + } else { + window.maximize(); + } + const nextState = toWindowState(window); + emitWindowState(window); + return nextState; + }); + + ipcMain.removeHandler(CLOSE_WINDOW_CHANNEL); + ipcMain.handle(CLOSE_WINDOW_CHANNEL, async (event) => { + const window = resolveEventWindow(event); + if (!window) return; + window.close(); + }); + + ipcMain.removeHandler(GET_WINDOW_STATE_CHANNEL); + ipcMain.handle(GET_WINDOW_STATE_CHANNEL, async (event) => { + const window = resolveEventWindow(event); + if (!window) { + return { maximized: false } satisfies DesktopWindowState; + } + return toWindowState(window); + }); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async () => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -1347,8 +1415,9 @@ function createWindow(): BrowserWindow { autoHideMenuBar: true, ...getIconOption(), title: APP_DISPLAY_NAME, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, + ...(process.platform === "win32" + ? { frame: false } + : { titleBarStyle: "hiddenInset" as const, trafficLightPosition: { x: 16, y: 18 } }), webPreferences: { preload: Path.join(__dirname, "preload.js"), contextIsolation: true, @@ -1400,6 +1469,13 @@ function createWindow(): BrowserWindow { window.webContents.on("did-finish-load", () => { window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); + emitWindowState(window); + }); + window.on("maximize", () => { + emitWindowState(window); + }); + window.on("unmaximize", () => { + emitWindowState(window); }); window.once("ready-to-show", () => { window.show(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3d59db1714..948e9370b2 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -13,6 +13,11 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const GET_WS_URL_CHANNEL = "desktop:get-ws-url"; +const MINIMIZE_WINDOW_CHANNEL = "desktop:window-minimize"; +const TOGGLE_MAXIMIZE_WINDOW_CHANNEL = "desktop:window-toggle-maximize"; +const CLOSE_WINDOW_CHANNEL = "desktop:window-close"; +const GET_WINDOW_STATE_CHANNEL = "desktop:window-state-get"; +const WINDOW_STATE_CHANNEL = "desktop:window-state"; contextBridge.exposeInMainWorld("desktopBridge", { getWsUrl: () => { @@ -50,4 +55,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener); }; }, + minimizeWindow: () => ipcRenderer.invoke(MINIMIZE_WINDOW_CHANNEL), + toggleMaximizeWindow: () => ipcRenderer.invoke(TOGGLE_MAXIMIZE_WINDOW_CHANNEL), + closeWindow: () => ipcRenderer.invoke(CLOSE_WINDOW_CHANNEL), + getWindowState: () => ipcRenderer.invoke(GET_WINDOW_STATE_CHANNEL), + onWindowState: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { + if (typeof state !== "object" || state === null) return; + listener(state as Parameters[0]); + }; + + ipcRenderer.on(WINDOW_STATE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(WINDOW_STATE_CHANNEL, wrappedListener); + }; + }, } satisfies DesktopBridge); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5d160f4bda..aac15d61e7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -29,7 +29,7 @@ import { useDebouncedValue } from "@tanstack/react-pacer"; import { useNavigate, useSearch } from "@tanstack/react-router"; import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; -import { isElectron } from "../env"; +import { isElectron, isWindowsElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { clampCollapsedComposerCursor, @@ -153,6 +153,7 @@ import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./Compose import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import { ChatHeader } from "./chat/ChatHeader"; +import { DesktopTitleBar } from "./DesktopTitleBar"; import { ContextWindowMeter } from "./chat/ContextWindowMeter"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; @@ -3904,7 +3905,8 @@ export default function ChatView({ threadId }: ChatViewProps) { )} - {isElectron && ( + {isWindowsElectron && } + {isElectron && !isWindowsElectron && (
No active thread
@@ -3920,11 +3922,14 @@ export default function ChatView({ threadId }: ChatViewProps) { return (
+ {isWindowsElectron && ( + + )} {/* Top bar */}
+
+
+ + T3 + + {APP_BASE_NAME} + + {APP_STAGE_LABEL} + +
+
+ +
+
+
{props.title}
+ {props.subtitle ? ( +
{props.subtitle}
+ ) : null} +
+
+ +
+ {props.trailing} + {props.showWindowControls === false ? null : } +
+
+ ); +} diff --git a/apps/web/src/components/DesktopWindowControls.test.tsx b/apps/web/src/components/DesktopWindowControls.test.tsx new file mode 100644 index 0000000000..049c60fbdf --- /dev/null +++ b/apps/web/src/components/DesktopWindowControls.test.tsx @@ -0,0 +1,38 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { DesktopWindowControls } from "./DesktopWindowControls"; + +function setDesktopBridge(value: unknown) { + vi.stubGlobal("window", { + desktopBridge: value, + }); +} + +describe("DesktopWindowControls", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("does not render controls when desktop bridge APIs are unavailable", () => { + setDesktopBridge(undefined); + const html = renderToStaticMarkup(); + + expect(html).toBe(""); + }); + + it("renders controls when desktop bridge APIs are available", () => { + setDesktopBridge({ + minimizeWindow: async () => undefined, + toggleMaximizeWindow: async () => ({ maximized: false }), + closeWindow: async () => undefined, + getWindowState: async () => ({ maximized: false }), + onWindowState: () => () => undefined, + }); + const html = renderToStaticMarkup(); + + expect(html).toContain("Minimize window"); + expect(html).toContain("Maximize window"); + expect(html).toContain("Close window"); + }); +}); diff --git a/apps/web/src/components/DesktopWindowControls.tsx b/apps/web/src/components/DesktopWindowControls.tsx new file mode 100644 index 0000000000..969c5f79c1 --- /dev/null +++ b/apps/web/src/components/DesktopWindowControls.tsx @@ -0,0 +1,95 @@ +import type { DesktopWindowState } from "@t3tools/contracts"; +import { Maximize2Icon, MinusIcon, Minimize2Icon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { cn } from "~/lib/utils"; + +function canControlDesktopWindow(): boolean { + return ( + typeof window !== "undefined" && + typeof window.desktopBridge?.minimizeWindow === "function" && + typeof window.desktopBridge?.toggleMaximizeWindow === "function" && + typeof window.desktopBridge?.closeWindow === "function" + ); +} + +export function DesktopWindowControls(props: { className?: string }) { + const [windowState, setWindowState] = useState({ maximized: false }); + const available = canControlDesktopWindow(); + + useEffect(() => { + if (!available) { + return; + } + const bridge = window.desktopBridge; + if (!bridge) { + return; + } + let disposed = false; + void bridge + .getWindowState() + .then((nextState) => { + if (!disposed) { + setWindowState(nextState); + } + }) + .catch(() => undefined); + const unsubscribe = bridge.onWindowState((nextState) => { + if (!disposed) { + setWindowState(nextState); + } + }); + return () => { + disposed = true; + unsubscribe(); + }; + }, [available]); + + if (!available) { + return null; + } + + return ( +
+ + + +
+ ); +} diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index c08c53325d..7fff32fc1c 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -1,14 +1,15 @@ import type { ReactNode } from "react"; -import { isElectron } from "~/env"; +import { isElectron, isWindowsElectron } from "~/env"; import { cn } from "~/lib/utils"; +import { DesktopTitleBar } from "./DesktopTitleBar"; import { Skeleton } from "./ui/skeleton"; export type DiffPanelMode = "inline" | "sheet" | "sidebar"; function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { - const shouldUseDragRegion = isElectron && mode !== "sheet"; + const shouldUseDragRegion = isElectron && mode !== "sheet" && !isWindowsElectron; return cn( "flex items-center justify-between gap-2 px-4", shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12", @@ -20,7 +21,8 @@ export function DiffPanelShell(props: { header: ReactNode; children: ReactNode; }) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet"; + const shouldUseDragRegion = isElectron && props.mode !== "sheet" && !isWindowsElectron; + const shouldUseWindowsTitleBar = isWindowsElectron && props.mode !== "sheet"; return (
- {shouldUseDragRegion ? ( + {shouldUseWindowsTitleBar ? ( + {props.header}
} + showWindowControls={false} + /> + ) : shouldUseDragRegion ? (
{props.header}
) : (
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d227e3a803..481369ee81 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -57,7 +57,13 @@ import { import { isElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { + isLinuxPlatform, + isMacPlatform, + isWindowsPlatform, + newCommandId, + newProjectId, +} from "../lib/utils"; import { useStore } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -739,6 +745,7 @@ export default function Sidebar() { const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); + const isWindowsDesktop = isElectron && isWindowsPlatform(navigator.platform); const platform = navigator.platform; const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; @@ -1960,7 +1967,11 @@ export default function Sidebar() { return ( <> {isElectron ? ( - + {wordmark} ) : ( diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index fb2e493cad..00d9c3132e 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -1,3 +1,5 @@ +import { isWindowsPlatform } from "./lib/utils"; + /** * True when running inside the Electron preload bridge, false in a regular browser. * The preload script sets window.nativeApi via contextBridge before any web-app @@ -6,3 +8,6 @@ export const isElectron = typeof window !== "undefined" && (window.desktopBridge !== undefined || window.nativeApi !== undefined); + +export const isWindowsElectron = + isElectron && typeof navigator !== "undefined" && isWindowsPlatform(navigator.platform); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 888e6ee74b..61f93f9df1 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,6 +1,7 @@ import { createFileRoute } from "@tanstack/react-router"; -import { isElectron } from "../env"; +import { isElectron, isWindowsElectron } from "../env"; +import { DesktopTitleBar } from "../components/DesktopTitleBar"; import { SidebarTrigger } from "../components/ui/sidebar"; function ChatIndexRouteView() { @@ -15,7 +16,9 @@ function ChatIndexRouteView() { )} - {isElectron && ( + {isWindowsElectron && } + + {isElectron && !isWindowsElectron && (
No active thread
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 45096fd6d6..d4d1a6a660 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -3,9 +3,10 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; +import { DesktopTitleBar } from "../components/DesktopTitleBar"; import { Button } from "../components/ui/button"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; -import { isElectron } from "../env"; +import { isElectron, isWindowsElectron } from "../env"; function SettingsContentLayout() { const [restoreSignal, setRestoreSignal] = useState(0); @@ -51,7 +52,24 @@ function SettingsContentLayout() { )} - {isElectron && ( + {isWindowsElectron && ( + void restoreDefaults()} + > + + Restore defaults + + } + /> + )} + + {isElectron && !isWindowsElectron && (
Settings diff --git a/apps/web/src/wsNativeApi.test.ts b/apps/web/src/wsNativeApi.test.ts index ae56f85991..0df9346cc2 100644 --- a/apps/web/src/wsNativeApi.test.ts +++ b/apps/web/src/wsNativeApi.test.ts @@ -140,6 +140,11 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg throw new Error("installUpdate not implemented in test"); }, onUpdateState: () => () => undefined, + minimizeWindow: async () => undefined, + toggleMaximizeWindow: async () => ({ maximized: false }), + closeWindow: async () => undefined, + getWindowState: async () => ({ maximized: false }), + onWindowState: () => () => undefined, ...overrides, }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 630ccd8249..233c2795c8 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -105,6 +105,10 @@ export interface DesktopUpdateCheckResult { state: DesktopUpdateState; } +export interface DesktopWindowState { + maximized: boolean; +} + export interface DesktopBridge { getWsUrl: () => string | null; pickFolder: () => Promise; @@ -121,6 +125,11 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + minimizeWindow: () => Promise; + toggleMaximizeWindow: () => Promise; + closeWindow: () => Promise; + getWindowState: () => Promise; + onWindowState: (listener: (state: DesktopWindowState) => void) => () => void; } export interface NativeApi { From 5b33d8f5aa750a1a3aefec2fb6e210f94ae6773a Mon Sep 17 00:00:00 2001 From: rot <96458554+rotlove@users.noreply.github.com> Date: Tue, 7 Apr 2026 02:28:45 +0300 Subject: [PATCH 2/7] Refine Windows custom title bar context and remove chat badge --- apps/web/src/components/ChatView.tsx | 22 +++++++++++-- apps/web/src/components/DesktopTitleBar.tsx | 34 ++++++++++++++------- apps/web/src/components/DiffPanelShell.tsx | 2 ++ apps/web/src/routes/_chat.index.tsx | 10 +++++- apps/web/src/routes/settings.tsx | 2 ++ 5 files changed, 55 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index aac15d61e7..17d39eac18 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3905,7 +3905,15 @@ export default function ChatView({ threadId }: ChatViewProps) {
)} - {isWindowsElectron && } + {isWindowsElectron && ( + + )} {isElectron && !isWindowsElectron && (
No active thread @@ -3923,13 +3931,21 @@ export default function ChatView({ threadId }: ChatViewProps) { return (
{isWindowsElectron && ( - + )} {/* Top bar */}
-
-
- - T3 - - {APP_BASE_NAME} - - {APP_STAGE_LABEL} - + {showContextChip ? ( +
+
+ + T3 + + + {contextLabel} + + {contextValue ? ( + + {contextValue} + + ) : null} +
-
+ ) : null}
diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 7fff32fc1c..1a3ca02711 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -36,6 +36,8 @@ export function DiffPanelShell(props: { {shouldUseWindowsTitleBar ? ( {props.header}
} showWindowControls={false} /> diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 61f93f9df1..1cb3c78062 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -16,7 +16,15 @@ function ChatIndexRouteView() {
)} - {isWindowsElectron && } + {isWindowsElectron && ( + + )} {isElectron && !isWindowsElectron && (
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index d4d1a6a660..e411c3919a 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -55,6 +55,8 @@ function SettingsContentLayout() { {isWindowsElectron && ( Date: Sat, 11 Apr 2026 21:18:50 -0700 Subject: [PATCH 3/7] Refine Windows title bar and header layout - Add native Windows title bar overlay sync - Rework desktop headers, diff panel, and window controls - Split chat header actions into a reusable component --- CLAUDE.md | 2 +- apps/desktop/src/main.ts | 33 +++- apps/web/src/components/ChatView.tsx | 110 +++++++---- apps/web/src/components/DesktopTitleBar.tsx | 69 +++++-- .../src/components/DesktopWindowControls.tsx | 38 ++-- apps/web/src/components/DiffPanelShell.tsx | 40 ++-- apps/web/src/components/Sidebar.tsx | 25 ++- apps/web/src/components/chat/ChatHeader.tsx | 173 ++++++++++-------- apps/web/src/index.css | 12 ++ apps/web/src/main.tsx | 48 +++++ apps/web/src/routes/_chat.index.tsx | 4 +- 11 files changed, 384 insertions(+), 170 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 47dc3e3d86..c317064255 120000 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1 @@ -AGENTS.md \ No newline at end of file +AGENTS.md diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f1f8bba3db..18e7eb0d2a 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -88,6 +88,11 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; +const WINDOWS_TITLEBAR_HEIGHT = 40; +const WINDOWS_TITLEBAR_LIGHT_COLOR = "#ffffff"; +const WINDOWS_TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937"; +const WINDOWS_TITLEBAR_DARK_COLOR = "#0e1218"; +const WINDOWS_TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -1272,6 +1277,9 @@ function registerIpcHandlers(): void { } nativeTheme.themeSource = theme; + if (mainWindow) { + syncWindowsTitleBarOverlay(mainWindow); + } }); ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); @@ -1405,6 +1413,23 @@ function getIconOption(): { icon: string } | Record { return iconPath ? { icon: iconPath } : {}; } +function getWindowsTitleBarOverlayOptions() { + const isDark = nativeTheme.shouldUseDarkColors; + return { + height: WINDOWS_TITLEBAR_HEIGHT, + color: isDark ? WINDOWS_TITLEBAR_DARK_COLOR : WINDOWS_TITLEBAR_LIGHT_COLOR, + symbolColor: isDark ? WINDOWS_TITLEBAR_DARK_SYMBOL_COLOR : WINDOWS_TITLEBAR_LIGHT_SYMBOL_COLOR, + } as const; +} + +function syncWindowsTitleBarOverlay(window: BrowserWindow) { + if (process.platform !== "win32") { + return; + } + + window.setTitleBarOverlay(getWindowsTitleBarOverlayOptions()); +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, @@ -1416,7 +1441,11 @@ function createWindow(): BrowserWindow { ...getIconOption(), title: APP_DISPLAY_NAME, ...(process.platform === "win32" - ? { frame: false } + ? { + frame: false, + titleBarStyle: "hidden" as const, + titleBarOverlay: getWindowsTitleBarOverlayOptions(), + } : { titleBarStyle: "hiddenInset" as const, trafficLightPosition: { x: 16, y: 18 } }), webPreferences: { preload: Path.join(__dirname, "preload.js"), @@ -1426,6 +1455,8 @@ function createWindow(): BrowserWindow { }, }); + syncWindowsTitleBarOverlay(window); + window.webContents.on("context-menu", (event, params) => { event.preventDefault(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 63e55423b2..19e98f2a59 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -153,7 +153,7 @@ import { selectThreadTerminalState, useTerminalStateStore } from "../terminalSta import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "./ComposerPromptEditor"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; -import { ChatHeader } from "./chat/ChatHeader"; +import { ChatHeader, ChatHeaderActions } from "./chat/ChatHeader"; import { DesktopTitleBar } from "./DesktopTitleBar"; import { ContextWindowMeter } from "./chat/ContextWindowMeter"; import { buildExpandedImagePreview, ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -3936,52 +3936,84 @@ export default function ChatView({ threadId }: ChatViewProps) { return (
- {isWindowsElectron && ( + {isWindowsElectron ? ( - )} - {/* Top bar */} -
- + { + void runProjectScript(script); + }} + onAddProjectScript={saveProjectScript} + onUpdateProjectScript={updateProjectScript} + onDeleteProjectScript={deleteProjectScript} + onToggleTerminal={toggleTerminalVisibility} + onToggleDiff={onToggleDiff} + /> +
} - keybindings={keybindings} - availableEditors={availableEditors} - terminalAvailable={activeProject !== undefined} - terminalOpen={terminalState.terminalOpen} - terminalToggleShortcutLabel={terminalToggleShortcutLabel} - diffToggleShortcutLabel={diffPanelShortcutLabel} - gitCwd={gitCwd} - diffOpen={diffOpen} - onRunProjectScript={(script) => { - void runProjectScript(script); - }} - onAddProjectScript={saveProjectScript} - onUpdateProjectScript={updateProjectScript} - onDeleteProjectScript={deleteProjectScript} - onToggleTerminal={toggleTerminalVisibility} - onToggleDiff={onToggleDiff} /> - + ) : ( +
+ { + void runProjectScript(script); + }} + onAddProjectScript={saveProjectScript} + onUpdateProjectScript={updateProjectScript} + onDeleteProjectScript={deleteProjectScript} + onToggleTerminal={toggleTerminalVisibility} + onToggleDiff={onToggleDiff} + /> +
+ )} {/* Error banner */} diff --git a/apps/web/src/components/DesktopTitleBar.tsx b/apps/web/src/components/DesktopTitleBar.tsx index d6964ac4ca..a1c800b28c 100644 --- a/apps/web/src/components/DesktopTitleBar.tsx +++ b/apps/web/src/components/DesktopTitleBar.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; +import { isWindowsElectron } from "~/env"; import { cn } from "~/lib/utils"; import { DesktopWindowControls } from "./DesktopWindowControls"; @@ -13,17 +14,28 @@ interface DesktopTitleBarProps { trailing?: ReactNode; className?: string; showWindowControls?: boolean; + titleViewportPaddingClassName?: string; + titleAlignment?: "center" | "left"; + useNativeWindowControlsOverlay?: boolean; + reserveNativeWindowControlsOverlay?: boolean; } export function DesktopTitleBar(props: DesktopTitleBarProps) { const showContextChip = props.showContextChip ?? true; const contextLabel = props.contextLabel ?? "Workspace"; const contextValue = props.contextValue; + const titleAlignment = props.titleAlignment ?? "center"; + const useNativeWindowControlsOverlay = props.useNativeWindowControlsOverlay ?? isWindowsElectron; + const reserveNativeWindowControlsOverlay = + props.reserveNativeWindowControlsOverlay ?? + (useNativeWindowControlsOverlay && props.showWindowControls !== false); return (
@@ -45,18 +57,53 @@ export function DesktopTitleBar(props: DesktopTitleBarProps) {
) : null} -
-
-
{props.title}
- {props.subtitle ? ( -
{props.subtitle}
- ) : null} + {titleAlignment === "center" ? ( +
+
+
{props.title}
+ {props.subtitle ? ( +
{props.subtitle}
+ ) : null} +
-
+ ) : ( +
+
+
+
+ {props.title} +
+ {props.subtitle ? ( +
+ {props.subtitle} +
+ ) : null} +
+
+
+ )} -
- {props.trailing} - {props.showWindowControls === false ? null : } +
+ {props.trailing ? ( +
+ {props.trailing} +
+ ) : null} + {props.showWindowControls === false ? null : reserveNativeWindowControlsOverlay ? ( +
); diff --git a/apps/web/src/components/DesktopWindowControls.tsx b/apps/web/src/components/DesktopWindowControls.tsx index 969c5f79c1..f70905a2c1 100644 --- a/apps/web/src/components/DesktopWindowControls.tsx +++ b/apps/web/src/components/DesktopWindowControls.tsx @@ -1,5 +1,5 @@ import type { DesktopWindowState } from "@t3tools/contracts"; -import { Maximize2Icon, MinusIcon, Minimize2Icon, XIcon } from "lucide-react"; +import { MinusIcon, XIcon } from "lucide-react"; import { useEffect, useState } from "react"; import { cn } from "~/lib/utils"; @@ -13,6 +13,19 @@ function canControlDesktopWindow(): boolean { ); } +function MaximizeGlyph() { + return