From fb71b3fdee9d3dfc8993b477bc3ec67e5d2a78e6 Mon Sep 17 00:00:00 2001 From: SpookySandwich <210268775+SpookySandwich@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:01:23 +0800 Subject: [PATCH 1/2] Add Windows support: NSIS installer, immersive title bar, CI Packaging - electron-builder: win NSIS target (x64 + arm64), icon.ico, **/*.exe asar unpack; package:win scripts. Skip @electron/rebuild on Windows (node-pty ships win32 N-API prebuilds; from-source rebuild fails on its bundled winpty), leaving mac/Linux build behavior unchanged. - CI: build the installers on every PR (ci.yml) and upload them on every tag (release.yml). Unsigned (no Authenticode cert). App behavior (Windows-gated; macOS/Linux unchanged) - Immersive title bar: titleBarStyle "hidden" + Window Controls Overlay (transparent background, theme-synced glyphs); drop the menu bar; add a topbar menu button with a native popup menu and a right-click edit menu. - Render the sidebar toggle last and mark the composer/model-selector menus no-drag so the topbar drag region doesn't swallow their clicks. - Suppress console-window flashes: default windowsHide on child_process spawns (covers the pi runtime's package install) and on our git calls. - ms-settings:notifications deep link; cmd.exe default integrated shell; thin scrollbars. --- .github/workflows/ci.yml | 20 +++ .github/workflows/release.yml | 41 ++++++ apps/desktop/electron-builder.yml | 17 +++ apps/desktop/electron/app-store-diff.ts | 10 +- apps/desktop/electron/app-store-files.ts | 2 +- apps/desktop/electron/main.ts | 121 ++++++++++++++++-- .../electron/notification-permission.ts | 5 + apps/desktop/electron/preload.ts | 1 + .../electron/suppress-windows-console.ts | 29 +++++ apps/desktop/electron/terminal-service.ts | 5 +- apps/desktop/electron/theme-manager.ts | 28 ++++ apps/desktop/electron/worktree-manager.ts | 1 + apps/desktop/package.json | 2 + apps/desktop/resources/icon.ico | Bin 0 -> 16407 bytes apps/desktop/src/App.tsx | 20 +-- apps/desktop/src/icons.tsx | 8 ++ apps/desktop/src/ipc.ts | 2 + apps/desktop/src/styles/main.css | 36 ++++++ apps/desktop/src/styles/sidebar.css | 5 + apps/desktop/src/topbar.tsx | 14 +- 20 files changed, 343 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/electron/suppress-windows-console.ts create mode 100644 apps/desktop/resources/icon.ico diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd43a2f7..0259a942 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,3 +66,23 @@ jobs: - name: Verify packaged runtime dependencies run: pnpm --dir apps/desktop run verify:packaged-runtime-deps:linux + + desktop-package-windows: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + # Builds both x64 and arm64 NSIS installers (electron-builder.yml win arch). + - name: Package Windows installers + run: pnpm --filter @pi-gui/desktop run package:win diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8801e92..46b6560c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,3 +162,44 @@ jobs: apps/desktop/release/latest-linux*.yml prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') }} generate_release_notes: true + + release-windows: + runs-on: windows-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + # Skip @electron/rebuild: node-pty ships win32 N-API prebuilds (rebuild + # fails on its bundled winpty). Installers are unsigned (no cert). + - name: Package Windows installers + shell: bash + run: | + cd apps/desktop + pnpm run build + pnpm exec electron-builder --win \ + -c.npmRebuild=false \ + --publish never \ + "-c.extraMetadata.version=${GITHUB_REF_NAME#v}" + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: | + apps/desktop/release/*.exe + apps/desktop/release/*.exe.blockmap + apps/desktop/release/latest.yml + prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'rc') }} + generate_release_notes: true diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index f175455d..b7bca812 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -18,6 +18,7 @@ extraResources: asar: true asarUnpack: - "**/*.node" + - "**/*.exe" - "**/*.wasm" - "**/node_modules/node-pty/**/*" - "**/node_modules/.pnpm/node-pty*/node_modules/node-pty/**/*" @@ -47,6 +48,22 @@ linux: target: - target: AppImage +win: + icon: resources/icon.ico + target: + - target: nsis + arch: + - x64 + - arm64 + +nsis: + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + createDesktopShortcut: true + createStartMenuShortcut: true + shortcutName: pi-gui + toolsets: appimage: "1.0.2" diff --git a/apps/desktop/electron/app-store-diff.ts b/apps/desktop/electron/app-store-diff.ts index c284ea1e..97bf5859 100644 --- a/apps/desktop/electron/app-store-diff.ts +++ b/apps/desktop/electron/app-store-diff.ts @@ -20,7 +20,7 @@ export function getChangedFiles(workspacePath: string): Promise { if (error) { resolve([]); @@ -56,14 +56,14 @@ export function getFileDiff(workspacePath: string, filePath: string): Promise { if (error || !stdout.trim()) { // Try staged diff execFile( "git", ["diff", "--cached", "--", filePath], - { cwd: workspacePath, maxBuffer: 5 * 1024 * 1024 }, + { cwd: workspacePath, maxBuffer: 5 * 1024 * 1024, windowsHide: true }, (error2, stdout2) => { if (!error2 && stdout2.trim()) { resolve(stdout2); @@ -73,7 +73,7 @@ export function getFileDiff(workspacePath: string, filePath: string): Promise { // git diff --no-index exits 1 when files differ, which is expected resolve(stdout3 || ""); @@ -95,7 +95,7 @@ export function stageFile(workspacePath: string, filePath: string): Promise { if (error) { reject(error); diff --git a/apps/desktop/electron/app-store-files.ts b/apps/desktop/electron/app-store-files.ts index c2812380..adb370ef 100644 --- a/apps/desktop/electron/app-store-files.ts +++ b/apps/desktop/electron/app-store-files.ts @@ -14,7 +14,7 @@ export function listWorkspaceFiles(workspacePath: string): Promise { execFile( "git", ["ls-files", "--cached", "--others", "--exclude-standard"], - { cwd: workspacePath, maxBuffer: 5 * 1024 * 1024 }, + { cwd: workspacePath, maxBuffer: 5 * 1024 * 1024, windowsHide: true }, (error, stdout) => { if (error) { resolve([]); diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index dbbcf24b..7c4337aa 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,3 +1,5 @@ +// Patch child_process for windowsHide before the pi runtime is imported below. Keep first. +import "./suppress-windows-console"; import { app, BrowserWindow, @@ -23,10 +25,10 @@ import { NotificationPermissionService, } from "./notification-permission"; import { checkForUpdate, initUpdateChecker } from "./update-checker"; -import { ThemeManager } from "./theme-manager"; +import { ThemeManager, windowsTitleBarOverlay } from "./theme-manager"; import { TerminalService } from "./terminal-service"; import type { DesktopAppState, ThemeMode } from "../src/desktop-state"; -import { desktopIpc, getDesktopCommandFromShortcut } from "../src/ipc"; +import { desktopCommands, desktopIpc, getDesktopCommandFromShortcut, type PiDesktopCommand } from "../src/ipc"; import { SUPPORTED_COMPOSER_IMAGE_TYPES } from "../src/composer-attachments"; import type { ComposerAttachment, @@ -118,17 +120,26 @@ function readClipboardImageAttachment(): ComposerImageAttachment | null { function createWindow(): BrowserWindow { const backgroundTestMode = windowTestMode === "background"; - const enableTransparency = store ? store.state.enableTransparency : false; + const isMac = process.platform === "darwin"; + // macOS: inset traffic lights + vibrancy. Windows: Window Controls Overlay. + // Linux: standard frame. + const useTransparency = isMac && (store ? store.state.enableTransparency : false); const window = new BrowserWindow({ width: 1480, height: 980, minWidth: 1200, minHeight: 760, - transparent: enableTransparency, - vibrancy: process.platform === "darwin" && enableTransparency ? "under-window" : undefined, - titleBarStyle: "hiddenInset", - backgroundColor: enableTransparency ? "#00000000" : "#f3f4f8", - trafficLightPosition: { x: 18, y: 18 }, + transparent: useTransparency, + vibrancy: useTransparency ? "under-window" : undefined, + backgroundColor: useTransparency ? "#00000000" : "#f3f4f8", + ...(isMac + ? { titleBarStyle: "hiddenInset" as const, trafficLightPosition: { x: 18, y: 18 } } + : process.platform === "win32" + ? { + titleBarStyle: "hidden" as const, + titleBarOverlay: windowsTitleBarOverlay(themeManager.getResolvedTheme()), + } + : {}), show: false, icon: appIcon, webPreferences: { @@ -184,6 +195,30 @@ function createWindow(): BrowserWindow { } }); + if (process.platform === "win32") { + // No menu bar on Windows: right-click edit menu for text fields. + window.webContents.on("context-menu", (_event, params) => { + const template: MenuItemConstructorOptions[] = params.isEditable + ? [ + { role: "undo" }, + { role: "redo" }, + { type: "separator" }, + { role: "cut", enabled: params.editFlags.canCut }, + { role: "copy", enabled: params.editFlags.canCopy }, + { role: "paste", enabled: params.editFlags.canPaste }, + { type: "separator" }, + { role: "selectAll" }, + ] + : params.selectionText.trim().length > 0 + ? [{ role: "copy" }, { type: "separator" }, { role: "selectAll" }] + : []; + if (template.length === 0) { + return; + } + Menu.buildFromTemplate(template).popup({ window }); + }); + } + if (isDev) { void window.loadURL(process.env.ELECTRON_RENDERER_URL as string); if (process.env.PI_APP_OPEN_DEVTOOLS !== "0") { @@ -326,6 +361,11 @@ async function runManualUpdateCheck(): Promise { } function installApplicationMenu(): void { + if (process.platform === "win32") { + // No menu bar on Windows; the in-app topbar is the only chrome. + Menu.setApplicationMenu(null); + return; + } if (process.platform !== "darwin") { return; } @@ -376,6 +416,67 @@ function installApplicationMenu(): void { Menu.setApplicationMenu(Menu.buildFromTemplate(template)); } +function sendDesktopCommand(command: PiDesktopCommand): void { + if (mainWindow && canPublishToWindow(mainWindow)) { + mainWindow.webContents.send(desktopIpc.appCommand, command); + } +} + +function showAboutDialog(): void { + const window = mainWindow && canPublishToWindow(mainWindow) ? mainWindow : undefined; + const options: MessageBoxOptions = { + type: "info", + title: "pi-gui", + message: "pi-gui", + detail: `Version ${app.getVersion()}\nElectron ${process.versions.electron}\nA Codex-style desktop app for pi.`, + buttons: ["OK"], + }; + if (window) { + void dialog.showMessageBox(window, options); + } else { + void dialog.showMessageBox(options); + } +} + +// Surfaced from the topbar menu button (popupAppMenu); Windows has no menu bar. +function buildWindowsAppMenu(): Menu { + const template: MenuItemConstructorOptions[] = [ + { + label: "File", + submenu: [ + { label: "New Thread", click: () => sendDesktopCommand(desktopCommands.openNewThread) }, + { + label: "Open Folder…", + click: () => { + void pickWorkspaceViaDialog(); + }, + }, + { type: "separator" }, + { label: "Settings", click: () => sendDesktopCommand(desktopCommands.openSettings) }, + { type: "separator" }, + { role: "quit" }, + ], + }, + { role: "editMenu" }, + { role: "viewMenu" }, + { role: "windowMenu" }, + { + label: "Help", + submenu: [ + { + id: CHECK_FOR_UPDATES_MENU_ITEM_ID, + label: "Check for Updates…", + click: () => { + void runManualUpdateCheck(); + }, + }, + { label: "About pi", click: () => showAboutDialog() }, + ], + }, + ]; + return Menu.buildFromTemplate(template); +} + app.setName("pi"); const configuredUserDataDir = process.env.PI_APP_USER_DATA_DIR?.trim() || app.getPath("userData"); @@ -735,6 +836,10 @@ app.whenReady().then(async () => { window.maximize(); }); + ipcMain.handle(desktopIpc.popupAppMenu, (event) => { + const window = BrowserWindow.fromWebContents(event.sender) ?? mainWindow ?? undefined; + buildWindowsAppMenu().popup(window ? { window } : undefined); + }); mainWindow = createWindow(); notificationManager.trackWindow(mainWindow); diff --git a/apps/desktop/electron/notification-permission.ts b/apps/desktop/electron/notification-permission.ts index d7d6915a..d85e5c57 100644 --- a/apps/desktop/electron/notification-permission.ts +++ b/apps/desktop/electron/notification-permission.ts @@ -255,6 +255,11 @@ async function openSystemNotificationSettingsInternal(): Promise { return; } + if (process.platform === "win32") { + await shell.openExternal("ms-settings:notifications"); + return; + } + if (process.platform !== "darwin") { await shell.openExternal("https://support.apple.com/guide/mac-help/change-notifications-settings-mh40583/mac"); return; diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 7af681bc..b4715759 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -257,6 +257,7 @@ contextBridge.exposeInMainWorld("piApp", { stageFile: (workspaceId: string, filePath: string) => ipcRenderer.invoke(desktopIpc.stageFile, workspaceId, filePath) as Promise, toggleWindowMaximize: () => ipcRenderer.invoke(desktopIpc.toggleWindowMaximize) as Promise, + popupAppMenu: () => ipcRenderer.invoke(desktopIpc.popupAppMenu) as Promise, openExternal: (url: string) => ipcRenderer.invoke(desktopIpc.openExternal, url) as Promise, getThemeMode: () => ipcRenderer.invoke(desktopIpc.getThemeMode) as Promise<"system" | "light" | "dark">, getResolvedTheme: () => ipcRenderer.invoke(desktopIpc.getResolvedTheme) as Promise<"light" | "dark">, diff --git a/apps/desktop/electron/suppress-windows-console.ts b/apps/desktop/electron/suppress-windows-console.ts new file mode 100644 index 00000000..f6e2b44b --- /dev/null +++ b/apps/desktop/electron/suppress-windows-console.ts @@ -0,0 +1,29 @@ +// A GUI process has no console, so spawning console programs (git, npm, bash) +// flashes a console window on Windows. Default windowsHide:true on every +// child_process spawn in the main process (no-op off Windows; explicit values win). +import childProcess from "node:child_process"; + +if (process.platform === "win32") { + const methods = ["spawn", "spawnSync", "exec", "execSync", "execFile", "execFileSync"] as const; + for (const name of methods) { + const original = (childProcess as unknown as Record)[name]; + if (typeof original !== "function") { + continue; + } + const wrapped = function patchedChildProcess(this: unknown, ...args: unknown[]) { + const hasTrailingCallback = typeof args[args.length - 1] === "function"; + const optionsIndex = hasTrailingCallback ? args.length - 2 : args.length - 1; + const candidate = optionsIndex >= 0 ? args[optionsIndex] : undefined; + if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) { + const options = candidate as Record; + if (options.windowsHide === undefined) { + options.windowsHide = true; + } + } else { + args.splice(hasTrailingCallback ? args.length - 1 : args.length, 0, { windowsHide: true }); + } + return (original as (...callArgs: unknown[]) => unknown).apply(this, args); + }; + (childProcess as unknown as Record)[name] = wrapped; + } +} diff --git a/apps/desktop/electron/terminal-service.ts b/apps/desktop/electron/terminal-service.ts index 40cc9eae..77ee54d7 100644 --- a/apps/desktop/electron/terminal-service.ts +++ b/apps/desktop/electron/terminal-service.ts @@ -375,7 +375,10 @@ export class TerminalService { private resolveShell(): string { const configuredShell = this.options.getIntegratedTerminalShell()?.trim(); - const shellPath = configuredShell || process.env.SHELL || defaultShellForPlatform(); + const shellPath = + configuredShell || + (process.platform === "win32" ? undefined : process.env.SHELL) || + defaultShellForPlatform(); if (process.platform !== "win32" && !path.isAbsolute(shellPath)) { throw new Error(`Integrated terminal shell must be an absolute path: ${shellPath}`); } diff --git a/apps/desktop/electron/theme-manager.ts b/apps/desktop/electron/theme-manager.ts index 740f5748..a9d4b3d3 100644 --- a/apps/desktop/electron/theme-manager.ts +++ b/apps/desktop/electron/theme-manager.ts @@ -2,6 +2,21 @@ import { nativeTheme, type BrowserWindow } from "electron"; import { desktopIpc } from "../src/ipc"; import type { ThemeMode } from "../src/desktop-state"; +// Window Controls Overlay caption colors for the Windows immersive title bar. +export function windowsTitleBarOverlay(theme: "light" | "dark"): { + color: string; + symbolColor: string; + height: number; +} { + // Transparent so the buttons blend with whatever view is behind them; only the + // glyph color is themed. + return { + color: "#00000000", + symbolColor: theme === "dark" ? "#e6e6e6" : "#2b2b2b", + height: 52, + }; +} + export class ThemeManager { private mode: ThemeMode = "system"; private window: BrowserWindow | null = null; @@ -14,6 +29,7 @@ export class ThemeManager { setWindow(win: BrowserWindow) { this.window = win; + this.applyTitleBarOverlay(); } getMode(): ThemeMode { @@ -38,6 +54,18 @@ export class ThemeManager { } private broadcast() { + this.applyTitleBarOverlay(); this.window?.webContents.send(desktopIpc.themeChanged, this.getResolvedTheme()); } + + private applyTitleBarOverlay() { + if (process.platform !== "win32" || !this.window || this.window.isDestroyed()) { + return; + } + try { + this.window.setTitleBarOverlay(windowsTitleBarOverlay(this.getResolvedTheme())); + } catch { + // Only valid when the window was created with an overlay enabled. + } + } } diff --git a/apps/desktop/electron/worktree-manager.ts b/apps/desktop/electron/worktree-manager.ts index 52b9c76b..d4363504 100644 --- a/apps/desktop/electron/worktree-manager.ts +++ b/apps/desktop/electron/worktree-manager.ts @@ -268,6 +268,7 @@ async function runGit(args: readonly string[]): Promise { const { stdout } = await execFileAsync("git", [...args], { encoding: "utf8", maxBuffer: 10 * 1024 * 1024, + windowsHide: true, }); return stdout; } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5e0405c9..276cc37a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -24,6 +24,8 @@ "bun:package:linux": "bun run bun:build && bun run scripts/bun-package-wrapper.mjs electron-builder --linux AppImage --publish never", "package:linux:dir": "pnpm run build && electron-builder --linux --dir --publish never", "bun:package:linux:dir": "bun run bun:build && bun run scripts/bun-package-wrapper.mjs electron-builder --linux --dir --publish never", + "package:win": "pnpm run build && electron-builder --win -c.npmRebuild=false --publish never", + "package:win:dir": "pnpm run build && electron-builder --win --dir -c.npmRebuild=false --publish never", "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.electron.json --noEmit", "verify:runtime-model-registry": "node scripts/assert-runtime-model-registry.mjs", "verify:packaged-runtime-deps": "pnpm run verify:runtime-model-registry && node scripts/assert-packaged-runtime-deps.mjs", diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..3c1d0993d270fb052ead08189de50f9ee83cea00 GIT binary patch literal 16407 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 TA?@qS{I97d;F;wAKEV86$YyfK literal 0 HcmV?d00001 diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 7343456f..d7e57401 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1995,17 +1995,12 @@ export default function App() { ); } - const shellClassName = `shell${snapshot.sidebarCollapsed ? " shell--sidebar-collapsed" : ""}`; + const shellClassName = `shell${snapshot.sidebarCollapsed ? " shell--sidebar-collapsed" : ""}${ + api?.platform === "win32" ? " shell--win" : "" + }`; return (
- {primarySidebarToggleVisible ? ( - - ) : null} {!snapshot.sidebarCollapsed ? ( ) : null} + {/* Rendered last so its no-drag region wins over the full-width topbar drag + region on Windows (else clicks are swallowed as window drag). */} + {primarySidebarToggleVisible ? ( + + ) : null}
); } diff --git a/apps/desktop/src/icons.tsx b/apps/desktop/src/icons.tsx index 65e1baf2..6e3fb75f 100644 --- a/apps/desktop/src/icons.tsx +++ b/apps/desktop/src/icons.tsx @@ -16,6 +16,14 @@ export function PlusIcon() { ); } +export function MenuIcon() { + return ( + + + + ); +} + export function TerminalIcon() { return ( diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index 233b3f7e..df716ea1 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -100,6 +100,7 @@ export const desktopIpc = { getSessionTree: "pi-gui:get-session-tree", navigateSessionTree: "pi-gui:navigate-session-tree", toggleWindowMaximize: "pi-gui:toggle-window-maximize", + popupAppMenu: "pi-gui:popup-app-menu", listWorkspaceFiles: "pi-gui:list-workspace-files", getChangedFiles: "pi-gui:get-changed-files", getFileDiff: "pi-gui:get-file-diff", @@ -326,6 +327,7 @@ export interface PiDesktopApi { getFileDiff(workspaceId: string, filePath: string): Promise; stageFile(workspaceId: string, filePath: string): Promise; toggleWindowMaximize(): Promise; + popupAppMenu(): Promise; openExternal(url: string): Promise; getThemeMode(): Promise<"system" | "light" | "dark">; getResolvedTheme(): Promise<"light" | "dark">; diff --git a/apps/desktop/src/styles/main.css b/apps/desktop/src/styles/main.css index 67c6ccba..d7d29296 100644 --- a/apps/desktop/src/styles/main.css +++ b/apps/desktop/src/styles/main.css @@ -61,6 +61,33 @@ min-width: 0; } +/* Reserve room for the Windows window-controls overlay (top-right). */ +.shell--win .topbar { + padding-right: 148px; +} + +/* Thin scrollbars on Windows (macOS already uses thin overlay scrollbars). */ +.shell--win ::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +.shell--win ::-webkit-scrollbar-track, +.shell--win ::-webkit-scrollbar-corner { + background: transparent; +} + +.shell--win ::-webkit-scrollbar-thumb { + background-color: rgba(140, 142, 152, 0.32); + border-radius: 8px; + border: 2px solid transparent; + background-clip: padding-box; +} + +.shell--win ::-webkit-scrollbar-thumb:hover { + background-color: rgba(140, 142, 152, 0.55); +} + .environment-picker, .environment-picker * { -webkit-app-region: no-drag; @@ -240,6 +267,9 @@ display: grid; gap: 8px; pointer-events: none; + /* Opens upward over the draggable topbar on Windows; keep clicks from being + swallowed as window drag. */ + -webkit-app-region: no-drag; } .chat-header { @@ -1291,6 +1321,12 @@ position: relative; } +/* Dropdown opens upward over the draggable topbar on Windows; keep it clickable. */ +.model-selector, +.model-selector * { + -webkit-app-region: no-drag; +} + .model-selector__anchor { position: relative; display: inline; diff --git a/apps/desktop/src/styles/sidebar.css b/apps/desktop/src/styles/sidebar.css index 093931ee..05676949 100644 --- a/apps/desktop/src/styles/sidebar.css +++ b/apps/desktop/src/styles/sidebar.css @@ -16,6 +16,11 @@ background: var(--window); } +/* No left-side traffic lights on Windows; tuck the sidebar toggle to the edge. */ +.shell--win { + --titlebar-toggle-left: 16px; +} + .shell--loading { grid-template-columns: 1fr; place-items: center; diff --git a/apps/desktop/src/topbar.tsx b/apps/desktop/src/topbar.tsx index ce314a07..e0a958da 100644 --- a/apps/desktop/src/topbar.tsx +++ b/apps/desktop/src/topbar.tsx @@ -1,6 +1,6 @@ import type { MouseEvent as ReactMouseEvent, Dispatch, SetStateAction } from "react"; import type { AppView, DesktopAppState, SessionRecord, WorkspaceRecord, WorktreeRecord } from "./desktop-state"; -import { DiffIcon, FolderIcon, TerminalIcon } from "./icons"; +import { DiffIcon, FolderIcon, MenuIcon, TerminalIcon } from "./icons"; import { getDesktopShortcutLabel, type PiDesktopApi } from "./ipc"; import type { WorkspaceMenuState } from "./hooks/use-workspace-menu"; @@ -133,6 +133,18 @@ export function Topbar(props: TopbarProps) {
+ {api.platform === "win32" ? ( + + ) : null}