From b3067f6535bda4c16049a40580743496743cd669 Mon Sep 17 00:00:00 2001 From: Ellie Date: Sat, 18 Apr 2026 01:11:20 -0400 Subject: [PATCH 1/3] Add desktop title bar mode settings - Persist native/custom title bar preference - Expose IPC bridge and settings UI control - Cover the new preference in tests --- .codex | 0 apps/desktop/src/desktopSettings.test.ts | 30 ++++ apps/desktop/src/desktopSettings.ts | 22 ++- apps/desktop/src/main.ts | 36 +++++ apps/desktop/src/preload.ts | 4 + .../settings/SettingsPanels.browser.tsx | 26 ++++ .../components/settings/SettingsPanels.tsx | 129 +++++++++++++++++- apps/web/src/localApi.test.ts | 2 + packages/contracts/src/ipc.ts | 3 + 9 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 .codex diff --git a/.codex b/.codex new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/desktop/src/desktopSettings.test.ts b/apps/desktop/src/desktopSettings.test.ts index 9b467d22cab..b800bbbd208 100644 --- a/apps/desktop/src/desktopSettings.test.ts +++ b/apps/desktop/src/desktopSettings.test.ts @@ -9,6 +9,7 @@ import { readDesktopSettings, resolveDefaultDesktopSettings, setDesktopServerExposurePreference, + setDesktopTitleBarModePreference, setDesktopUpdateChannelPreference, writeDesktopSettings, } from "./desktopSettings.ts"; @@ -37,6 +38,7 @@ describe("desktopSettings", () => { serverExposureMode: "local-only", updateChannel: "nightly", updateChannelConfiguredByUser: false, + titleBarMode: "custom", }); }); @@ -47,12 +49,14 @@ describe("desktopSettings", () => { serverExposureMode: "network-accessible", updateChannel: "latest", updateChannelConfiguredByUser: true, + titleBarMode: "native", }); expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({ serverExposureMode: "network-accessible", updateChannel: "latest", updateChannelConfiguredByUser: true, + titleBarMode: "native", }); }); @@ -63,6 +67,7 @@ describe("desktopSettings", () => { serverExposureMode: "local-only", updateChannel: "latest", updateChannelConfiguredByUser: false, + titleBarMode: "custom", }, "network-accessible", ), @@ -70,6 +75,7 @@ describe("desktopSettings", () => { serverExposureMode: "network-accessible", updateChannel: "latest", updateChannelConfiguredByUser: false, + titleBarMode: "custom", }); }); @@ -80,6 +86,7 @@ describe("desktopSettings", () => { serverExposureMode: "local-only", updateChannel: "latest", updateChannelConfiguredByUser: false, + titleBarMode: "custom", }, "nightly", ), @@ -87,6 +94,26 @@ describe("desktopSettings", () => { serverExposureMode: "local-only", updateChannel: "nightly", updateChannelConfiguredByUser: true, + titleBarMode: "custom", + }); + }); + + it("persists the requested titlebar mode", () => { + expect( + setDesktopTitleBarModePreference( + { + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: false, + titleBarMode: "custom", + }, + "native", + ), + ).toEqual({ + serverExposureMode: "local-only", + updateChannel: "latest", + updateChannelConfiguredByUser: false, + titleBarMode: "native", }); }); @@ -105,6 +132,7 @@ describe("desktopSettings", () => { serverExposureMode: "local-only", updateChannel: "nightly", updateChannelConfiguredByUser: false, + titleBarMode: "custom", }); }); @@ -123,6 +151,7 @@ describe("desktopSettings", () => { serverExposureMode: "local-only", updateChannel: "nightly", updateChannelConfiguredByUser: false, + titleBarMode: "custom", }); }); @@ -142,6 +171,7 @@ describe("desktopSettings", () => { serverExposureMode: "local-only", updateChannel: "latest", updateChannelConfiguredByUser: true, + titleBarMode: "custom", }); }); }); diff --git a/apps/desktop/src/desktopSettings.ts b/apps/desktop/src/desktopSettings.ts index 6ece5189cce..7498b83c27c 100644 --- a/apps/desktop/src/desktopSettings.ts +++ b/apps/desktop/src/desktopSettings.ts @@ -1,6 +1,10 @@ import * as FS from "node:fs"; import * as Path from "node:path"; -import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts"; +import type { + DesktopServerExposureMode, + DesktopTitleBarMode, + DesktopUpdateChannel, +} from "@t3tools/contracts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; @@ -8,12 +12,14 @@ export interface DesktopSettings { readonly serverExposureMode: DesktopServerExposureMode; readonly updateChannel: DesktopUpdateChannel; readonly updateChannelConfiguredByUser: boolean; + readonly titleBarMode: DesktopTitleBarMode; } export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { serverExposureMode: "local-only", updateChannel: "latest", updateChannelConfiguredByUser: false, + titleBarMode: "custom", }; export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings { @@ -46,6 +52,18 @@ export function setDesktopUpdateChannelPreference( }; } +export function setDesktopTitleBarModePreference( + settings: DesktopSettings, + requestedMode: DesktopTitleBarMode, +): DesktopSettings { + return settings.titleBarMode === requestedMode + ? settings + : { + ...settings, + titleBarMode: requestedMode, + }; +} + export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings { const defaultSettings = resolveDefaultDesktopSettings(appVersion); @@ -59,6 +77,7 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D readonly serverExposureMode?: unknown; readonly updateChannel?: unknown; readonly updateChannelConfiguredByUser?: unknown; + readonly titleBarMode?: unknown; }; const parsedUpdateChannel = parsed.updateChannel === "nightly" || parsed.updateChannel === "latest" @@ -77,6 +96,7 @@ export function readDesktopSettings(settingsPath: string, appVersion: string): D ? parsedUpdateChannel : defaultSettings.updateChannel, updateChannelConfiguredByUser, + titleBarMode: parsed.titleBarMode === "native" ? "native" : "custom", }; } catch { return defaultSettings; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 529ed55d03f..0cea4319685 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -22,6 +22,7 @@ import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron"; import type { ClientSettings, DesktopTheme, + DesktopTitleBarMode, DesktopAppBranding, DesktopServerExposureMode, DesktopServerExposureState, @@ -41,6 +42,7 @@ import { DEFAULT_DESKTOP_SETTINGS, readDesktopSettings, setDesktopServerExposurePreference, + setDesktopTitleBarModePreference, setDesktopUpdateChannelPreference, writeDesktopSettings, } from "./desktopSettings.ts"; @@ -101,6 +103,8 @@ 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 GET_TITLE_BAR_MODE_CHANNEL = "desktop:get-titlebar-mode"; +const SET_TITLE_BAR_MODE_CHANNEL = "desktop:set-titlebar-mode"; 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"); @@ -434,6 +438,14 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { return null; } +function getSafeTitleBarMode(rawMode: unknown): DesktopTitleBarMode | null { + if (rawMode === "custom" || rawMode === "native") { + return rawMode; + } + + return null; +} + async function waitForBackendHttpReady( baseUrl: string, options?: Parameters[1], @@ -1668,6 +1680,26 @@ function registerIpcHandlers(): void { return nextState; }); + ipcMain.removeHandler(GET_TITLE_BAR_MODE_CHANNEL); + ipcMain.handle(GET_TITLE_BAR_MODE_CHANNEL, async () => desktopSettings.titleBarMode); + + ipcMain.removeHandler(SET_TITLE_BAR_MODE_CHANNEL); + ipcMain.handle(SET_TITLE_BAR_MODE_CHANNEL, async (_event, rawMode: unknown) => { + const mode = getSafeTitleBarMode(rawMode); + if (!mode) { + throw new Error("Invalid titlebar mode input."); + } + + if (desktopSettings.titleBarMode === mode) { + return mode; + } + + desktopSettings = setDesktopTitleBarModePreference(desktopSettings, mode); + writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings); + relaunchDesktopApp(`titleBarMode=${mode}`); + return mode; + }); + ipcMain.removeHandler(PICK_FOLDER_CHANNEL); ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => { const owner = BrowserWindow.getFocusedWindow() ?? mainWindow; @@ -1878,6 +1910,10 @@ function getInitialWindowBackgroundColor(): string { } function getWindowTitleBarOptions(): WindowTitleBarOptions { + if (desktopSettings.titleBarMode === "native") { + return {}; + } + if (process.platform === "darwin") { return { titleBarStyle: "hiddenInset", diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index a6756048725..1ff884c93db 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -24,6 +24,8 @@ 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 GET_TITLE_BAR_MODE_CHANNEL = "desktop:get-titlebar-mode"; +const SET_TITLE_BAR_MODE_CHANNEL = "desktop:set-titlebar-mode"; contextBridge.exposeInMainWorld("desktopBridge", { getAppBranding: () => { @@ -53,6 +55,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId), getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), + getTitleBarMode: () => ipcRenderer.invoke(GET_TITLE_BAR_MODE_CHANNEL), + setTitleBarMode: (mode) => ipcRenderer.invoke(SET_TITLE_BAR_MODE_CHANNEL, mode), pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options), confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message), setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme), diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index b508b29b77e..f53f5f02337 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -260,6 +260,8 @@ const createDesktopBridgeStub = (overrides?: { readonly serverExposureState?: Awaited>; readonly setServerExposureMode?: DesktopBridge["setServerExposureMode"]; readonly setUpdateChannel?: DesktopBridge["setUpdateChannel"]; + readonly titleBarMode?: Awaited>; + readonly setTitleBarMode?: DesktopBridge["setTitleBarMode"]; }): DesktopBridge => { const idleUpdateState: DesktopUpdateState = { enabled: false, @@ -310,6 +312,8 @@ const createDesktopBridgeStub = (overrides?: { pickFolder: vi.fn().mockResolvedValue(null), confirm: vi.fn().mockResolvedValue(false), setTheme: vi.fn().mockResolvedValue(undefined), + getTitleBarMode: vi.fn().mockResolvedValue(overrides?.titleBarMode ?? "custom"), + setTitleBarMode: overrides?.setTitleBarMode ?? vi.fn().mockImplementation(async (mode) => mode), showContextMenu: vi.fn().mockResolvedValue(null), openExternal: vi.fn().mockResolvedValue(true), onMenuAction: () => () => {}, @@ -453,6 +457,28 @@ describe("GeneralSettingsPanel observability", () => { .toBeInTheDocument(); }); + it("changes desktop titlebar mode after confirmation", async () => { + const desktopBridge = createDesktopBridgeStub({ + titleBarMode: "custom", + }); + vi.mocked(desktopBridge.confirm).mockResolvedValue(true); + window.desktopBridge = desktopBridge; + setServerConfigSnapshot(createBaseServerConfig()); + + mounted = await render( + + + , + ); + + await expect.element(page.getByLabelText("Window titlebar mode")).toBeInTheDocument(); + await page.getByLabelText("Window titlebar mode").click(); + await page.getByText("Native", { exact: true }).click(); + await vi.waitFor(() => { + expect(desktopBridge.setTitleBarMode).toHaveBeenCalledWith("native"); + }); + }); + it("creates and shows a pairing link when network access is enabled", async () => { window.desktopBridge = createDesktopBridgeStub({ serverExposureState: { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 230b0a9965d..783296ff92a 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -9,9 +9,10 @@ import { XIcon, } from "lucide-react"; import { useQueryClient } from "@tanstack/react-query"; -import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PROVIDER_DISPLAY_NAMES, + type DesktopTitleBarMode, type DesktopUpdateChannel, type ScopedThreadRef, type ProviderKind, @@ -95,6 +96,14 @@ const THEME_OPTIONS = [ }, ] as const; +const TITLE_BAR_MODE_OPTIONS: ReadonlyArray<{ + readonly value: DesktopTitleBarMode; + readonly label: string; +}> = [ + { value: "custom", label: "Custom" }, + { value: "native", label: "Native" }, +]; + const TIMESTAMP_FORMAT_LABELS = { locale: "System default", "12-hour": "12-hour", @@ -520,6 +529,9 @@ export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = useSettings(); const { updateSettings } = useUpdateSettings(); + const [titleBarMode, setTitleBarMode] = useState("custom"); + const [isLoadingTitleBarMode, setIsLoadingTitleBarMode] = useState(false); + const [isUpdatingTitleBarMode, setIsUpdatingTitleBarMode] = useState(false); const [openingPathByTarget, setOpeningPathByTarget] = useState({ keybindings: false, logsDirectory: false, @@ -665,6 +677,76 @@ export function GeneralSettingsPanel() { const isOpeningKeybindings = openingPathByTarget.keybindings; const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; + useEffect(() => { + const bridge = window.desktopBridge; + if (!bridge) { + return; + } + + let cancelled = false; + setIsLoadingTitleBarMode(true); + void bridge + .getTitleBarMode() + .then((mode) => { + if (cancelled) return; + setTitleBarMode(mode); + }) + .catch((error: unknown) => { + if (cancelled) return; + toastManager.add({ + type: "error", + title: "Could not load titlebar mode", + description: + error instanceof Error ? error.message : "Failed to load desktop titlebar mode.", + }); + }) + .finally(() => { + if (cancelled) return; + setIsLoadingTitleBarMode(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + const updateTitleBarMode = useCallback( + async (nextMode: DesktopTitleBarMode) => { + const bridge = window.desktopBridge; + if (!bridge || nextMode === titleBarMode || isUpdatingTitleBarMode) { + return; + } + + const api = readLocalApi(); + const confirmed = await (api ?? ensureLocalApi()).dialogs.confirm( + [`Switch to the ${nextMode} titlebar?`, "T3 Code will restart to apply this change."].join( + "\n", + ), + ); + if (!confirmed) { + return; + } + + setIsUpdatingTitleBarMode(true); + void bridge + .setTitleBarMode(nextMode) + .then((mode) => { + setTitleBarMode(mode); + }) + .catch((error: unknown) => { + toastManager.add({ + type: "error", + title: "Could not change titlebar mode", + description: error instanceof Error ? error.message : "Titlebar mode change failed.", + }); + }) + .finally(() => { + setIsUpdatingTitleBarMode(false); + }); + }, + [isUpdatingTitleBarMode, titleBarMode], + ); + const addCustomModel = useCallback( (provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; @@ -843,6 +925,51 @@ export function GeneralSettingsPanel() { } /> + {isElectron ? ( + { + void updateTitleBarMode("custom"); + }} + /> + ) : null + } + control={ + + } + /> + ) : null} + = {}): DesktopBridg endpointUrl: null, advertisedHost: null, }), + getTitleBarMode: async () => "custom", + setTitleBarMode: async () => "custom", pickFolder: async () => null, confirm: async () => true, setTheme: async () => undefined, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a1abc0fa4a0..fc2f0ccd0c7 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -76,6 +76,7 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; +export type DesktopTitleBarMode = "custom" | "native"; export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; export interface DesktopAppBranding { @@ -160,6 +161,8 @@ export interface DesktopBridge { removeSavedEnvironmentSecret: (environmentId: EnvironmentId) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; + getTitleBarMode: () => Promise; + setTitleBarMode: (mode: DesktopTitleBarMode) => Promise; pickFolder: (options?: PickFolderOptions) => Promise; confirm: (message: string) => Promise; setTheme: (theme: DesktopTheme) => Promise; From 90ebef16d10ab2ce36ac1cee3fe5dc745ecd3aab Mon Sep 17 00:00:00 2001 From: Ellie Gummere Date: Sat, 18 Apr 2026 01:29:37 -0400 Subject: [PATCH 2/3] Mock env isElectron in settings browser tests --- apps/web/src/components/settings/SettingsPanels.browser.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index f53f5f02337..c8d630bc0e0 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -23,6 +23,10 @@ import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/ser import { ConnectionsSettings } from "./ConnectionsSettings"; import { GeneralSettingsPanel } from "./SettingsPanels"; +vi.mock("../../env", () => ({ + isElectron: true, +})); + const authAccessHarness = vi.hoisted(() => { type Snapshot = AuthAccessSnapshot; let snapshot: Snapshot = { From e133fd291eed514b8a4b24556d962dba090fdaee Mon Sep 17 00:00:00 2001 From: Ellie Gummere Date: Thu, 23 Apr 2026 00:21:58 -0400 Subject: [PATCH 3/3] Mock isElectron as runtime getter in tests Return a runtime getter for isElectron so tests can control whether the simple AboutVersionTitle or the full AboutVersionSection (which calls useQueryClient) is rendered by toggling window.desktopBridge or window.nativeApi --- .../settings/SettingsPanels.browser.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index c8d630bc0e0..4b15e220bfa 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -23,9 +23,20 @@ import { resetServerStateForTests, setServerConfigSnapshot } from "../../rpc/ser import { ConnectionsSettings } from "./ConnectionsSettings"; import { GeneralSettingsPanel } from "./SettingsPanels"; -vi.mock("../../env", () => ({ - isElectron: true, -})); +vi.mock("../../env", () => { + // Export a runtime getter for `isElectron` so tests that don't provide a + // QueryClientProvider can keep rendering the simpler AboutVersionTitle, + // while tests that set up a desktop bridge/nativeApi will exercise the + // full AboutVersionSection which calls `useQueryClient()`. + return { + get isElectron() { + return ( + typeof window !== "undefined" && + (window.desktopBridge !== undefined || window.nativeApi !== undefined) + ); + }, + }; +}); const authAccessHarness = vi.hoisted(() => { type Snapshot = AuthAccessSnapshot;