From 599e65c06c0bc41d72debc24387bf46629bce597 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sat, 11 Apr 2026 16:15:59 +0300 Subject: [PATCH 01/14] make t3code look cool on linux --- apps/desktop/src/desktopSettings.test.ts | 20 +++ apps/desktop/src/desktopSettings.ts | 20 +++ apps/desktop/src/env.test.ts | 40 ++++++ apps/desktop/src/env.ts | 104 ++++++++++++++ apps/desktop/src/linuxWindowControls.test.ts | 55 ++++++++ apps/desktop/src/linuxWindowControls.ts | 124 +++++++++++++++++ apps/desktop/src/main.ts | 114 ++++++++++++---- apps/desktop/src/preload.ts | 28 ++++ apps/desktop/src/runtimeArch.test.ts | 4 +- apps/desktop/src/runtimeArch.ts | 6 +- apps/desktop/src/updateState.test.ts | 8 +- apps/desktop/src/updateState.ts | 4 +- apps/web/src/components/ChatView.tsx | 18 ++- apps/web/src/components/DiffPanelShell.tsx | 5 +- .../src/components/LinuxWindowControls.tsx | 78 +++++++++++ .../src/components/NoActiveThreadState.tsx | 18 ++- apps/web/src/components/Sidebar.tsx | 76 ++++++++++- .../settings/SettingsPanels.browser.tsx | 4 + .../components/settings/SettingsPanels.tsx | 129 +++++++++++++++++- apps/web/src/env.ts | 31 +++-- apps/web/src/index.css | 26 ++++ apps/web/src/localApi.test.ts | 4 + apps/web/src/routes/settings.tsx | 10 +- packages/contracts/src/ipc.ts | 19 ++- packages/contracts/src/settings.ts | 4 + 25 files changed, 880 insertions(+), 69 deletions(-) create mode 100644 apps/desktop/src/env.test.ts create mode 100644 apps/desktop/src/env.ts create mode 100644 apps/desktop/src/linuxWindowControls.test.ts create mode 100644 apps/desktop/src/linuxWindowControls.ts create mode 100644 apps/web/src/components/LinuxWindowControls.tsx diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts index e687bf544e..5f05fcd274 100644 --- a/apps/desktop/src/desktopSettings.test.ts +++ b/apps/desktop/src/desktopSettings.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, + setDesktopLinuxTitleBarMode, setDesktopServerExposurePreference, writeDesktopSettings, } from "./desktopSettings"; @@ -35,10 +36,12 @@ describe("desktopSettings", () => { writeDesktopSettings(settingsPath, { serverExposureMode: "network-accessible", + linuxTitleBarMode: "custom", }); expect(readDesktopSettings(settingsPath)).toEqual({ serverExposureMode: "network-accessible", + linuxTitleBarMode: "custom", }); }); @@ -47,11 +50,13 @@ describe("desktopSettings", () => { setDesktopServerExposurePreference( { serverExposureMode: "local-only", + linuxTitleBarMode: DEFAULT_DESKTOP_SETTINGS.linuxTitleBarMode, }, "network-accessible", ), ).toEqual({ serverExposureMode: "network-accessible", + linuxTitleBarMode: DEFAULT_DESKTOP_SETTINGS.linuxTitleBarMode, }); }); @@ -61,4 +66,19 @@ describe("desktopSettings", () => { expect(readDesktopSettings(settingsPath)).toEqual(DEFAULT_DESKTOP_SETTINGS); }); + + it("updates the requested linux title bar mode", () => { + expect( + setDesktopLinuxTitleBarMode( + { + ...DEFAULT_DESKTOP_SETTINGS, + linuxTitleBarMode: "native", + }, + "overlay", + ), + ).toEqual({ + ...DEFAULT_DESKTOP_SETTINGS, + linuxTitleBarMode: "overlay", + }); + }); }); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts index 80ef229ea2..a3338e5181 100644 --- a/apps/desktop/src/desktopSettings.ts +++ b/apps/desktop/src/desktopSettings.ts @@ -1,13 +1,16 @@ import * as FS from "node:fs"; import * as Path from "node:path"; import type { DesktopServerExposureMode } from "@t3tools/contracts"; +import { DEFAULT_LINUX_TITLE_BAR_MODE, type LinuxTitleBarMode } from "@t3tools/contracts/settings"; export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; + readonly linuxTitleBarMode: LinuxTitleBarMode; } export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { serverExposureMode: "local-only", + linuxTitleBarMode: DEFAULT_LINUX_TITLE_BAR_MODE, }; export function setDesktopServerExposurePreference( @@ -22,6 +25,18 @@ export function setDesktopServerExposurePreference( }; } +export function setDesktopLinuxTitleBarMode( + settings: DesktopSettings, + requestedMode: LinuxTitleBarMode, +): DesktopSettings { + return settings.linuxTitleBarMode === requestedMode + ? settings + : { + ...settings, + linuxTitleBarMode: requestedMode, + }; +} + export function readDesktopSettings(settingsPath: string): DesktopSettings { try { if (!FS.existsSync(settingsPath)) { @@ -31,11 +46,16 @@ export function readDesktopSettings(settingsPath: string): DesktopSettings { const raw = FS.readFileSync(settingsPath, "utf8"); const parsed = JSON.parse(raw) as { readonly serverExposureMode?: unknown; + readonly linuxTitleBarMode?: unknown; }; return { serverExposureMode: parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only", + linuxTitleBarMode: + parsed.linuxTitleBarMode === "overlay" || parsed.linuxTitleBarMode === "custom" + ? parsed.linuxTitleBarMode + : DEFAULT_LINUX_TITLE_BAR_MODE, }; } catch { return DEFAULT_DESKTOP_SETTINGS; diff --git a/apps/desktop/src/env.test.ts b/apps/desktop/src/env.test.ts new file mode 100644 index 0000000000..7668b7aa47 --- /dev/null +++ b/apps/desktop/src/env.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { getWindowControlsLayout } from "./env"; + +describe("getWindowControlsLayout", () => { + it("uses the standard macOS traffic-light placement in ltr locales", () => { + expect(getWindowControlsLayout({ locale: "en-US", platform: "macos" })).toEqual({ + left: ["close", "minimize", "maximize"], + right: [], + }); + }); + + it("mirrors macOS traffic lights in rtl locales", () => { + expect(getWindowControlsLayout({ locale: "ar", platform: "macos" })).toEqual({ + left: [], + right: ["maximize", "minimize", "close"], + }); + }); + + it("uses the standard Windows control layout in ltr locales", () => { + expect(getWindowControlsLayout({ locale: "en-US", platform: "windows" })).toEqual({ + left: [], + right: ["minimize", "maximize", "close"], + }); + }); + + it("mirrors Windows controls in rtl locales", () => { + expect(getWindowControlsLayout({ locale: "he", platform: "windows" })).toEqual({ + left: ["close", "maximize", "minimize"], + right: [], + }); + }); + + it("keeps Linux layout unchanged even in rtl locales", () => { + expect(getWindowControlsLayout({ locale: "ar", platform: "linux" })).toEqual({ + left: [], + right: ["minimize", "maximize", "close"], + }); + }); +}); diff --git a/apps/desktop/src/env.ts b/apps/desktop/src/env.ts new file mode 100644 index 0000000000..4f406a9830 --- /dev/null +++ b/apps/desktop/src/env.ts @@ -0,0 +1,104 @@ +import type { DesktopPlatform, DesktopWindowControlsLayout } from "@t3tools/contracts"; +import type { LinuxTitleBarMode } from "@t3tools/contracts/settings"; +import { getLinuxWindowControlsLayout } from "./linuxWindowControls"; + +const DESKTOP_TITLEBAR_HEIGHT = 52; +const RTL_LANGUAGES = new Set(["ar", "dv", "fa", "he", "ku", "ps", "sd", "ug", "ur", "yi"]); +const MACOS_WINDOW_CONTROLS_LAYOUT: DesktopWindowControlsLayout = { + left: ["close", "minimize", "maximize"], + right: [], +}; +const WINDOWS_WINDOW_CONTROLS_LAYOUT: DesktopWindowControlsLayout = { + left: [], + right: ["minimize", "maximize", "close"], +}; + +export const platform: DesktopPlatform = (() => { + switch (process.platform) { + case "darwin": + return "macos"; + case "win32": + return "windows"; + case "linux": + return "linux"; + default: + throw new Error(`Unsupported desktop platform: ${process.platform}`); + } +})(); + +function isRightToLeftLocale(locale: string | undefined): boolean { + if (!locale) { + return false; + } + + const language = locale.split(/[-_]/, 1)[0]?.toLowerCase(); + return language !== undefined && RTL_LANGUAGES.has(language); +} + +function mirrorWindowControlsLayout( + layout: DesktopWindowControlsLayout, +): DesktopWindowControlsLayout { + return { + left: layout.right.toReversed(), + right: layout.left.toReversed(), + }; +} + +export function getWindowControlsLayout(options?: { + locale?: string; + platform?: DesktopPlatform; +}): DesktopWindowControlsLayout { + const resolvedPlatform = options?.platform ?? platform; + if (resolvedPlatform === "linux") { + return getLinuxWindowControlsLayout(); + } + + const rtl = isRightToLeftLocale(options?.locale); + const layout = + resolvedPlatform === "macos" ? MACOS_WINDOW_CONTROLS_LAYOUT : WINDOWS_WINDOW_CONTROLS_LAYOUT; + + if (!rtl) { + return layout; + } + + return mirrorWindowControlsLayout(layout); +} + +export function getWindowChromeOptions(linuxTitleBarMode: LinuxTitleBarMode): { + titleBarStyle?: "hiddenInset" | "hidden"; + titleBarOverlay?: { height: number }; + trafficLightPosition?: { x: number; y: number }; +} { + if (platform === "macos") { + return { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 16, y: 18 }, + }; + } + + if (platform === "linux") { + if (linuxTitleBarMode === "native") { + return {}; + } + + if (linuxTitleBarMode === "overlay") { + return { + titleBarStyle: "hidden", + titleBarOverlay: { + height: DESKTOP_TITLEBAR_HEIGHT, + }, + }; + } + + return { + titleBarStyle: "hidden", + }; + } + + return { + titleBarStyle: "hidden", + titleBarOverlay: { + height: DESKTOP_TITLEBAR_HEIGHT, + }, + }; +} diff --git a/apps/desktop/src/linuxWindowControls.test.ts b/apps/desktop/src/linuxWindowControls.test.ts new file mode 100644 index 0000000000..2c0785f27e --- /dev/null +++ b/apps/desktop/src/linuxWindowControls.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getLinuxWindowControlsLayout } from "./linuxWindowControls"; + +describe("getLinuxWindowControlsLayout", () => { + it("reads KDE button placement from kwinrc", () => { + const layout = getLinuxWindowControlsLayout({ + existsSync: vi.fn().mockReturnValue(true), + homeDir: "/home/tester", + readFileSync: vi + .fn() + .mockReturnValue("[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=M\n"), + spawnSync: vi.fn(), + }); + + expect(layout).toEqual({ + left: ["close", "minimize", "maximize"], + right: [], + }); + }); + + it("falls back to GNOME button placement when KDE config is unavailable", () => { + const layout = getLinuxWindowControlsLayout({ + existsSync: vi.fn().mockReturnValue(false), + homeDir: "/home/tester", + readFileSync: vi.fn(), + spawnSync: vi.fn().mockReturnValue({ + status: 0, + stdout: "'close,minimize:maximize'\n", + }), + }); + + expect(layout).toEqual({ + left: ["close", "minimize"], + right: ["maximize"], + }); + }); + + it("uses the default right-side controls when no desktop layout is available", () => { + const layout = getLinuxWindowControlsLayout({ + existsSync: vi.fn().mockReturnValue(false), + homeDir: "/home/tester", + readFileSync: vi.fn(), + spawnSync: vi.fn().mockReturnValue({ + status: 1, + stdout: "", + }), + }); + + expect(layout).toEqual({ + left: [], + right: ["minimize", "maximize", "close"], + }); + }); +}); diff --git a/apps/desktop/src/linuxWindowControls.ts b/apps/desktop/src/linuxWindowControls.ts new file mode 100644 index 0000000000..a7d802c129 --- /dev/null +++ b/apps/desktop/src/linuxWindowControls.ts @@ -0,0 +1,124 @@ +import * as ChildProcess from "node:child_process"; +import * as FS from "node:fs"; +import * as OS from "node:os"; +import * as Path from "node:path"; + +import type { DesktopWindowControl, DesktopWindowControlsLayout } from "@t3tools/contracts"; + +interface LinuxWindowControlsDependencies { + existsSync?: typeof FS.existsSync; + homeDir?: string; + readFileSync?: typeof FS.readFileSync; + spawnSync?: typeof ChildProcess.spawnSync; +} + +const FALLBACK_WINDOW_CONTROLS_LAYOUT: DesktopWindowControlsLayout = { + left: [], + right: ["minimize", "maximize", "close"], +}; + +function mapKdeButtonCode(code: string): DesktopWindowControl | null { + switch (code) { + case "I": + return "minimize"; + case "A": + return "maximize"; + case "X": + return "close"; + default: + return null; + } +} + +function parseKdeButtonBank(value: string | undefined): readonly DesktopWindowControl[] { + if (!value) { + return []; + } + + return [...value].flatMap((code) => { + const mapped = mapKdeButtonCode(code); + return mapped ? [mapped] : []; + }); +} + +function readKdeWindowControlsLayout( + dependencies: Required, +): DesktopWindowControlsLayout | null { + const kwinConfigPath = Path.join(dependencies.homeDir, ".config/kwinrc"); + if (!dependencies.existsSync(kwinConfigPath)) { + return null; + } + + const content = dependencies.readFileSync(kwinConfigPath, "utf8"); + const left = content.match(/^ButtonsOnLeft=(.*)$/m)?.[1]?.trim(); + const right = content.match(/^ButtonsOnRight=(.*)$/m)?.[1]?.trim(); + + return { + left: parseKdeButtonBank(left), + right: parseKdeButtonBank(right), + }; +} + +function mapGnomeButtonName(name: string): DesktopWindowControl | null { + switch (name) { + case "minimize": + return "minimize"; + case "maximize": + return "maximize"; + case "close": + return "close"; + default: + return null; + } +} + +function parseGnomeButtonBank(value: string): readonly DesktopWindowControl[] { + return value + .split(",") + .map((part) => part.trim()) + .flatMap((name) => { + const mapped = mapGnomeButtonName(name); + return mapped ? [mapped] : []; + }); +} + +function readGnomeWindowControlsLayout( + dependencies: Required, +): DesktopWindowControlsLayout | null { + const result = dependencies.spawnSync( + "gsettings", + ["get", "org.gnome.desktop.wm.preferences", "button-layout"], + { encoding: "utf8" }, + ); + if (result.status !== 0) { + return null; + } + + const value = result.stdout.trim().replace(/^'+|'+$/g, ""); + if (!value) { + return null; + } + + const [left = "", right = ""] = value.split(":"); + return { + left: parseGnomeButtonBank(left), + right: parseGnomeButtonBank(right), + }; +} + +export function getLinuxWindowControlsLayout( + dependencies: LinuxWindowControlsDependencies = {}, +): DesktopWindowControlsLayout { + const resolvedDependencies: Required = { + existsSync: dependencies.existsSync ?? FS.existsSync, + homeDir: dependencies.homeDir ?? OS.homedir(), + readFileSync: dependencies.readFileSync ?? FS.readFileSync, + spawnSync: dependencies.spawnSync ?? ChildProcess.spawnSync, + }; + + return ( + readKdeWindowControlsLayout(resolvedDependencies) ?? + readGnomeWindowControlsLayout(resolvedDependencies) ?? + FALLBACK_WINDOW_CONTROLS_LAYOUT + ); +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f6e7544d0b..c0cf13c861 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -29,14 +29,17 @@ import type { DesktopUpdateState, } from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; +import { Schema } from "effect"; import type { ContextMenuItem } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS, LinuxTitleBarMode } from "@t3tools/contracts/settings"; import { RotatingFileSink } from "@t3tools/shared/logging"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort"; import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, + setDesktopLinuxTitleBarMode, setDesktopServerExposurePreference, writeDesktopSettings, } from "./desktopSettings"; @@ -67,6 +70,7 @@ import { reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch"; +import { getWindowChromeOptions, getWindowControlsLayout, platform } from "./env"; syncShellEnvironment(); @@ -82,6 +86,10 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_PLATFORM_CHANNEL = "desktop:get-platform"; +const GET_LINUX_TITLE_BAR_MODE_CHANNEL = "desktop:get-linux-title-bar-mode"; +const SET_LINUX_TITLE_BAR_MODE_CHANNEL = "desktop:set-linux-title-bar-mode"; +const GET_WINDOW_CONTROLS_LAYOUT_CHANNEL = "desktop:get-window-controls-layout"; const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; @@ -91,6 +99,10 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const MINIMIZE_WINDOW_CHANNEL = "desktop:minimize-window"; +const TOGGLE_MAXIMIZE_WINDOW_CHANNEL = "desktop:toggle-maximize-window"; +const CLOSE_WINDOW_CHANNEL = "desktop:close-window"; +const RESTART_APP_CHANNEL = "desktop:restart-app"; const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3"); const STATE_DIR = Path.join(BASE_DIR, "userdata"); const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json"); @@ -118,7 +130,6 @@ const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; - type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; type LinuxDesktopNamedApp = Electron.App & { setDesktopName?: (desktopName: string) => void; @@ -145,11 +156,13 @@ let restoreStdIoCapture: (() => void) | null = null; let backendObservabilitySettings = readPersistedBackendObservabilitySettings(); let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH); let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode; +let currentClientSettings: ClientSettings = + readClientSettings(CLIENT_SETTINGS_PATH) ?? DEFAULT_CLIENT_SETTINGS; let destructiveMenuIconCache: Electron.NativeImage | null | undefined; const expectedBackendExitChildren = new WeakSet(); const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ - platform: process.platform, + platform, processArch: process.arch, runningUnderArm64Translation: app.runningUnderARM64Translation === true, }); @@ -462,12 +475,12 @@ function captureBackendOutput(child: ChildProcess.ChildProcess): void { initializePackagedLogging(); -if (process.platform === "linux") { +if (platform === "linux") { app.commandLine.appendSwitch("class", LINUX_WM_CLASS); } function getDestructiveMenuIcon(): Electron.NativeImage | undefined { - if (process.platform !== "darwin") return undefined; + if (platform !== "macos") return undefined; if (destructiveMenuIconCache !== undefined) { return destructiveMenuIconCache ?? undefined; } @@ -734,7 +747,7 @@ function handleCheckForUpdatesMenuClick(): void { const disabledReason = getAutoUpdateDisabledReason({ isDevelopment, isPackaged: app.isPackaged, - platform: process.platform, + platform, appImage: process.env.APPIMAGE, disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", hasUpdateFeedConfig, @@ -781,7 +794,7 @@ async function checkForUpdatesFromMenu(): Promise { function configureApplicationMenu(): void { const template: MenuItemConstructorOptions[] = []; - if (process.platform === "darwin") { + if (platform === "macos") { template.push({ label: app.name, submenu: [ @@ -812,7 +825,7 @@ function configureApplicationMenu(): void { { label: "File", submenu: [ - ...(process.platform === "darwin" + ...(platform === "macos" ? [] : [ { @@ -822,7 +835,7 @@ function configureApplicationMenu(): void { }, { type: "separator" as const }, ]), - { role: process.platform === "darwin" ? "close" : "quit" }, + { role: platform === "macos" ? "close" : "quit" }, ], }, { role: "editMenu" }, @@ -891,9 +904,9 @@ function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { */ function resolveUserDataPath(): string { const appDataBase = - process.platform === "win32" + platform === "windows" ? process.env.APPDATA || Path.join(OS.homedir(), "AppData", "Roaming") - : process.platform === "darwin" + : platform === "macos" ? Path.join(OS.homedir(), "Library", "Application Support") : process.env.XDG_CONFIG_HOME || Path.join(OS.homedir(), ".config"); @@ -914,15 +927,15 @@ function configureAppIdentity(): void { version: commitHash ?? "unknown", }); - if (process.platform === "win32") { + if (platform === "windows") { app.setAppUserModelId(APP_USER_MODEL_ID); } - if (process.platform === "linux") { + if (platform === "linux") { (app as LinuxDesktopNamedApp).setDesktopName?.(LINUX_DESKTOP_ENTRY_NAME); } - if (process.platform === "darwin" && app.dock) { + if (platform === "macos" && app.dock) { const iconPath = resolveIconPath("png"); if (iconPath) { app.dock.setIcon(iconPath); @@ -954,7 +967,7 @@ function revealWindow(window: BrowserWindow): void { window.show(); } - if (process.platform === "darwin") { + if (platform === "macos") { app.focus({ steal: true }); } @@ -980,7 +993,7 @@ function shouldEnableAutoUpdates(): boolean { getAutoUpdateDisabledReason({ isDevelopment, isPackaged: app.isPackaged, - platform: process.platform, + platform, appImage: process.env.APPIMAGE, disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1", hasUpdateFeedConfig, @@ -1378,8 +1391,34 @@ function registerIpcHandlers(): void { } as const; }); + ipcMain.removeAllListeners(GET_PLATFORM_CHANNEL); + ipcMain.on(GET_PLATFORM_CHANNEL, (event) => { + event.returnValue = platform; + }); + + ipcMain.removeAllListeners(GET_LINUX_TITLE_BAR_MODE_CHANNEL); + ipcMain.on(GET_LINUX_TITLE_BAR_MODE_CHANNEL, (event) => { + event.returnValue = platform === "linux" ? desktopSettings.linuxTitleBarMode : null; + }); + + ipcMain.removeAllListeners(GET_WINDOW_CONTROLS_LAYOUT_CHANNEL); + ipcMain.on(GET_WINDOW_CONTROLS_LAYOUT_CHANNEL, (event) => { + event.returnValue = getWindowControlsLayout({ locale: app.getLocale() }); + }); + + ipcMain.removeHandler(SET_LINUX_TITLE_BAR_MODE_CHANNEL); + ipcMain.handle(SET_LINUX_TITLE_BAR_MODE_CHANNEL, async (_event, rawMode: unknown) => { + if (!Schema.is(LinuxTitleBarMode)(rawMode)) { + throw new Error("Invalid linux title bar mode."); + } + + desktopSettings = setDesktopLinuxTitleBarMode(desktopSettings, rawMode); + writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); + return desktopSettings.linuxTitleBarMode; + }); + ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL); - ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH)); + ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => currentClientSettings); ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL); ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => { @@ -1387,7 +1426,9 @@ function registerIpcHandlers(): void { throw new Error("Invalid client settings payload."); } - writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings); + const nextSettings = rawSettings as ClientSettings; + currentClientSettings = nextSettings; + writeClientSettings(CLIENT_SETTINGS_PATH, nextSettings); }); ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL); @@ -1514,6 +1555,34 @@ function registerIpcHandlers(): void { nativeTheme.themeSource = theme; }); + ipcMain.removeHandler(MINIMIZE_WINDOW_CHANNEL); + ipcMain.handle(MINIMIZE_WINDOW_CHANNEL, async (event) => { + BrowserWindow.fromWebContents(event.sender)?.minimize(); + }); + + ipcMain.removeHandler(TOGGLE_MAXIMIZE_WINDOW_CHANNEL); + ipcMain.handle(TOGGLE_MAXIMIZE_WINDOW_CHANNEL, async (event) => { + const window = BrowserWindow.fromWebContents(event.sender); + if (!window) { + return; + } + if (window.isMaximized()) { + window.unmaximize(); + return; + } + window.maximize(); + }); + + ipcMain.removeHandler(CLOSE_WINDOW_CHANNEL); + ipcMain.handle(CLOSE_WINDOW_CHANNEL, async (event) => { + BrowserWindow.fromWebContents(event.sender)?.close(); + }); + + ipcMain.removeHandler(RESTART_APP_CHANNEL); + ipcMain.handle(RESTART_APP_CHANNEL, async () => { + relaunchDesktopApp("manualRestart"); + }); + ipcMain.removeHandler(CONTEXT_MENU_CHANNEL); ipcMain.handle( CONTEXT_MENU_CHANNEL, @@ -1639,8 +1708,8 @@ function registerIpcHandlers(): void { } function getIconOption(): { icon: string } | Record { - if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle - const ext = process.platform === "win32" ? "ico" : "png"; + if (platform === "macos") return {}; // macOS uses .icns from app bundle + const ext = platform === "windows" ? "ico" : "png"; const iconPath = resolveIconPath(ext); return iconPath ? { icon: iconPath } : {}; } @@ -1660,8 +1729,7 @@ function createWindow(): BrowserWindow { backgroundColor: getInitialWindowBackgroundColor(), ...getIconOption(), title: APP_DISPLAY_NAME, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 16, y: 18 }, + ...getWindowChromeOptions(desktopSettings.linuxTitleBarMode), webPreferences: { preload: Path.join(__dirname, "preload.js"), contextIsolation: true, @@ -1881,12 +1949,12 @@ app }); app.on("window-all-closed", () => { - if (process.platform !== "darwin" && !isQuitting) { + if (platform !== "macos" && !isQuitting) { app.quit(); } }); -if (process.platform !== "win32") { +if (platform !== "windows") { process.on("SIGINT", () => { if (isQuitting) return; isQuitting = true; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index e3107a9248..5d80957f4b 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -13,6 +13,10 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap"; +const GET_PLATFORM_CHANNEL = "desktop:get-platform"; +const GET_LINUX_TITLE_BAR_MODE_CHANNEL = "desktop:get-linux-title-bar-mode"; +const SET_LINUX_TITLE_BAR_MODE_CHANNEL = "desktop:set-linux-title-bar-mode"; +const GET_WINDOW_CONTROLS_LAYOUT_CHANNEL = "desktop:get-window-controls-layout"; const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry"; @@ -22,6 +26,10 @@ const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secr const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret"; const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; +const MINIMIZE_WINDOW_CHANNEL = "desktop:minimize-window"; +const TOGGLE_MAXIMIZE_WINDOW_CHANNEL = "desktop:toggle-maximize-window"; +const CLOSE_WINDOW_CHANNEL = "desktop:close-window"; +const RESTART_APP_CHANNEL = "desktop:restart-app"; contextBridge.exposeInMainWorld("desktopBridge", { getLocalEnvironmentBootstrap: () => { @@ -31,6 +39,22 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getPlatform: () => { + const result = ipcRenderer.sendSync(GET_PLATFORM_CHANNEL); + return result === "macos" || result === "windows" || result === "linux" ? result : null; + }, + getLinuxTitleBarMode: () => { + const result = ipcRenderer.sendSync(GET_LINUX_TITLE_BAR_MODE_CHANNEL); + return result === "native" || result === "overlay" || result === "custom" ? result : null; + }, + setLinuxTitleBarMode: (mode) => ipcRenderer.invoke(SET_LINUX_TITLE_BAR_MODE_CHANNEL, mode), + getWindowControlsLayout: () => { + const result = ipcRenderer.sendSync(GET_WINDOW_CONTROLS_LAYOUT_CHANNEL); + if (typeof result !== "object" || result === null) { + return null; + } + return result as NonNullable>>; + }, getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings), getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL), @@ -47,6 +71,10 @@ contextBridge.exposeInMainWorld("desktopBridge", { pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), + minimizeWindow: () => ipcRenderer.invoke(MINIMIZE_WINDOW_CHANNEL), + toggleMaximizeWindow: () => ipcRenderer.invoke(TOGGLE_MAXIMIZE_WINDOW_CHANNEL), + closeWindow: () => ipcRenderer.invoke(CLOSE_WINDOW_CHANNEL), + restartApp: () => ipcRenderer.invoke(RESTART_APP_CHANNEL), showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position), openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url), onMenuAction: (listener) => { diff --git a/apps/desktop/src/runtimeArch.test.ts b/apps/desktop/src/runtimeArch.test.ts index 258a8fb215..e689cc8c4a 100644 --- a/apps/desktop/src/runtimeArch.test.ts +++ b/apps/desktop/src/runtimeArch.test.ts @@ -5,7 +5,7 @@ import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runti describe("resolveDesktopRuntimeInfo", () => { it("detects Rosetta-translated Intel builds on Apple Silicon", () => { const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "darwin", + platform: "macos", processArch: "x64", runningUnderArm64Translation: true, }); @@ -20,7 +20,7 @@ describe("resolveDesktopRuntimeInfo", () => { it("detects native Apple Silicon builds", () => { const runtimeInfo = resolveDesktopRuntimeInfo({ - platform: "darwin", + platform: "macos", processArch: "arm64", runningUnderArm64Translation: false, }); diff --git a/apps/desktop/src/runtimeArch.ts b/apps/desktop/src/runtimeArch.ts index 127abf51ab..58a1183a87 100644 --- a/apps/desktop/src/runtimeArch.ts +++ b/apps/desktop/src/runtimeArch.ts @@ -1,7 +1,7 @@ -import type { DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts"; +import type { DesktopPlatform, DesktopRuntimeArch, DesktopRuntimeInfo } from "@t3tools/contracts"; interface ResolveDesktopRuntimeInfoInput { - readonly platform: NodeJS.Platform; + readonly platform: DesktopPlatform; readonly processArch: string; readonly runningUnderArm64Translation: boolean; } @@ -17,7 +17,7 @@ export function resolveDesktopRuntimeInfo( ): DesktopRuntimeInfo { const appArch = normalizeDesktopArch(input.processArch); - if (input.platform !== "darwin") { + if (input.platform !== "macos") { return { hostArch: appArch, appArch, diff --git a/apps/desktop/src/updateState.test.ts b/apps/desktop/src/updateState.test.ts index 8db92e1915..01a6123f52 100644 --- a/apps/desktop/src/updateState.test.ts +++ b/apps/desktop/src/updateState.test.ts @@ -68,7 +68,7 @@ describe("getAutoUpdateDisabledReason", () => { getAutoUpdateDisabledReason({ isDevelopment: true, isPackaged: false, - platform: "darwin", + platform: "macos", appImage: undefined, disabledByEnv: false, hasUpdateFeedConfig: true, @@ -81,7 +81,7 @@ describe("getAutoUpdateDisabledReason", () => { getAutoUpdateDisabledReason({ isDevelopment: false, isPackaged: true, - platform: "darwin", + platform: "macos", appImage: undefined, disabledByEnv: false, hasUpdateFeedConfig: false, @@ -94,7 +94,7 @@ describe("getAutoUpdateDisabledReason", () => { getAutoUpdateDisabledReason({ isDevelopment: false, isPackaged: true, - platform: "darwin", + platform: "macos", appImage: undefined, disabledByEnv: false, hasUpdateFeedConfig: true, @@ -107,7 +107,7 @@ describe("getAutoUpdateDisabledReason", () => { getAutoUpdateDisabledReason({ isDevelopment: false, isPackaged: true, - platform: "darwin", + platform: "macos", appImage: undefined, disabledByEnv: true, hasUpdateFeedConfig: true, diff --git a/apps/desktop/src/updateState.ts b/apps/desktop/src/updateState.ts index 928bb40886..b9204cddc4 100644 --- a/apps/desktop/src/updateState.ts +++ b/apps/desktop/src/updateState.ts @@ -1,4 +1,4 @@ -import type { DesktopUpdateState } from "@t3tools/contracts"; +import type { DesktopPlatform, DesktopUpdateState } from "@t3tools/contracts"; export function shouldBroadcastDownloadProgress( currentState: DesktopUpdateState, @@ -31,7 +31,7 @@ export function getCanRetryAfterDownloadFailure(currentState: DesktopUpdateState export function getAutoUpdateDisabledReason(args: { isDevelopment: boolean; isPackaged: boolean; - platform: NodeJS.Platform; + platform: DesktopPlatform; appImage?: string | undefined; disabledByEnv: boolean; hasUpdateFeedConfig: boolean; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 25ed763952..ec5a847d8c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -34,7 +34,7 @@ import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; -import { isElectron } from "../env"; +import { isElectron, usesWCO } from "../env"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -3283,15 +3283,19 @@ export default function ChatView(props: ChatViewProps) { return ; } + let headerClassName = "border-b border-border px-3 py-2 sm:px-5 sm:py-3"; + if (isElectron) { + headerClassName = "border-b border-border drag-region flex h-[52px] items-center px-3 sm:px-5"; + } + if (usesWCO) { + headerClassName = + "border-b border-border drag-region flex h-[52px] items-center titlebar-overlay-safe titlebar-overlay-safe-sm titlebar-overlay-safe-sm-up-lg"; + } + return (
{/* Top bar */} -
+
{ + switch (props.action) { + case "minimize": + await bridge?.minimizeWindow?.(); + return; + case "maximize": + await bridge?.toggleMaximizeWindow?.(); + return; + case "close": + await bridge?.closeWindow?.(); + return; + } + }; + + return ( + + ); +} + +function LinuxWindowControlBank(props: { + actions: readonly ("minimize" | "maximize" | "close")[]; + align: "left" | "right"; +}) { + return ( +
+ {props.actions.map((action) => ( + + ))} +
+ ); +} + +export function LinuxWindowControls() { + if (!windowControlsLayout) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 39aa4e2f72..431710ba69 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -1,18 +1,22 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset, SidebarTrigger } from "./ui/sidebar"; -import { isElectron } from "../env"; +import { isElectron, usesWCO } from "../env"; import { cn } from "~/lib/utils"; export function NoActiveThreadState() { + let headerClassName = "px-3 py-2 sm:px-5 sm:py-3"; + if (isElectron) { + headerClassName = "drag-region flex h-[52px] items-center px-3 sm:px-5"; + } + if (usesWCO) { + headerClassName = + "drag-region flex h-[52px] items-center titlebar-overlay-safe titlebar-overlay-safe-sm titlebar-overlay-safe-sm-up-lg"; + } + return (
-
+
{isElectron ? ( No active thread ) : ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c5725c6d0d..52b8a5d7fc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -11,6 +11,7 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; +import { LinuxWindowControls } from "./LinuxWindowControls"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -53,7 +54,14 @@ import { type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { usePrimaryEnvironmentId } from "../environments/primary"; -import { isElectron } from "../env"; +import { + desktopPlatform, + isElectron, + windowControlsLayout, + usesNativeLinuxTitleBar, + usesCustomLinuxWindowControls, + usesWCO, +} from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; @@ -1942,6 +1950,8 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({ }: { isElectron: boolean; }) { + const usesDesktopChromeHeader = isElectron && !usesNativeLinuxTitleBar; + const usesCenteredLinuxWordmark = usesCustomLinuxWindowControls; const wordmark = (
@@ -1970,12 +1980,66 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader({
); - return isElectron ? ( - - {wordmark} + const linuxLeftControlsCount = windowControlsLayout?.left.length ?? 0; + const linuxRightControlsCount = windowControlsLayout?.right.length ?? 0; + const linuxSidebarHeaderInsetStyle = useMemo(() => { + if (!usesCenteredLinuxWordmark) { + return undefined; + } + + const paddingLeft = + linuxLeftControlsCount === 0 ? undefined : `${linuxLeftControlsCount * 1.75 + 0.5}rem`; + const paddingRight = + linuxRightControlsCount === 0 ? undefined : `${linuxRightControlsCount * 1.75 + 0.5}rem`; + + return { + paddingLeft, + paddingRight, + }; + }, [linuxLeftControlsCount, linuxRightControlsCount, usesCenteredLinuxWordmark]); + + const desktopHeaderClassName = useMemo(() => { + if (desktopPlatform === "macos") { + return "drag-region relative h-[52px] flex-row items-center gap-2 py-0 px-4 pl-[90px]"; + } + + if (usesCenteredLinuxWordmark) { + return "drag-region relative h-[52px] flex-row items-center gap-2 px-3 py-0"; + } + + if (usesWCO) { + return "drag-region relative h-[52px] flex-row items-center gap-2 py-0 titlebar-overlay-safe titlebar-overlay-safe-md"; + } + + return "drag-region relative h-[52px] flex-row items-center gap-2 px-4 py-0"; + }, [usesCenteredLinuxWordmark]); + + if (!usesDesktopChromeHeader) { + return ( + + {wordmark} + + ); + } + + return ( + + {usesCenteredLinuxWordmark ? ( +
+ +
+ ) : null} + {usesCenteredLinuxWordmark ? ( +
+
{wordmark}
+
+ ) : ( + wordmark + )}
- ) : ( - {wordmark} ); }); diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 0337da23a4..874621bcac 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -282,6 +282,9 @@ const createDesktopBridgeStub = (overrides?: { wsBaseUrl: "ws://127.0.0.1:3773", bootstrapToken: "desktop-bootstrap-token", }), + getPlatform: () => "linux", + getLinuxTitleBarMode: () => "native", + setLinuxTitleBarMode: vi.fn().mockImplementation(async (mode) => mode), getClientSettings: vi.fn().mockResolvedValue(null), setClientSettings: vi.fn().mockResolvedValue(undefined), getSavedEnvironmentRegistry: vi.fn().mockResolvedValue([]), @@ -308,6 +311,7 @@ const createDesktopBridgeStub = (overrides?: { setTheme: vi.fn().mockResolvedValue(undefined), showContextMenu: vi.fn().mockResolvedValue(null), openExternal: vi.fn().mockResolvedValue(true), + restartApp: vi.fn().mockResolvedValue(undefined), onMenuAction: () => () => {}, getUpdateState: vi.fn().mockResolvedValue(idleUpdateState), checkForUpdate: vi.fn().mockResolvedValue({ checked: false, state: idleUpdateState }), diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index c8766f2643..c802ab6b55 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -18,7 +18,11 @@ import { type ServerProviderModel, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; -import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +import { + DEFAULT_LINUX_TITLE_BAR_MODE, + DEFAULT_UNIFIED_SETTINGS, + type LinuxTitleBarMode, +} from "@t3tools/contracts/settings"; import { normalizeModelSlug } from "@t3tools/shared/model"; import { Equal } from "effect"; import { APP_VERSION } from "../../branding"; @@ -32,7 +36,7 @@ import { import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; -import { isElectron } from "../../env"; +import { desktopPlatform, isElectron, runningLinuxTitleBarMode } from "../../env"; import { useTheme } from "../../hooks/useTheme"; import { useSettings, useUpdateSettings } from "../../hooks/useSettings"; import { useThreadActions } from "../../hooks/useThreadActions"; @@ -98,6 +102,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const LINUX_TITLE_BAR_MODE_LABELS: Record = { + native: "Native", + overlay: "Overlay", + custom: "Custom", +}; + type InstallProviderSettings = { provider: ProviderKind; title: string; @@ -424,6 +434,13 @@ export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); + const [selectedLinuxTitleBarMode, setSelectedLinuxTitleBarMode] = useState( + desktopPlatform === "linux" + ? (window.desktopBridge?.getLinuxTitleBarMode?.() ?? DEFAULT_LINUX_TITLE_BAR_MODE) + : DEFAULT_LINUX_TITLE_BAR_MODE, + ); + const [isSavingLinuxTitleBarMode, setIsSavingLinuxTitleBarMode] = useState(false); + const [isRestartingForLinuxTitleBarMode, setIsRestartingForLinuxTitleBarMode] = useState(false); const [openingPathByTarget, setOpeningPathByTarget] = useState({ keybindings: false, logsDirectory: false, @@ -546,6 +563,49 @@ export function GeneralSettingsPanel() { const openDiagnosticsError = openPathErrorByTarget.logsDirectory ?? null; const isOpeningKeybindings = openingPathByTarget.keybindings; const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; + const linuxTitleBarModeNeedsRestart = + desktopPlatform === "linux" && selectedLinuxTitleBarMode !== runningLinuxTitleBarMode; + + const persistLinuxTitleBarMode = useCallback((mode: LinuxTitleBarMode) => { + const setLinuxTitleBarMode = window.desktopBridge?.setLinuxTitleBarMode; + if (!setLinuxTitleBarMode) { + return; + } + + setSelectedLinuxTitleBarMode(mode); + setIsSavingLinuxTitleBarMode(true); + void setLinuxTitleBarMode(mode) + .catch((error: unknown) => { + setSelectedLinuxTitleBarMode( + window.desktopBridge?.getLinuxTitleBarMode?.() ?? runningLinuxTitleBarMode, + ); + toastManager.add({ + type: "error", + title: "Could not update Linux title bar mode", + description: error instanceof Error ? error.message : "Saving failed.", + }); + }) + .finally(() => { + setIsSavingLinuxTitleBarMode(false); + }); + }, []); + + const restartForLinuxTitleBarMode = useCallback(() => { + const restartApp = window.desktopBridge?.restartApp; + if (!restartApp || isRestartingForLinuxTitleBarMode) { + return; + } + + setIsRestartingForLinuxTitleBarMode(true); + void restartApp().catch((error: unknown) => { + setIsRestartingForLinuxTitleBarMode(false); + toastManager.add({ + type: "error", + title: "Could not restart T3 Code", + description: error instanceof Error ? error.message : "Restart failed.", + }); + }); + }, [isRestartingForLinuxTitleBarMode]); const addCustomModel = useCallback( (provider: ProviderKind) => { @@ -718,6 +778,71 @@ export function GeneralSettingsPanel() { } /> + {desktopPlatform === "linux" ? ( + + {linuxTitleBarModeNeedsRestart ? ( + + ) : null} + {linuxTitleBarModeNeedsRestart + ? "Restart required to apply this change." + : isSavingLinuxTitleBarMode + ? "Saving preference..." + : undefined} +
+ } + resetAction={ + selectedLinuxTitleBarMode !== DEFAULT_LINUX_TITLE_BAR_MODE ? ( + persistLinuxTitleBarMode(DEFAULT_LINUX_TITLE_BAR_MODE)} + /> + ) : null + } + control={ + + } + /> + ) : null} + = {}): DesktopBridge { return { getLocalEnvironmentBootstrap: () => null, + getPlatform: () => "linux", + getLinuxTitleBarMode: () => "native", + setLinuxTitleBarMode: async (mode) => mode, getClientSettings: async () => null, setClientSettings: async () => undefined, getSavedEnvironmentRegistry: async () => [], @@ -182,6 +185,7 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg setTheme: async () => undefined, showContextMenu: async () => null, openExternal: async () => true, + restartApp: async () => undefined, onMenuAction: () => () => undefined, getUpdateState: async () => { throw new Error("getUpdateState not implemented in test"); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 543b182c37..4c30ff2f74 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -1,6 +1,7 @@ import { RotateCcwIcon } from "lucide-react"; import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect, useState } from "react"; +import { cn } from "~/lib/utils"; import { ensurePrimaryEnvironmentReady, @@ -9,7 +10,7 @@ import { import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; -import { isElectron } from "../env"; +import { isElectron, usesWCO } from "../env"; function SettingsContentLayout() { const [restoreSignal, setRestoreSignal] = useState(0); @@ -56,7 +57,12 @@ function SettingsContentLayout() { )} {isElectron && ( -
+
Settings diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 31e8693805..4e0339a70e 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -51,7 +51,7 @@ import type { } from "./orchestration"; import type { EnvironmentId } from "./baseSchemas"; import { EditorId } from "./editor"; -import { ClientSettings, ServerSettings, ServerSettingsPatch } from "./settings"; +import { ClientSettings, LinuxTitleBarMode, ServerSettings, ServerSettingsPatch } from "./settings"; export interface ContextMenuItem { id: T; @@ -71,7 +71,16 @@ export type DesktopUpdateStatus = | "error"; export type DesktopRuntimeArch = "arm64" | "x64" | "other"; +export type DesktopPlatform = "macos" | "windows" | "linux"; +export type WebPlatform = "web"; +export type Platform = DesktopPlatform | WebPlatform; export type DesktopTheme = "light" | "dark" | "system"; +export type DesktopWindowControl = "minimize" | "maximize" | "close"; + +export interface DesktopWindowControlsLayout { + left: readonly DesktopWindowControl[]; + right: readonly DesktopWindowControl[]; +} export interface DesktopRuntimeInfo { hostArch: DesktopRuntimeArch; @@ -132,6 +141,10 @@ export interface DesktopServerExposureState { export interface DesktopBridge { getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; + getPlatform?: () => DesktopPlatform | null; + getLinuxTitleBarMode?: () => LinuxTitleBarMode | null; + setLinuxTitleBarMode?: (mode: LinuxTitleBarMode) => Promise; + getWindowControlsLayout?: () => DesktopWindowControlsLayout | null; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; getSavedEnvironmentRegistry: () => Promise; @@ -146,6 +159,10 @@ export interface DesktopBridge { pickFolder: () => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; + minimizeWindow?: () => Promise; + toggleMaximizeWindow?: () => Promise; + closeWindow?: () => Promise; + restartApp?: () => Promise; showContextMenu: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 426f8bee56..8d87c121d5 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -23,6 +23,10 @@ export const SidebarThreadSortOrder = Schema.Literals(["updated_at", "created_at export type SidebarThreadSortOrder = typeof SidebarThreadSortOrder.Type; export const DEFAULT_SIDEBAR_THREAD_SORT_ORDER: SidebarThreadSortOrder = "updated_at"; +export const LinuxTitleBarMode = Schema.Literals(["native", "overlay", "custom"]); +export type LinuxTitleBarMode = typeof LinuxTitleBarMode.Type; +export const DEFAULT_LINUX_TITLE_BAR_MODE: LinuxTitleBarMode = "native"; + export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), From 4c0715b111cc68b01464183dc2b3c2a1868948e9 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sat, 11 Apr 2026 17:02:06 +0300 Subject: [PATCH 02/14] reviews update --- apps/desktop/src/env.test.ts | 15 +++++--- apps/desktop/src/env.ts | 2 +- apps/desktop/src/linuxWindowControls.test.ts | 36 +++++++++++++++++++ apps/desktop/src/linuxWindowControls.ts | 30 +++++++++++++--- apps/web/src/components/ChatView.tsx | 4 +-- apps/web/src/components/DiffPanelShell.tsx | 6 ++-- .../src/components/NoActiveThreadState.tsx | 6 ++-- apps/web/src/components/Sidebar.tsx | 11 ++---- apps/web/src/env.test.ts | 35 ++++++++++++++++++ apps/web/src/env.ts | 19 ++++++++++ apps/web/src/routes/settings.tsx | 6 ++-- 11 files changed, 142 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/env.test.ts diff --git a/apps/desktop/src/env.test.ts b/apps/desktop/src/env.test.ts index 7668b7aa47..9d47aeab83 100644 --- a/apps/desktop/src/env.test.ts +++ b/apps/desktop/src/env.test.ts @@ -1,7 +1,14 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { getWindowControlsLayout } from "./env"; +vi.mock("./linuxWindowControls", () => ({ + getLinuxWindowControlsLayout: vi.fn().mockReturnValue({ + left: [], + right: ["minimize", "maximize", "close"], + }), +})); + describe("getWindowControlsLayout", () => { it("uses the standard macOS traffic-light placement in ltr locales", () => { expect(getWindowControlsLayout({ locale: "en-US", platform: "macos" })).toEqual({ @@ -10,10 +17,10 @@ describe("getWindowControlsLayout", () => { }); }); - it("mirrors macOS traffic lights in rtl locales", () => { + it("keeps macOS traffic lights left-aligned in rtl locales", () => { expect(getWindowControlsLayout({ locale: "ar", platform: "macos" })).toEqual({ - left: [], - right: ["maximize", "minimize", "close"], + left: ["close", "minimize", "maximize"], + right: [], }); }); diff --git a/apps/desktop/src/env.ts b/apps/desktop/src/env.ts index 4f406a9830..4e81bc808c 100644 --- a/apps/desktop/src/env.ts +++ b/apps/desktop/src/env.ts @@ -57,7 +57,7 @@ export function getWindowControlsLayout(options?: { const layout = resolvedPlatform === "macos" ? MACOS_WINDOW_CONTROLS_LAYOUT : WINDOWS_WINDOW_CONTROLS_LAYOUT; - if (!rtl) { + if (!rtl || resolvedPlatform === "macos") { return layout; } diff --git a/apps/desktop/src/linuxWindowControls.test.ts b/apps/desktop/src/linuxWindowControls.test.ts index 2c0785f27e..87d2312906 100644 --- a/apps/desktop/src/linuxWindowControls.test.ts +++ b/apps/desktop/src/linuxWindowControls.test.ts @@ -36,6 +36,42 @@ describe("getLinuxWindowControlsLayout", () => { }); }); + it("falls back when kwinrc exists but does not define button placement", () => { + const layout = getLinuxWindowControlsLayout({ + existsSync: vi.fn().mockReturnValue(true), + homeDir: "/home/tester", + readFileSync: vi.fn().mockReturnValue("[org.kde.kdecoration2]\n"), + spawnSync: vi.fn().mockReturnValue({ + status: 0, + stdout: "'close,minimize:maximize'\n", + }), + }); + + expect(layout).toEqual({ + left: ["close", "minimize"], + right: ["maximize"], + }); + }); + + it("falls back to GNOME when reading kwinrc throws", () => { + const layout = getLinuxWindowControlsLayout({ + existsSync: vi.fn().mockReturnValue(true), + homeDir: "/home/tester", + readFileSync: vi.fn().mockImplementation(() => { + throw new Error("permission denied"); + }), + spawnSync: vi.fn().mockReturnValue({ + status: 0, + stdout: "'close,minimize:maximize'\n", + }), + }); + + expect(layout).toEqual({ + left: ["close", "minimize"], + right: ["maximize"], + }); + }); + it("uses the default right-side controls when no desktop layout is available", () => { const layout = getLinuxWindowControlsLayout({ existsSync: vi.fn().mockReturnValue(false), diff --git a/apps/desktop/src/linuxWindowControls.ts b/apps/desktop/src/linuxWindowControls.ts index a7d802c129..9cb390a2ae 100644 --- a/apps/desktop/src/linuxWindowControls.ts +++ b/apps/desktop/src/linuxWindowControls.ts @@ -52,10 +52,16 @@ function readKdeWindowControlsLayout( const content = dependencies.readFileSync(kwinConfigPath, "utf8"); const left = content.match(/^ButtonsOnLeft=(.*)$/m)?.[1]?.trim(); const right = content.match(/^ButtonsOnRight=(.*)$/m)?.[1]?.trim(); + const parsedLeft = parseKdeButtonBank(left); + const parsedRight = parseKdeButtonBank(right); + + if (parsedLeft.length === 0 && parsedRight.length === 0) { + return null; + } return { - left: parseKdeButtonBank(left), - right: parseKdeButtonBank(right), + left: parsedLeft, + right: parsedRight, }; } @@ -106,6 +112,18 @@ function readGnomeWindowControlsLayout( }; } +function safelyReadWindowControlsLayout( + source: string, + readLayout: () => DesktopWindowControlsLayout | null, +): DesktopWindowControlsLayout | null { + try { + return readLayout(); + } catch (error) { + console.warn(`[desktop] failed to read linux window controls from ${source}`, error); + return null; + } +} + export function getLinuxWindowControlsLayout( dependencies: LinuxWindowControlsDependencies = {}, ): DesktopWindowControlsLayout { @@ -117,8 +135,12 @@ export function getLinuxWindowControlsLayout( }; return ( - readKdeWindowControlsLayout(resolvedDependencies) ?? - readGnomeWindowControlsLayout(resolvedDependencies) ?? + safelyReadWindowControlsLayout("kde", () => + readKdeWindowControlsLayout(resolvedDependencies), + ) ?? + safelyReadWindowControlsLayout("gnome", () => + readGnomeWindowControlsLayout(resolvedDependencies), + ) ?? FALLBACK_WINDOW_CONTROLS_LAYOUT ); } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ec5a847d8c..d2d5d98328 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -34,7 +34,7 @@ import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; -import { isElectron, usesWCO } from "../env"; +import { isElectron, usesDesktopChromeHeader, usesWCO } from "../env"; import { readLocalApi } from "../localApi"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { @@ -3284,7 +3284,7 @@ export default function ChatView(props: ChatViewProps) { } let headerClassName = "border-b border-border px-3 py-2 sm:px-5 sm:py-3"; - if (isElectron) { + if (usesDesktopChromeHeader) { headerClassName = "border-b border-border drag-region flex h-[52px] items-center px-3 sm:px-5"; } if (usesWCO) { diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 76ef7b343a..03a3477881 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, usesWCO } from "~/env"; +import { usesDesktopChromeHeader, usesWCO } from "~/env"; import { cn } from "~/lib/utils"; import { Skeleton } from "./ui/skeleton"; @@ -8,7 +8,7 @@ import { Skeleton } from "./ui/skeleton"; export type DiffPanelMode = "inline" | "sheet" | "sidebar"; function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { - const shouldUseDragRegion = isElectron && mode !== "sheet"; + const shouldUseDragRegion = usesDesktopChromeHeader && mode !== "sheet"; return cn( "flex items-center justify-between gap-2", usesWCO ? "titlebar-overlay-safe titlebar-overlay-safe-md" : "px-4", @@ -21,7 +21,7 @@ export function DiffPanelShell(props: { header: ReactNode; children: ReactNode; }) { - const shouldUseDragRegion = isElectron && props.mode !== "sheet"; + const shouldUseDragRegion = usesDesktopChromeHeader && props.mode !== "sheet"; return (
- {isElectron ? ( + {usesDesktopChromeHeader ? ( No active thread ) : (
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 52b8a5d7fc..16e9aa329d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -58,8 +58,8 @@ import { desktopPlatform, isElectron, windowControlsLayout, - usesNativeLinuxTitleBar, usesCustomLinuxWindowControls, + usesDesktopChromeHeader, usesWCO, } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; @@ -1945,12 +1945,7 @@ function SortableProjectItem({ ); } -const SidebarChromeHeader = memo(function SidebarChromeHeader({ - isElectron, -}: { - isElectron: boolean; -}) { - const usesDesktopChromeHeader = isElectron && !usesNativeLinuxTitleBar; +const SidebarChromeHeader = memo(function SidebarChromeHeader() { const usesCenteredLinuxWordmark = usesCustomLinuxWindowControls; const wordmark = (
@@ -3188,7 +3183,7 @@ export default function Sidebar() { return ( <> - + {isOnSettings ? ( diff --git a/apps/web/src/env.test.ts b/apps/web/src/env.test.ts new file mode 100644 index 0000000000..5999ab3ee5 --- /dev/null +++ b/apps/web/src/env.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { shouldRenderDesktopChromeHeader } from "./env"; + +describe("shouldRenderDesktopChromeHeader", () => { + it("returns false on web", () => { + expect(shouldRenderDesktopChromeHeader({ platform: "web" })).toBe(false); + }); + + it("returns true on macos", () => { + expect(shouldRenderDesktopChromeHeader({ platform: "macos" })).toBe(true); + }); + + it("returns true on windows", () => { + expect(shouldRenderDesktopChromeHeader({ platform: "windows" })).toBe(true); + }); + + it("returns true on linux when the title bar is custom", () => { + expect( + shouldRenderDesktopChromeHeader({ + platform: "linux", + linuxTitleBarMode: "custom", + }), + ).toBe(true); + }); + + it("returns false on linux native title bar mode", () => { + expect( + shouldRenderDesktopChromeHeader({ + platform: "linux", + linuxTitleBarMode: "native", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 6d5d87f5c2..38029ce079 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -21,3 +21,22 @@ export const usesNativeLinuxTitleBar = export const usesWCO = desktopPlatform === "windows" || (desktopPlatform === "linux" && runningLinuxTitleBarMode === "overlay"); + +export function shouldRenderDesktopChromeHeader(options?: { + platform?: Platform; + linuxTitleBarMode?: LinuxTitleBarMode; +}): boolean { + const resolvedPlatform = options?.platform ?? desktopPlatform; + if (resolvedPlatform === "web") { + return false; + } + + if (resolvedPlatform === "linux") { + const resolvedLinuxTitleBarMode = options?.linuxTitleBarMode ?? runningLinuxTitleBarMode; + return resolvedLinuxTitleBarMode !== "native"; + } + + return true; +} + +export const usesDesktopChromeHeader = shouldRenderDesktopChromeHeader(); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 4c30ff2f74..6ab1f75eda 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -10,7 +10,7 @@ import { import { useSettingsRestore } from "../components/settings/SettingsPanels"; import { Button } from "../components/ui/button"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; -import { isElectron, usesWCO } from "../env"; +import { usesDesktopChromeHeader, usesWCO } from "../env"; function SettingsContentLayout() { const [restoreSignal, setRestoreSignal] = useState(0); @@ -36,7 +36,7 @@ function SettingsContentLayout() { return (
- {!isElectron && ( + {!usesDesktopChromeHeader && (
@@ -56,7 +56,7 @@ function SettingsContentLayout() {
)} - {isElectron && ( + {usesDesktopChromeHeader && (
Date: Sat, 11 Apr 2026 17:10:11 +0300 Subject: [PATCH 03/14] cast desktop bridge return values --- apps/desktop/src/preload.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 5d80957f4b..1ccc4d1d77 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -40,12 +40,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { return result as ReturnType; }, getPlatform: () => { - const result = ipcRenderer.sendSync(GET_PLATFORM_CHANNEL); - return result === "macos" || result === "windows" || result === "linux" ? result : null; + return ipcRenderer.sendSync(GET_PLATFORM_CHANNEL) as ReturnType< + NonNullable + >; }, getLinuxTitleBarMode: () => { - const result = ipcRenderer.sendSync(GET_LINUX_TITLE_BAR_MODE_CHANNEL); - return result === "native" || result === "overlay" || result === "custom" ? result : null; + return ipcRenderer.sendSync(GET_LINUX_TITLE_BAR_MODE_CHANNEL) as ReturnType< + NonNullable + >; }, setLinuxTitleBarMode: (mode) => ipcRenderer.invoke(SET_LINUX_TITLE_BAR_MODE_CHANNEL, mode), getWindowControlsLayout: () => { From bdbf006a2c6af2c91ead78fc37ffa4a75ba66968 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sat, 11 Apr 2026 17:12:32 +0300 Subject: [PATCH 04/14] rm dead code --- apps/web/src/env.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 38029ce079..1552c6e42f 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -16,8 +16,6 @@ export const usesCustomLinuxWindowControls = desktopPlatform === "linux" && runningLinuxTitleBarMode === "custom" && windowControlsLayout !== null; -export const usesNativeLinuxTitleBar = - desktopPlatform === "linux" && runningLinuxTitleBarMode === "native"; export const usesWCO = desktopPlatform === "windows" || (desktopPlatform === "linux" && runningLinuxTitleBarMode === "overlay"); From 1e634c5fffe07ef5c320a7ce12c1fbf13823f8b2 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 12 Apr 2026 10:11:37 +0300 Subject: [PATCH 05/14] update sidebar styles --- apps/web/src/components/Sidebar.tsx | 56 ++++++++++------------------- 1 file changed, 19 insertions(+), 37 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 16e9aa329d..f4773284d1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1946,7 +1946,6 @@ function SortableProjectItem({ } const SidebarChromeHeader = memo(function SidebarChromeHeader() { - const usesCenteredLinuxWordmark = usesCustomLinuxWindowControls; const wordmark = (
@@ -1976,38 +1975,21 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader() { ); const linuxLeftControlsCount = windowControlsLayout?.left.length ?? 0; - const linuxRightControlsCount = windowControlsLayout?.right.length ?? 0; - const linuxSidebarHeaderInsetStyle = useMemo(() => { - if (!usesCenteredLinuxWordmark) { - return undefined; - } - - const paddingLeft = - linuxLeftControlsCount === 0 ? undefined : `${linuxLeftControlsCount * 1.75 + 0.5}rem`; - const paddingRight = - linuxRightControlsCount === 0 ? undefined : `${linuxRightControlsCount * 1.75 + 0.5}rem`; - - return { - paddingLeft, - paddingRight, - }; - }, [linuxLeftControlsCount, linuxRightControlsCount, usesCenteredLinuxWordmark]); - - const desktopHeaderClassName = useMemo(() => { - if (desktopPlatform === "macos") { - return "drag-region relative h-[52px] flex-row items-center gap-2 py-0 px-4 pl-[90px]"; - } - - if (usesCenteredLinuxWordmark) { - return "drag-region relative h-[52px] flex-row items-center gap-2 px-3 py-0"; - } - - if (usesWCO) { - return "drag-region relative h-[52px] flex-row items-center gap-2 py-0 titlebar-overlay-safe titlebar-overlay-safe-md"; - } + const linuxWordmarkPaddingStyle = usesCustomLinuxWindowControls + ? { + paddingLeft: + linuxLeftControlsCount === 0 ? undefined : `${linuxLeftControlsCount * 1.75 + 0.5}rem`, + } + : undefined; - return "drag-region relative h-[52px] flex-row items-center gap-2 px-4 py-0"; - }, [usesCenteredLinuxWordmark]); + const desktopHeaderClassName = + desktopPlatform === "macos" + ? "bg-background drag-region relative h-[52px] flex-row items-center gap-2 py-0 px-4 pl-[90px]" + : usesCustomLinuxWindowControls + ? "bg-background drag-region relative h-[52px] flex-row items-center gap-2 px-3 py-0" + : usesWCO + ? "bg-background drag-region relative h-[52px] flex-row items-center gap-2 py-0 titlebar-overlay-safe titlebar-overlay-safe-md" + : "bg-background drag-region relative h-[52px] flex-row items-center gap-2 px-4 py-0"; if (!usesDesktopChromeHeader) { return ( @@ -2019,17 +2001,17 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader() { return ( - {usesCenteredLinuxWordmark ? ( + {usesCustomLinuxWindowControls ? (
) : null} - {usesCenteredLinuxWordmark ? ( + {usesCustomLinuxWindowControls ? (
-
{wordmark}
+ {wordmark}
) : ( wordmark From 9a2bc1747e3513c21d57cff76410bf8fde69ba61 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 12 Apr 2026 10:26:14 +0300 Subject: [PATCH 06/14] improve overlay background color --- apps/desktop/src/env.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/env.ts b/apps/desktop/src/env.ts index 4e81bc808c..7d61ad6a93 100644 --- a/apps/desktop/src/env.ts +++ b/apps/desktop/src/env.ts @@ -66,7 +66,7 @@ export function getWindowControlsLayout(options?: { export function getWindowChromeOptions(linuxTitleBarMode: LinuxTitleBarMode): { titleBarStyle?: "hiddenInset" | "hidden"; - titleBarOverlay?: { height: number }; + titleBarOverlay?: { height: number; color?: string }; trafficLightPosition?: { x: number; y: number }; } { if (platform === "macos") { @@ -86,6 +86,7 @@ export function getWindowChromeOptions(linuxTitleBarMode: LinuxTitleBarMode): { titleBarStyle: "hidden", titleBarOverlay: { height: DESKTOP_TITLEBAR_HEIGHT, + color: "#01000000", // #00000000 doesn't work falling back to default value, not sure why, probably some bug in Electron }, }; } From c1d1ae45e1ebd4685cedc0eaba8c0e1944a2f1c0 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 12 Apr 2026 12:39:40 +0300 Subject: [PATCH 07/14] refactor --- .../components/DesktopChromeOverlay.test.tsx | 23 ++++++++ .../src/components/DesktopChromeOverlay.tsx | 36 ++++++++++++ .../components/LinuxWindowControls.test.tsx | 31 ++++++++++ .../src/components/LinuxWindowControls.tsx | 35 +++--------- apps/web/src/components/Sidebar.tsx | 57 ++----------------- apps/web/src/components/chat/ChatHeader.tsx | 2 +- .../components/desktopChromeLayout.test.ts | 25 ++++++++ .../web/src/components/desktopChromeLayout.ts | 28 +++++++++ apps/web/src/env.ts | 4 -- apps/web/src/routes/__root.tsx | 29 ++++++++-- apps/web/src/routes/settings.tsx | 4 +- 11 files changed, 185 insertions(+), 89 deletions(-) create mode 100644 apps/web/src/components/DesktopChromeOverlay.test.tsx create mode 100644 apps/web/src/components/DesktopChromeOverlay.tsx create mode 100644 apps/web/src/components/LinuxWindowControls.test.tsx create mode 100644 apps/web/src/components/desktopChromeLayout.test.ts create mode 100644 apps/web/src/components/desktopChromeLayout.ts diff --git a/apps/web/src/components/DesktopChromeOverlay.test.tsx b/apps/web/src/components/DesktopChromeOverlay.test.tsx new file mode 100644 index 0000000000..339d98e37c --- /dev/null +++ b/apps/web/src/components/DesktopChromeOverlay.test.tsx @@ -0,0 +1,23 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { DesktopChromeOverlay } from "./DesktopChromeOverlay"; + +describe("DesktopChromeOverlay", () => { + it("renders both control banks as separate minimal fixed roots", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("fixed left-3 top-0 z-50"); + expect(markup).toContain("fixed right-3 top-0 z-50"); + expect(markup).not.toContain("drag-region"); + expect(markup).toContain('aria-label="minimize"'); + expect(markup).toContain('aria-label="close"'); + }); +}); diff --git a/apps/web/src/components/DesktopChromeOverlay.tsx b/apps/web/src/components/DesktopChromeOverlay.tsx new file mode 100644 index 0000000000..a5c21e0b14 --- /dev/null +++ b/apps/web/src/components/DesktopChromeOverlay.tsx @@ -0,0 +1,36 @@ +import type { DesktopWindowControl, DesktopWindowControlsLayout } from "@t3tools/contracts"; +import { LinuxWindowControls } from "./LinuxWindowControls"; + +export function DesktopChromeOverlay(props: { layout: DesktopWindowControlsLayout | null }) { + if (!props.layout) { + return null; + } + + return ( + <> + + + + ); +} + +function DesktopChromeOverlayBank(props: { + actions: readonly DesktopWindowControl[]; + side: "left" | "right"; +}) { + if (props.actions.length === 0) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/LinuxWindowControls.test.tsx b/apps/web/src/components/LinuxWindowControls.test.tsx new file mode 100644 index 0000000000..80a460760d --- /dev/null +++ b/apps/web/src/components/LinuxWindowControls.test.tsx @@ -0,0 +1,31 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { LinuxWindowControls } from "./LinuxWindowControls"; + +const testWindow = { desktopBridge: undefined }; + +describe("LinuxWindowControls", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders buttons as a simple inline row", () => { + vi.stubGlobal("window", testWindow); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain("flex shrink-0 items-center gap-1"); + expect(markup).not.toContain("absolute inset-x-3"); + expect(markup).toContain('aria-label="minimize"'); + }); + + it("renders multiple buttons in order", () => { + vi.stubGlobal("window", testWindow); + + const markup = renderToStaticMarkup(); + + expect(markup).toContain('aria-label="minimize"'); + expect(markup).toContain('aria-label="close"'); + }); +}); diff --git a/apps/web/src/components/LinuxWindowControls.tsx b/apps/web/src/components/LinuxWindowControls.tsx index dc6dfd07ae..bb90bb3aad 100644 --- a/apps/web/src/components/LinuxWindowControls.tsx +++ b/apps/web/src/components/LinuxWindowControls.tsx @@ -1,11 +1,11 @@ import { MinusIcon, SquareIcon, XIcon } from "lucide-react"; -import { windowControlsLayout } from "../env"; +import type { DesktopWindowControl } from "@t3tools/contracts"; import { cn } from "~/lib/utils"; import { Button } from "./ui/button"; -function LinuxWindowControlButton(props: { action: "minimize" | "maximize" | "close" }) { +function LinuxWindowControlButton(props: { action: DesktopWindowControl }) { const bridge = window.desktopBridge; const onClick = async () => { @@ -26,7 +26,7 @@ function LinuxWindowControlButton(props: { action: "minimize" | "maximize" | "cl
- ); -} - -export function LinuxWindowControls() { - if (!windowControlsLayout) { +export function LinuxWindowControls(props: { actions: readonly DesktopWindowControl[] }) { + if (props.actions.length === 0) { return null; } return ( -
- - +
+ {props.actions.map((action) => ( + + ))}
); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index f4773284d1..c5af60a715 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -11,7 +11,6 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; -import { LinuxWindowControls } from "./LinuxWindowControls"; import { ProjectFavicon } from "./ProjectFavicon"; import { autoAnimate } from "@formkit/auto-animate"; import React, { useCallback, useEffect, memo, useMemo, useRef, useState } from "react"; @@ -54,14 +53,7 @@ import { type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { usePrimaryEnvironmentId } from "../environments/primary"; -import { - desktopPlatform, - isElectron, - windowControlsLayout, - usesCustomLinuxWindowControls, - usesDesktopChromeHeader, - usesWCO, -} from "../env"; +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"; @@ -1974,49 +1966,12 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader() {
); - const linuxLeftControlsCount = windowControlsLayout?.left.length ?? 0; - const linuxWordmarkPaddingStyle = usesCustomLinuxWindowControls - ? { - paddingLeft: - linuxLeftControlsCount === 0 ? undefined : `${linuxLeftControlsCount * 1.75 + 0.5}rem`, - } - : undefined; - - const desktopHeaderClassName = - desktopPlatform === "macos" - ? "bg-background drag-region relative h-[52px] flex-row items-center gap-2 py-0 px-4 pl-[90px]" - : usesCustomLinuxWindowControls - ? "bg-background drag-region relative h-[52px] flex-row items-center gap-2 px-3 py-0" - : usesWCO - ? "bg-background drag-region relative h-[52px] flex-row items-center gap-2 py-0 titlebar-overlay-safe titlebar-overlay-safe-md" - : "bg-background drag-region relative h-[52px] flex-row items-center gap-2 px-4 py-0"; - - if (!usesDesktopChromeHeader) { - return ( - - {wordmark} - - ); - } - - return ( - - {usesCustomLinuxWindowControls ? ( -
- -
- ) : null} - {usesCustomLinuxWindowControls ? ( -
- {wordmark} -
- ) : ( - wordmark - )} + return isElectron ? ( + + {wordmark} + ) : ( + {wordmark} ); }); diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index cda0bb1367..b8734edbca 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -89,7 +89,7 @@ export const ChatHeader = memo(function ChatHeader({ )}
-
+
{activeProjectScripts && ( { + it("computes a safe inline inset from the number of controls", () => { + expect(resolveDesktopChromeSafeInlineSize(0)).toBe("1rem"); + expect(resolveDesktopChromeSafeInlineSize(2)).toBe("4.5rem"); + }); + + it("maps both control banks into root CSS variables", () => { + expect( + resolveDesktopChromeSafeAreaStyle({ + leftControlCount: 1, + rightControlCount: 3, + }), + ).toEqual({ + "--desktop-chrome-safe-inline-start": "2.75rem", + "--desktop-chrome-safe-inline-end": "6.25rem", + }); + }); +}); diff --git a/apps/web/src/components/desktopChromeLayout.ts b/apps/web/src/components/desktopChromeLayout.ts new file mode 100644 index 0000000000..445bd65d37 --- /dev/null +++ b/apps/web/src/components/desktopChromeLayout.ts @@ -0,0 +1,28 @@ +import type * as React from "react"; + +export const DESKTOP_CHROME_TITLEBAR_HEIGHT_PX = 52; +const DESKTOP_CHROME_SAFE_INLINE_BASE_REM = 1; +const DESKTOP_CHROME_SAFE_INLINE_STEP_REM = 1.75; + +export type DesktopChromeSafeAreaStyle = React.CSSProperties & { + "--desktop-chrome-safe-inline-start"?: string; + "--desktop-chrome-safe-inline-end"?: string; + "--desktop-chrome-titlebar-height"?: string; +}; + +export function resolveDesktopChromeSafeInlineSize(controlCount: number): string { + return `${Math.max(0, controlCount) * DESKTOP_CHROME_SAFE_INLINE_STEP_REM + DESKTOP_CHROME_SAFE_INLINE_BASE_REM}rem`; +} + +export function resolveDesktopChromeSafeAreaStyle(input: { + leftControlCount: number; + rightControlCount: number; +}): DesktopChromeSafeAreaStyle { + const leftSafeInline = resolveDesktopChromeSafeInlineSize(input.leftControlCount); + const rightSafeInline = resolveDesktopChromeSafeInlineSize(input.rightControlCount); + + return { + "--desktop-chrome-safe-inline-start": leftSafeInline, + "--desktop-chrome-safe-inline-end": rightSafeInline, + }; +} diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 1552c6e42f..d1838ea065 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -12,10 +12,6 @@ export const windowControlsLayout: DesktopWindowControlsLayout | null = typeof window === "undefined" ? null : (window.desktopBridge?.getWindowControlsLayout?.() ?? null); -export const usesCustomLinuxWindowControls = - desktopPlatform === "linux" && - runningLinuxTitleBarMode === "custom" && - windowControlsLayout !== null; export const usesWCO = desktopPlatform === "windows" || (desktopPlatform === "linux" && runningLinuxTitleBarMode === "overlay"); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index ce8d0d5dc9..7a923609f9 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -12,6 +12,12 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; +import { DesktopChromeOverlay } from "../components/DesktopChromeOverlay"; +import { + type DesktopChromeSafeAreaStyle, + resolveDesktopChromeSafeAreaStyle, + DESKTOP_CHROME_TITLEBAR_HEIGHT_PX, +} from "../components/desktopChromeLayout"; import { SlowRpcAckToastCoordinator, WebSocketConnectionCoordinator, @@ -21,6 +27,7 @@ import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { readLocalApi } from "../localApi"; +import { windowControlsLayout } from "../env"; import { getServerConfigUpdatedNotification, ServerConfigUpdatedNotification, @@ -66,6 +73,15 @@ export const Route = createRootRouteWithContext<{ function RootRouteView() { const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); + const desktopChromeStyle: DesktopChromeSafeAreaStyle | undefined = windowControlsLayout + ? { + ...resolveDesktopChromeSafeAreaStyle({ + leftControlCount: windowControlsLayout.left.length, + rightControlCount: windowControlsLayout.right.length, + }), + "--desktop-chrome-titlebar-height": `${DESKTOP_CHROME_TITLEBAR_HEIGHT_PX}px`, + } + : undefined; useEffect(() => { const frame = window.requestAnimationFrame(() => { @@ -92,11 +108,14 @@ function RootRouteView() { - - - - - +
+ + + + + + +
); diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index 6ab1f75eda..c85d217fd4 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -8,9 +8,9 @@ import { resolveInitialServerAuthGateState, } from "../environments/primary"; import { useSettingsRestore } from "../components/settings/SettingsPanels"; +import { usesDesktopChromeHeader, usesWCO } from "../env"; import { Button } from "../components/ui/button"; import { SidebarInset, SidebarTrigger } from "../components/ui/sidebar"; -import { usesDesktopChromeHeader, usesWCO } from "../env"; function SettingsContentLayout() { const [restoreSignal, setRestoreSignal] = useState(0); @@ -66,7 +66,7 @@ function SettingsContentLayout() { Settings -
+
From eb7eb9125962271a79b9ffb8123e212a11333ab9 Mon Sep 17 00:00:00 2001 From: Tarik02 Date: Sun, 12 Apr 2026 13:47:30 +0300 Subject: [PATCH 09/14] refactor once more --- apps/web/src/components/ChatView.tsx | 18 ++++++------- .../src/components/DesktopChromeOverlay.tsx | 10 +++---- apps/web/src/components/DiffPanelShell.tsx | 9 ++++--- .../src/components/NoActiveThreadState.tsx | 18 ++++++------- apps/web/src/components/Sidebar.tsx | 6 ++--- apps/web/src/components/chat/ChatHeader.tsx | 2 +- .../web/src/components/desktopChromeLayout.ts | 3 +++ apps/web/src/index.css | 26 ------------------- apps/web/src/routes/__root.tsx | 23 +++++++++------- apps/web/src/routes/settings.tsx | 5 ++-- 10 files changed, 49 insertions(+), 71 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3903aa66c6..7fe701e74c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3288,19 +3288,17 @@ export default function ChatView(props: ChatViewProps) { return ; } - let headerClassName = "border-b border-border px-3 py-2 sm:px-5 sm:py-3"; - if (usesDesktopChromeHeader) { - headerClassName = "border-b border-border drag-region flex h-[52px] items-center px-3 sm:px-5"; - } - if (usesWCO) { - headerClassName = - "border-b border-border drag-region flex h-[52px] items-center titlebar-overlay-safe titlebar-overlay-safe-sm titlebar-overlay-safe-sm-up-lg"; - } - return (
{/* Top bar */} -
+
diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 39be7c96a7..120f8f5de6 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 { usesDesktopChromeHeader, usesWCO } from "~/env"; +import { usesDesktopChromeHeader } from "~/env"; import { cn } from "~/lib/utils"; import { Skeleton } from "./ui/skeleton"; @@ -11,9 +11,10 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { const shouldUseDragRegion = usesDesktopChromeHeader && mode !== "sheet"; return cn( "flex items-center justify-between gap-2", - usesWCO ? "titlebar-overlay-safe titlebar-overlay-safe-md" : "px-4", - "pr-[var(--desktop-chrome-safe-inline-end,0px)]", - shouldUseDragRegion ? "drag-region h-[52px] border-b border-border" : "h-12", + usesDesktopChromeHeader ? "pl-4 pr-[var(--desktop-chrome-safe-inline-end,0px)]" : "px-4", + shouldUseDragRegion + ? "drag-region h-[var(--desktop-chrome-titlebar-height)] border-b border-border" + : "h-12", ); } diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 0f938479e4..391332ca28 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -4,19 +4,17 @@ import { usesDesktopChromeHeader, usesWCO } from "../env"; import { cn } from "~/lib/utils"; export function NoActiveThreadState() { - let headerClassName = "px-3 py-2 sm:px-5 sm:py-3"; - if (usesDesktopChromeHeader) { - headerClassName = "drag-region flex h-[52px] items-center px-3 sm:px-5"; - } - if (usesWCO) { - headerClassName = - "drag-region flex h-[52px] items-center titlebar-overlay-safe titlebar-overlay-safe-sm titlebar-overlay-safe-sm-up-lg"; - } - return (
-
+
{usesDesktopChromeHeader ? ( No active thread ) : ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c173bd18c2..80f1c70a4a 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -54,7 +54,7 @@ import { type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { usePrimaryEnvironmentId } from "../environments/primary"; -import { isElectron } from "../env"; +import { isElectron, usesDesktopChromeHeader } from "../env"; import { APP_STAGE_LABEL, APP_VERSION } from "../branding"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils"; @@ -1969,8 +1969,8 @@ const SidebarChromeHeader = memo(function SidebarChromeHeader() {
); - return isElectron ? ( - + return usesDesktopChromeHeader ? ( + {wordmark} ) : ( diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index b8734edbca..cda0bb1367 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -89,7 +89,7 @@ export const ChatHeader = memo(function ChatHeader({ )}
-
+
{activeProjectScripts && ( location.pathname }); const { authGateState } = Route.useRouteContext(); - const desktopChromeStyle: DesktopChromeSafeAreaStyle | undefined = windowControlsLayout - ? { - ...resolveDesktopChromeSafeAreaStyle({ - leftControlCount: windowControlsLayout.left.length, - rightControlCount: usesCustomWindowControls ? windowControlsLayout.right.length : 0, - }), - "--desktop-chrome-titlebar-height": `${DESKTOP_CHROME_TITLEBAR_HEIGHT_PX}px`, - } - : undefined; + const desktopChromeStyle: DesktopChromeSafeAreaStyle = + usesCustomWindowControls && windowControlsLayout + ? { + ...resolveDesktopChromeSafeAreaStyle({ + leftControlCount: windowControlsLayout.left.length, + rightControlCount: windowControlsLayout.right.length, + }), + "--desktop-chrome-titlebar-height": `${DESKTOP_CHROME_TITLEBAR_HEIGHT_PX}px`, + } + : { + "--desktop-chrome-safe-inline-start": "env(safe-area-inset-left)", + "--desktop-chrome-safe-inline-end": "env(safe-area-inset-right)", + "--desktop-chrome-titlebar-height": `${DESKTOP_CHROME_TITLEBAR_HEIGHT_PX}px`, + }; useEffect(() => { const frame = window.requestAnimationFrame(() => { diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index c85d217fd4..9b7177314b 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -59,14 +59,13 @@ function SettingsContentLayout() { {usesDesktopChromeHeader && (
Settings -
+