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 3d61571df9..c838f1c0c4 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -82,6 +82,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 = "#161616"; +const WINDOWS_TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc"; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { @@ -1204,6 +1209,7 @@ function registerIpcHandlers(): void { } nativeTheme.themeSource = theme; + syncAllWindowsTitleBarOverlays(); }); ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); @@ -1337,6 +1343,36 @@ function getIconOption(): { icon: string } | Record { return iconPath ? { icon: iconPath } : {}; } +function getWindowsTitleBarOverlayOptions() { + const isDark = + nativeTheme.themeSource === "dark" || + (nativeTheme.themeSource === "system" && 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 syncAllWindowsTitleBarOverlays() { + if (process.platform !== "win32") { + return; + } + + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) continue; + syncWindowsTitleBarOverlay(window); + } +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, @@ -1347,8 +1383,13 @@ function createWindow(): BrowserWindow { autoHideMenuBar: true, ...getIconOption(), title: APP_DISPLAY_NAME, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, + ...(process.platform === "win32" + ? { + frame: false, + titleBarStyle: "hidden" as const, + titleBarOverlay: getWindowsTitleBarOverlayOptions(), + } + : { titleBarStyle: "hiddenInset" as const, trafficLightPosition: { x: 16, y: 18 } }), webPreferences: { preload: Path.join(__dirname, "preload.js"), contextIsolation: true, @@ -1357,6 +1398,8 @@ function createWindow(): BrowserWindow { }, }); + syncWindowsTitleBarOverlay(window); + window.webContents.on("context-menu", (event, params) => { event.preventDefault(); @@ -1463,6 +1506,9 @@ app .then(() => { writeDesktopLogHeader("app ready"); configureAppIdentity(); + nativeTheme.on("updated", () => { + syncAllWindowsTitleBarOverlays(); + }); configureApplicationMenu(); registerDesktopProtocol(); configureAutoUpdater(); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7c649b5003..fb756d2854 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,7 +153,8 @@ 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"; import { AVAILABLE_PROVIDER_OPTIONS, ProviderModelPicker } from "./chat/ProviderModelPicker"; @@ -3911,9 +3912,10 @@ export default function ChatView({ threadId }: ChatViewProps) { )} {isElectron && ( -
- No active thread -
+ )}
@@ -3926,41 +3928,80 @@ export default function ChatView({ threadId }: ChatViewProps) { return (
- {/* 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 new file mode 100644 index 0000000000..bc4639c7d4 --- /dev/null +++ b/apps/web/src/components/DesktopTitleBar.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from "react"; + +import { isWindowsElectron } from "~/env"; +import { cn } from "~/lib/utils"; + +interface DesktopTitleBarProps { + title: string; + subtitle?: string; + trailing?: ReactNode; + className?: string; + reserveNativeWindowControlsOverlay?: boolean; + tone?: "default" | "subtle"; +} + +export function DesktopTitleBar(props: DesktopTitleBarProps) { + const reserveNativeWindowControlsOverlay = + props.reserveNativeWindowControlsOverlay ?? isWindowsElectron; + const tone = props.tone ?? "default"; + + return ( +
+
+
+
+
+ {props.title} +
+ {props.subtitle ? ( +
+ {props.subtitle} +
+ ) : null} +
+
+
+ +
+ {props.trailing ? ( +
+ {props.trailing} +
+ ) : null} + {reserveNativeWindowControlsOverlay ? ( + +
+ ); +} diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index c08c53325d..ab865469e1 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from "react"; -import { isElectron } from "~/env"; +import { isElectron, isWindowsElectron } from "~/env"; import { cn } from "~/lib/utils"; import { Skeleton } from "./ui/skeleton"; @@ -8,19 +8,28 @@ 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", + "flex items-center justify-between gap-2", + shouldUseDragRegion + ? "drag-region h-[52px] border-b border-border px-4" + : mode !== "sheet" + ? "h-12 px-4 desktop-windows:h-[var(--desktop-titlebar-height)]" + : "h-12 px-4", ); } +function shouldReserveNativeOverlayInset(mode: DiffPanelMode) { + return isWindowsElectron && mode !== "sheet"; +} + export function DiffPanelShell(props: { mode: DiffPanelMode; header: ReactNode; children: ReactNode; }) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet"; + const shouldUseDragRegion = isElectron && props.mode !== "sheet" && !isWindowsElectron; + const reserveNativeOverlayInset = shouldReserveNativeOverlayInset(props.mode); return (
{props.header}
) : ( -
-
{props.header}
+
+
+ {props.header} + {reserveNativeOverlayInset ? ( +
)} {props.children} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5b4da4655c..f59ac16426 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -54,10 +54,10 @@ import { type SidebarProjectSortOrder, type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; -import { isElectron } from "../env"; +import { isElectron, isWindowsElectron } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; +import { cn, isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; import { useStore } from "../store"; import { selectThreadTerminalState, useTerminalStateStore } from "../terminalStateStore"; import { useUiStateStore } from "../uiStateStore"; @@ -2051,7 +2051,10 @@ export default function Sidebar() { render={ @@ -2074,7 +2077,14 @@ export default function Sidebar() { return ( <> {isElectron ? ( - + {wordmark} ) : ( diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index f04c9879fa..a6c935023c 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -5,18 +5,18 @@ import { type ThreadId, } from "@t3tools/contracts"; import { memo } from "react"; -import GitActionsControl from "../GitActionsControl"; import { DiffIcon, TerminalSquareIcon } from "lucide-react"; +import GitActionsControl from "../GitActionsControl"; +import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Badge } from "../ui/badge"; +import { cn } from "~/lib/utils"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Toggle } from "../ui/toggle"; import { SidebarTrigger } from "../ui/sidebar"; import { OpenInPicker } from "./OpenInPicker"; -interface ChatHeaderProps { +interface ChatHeaderActionsProps { activeThreadId: ThreadId; - activeThreadTitle: string; activeProjectName: string | undefined; isGitRepo: boolean; openInCwd: string | null; @@ -36,11 +36,15 @@ interface ChatHeaderProps { onDeleteProjectScript: (scriptId: string) => Promise; onToggleTerminal: () => void; onToggleDiff: () => void; + className?: string; } -export const ChatHeader = memo(function ChatHeader({ +interface ChatHeaderProps extends ChatHeaderActionsProps { + activeThreadTitle: string; +} + +export const ChatHeaderActions = memo(function ChatHeaderActions({ activeThreadId, - activeThreadTitle, activeProjectName, isGitRepo, openInCwd, @@ -60,9 +64,94 @@ export const ChatHeader = memo(function ChatHeader({ onDeleteProjectScript, onToggleTerminal, onToggleDiff, + className, +}: ChatHeaderActionsProps) { + return ( +
+ {activeProjectScripts && ( + + )} + {activeProjectName && ( + + )} + {activeProjectName && } + + + + + } + /> + + {!terminalAvailable + ? "Terminal is unavailable until this thread has an active project." + : terminalToggleShortcutLabel + ? `Toggle terminal drawer (${terminalToggleShortcutLabel})` + : "Toggle terminal drawer"} + + + + + + + } + /> + + {!isGitRepo + ? "Diff panel is unavailable because this project is not a git repository." + : diffToggleShortcutLabel + ? `Toggle diff panel (${diffToggleShortcutLabel})` + : "Toggle diff panel"} + + +
+ ); +}); + +export const ChatHeader = memo(function ChatHeader({ + activeThreadTitle, + activeProjectName, + isGitRepo, + ...actions }: ChatHeaderProps) { return ( -
+

)}

-
- {activeProjectScripts && ( - - )} - {activeProjectName && ( - - )} - {activeProjectName && } - - - - - } - /> - - {!terminalAvailable - ? "Terminal is unavailable until this thread has an active project." - : terminalToggleShortcutLabel - ? `Toggle terminal drawer (${terminalToggleShortcutLabel})` - : "Toggle terminal drawer"} - - - - - - - } - /> - - {!isGitRepo - ? "Diff panel is unavailable because this project is not a git repository." - : diffToggleShortcutLabel - ? `Toggle diff panel (${diffToggleShortcutLabel})` - : "Toggle diff panel"} - - -
+
); }); 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/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 6afe83dfe3..03af6ee0e8 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -12,6 +12,7 @@ const MEDIA_QUERY = "(prefers-color-scheme: dark)"; let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; +let pendingDesktopThemeSyncFrame: number | null = null; function emitChange() { for (const listener of listeners) listener(); } @@ -32,7 +33,13 @@ function applyTheme(theme: Theme, suppressTransitions = false) { } const isDark = theme === "dark" || (theme === "system" && getSystemDark()); document.documentElement.classList.toggle("dark", isDark); - syncDesktopTheme(theme); + if (pendingDesktopThemeSyncFrame !== null) { + cancelAnimationFrame(pendingDesktopThemeSyncFrame); + } + pendingDesktopThemeSyncFrame = requestAnimationFrame(() => { + pendingDesktopThemeSyncFrame = null; + syncDesktopTheme(theme); + }); if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal // oxlint-disable-next-line no-unused-expressions diff --git a/apps/web/src/index.css b/apps/web/src/index.css index ea76f24fac..0d3c3e7630 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,6 +1,11 @@ @import "tailwindcss"; @custom-variant dark (&:is(.dark, .dark *)); +@custom-variant electron (&:where(.electron *)); +@custom-variant windows (&:where(.os-windows *)); +@custom-variant macos (&:where(.os-macos *)); +@custom-variant linux (&:where(.os-linux *)); +@custom-variant desktop-windows (&:where(.electron.os-windows *)); @theme inline { --animate-skeleton: skeleton 2s -1s infinite linear; @@ -63,6 +68,8 @@ :root { color-scheme: light; + --desktop-titlebar-height: 40px; + --desktop-titlebar-surface: var(--background); --radius: 0.625rem; --background: var(--color-white); --foreground: var(--color-neutral-800); @@ -156,6 +163,10 @@ body::after { background-size: 256px 256px; } +.electron.os-windows body::after { + top: var(--desktop-titlebar-height); +} + pre, code, textarea, diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index fda5913c97..2a18ca7c16 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -7,6 +7,7 @@ import "@xterm/xterm/css/xterm.css"; import "./index.css"; import { isElectron } from "./env"; +import { isLinuxPlatform, isMacPlatform, isWindowsPlatform } from "./lib/utils"; import { getRouter } from "./router"; import { APP_DISPLAY_NAME } from "./branding"; @@ -17,6 +18,53 @@ const router = getRouter(history); document.title = APP_DISPLAY_NAME; +const rootElement = document.documentElement; +rootElement.classList.remove("electron", "os-windows", "os-macos", "os-linux"); +rootElement.style.setProperty("--desktop-titlebar-height", "40px"); + +if (isElectron) { + rootElement.classList.add("electron"); +} + +if (typeof navigator !== "undefined") { + if (isWindowsPlatform(navigator.platform)) { + rootElement.classList.add("os-windows"); + rootElement.dataset.platform = "windows"; + } else if (isMacPlatform(navigator.platform)) { + rootElement.classList.add("os-macos"); + rootElement.dataset.platform = "macos"; + } else if (isLinuxPlatform(navigator.platform)) { + rootElement.classList.add("os-linux"); + rootElement.dataset.platform = "linux"; + } else { + delete rootElement.dataset.platform; + } +} + +interface WindowControlsOverlayLike extends EventTarget { + getTitlebarAreaRect(): DOMRect; +} + +const windowControlsOverlay = ( + navigator as Navigator & { windowControlsOverlay?: WindowControlsOverlayLike } +).windowControlsOverlay; + +if (windowControlsOverlay && typeof windowControlsOverlay.getTitlebarAreaRect === "function") { + const syncDesktopTitlebarHeight = (rect?: DOMRect) => { + const nextRect = rect ?? windowControlsOverlay.getTitlebarAreaRect(); + const nextHeight = nextRect.height; + rootElement.style.setProperty( + "--desktop-titlebar-height", + Number.isFinite(nextHeight) && nextHeight > 0 ? `${nextHeight}px` : "40px", + ); + }; + + syncDesktopTitlebarHeight(); + windowControlsOverlay.addEventListener("geometrychange", (event) => { + syncDesktopTitlebarHeight((event as Event & { titlebarAreaRect?: DOMRect }).titlebarAreaRect); + }); +} + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 888e6ee74b..d18634c5b5 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() { @@ -16,9 +17,7 @@ function ChatIndexRouteView() { )} {isElectron && ( -
- No active thread -
+ )}
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 45096fd6d6..d48da80fbb 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -3,15 +3,27 @@ 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); const { changedSettingLabels, restoreDefaults } = useSettingsRestore(() => setRestoreSignal((value) => value + 1), ); + const restoreDefaultsButton = ( + + ); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { @@ -36,38 +48,17 @@ function SettingsContentLayout() {
Settings -
- -
+
{restoreDefaultsButton}
)} {isElectron && ( -
- - Settings - -
- -
-
+ )}