diff --git a/src/browser/components/AppLoader/AppLoader.tsx b/src/browser/components/AppLoader/AppLoader.tsx index 968584bb9a..507499c283 100644 --- a/src/browser/components/AppLoader/AppLoader.tsx +++ b/src/browser/components/AppLoader/AppLoader.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, type ReactNode } from "react"; import { AnimatePresence, motion } from "motion/react"; import App from "../../App"; import { AuthTokenModal } from "../AuthTokenModal/AuthTokenModal"; @@ -18,8 +18,96 @@ import { APIProvider, useAPI, type APIClient } from "@/browser/contexts/API"; import { WorkspaceProvider, useWorkspaceContext } from "../../contexts/WorkspaceContext"; import { RouterProvider } from "../../contexts/RouterContext"; import { TelemetryEnabledProvider } from "../../contexts/TelemetryEnabledContext"; +import { + hydrateUserPreferencesLocalCache, + UserPreferencesProvider, +} from "@/browser/contexts/UserPreferencesContext"; import { TerminalRouterProvider } from "../../terminal/TerminalRouterContext"; +const USER_PREFERENCES_BOOTSTRAP_TIMEOUT_MS = 2000; + +function UserPreferencesStartupGate(props: { children: ReactNode }) { + const apiState = useAPI(); + const [ready, setReady] = useState(false); + const bootstrappedRef = useRef(false); + + useEffect(() => { + if (bootstrappedRef.current || !apiState.api) { + return; + } + + const abortController = new AbortController(); + let timeoutId: ReturnType | null = null; + const timeoutPromise = new Promise<"timeout">((resolve) => { + timeoutId = setTimeout(resolve, USER_PREFERENCES_BOOTSTRAP_TIMEOUT_MS, "timeout"); + }); + const hydratePromise = hydrateUserPreferencesLocalCache({ + configClient: apiState.api.config, + signal: abortController.signal, + }).catch((error) => { + console.warn("Failed to bootstrap user preferences:", error); + return undefined; + }); + + const startup = Promise.race([hydratePromise, timeoutPromise]); + // User preference hydration must happen before RouterProvider reads launch behavior, but + // startup still needs a hard fallback so a slow backend cannot trap users on the boot screen. + void startup + .then((result) => { + if (result === "timeout") { + abortController.abort(); + } + if (abortController.signal.aborted && result !== "timeout") { + return; + } + + bootstrappedRef.current = true; + setReady(true); + }) + .finally(() => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }); + + return () => { + abortController.abort(); + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [apiState.api]); + + if (bootstrappedRef.current || ready) { + return <>{props.children}; + } + + if (apiState.status === "auth_required") { + return ( + + ); + } + + if (apiState.status === "error") { + return ; + } + + return ( + + ); +} + interface AppLoaderProps { /** Optional pre-created ORPC api?. If provided, skips internal connection setup. */ client?: APIClient; @@ -41,15 +129,19 @@ export function AppLoader(props: AppLoaderProps) { return ( - - - - - - - - - + + + + + + + + + + + + + ); diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx index 3ac855b4fd..947c4e0b96 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.test.tsx @@ -228,6 +228,8 @@ function createProjectContextValue( resolveProjectPath: () => null, getProjectConfig: () => undefined, loading: false, + loaded: true, + loadError: null, refreshProjects: () => Promise.resolve(), addProject: () => undefined, removeProject: () => Promise.resolve({ success: true }), @@ -852,6 +854,8 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { resolveProjectPath: () => null, getProjectConfig: () => projectConfig, loading: false, + loaded: true, + loadError: null, refreshProjects: () => Promise.resolve(), addProject: () => undefined, removeProject: () => Promise.resolve({ success: true }), @@ -958,6 +962,8 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { resolveProjectPath: () => null, getProjectConfig: () => projectConfig, loading: false, + loaded: true, + loadError: null, refreshProjects: () => Promise.resolve(), addProject: () => undefined, removeProject: () => Promise.resolve({ success: true }), @@ -1069,6 +1075,8 @@ describe("ProjectSidebar multi-project completed-subagent toggles", () => { resolveProjectPath: () => null, getProjectConfig: () => projectConfig, loading: false, + loaded: true, + loadError: null, refreshProjects: () => Promise.resolve(), addProject: () => undefined, removeProject: () => Promise.resolve({ success: true }), diff --git a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx index 3c54580f63..ec21390c54 100644 --- a/src/browser/components/ProjectSidebar/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar/ProjectSidebar.tsx @@ -31,6 +31,7 @@ import { reorderProjects, normalizeOrder, } from "@/common/utils/projectOrdering"; +import { PROJECT_ORDER_KEY } from "@/common/constants/storage"; import { matchesKeybind, formatKeybind, @@ -1579,7 +1580,7 @@ const ProjectSidebarInner: React.FC = ({ ]); // UI preference: project order persists in localStorage - const [projectOrder, setProjectOrder] = usePersistedState("mux:projectOrder", []); + const [projectOrder, setProjectOrder] = usePersistedState(PROJECT_ORDER_KEY, []); // Build a stable signature of the project keys so effects don't fire on Map identity churn const projectPathsSignature = React.useMemo(() => { diff --git a/src/browser/components/ShareMessagePopover/ShareMessagePopover.tsx b/src/browser/components/ShareMessagePopover/ShareMessagePopover.tsx index 317c29785e..20efca9126 100644 --- a/src/browser/components/ShareMessagePopover/ShareMessagePopover.tsx +++ b/src/browser/components/ShareMessagePopover/ShareMessagePopover.tsx @@ -80,7 +80,9 @@ export const ShareMessagePopover: React.FC = ({ // Signing capabilities and enabled state const [signingCapabilities, setSigningCapabilities] = useState(null); const [signingCapabilitiesLoaded, setSigningCapabilitiesLoaded] = useState(false); - const [signingEnabled, setSigningEnabled] = usePersistedState(SHARE_SIGNING_KEY, true); + const [signingEnabled, setSigningEnabled] = usePersistedState(SHARE_SIGNING_KEY, true, { + listener: true, + }); // Load signing capabilities on first popover open useEffect(() => { diff --git a/src/browser/components/ShareTranscriptDialog/ShareTranscriptDialog.tsx b/src/browser/components/ShareTranscriptDialog/ShareTranscriptDialog.tsx index f8bd883e9d..3aba3ace8b 100644 --- a/src/browser/components/ShareTranscriptDialog/ShareTranscriptDialog.tsx +++ b/src/browser/components/ShareTranscriptDialog/ShareTranscriptDialog.tsx @@ -102,7 +102,9 @@ export function ShareTranscriptDialog(props: ShareTranscriptDialogProps) { // Signing capabilities and enabled state (matching per-message sharing) const [signingCapabilities, setSigningCapabilities] = useState(null); const [signingCapabilitiesLoaded, setSigningCapabilitiesLoaded] = useState(false); - const [signingEnabled, setSigningEnabled] = usePersistedState(SHARE_SIGNING_KEY, true); + const [signingEnabled, setSigningEnabled] = usePersistedState(SHARE_SIGNING_KEY, true, { + listener: true, + }); const [signed, setSigned] = useState(false); const urlInputRef = useRef(null); diff --git a/src/browser/components/TerminalView/TerminalView.tsx b/src/browser/components/TerminalView/TerminalView.tsx index d4b2cdbf8e..b0fb03bcda 100644 --- a/src/browser/components/TerminalView/TerminalView.tsx +++ b/src/browser/components/TerminalView/TerminalView.tsx @@ -5,6 +5,7 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { DEFAULT_TERMINAL_FONT_CONFIG, TERMINAL_FONT_CONFIG_KEY, + normalizeTerminalFontConfig, type TerminalFontConfig, } from "@/common/constants/storage"; import { useTerminalRouter } from "@/browser/terminal/TerminalRouterContext"; @@ -19,27 +20,6 @@ import { } from "@/browser/terminal/terminalFontFamily"; import { TERMINAL_CONTAINER_ATTR } from "@/browser/utils/ui/keybinds"; -function normalizeTerminalFontConfig(value: unknown): TerminalFontConfig { - if (!value || typeof value !== "object") { - return DEFAULT_TERMINAL_FONT_CONFIG; - } - - const record = value as { fontFamily?: unknown; fontSize?: unknown }; - - const fontFamily = - typeof record.fontFamily === "string" && record.fontFamily.trim() - ? record.fontFamily - : DEFAULT_TERMINAL_FONT_CONFIG.fontFamily; - - const fontSizeNumber = Number(record.fontSize); - const fontSize = - Number.isFinite(fontSizeNumber) && fontSizeNumber > 0 - ? fontSizeNumber - : DEFAULT_TERMINAL_FONT_CONFIG.fontSize; - - return { fontFamily, fontSize }; -} - function canLoadFontFamily(primary: string, fontSize: number): boolean { const family = stripOuterQuotes(primary).trim(); if (!family) { diff --git a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx index 8525f09060..ec33625d68 100644 --- a/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx +++ b/src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx @@ -149,13 +149,15 @@ export const WorkspaceMenuBar: React.FC = ({ // Notification on response toggle (workspace-level) - defaults to disabled const [notifyOnResponse, setNotifyOnResponse] = usePersistedState( getNotifyOnResponseKey(workspaceId), - false + false, + { listener: true } ); // Auto-enable notifications for new workspaces (project-level) const [autoEnableNotifications, setAutoEnableNotifications] = usePersistedState( getNotifyOnResponseAutoEnableKey(projectPath), - false + false, + { listener: true } ); // Popover state for notification settings (interactive on click) diff --git a/src/browser/contexts/ProjectContext.test.tsx b/src/browser/contexts/ProjectContext.test.tsx index 4d7a1c9b2e..0db4b93c8e 100644 --- a/src/browser/contexts/ProjectContext.test.tsx +++ b/src/browser/contexts/ProjectContext.test.tsx @@ -124,6 +124,19 @@ describe("ProjectContext", () => { expect(ctx().userProjects.has("/alpha")).toBe(false); }); + test("exposes project list load failures without marking projects as loaded", async () => { + createMockAPI({ + list: () => Promise.reject(new Error("projects unavailable")), + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().loading).toBe(false)); + expect(ctx().loaded).toBe(false); + expect(ctx().loadError).toContain("projects unavailable"); + expect(ctx().userProjects.size).toBe(0); + }); + test("exposes intent-based project resolvers for user/system project lookups", async () => { const systemProjectPath = "/path/to/system-project"; createMockAPI({ diff --git a/src/browser/contexts/ProjectContext.tsx b/src/browser/contexts/ProjectContext.tsx index 0d09df08cd..fd5c448684 100644 --- a/src/browser/contexts/ProjectContext.tsx +++ b/src/browser/contexts/ProjectContext.tsx @@ -69,8 +69,12 @@ export interface ProjectContext { resolveProjectPath: (query: ProjectQuery) => string | null; /** Read project config from the full project map (includes system projects). */ getProjectConfig: (projectPath: string) => ProjectConfig | undefined; - /** True while initial project list is loading */ + /** True while the initial project list request is in flight. */ loading: boolean; + /** True after at least one project list request completes successfully. */ + loaded: boolean; + /** Last project list load failure, if the latest completed request failed. */ + loadError: string | null; refreshProjects: () => Promise; addProject: (normalizedPath: string, projectConfig: ProjectConfig) => void; removeProject: (path: string, options?: { force?: boolean }) => Promise; @@ -136,6 +140,8 @@ export function ProjectProvider(props: { children: ReactNode }) { [allProjectsInternal] ); const [loading, setLoading] = useState(true); + const [loaded, setLoaded] = useState(false); + const [loadError, setLoadError] = useState(null); const [projectCreateInitialPath, setProjectCreateInitialPath] = useState(); const [isProjectCreateModalOpen, setProjectCreateModalOpen] = useState(false); const [workspaceModalState, setWorkspaceModalState] = useState({ @@ -159,7 +165,11 @@ export function ProjectProvider(props: { children: ReactNode }) { const latestAppliedProjectsRefreshSeqRef = useRef(0); const refreshProjects = useCallback(async () => { - if (!api) return; + if (!api) { + setLoaded(false); + setLoadError("API not connected"); + return; + } const refreshSeq = projectsRefreshSeqRef.current + 1; projectsRefreshSeqRef.current = refreshSeq; @@ -174,22 +184,42 @@ export function ProjectProvider(props: { children: ReactNode }) { latestAppliedProjectsRefreshSeqRef.current = refreshSeq; setAllProjectsInternal(new Map(projectsList)); + setLoaded(true); + setLoadError(null); } catch (error) { // Ignore out-of-date refreshes so an older error can't clobber a newer success. if (refreshSeq < latestAppliedProjectsRefreshSeqRef.current) { return; } - // Keep the previous project list on error to avoid emptying the sidebar. + // Keep the previous project list on error so scoped user preferences are not pruned. console.error("Failed to load projects:", error); + setLoadError(getErrorMessage(error)); } }, [api]); useEffect(() => { - void (async () => { + let cancelled = false; + setLoading(true); + + const initialRefresh = async () => { await refreshProjects(); - setLoading(false); - })(); + if (!cancelled) { + setLoading(false); + } + }; + + const refreshPromise = initialRefresh(); + refreshPromise.catch((error) => { + if (!cancelled) { + setLoadError(getErrorMessage(error)); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; }, [refreshProjects]); const addProject = useCallback((normalizedPath: string, projectConfig: ProjectConfig) => { @@ -502,6 +532,8 @@ export function ProjectProvider(props: { children: ReactNode }) { resolveNewChatProjectPath, getProjectConfig, loading, + loaded, + loadError, refreshProjects, addProject, removeProject, @@ -533,6 +565,8 @@ export function ProjectProvider(props: { children: ReactNode }) { resolveNewChatProjectPath, getProjectConfig, loading, + loaded, + loadError, refreshProjects, addProject, removeProject, diff --git a/src/browser/contexts/ProviderOptionsContext.tsx b/src/browser/contexts/ProviderOptionsContext.tsx index 4fde8146d9..a99177d0f1 100644 --- a/src/browser/contexts/ProviderOptionsContext.tsx +++ b/src/browser/contexts/ProviderOptionsContext.tsx @@ -1,5 +1,9 @@ import React, { createContext, useContext, useRef } from "react"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + PROVIDER_OPTIONS_ANTHROPIC_KEY, + PROVIDER_OPTIONS_GOOGLE_KEY, +} from "@/common/constants/storage"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; import { supports1MContext } from "@/common/utils/ai/models"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; @@ -52,7 +56,7 @@ function migrateGlobalToPerModel( export function ProviderOptionsProvider({ children }: { children: React.ReactNode }) { const [anthropicOptions, setAnthropicOptions] = usePersistedState< MuxProviderOptions["anthropic"] - >("provider_options_anthropic", {}); + >(PROVIDER_OPTIONS_ANTHROPIC_KEY, {}, { listener: true }); // One-time migration from global boolean to per-model set const didMigrate = useRef(false); @@ -65,8 +69,9 @@ export function ProviderOptionsProvider({ children }: { children: React.ReactNod } const [googleOptions, setGoogleOptions] = usePersistedState( - "provider_options_google", - {} + PROVIDER_OPTIONS_GOOGLE_KEY, + {}, + { listener: true } ); const models1M = anthropicOptions?.use1MContextModels ?? []; diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index b0314f57f5..dca1dd9535 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -151,6 +151,8 @@ function createWorkspaceContextValue(): WorkspaceContextValue { return { workspaceMetadata: metadataMap, loading: false, + loaded: true, + loadError: null, workspaceDraftPromotionsByProject: {}, promoteWorkspaceDraft: () => undefined, createWorkspace: () => diff --git a/src/browser/contexts/UserPreferencesContext.test.ts b/src/browser/contexts/UserPreferencesContext.test.ts new file mode 100644 index 0000000000..0755ad80bd --- /dev/null +++ b/src/browser/contexts/UserPreferencesContext.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, test } from "bun:test"; + +import { + applyLocalPreferenceWrite, + canPrunePreferenceScopes, + createUserPreferenceSaveQueue, + hydrateUserPreferencesLocalCache, + mergeMissingLocalPreferences, + mirrorBackendPreferences, + overlayDirtyLocalValues, + prunePreferenceScopes, + retryUserPreferenceHydration, + shouldBackfillLocalPreferences, +} from "./UserPreferencesContext"; +import { + LAUNCH_BEHAVIOR_KEY, + PROJECT_ORDER_KEY, + UI_THEME_KEY, + VIM_ENABLED_KEY, +} from "@/common/constants/storage"; +import type { UserPreferences } from "@/common/config/schemas/userPreferences"; + +class MemoryStorage implements Storage { + private values = new Map(); + + get length() { + return this.values.size; + } + + clear(): void { + this.values.clear(); + } + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + key(index: number): string | null { + return Array.from(this.values.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.values.delete(key); + } + + setItem(key: string, value: string): void { + this.values.set(key, value); + } + + setJSON(key: string, value: unknown): void { + this.setItem(key, JSON.stringify(value)); + } +} + +async function waitUntil(assertion: () => void): Promise { + const deadline = Date.now() + 1000; + let lastError: unknown; + while (Date.now() < deadline) { + try { + assertion(); + return; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + throw lastError; +} + +describe("UserPreferencesProvider bridge helpers", () => { + test("seeds local writes from the full local cache before hydration", () => { + const storage = new MemoryStorage(); + storage.setJSON(UI_THEME_KEY, "dark"); + storage.setJSON(VIM_ENABLED_KEY, true); + + expect( + applyLocalPreferenceWrite({ + preferences: undefined, + key: PROJECT_ORDER_KEY, + newValue: ["/repo"], + storage, + }) + ).toEqual({ + appearance: { theme: "dark", vimEnabled: true }, + navigation: { projectOrder: ["/repo"] }, + }); + }); + + test("keeps local backfill active until backend preferences are initialized", () => { + expect( + shouldBackfillLocalPreferences({ + backendPreferences: undefined, + userPreferencesInitialized: false, + }) + ).toBe(true); + expect( + shouldBackfillLocalPreferences({ + backendPreferences: undefined, + userPreferencesInitialized: undefined, + }) + ).toBe(true); + expect( + shouldBackfillLocalPreferences({ + backendPreferences: undefined, + userPreferencesInitialized: true, + }) + ).toBe(false); + expect( + shouldBackfillLocalPreferences({ + backendPreferences: { appearance: { theme: "dark" } }, + userPreferencesInitialized: false, + }) + ).toBe(false); + }); + + test("hydrates backend preferences into the local startup cache", async () => { + const storage = new MemoryStorage(); + + await hydrateUserPreferencesLocalCache({ + storage, + configClient: { + getConfig: () => + Promise.resolve({ + userPreferences: { navigation: { launchBehavior: "last-workspace" } }, + }), + saveConfig: () => Promise.resolve(), + }, + }); + + expect(JSON.parse(storage.getItem(LAUNCH_BEHAVIOR_KEY) ?? "null")).toBe("last-workspace"); + }); + + test("does not backfill stale local cache after backend preferences are initialized", async () => { + const storage = new MemoryStorage(); + storage.setJSON(UI_THEME_KEY, "dark"); + storage.setJSON(VIM_ENABLED_KEY, true); + + await hydrateUserPreferencesLocalCache({ + storage, + configClient: { + getConfig: () => + Promise.resolve({ + userPreferencesInitialized: true, + userPreferences: undefined, + }), + saveConfig: () => Promise.resolve(), + }, + }); + + expect(storage.getItem(UI_THEME_KEY)).toBeNull(); + expect(storage.getItem(VIM_ENABLED_KEY)).toBeNull(); + }); + + test("removes stale local cache entries on non-initial backend refresh", () => { + const storage = new MemoryStorage(); + storage.setJSON(UI_THEME_KEY, "dark"); + storage.setJSON(VIM_ENABLED_KEY, true); + + mirrorBackendPreferences({ + backendPreferences: { appearance: { theme: "light" } }, + dirtyKeys: new Set(), + initial: false, + storage, + }); + + expect(JSON.parse(storage.getItem(UI_THEME_KEY) ?? "null")).toBe("light"); + expect(storage.getItem(VIM_ENABLED_KEY)).toBeNull(); + }); + + test("does not overwrite dirty local cache entries with backend values", () => { + const storage = new MemoryStorage(); + storage.setJSON(UI_THEME_KEY, "flexoki-dark"); + + mirrorBackendPreferences({ + backendPreferences: { appearance: { theme: "light" } }, + dirtyKeys: new Set([UI_THEME_KEY]), + initial: false, + storage, + }); + + expect(JSON.parse(storage.getItem(UI_THEME_KEY) ?? "null")).toBe("flexoki-dark"); + }); + + test("keeps dirty local cache entries on non-initial backend refresh", () => { + const storage = new MemoryStorage(); + storage.setJSON(UI_THEME_KEY, "dark"); + storage.setJSON(VIM_ENABLED_KEY, true); + + mirrorBackendPreferences({ + backendPreferences: { appearance: { theme: "light" } }, + dirtyKeys: new Set([VIM_ENABLED_KEY]), + initial: false, + storage, + }); + + expect(JSON.parse(storage.getItem(UI_THEME_KEY) ?? "null")).toBe("light"); + expect(JSON.parse(storage.getItem(VIM_ENABLED_KEY) ?? "null")).toBe(true); + }); + + test("backfills only local preferences that are missing from backend config", () => { + const storage = new MemoryStorage(); + storage.setJSON(UI_THEME_KEY, "light"); + storage.setJSON(PROJECT_ORDER_KEY, ["/repo/a", "/repo/b"]); + + expect( + mergeMissingLocalPreferences( + { + appearance: { theme: "dark" }, + }, + storage + ) + ).toEqual({ + appearance: { theme: "dark" }, + navigation: { projectOrder: ["/repo/a", "/repo/b"] }, + }); + }); + + test("overlays dirty local values over a backend refresh", () => { + const storage = new MemoryStorage(); + storage.setJSON(UI_THEME_KEY, "flexoki-dark"); + + expect( + overlayDirtyLocalValues( + { + appearance: { theme: "light", vimEnabled: true }, + }, + [UI_THEME_KEY, VIM_ENABLED_KEY], + storage + ) + ).toEqual({ + appearance: { theme: "flexoki-dark" }, + }); + }); + + test("only prunes scoped preferences after successful project and workspace loads", () => { + const ready = { + hydrated: true, + projectLoading: false, + projectLoaded: true, + projectLoadError: null, + workspaceLoading: false, + workspaceLoaded: true, + workspaceLoadError: null, + }; + + expect(canPrunePreferenceScopes(ready)).toBe(true); + expect(canPrunePreferenceScopes({ ...ready, projectLoaded: false })).toBe(false); + expect(canPrunePreferenceScopes({ ...ready, workspaceLoaded: false })).toBe(false); + expect(canPrunePreferenceScopes({ ...ready, projectLoadError: "failed" })).toBe(false); + expect(canPrunePreferenceScopes({ ...ready, workspaceLoadError: "failed" })).toBe(false); + }); + + test("prunes project and workspace scoped preferences that no longer exist", () => { + const projects = new Map([ + ["/repo/a", { workspaces: [] }], + ["/repo/c", { workspaces: [] }], + ]); + + expect( + prunePreferenceScopes({ + preferences: { + navigation: { projectOrder: ["/repo/b", "/repo/a"] }, + ai: { + projectDefaults: { + "/repo/a": { agentId: "exec" }, + "/repo/b": { agentId: "plan" }, + }, + }, + workspaceCreation: { + byProject: { + "/repo/b": { trunkBranch: "origin/main" }, + }, + }, + notifications: { + notifyOnResponseByWorkspace: { "ws-keep": true, "ws-drop": true }, + }, + review: { + defaultBaseByProject: { "/repo/b": "origin/main" }, + }, + }, + projectPaths: new Set(["/repo/a", "/repo/c"]), + workspaceIds: new Set(["ws-keep"]), + userProjects: projects, + }) + ).toEqual({ + navigation: { projectOrder: ["/repo/c", "/repo/a"] }, + ai: { projectDefaults: { "/repo/a": { agentId: "exec" } } }, + notifications: { notifyOnResponseByWorkspace: { "ws-keep": true } }, + }); + }); + + test("retries initial hydration failures until backend config loads", async () => { + const controller = new AbortController(); + const errors: string[] = []; + let attempts = 0; + + await retryUserPreferenceHydration({ + signal: controller.signal, + applyBackendConfig: () => { + attempts += 1; + if (attempts === 1) { + return Promise.reject(new Error("temporary config failure")); + } + return Promise.resolve(); + }, + getRetryDelayMs: () => 0, + waitForDelay: () => Promise.resolve(), + onError: (message) => { + errors.push(message); + }, + }); + + expect(attempts).toBe(2); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain("retrying"); + }); + + test("save queue retries failed saves without dropping pending preferences", async () => { + const controller = new AbortController(); + const saves: Array = []; + const currentPreferences: UserPreferences | undefined = { appearance: { theme: "dark" } }; + let saveAttempts = 0; + let dirtyClears = 0; + const errors: string[] = []; + + const queue = createUserPreferenceSaveQueue({ + signal: controller.signal, + configClient: { + getConfig: () => Promise.resolve({}), + saveConfig: (input) => { + saveAttempts += 1; + if (saveAttempts === 1) { + return Promise.reject(new Error("temporary failure")); + } + saves.push(input.userPreferences); + return Promise.resolve(); + }, + }, + getCurrentPreferences: () => currentPreferences, + clearDirtyKeys: () => { + dirtyClears += 1; + }, + onError: (message) => { + errors.push(message); + }, + }); + + queue(currentPreferences); + + await waitUntil(() => expect(saves).toEqual([currentPreferences])); + expect(saveAttempts).toBe(2); + expect(dirtyClears).toBe(1); + expect(errors[0]).toContain("retrying"); + }); + + test("save queue stops retrying after abort", async () => { + const controller = new AbortController(); + const currentPreferences: UserPreferences | undefined = { appearance: { theme: "dark" } }; + let saveAttempts = 0; + const errors: string[] = []; + + const queue = createUserPreferenceSaveQueue({ + signal: controller.signal, + configClient: { + getConfig: () => Promise.resolve({}), + saveConfig: () => { + saveAttempts += 1; + return Promise.reject(new Error("temporary failure")); + }, + }, + getCurrentPreferences: () => currentPreferences, + clearDirtyKeys: () => { + throw new Error("dirty keys should not clear after an aborted save"); + }, + onError: (message) => { + errors.push(message); + }, + }); + + queue(currentPreferences); + + await waitUntil(() => expect(errors).toHaveLength(1)); + controller.abort(); + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(saveAttempts).toBe(1); + }); + + test("save queue serializes in-flight saves and persists the latest pending preferences", async () => { + const controller = new AbortController(); + const saves: Array = []; + const firstSave = { release: undefined as (() => void) | undefined }; + let saveCalls = 0; + let currentPreferences: UserPreferences | undefined = { appearance: { theme: "dark" } }; + let dirtyClears = 0; + + const queue = createUserPreferenceSaveQueue({ + signal: controller.signal, + configClient: { + getConfig: () => Promise.resolve({}), + saveConfig: async (input) => { + saveCalls += 1; + if (saveCalls === 1) { + await new Promise((resolve) => { + firstSave.release = resolve; + }); + } + saves.push(input.userPreferences); + }, + }, + getCurrentPreferences: () => currentPreferences, + clearDirtyKeys: () => { + dirtyClears += 1; + }, + onError: (message, error) => { + throw new Error(`${message} ${String(error)}`); + }, + }); + + queue({ appearance: { theme: "dark" } }); + currentPreferences = { appearance: { theme: "light" } }; + queue(currentPreferences); + + await waitUntil(() => expect(firstSave.release).toBeDefined()); + const releaseFirst = firstSave.release; + if (!releaseFirst) { + throw new Error("Expected first save release callback"); + } + releaseFirst(); + + await waitUntil(() => expect(saves).toHaveLength(2)); + expect(saves).toEqual([{ appearance: { theme: "dark" } }, { appearance: { theme: "light" } }]); + expect(dirtyClears).toBe(1); + }); +}); diff --git a/src/browser/contexts/UserPreferencesContext.tsx b/src/browser/contexts/UserPreferencesContext.tsx new file mode 100644 index 0000000000..e9c1af8984 --- /dev/null +++ b/src/browser/contexts/UserPreferencesContext.tsx @@ -0,0 +1,581 @@ +import { useEffect, useRef, useState, type ReactNode } from "react"; + +import { useAPI } from "@/browser/contexts/API"; +import { useProjectContext } from "@/browser/contexts/ProjectContext"; +import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; +import { + subscribePersistedStateWrites, + syncPersistedStateFromBackend, +} from "@/browser/hooks/usePersistedState"; +import { + normalizeUserPreferences, + type UserPreferences, +} from "@/common/config/schemas/userPreferences"; +import { + applyStoredUserPreference, + entriesFromUserPreferences, + getStoredUserPreferenceEntries, + getStoredUserPreferenceKeys, + isUserPreferenceStorageKey, + readStoredUserPreferenceValue, + removeStoredUserPreference, +} from "@/common/preferences/userPreferencesStorage"; +import { normalizeOrder } from "@/common/utils/projectOrdering"; +import { stableStringify } from "@/common/utils/stableStringify"; + +function getLocalStorage(): Storage | null { + if (typeof window === "undefined" || !window.localStorage) { + return null; + } + + return window.localStorage; +} + +function writeBackendEntryToLocalStorage(entry: { key: string; value: unknown }, storage: Storage) { + if (storage === getLocalStorage()) { + syncPersistedStateFromBackend(entry.key, entry.value); + return; + } + + storage.setItem(entry.key, JSON.stringify(entry.value)); +} + +function removeBackendEntryFromLocalStorage(key: string, storage: Storage) { + if (storage === getLocalStorage()) { + syncPersistedStateFromBackend(key, undefined); + return; + } + + storage.removeItem(key); +} + +export function overlayDirtyLocalValues( + preferences: UserPreferences | undefined, + dirtyKeys: Iterable, + storage: Storage +): UserPreferences | undefined { + let next = preferences; + for (const key of dirtyKeys) { + const value = readStoredUserPreferenceValue(storage, key); + next = + value === undefined + ? removeStoredUserPreference(next, key) + : applyStoredUserPreference(next, key, value); + } + + return next; +} + +export function mergeMissingLocalPreferences( + backendPreferences: UserPreferences | undefined, + storage: Storage +): UserPreferences | undefined { + const backendKeys = new Set( + entriesFromUserPreferences(backendPreferences).map((entry) => entry.key) + ); + let next = backendPreferences; + for (const entry of getStoredUserPreferenceEntries(storage)) { + if (backendKeys.has(entry.key)) { + continue; + } + next = applyStoredUserPreference(next, entry.key, entry.value); + } + + return next; +} + +export function mirrorBackendPreferences(params: { + backendPreferences: UserPreferences | undefined; + dirtyKeys: ReadonlySet; + initial: boolean; + storage: Storage; +}) { + const backendEntries = entriesFromUserPreferences(params.backendPreferences); + const backendKeys = new Set(backendEntries.map((entry) => entry.key)); + + for (const entry of backendEntries) { + if (!params.dirtyKeys.has(entry.key)) { + writeBackendEntryToLocalStorage(entry, params.storage); + } + } + + if (params.initial) { + return; + } + + for (const key of getStoredUserPreferenceKeys(params.storage)) { + if (!backendKeys.has(key) && !params.dirtyKeys.has(key)) { + removeBackendEntryFromLocalStorage(key, params.storage); + } + } +} + +export function prunePreferenceScopes(params: { + preferences: UserPreferences | undefined; + projectPaths: Set; + workspaceIds: Set; + userProjects: Parameters[1]; +}): UserPreferences | undefined { + const next = params.preferences + ? (JSON.parse(JSON.stringify(params.preferences)) as UserPreferences) + : undefined; + if (!next) { + return undefined; + } + + const pruneProjectRecord = (record: Record | undefined) => { + if (!record) { + return; + } + for (const projectPath of Object.keys(record)) { + if (!params.projectPaths.has(projectPath)) { + delete record[projectPath]; + } + } + }; + + if (next.navigation?.projectOrder) { + next.navigation.projectOrder = normalizeOrder( + next.navigation.projectOrder, + params.userProjects + ); + } + + pruneProjectRecord(next.ai?.projectDefaults); + pruneProjectRecord(next.workspaceCreation?.byProject); + pruneProjectRecord(next.review?.defaultBaseByProject); + + const workspaceNotifications = next.notifications?.notifyOnResponseByWorkspace; + if (workspaceNotifications) { + for (const workspaceId of Object.keys(workspaceNotifications)) { + if (!params.workspaceIds.has(workspaceId)) { + delete workspaceNotifications[workspaceId]; + } + } + } + + return normalizeUserPreferences(next); +} + +export function canPrunePreferenceScopes(params: { + hydrated: boolean; + projectLoading: boolean; + projectLoaded: boolean; + projectLoadError: string | null | undefined; + workspaceLoading: boolean; + workspaceLoaded: boolean; + workspaceLoadError: string | null | undefined; +}): boolean { + return ( + params.hydrated && + !params.projectLoading && + params.projectLoaded && + params.projectLoadError == null && + !params.workspaceLoading && + params.workspaceLoaded && + params.workspaceLoadError == null + ); +} + +const USER_PREFERENCE_RETRY_BASE_DELAY_MS = 250; +const USER_PREFERENCE_RETRY_MAX_DELAY_MS = 5000; + +function getUserPreferenceRetryDelayMs(retryAttempt: number): number { + return Math.min( + USER_PREFERENCE_RETRY_BASE_DELAY_MS * 2 ** retryAttempt, + USER_PREFERENCE_RETRY_MAX_DELAY_MS + ); +} + +function waitForRetryDelay(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(); + } + + return new Promise((resolve) => { + let settled = false; + const timeoutId = setTimeout(finish, delayMs); + function finish() { + if (settled) { + return; + } + settled = true; + clearTimeout(timeoutId); + signal.removeEventListener("abort", finish); + resolve(); + } + signal.addEventListener("abort", finish, { once: true }); + }); +} + +export async function retryUserPreferenceHydration(params: { + signal: AbortSignal; + applyBackendConfig: () => Promise; + onError: (message: string, error: unknown) => void; + getRetryDelayMs?: (retryAttempt: number) => number; + waitForDelay?: (delayMs: number, signal: AbortSignal) => Promise; +}): Promise { + const getRetryDelayMs = params.getRetryDelayMs ?? getUserPreferenceRetryDelayMs; + const waitForDelay = params.waitForDelay ?? waitForRetryDelay; + let retryAttempt = 0; + + while (!params.signal.aborted) { + try { + await params.applyBackendConfig(); + return; + } catch (error) { + const retryDelayMs = getRetryDelayMs(retryAttempt); + retryAttempt += 1; + params.onError(`Failed to hydrate user preferences, retrying in ${retryDelayMs}ms:`, error); + await waitForDelay(retryDelayMs, params.signal); + } + } +} + +interface UserPreferenceConfigClient { + getConfig: () => Promise<{ userPreferences?: unknown; userPreferencesInitialized?: boolean }>; + saveConfig: (input: { userPreferences?: UserPreferences | null }) => Promise; +} + +export function applyLocalPreferenceWrite(params: { + preferences: UserPreferences | undefined; + key: string; + newValue: unknown; + storage: Storage; +}): UserPreferences | undefined { + const basePreferences = + params.preferences ?? mergeMissingLocalPreferences(undefined, params.storage); + return params.newValue === undefined || params.newValue === null + ? removeStoredUserPreference(basePreferences, params.key) + : applyStoredUserPreference(basePreferences, params.key, params.newValue); +} + +export function shouldBackfillLocalPreferences(params: { + backendPreferences: UserPreferences | undefined; + userPreferencesInitialized: boolean | undefined; +}): boolean { + return params.userPreferencesInitialized !== true && params.backendPreferences === undefined; +} + +export async function hydrateUserPreferencesLocalCache(params: { + configClient: UserPreferenceConfigClient; + signal?: AbortSignal; + storage?: Storage | null; +}): Promise { + const storage = params.storage ?? getLocalStorage(); + if (!storage || params.signal?.aborted) { + return undefined; + } + + const config = await params.configClient.getConfig(); + if (params.signal?.aborted) { + return undefined; + } + + const backendPreferences = normalizeUserPreferences(config.userPreferences); + const shouldBackfill = shouldBackfillLocalPreferences({ + backendPreferences, + userPreferencesInitialized: config.userPreferencesInitialized, + }); + mirrorBackendPreferences({ + backendPreferences, + dirtyKeys: new Set(), + initial: shouldBackfill, + storage, + }); + + return shouldBackfill + ? mergeMissingLocalPreferences(backendPreferences, storage) + : backendPreferences; +} + +export function createUserPreferenceSaveQueue(params: { + configClient: UserPreferenceConfigClient; + signal: AbortSignal; + getCurrentPreferences: () => UserPreferences | undefined; + clearDirtyKeys: () => void; + onError: (message: string, error: unknown) => void; +}): (preferences: UserPreferences | undefined) => void { + interface PendingPreferenceSave { + value: UserPreferences | undefined; + } + let saveInFlight = false; + let pendingSave: PendingPreferenceSave | null = null; + let retryAttempt = 0; + + const flush = async () => { + saveInFlight = true; + try { + while (pendingSave !== null && !params.signal.aborted) { + const preferencesToSave = pendingSave.value; + pendingSave = null; + const savedFingerprint = stableStringify(preferencesToSave); + + try { + await params.configClient.saveConfig({ userPreferences: preferencesToSave ?? null }); + } catch (error) { + const hasNewerPendingSave = pendingSave !== null; + if (!hasNewerPendingSave) { + pendingSave = { value: preferencesToSave }; + } + + const retryDelayMs = getUserPreferenceRetryDelayMs(retryAttempt); + retryAttempt += 1; + params.onError( + `Failed to persist user preferences, retrying in ${retryDelayMs}ms:`, + error + ); + await waitForRetryDelay(retryDelayMs, params.signal); + continue; + } + + retryAttempt = 0; + if (params.signal.aborted) { + return; + } + + if (stableStringify(params.getCurrentPreferences()) === savedFingerprint) { + params.clearDirtyKeys(); + } + } + } finally { + saveInFlight = false; + if (pendingSave !== null && !params.signal.aborted) { + const retry = flush(); + retry.catch((error) => { + params.onError("Failed to retry user preference persistence:", error); + }); + } + } + }; + + return (preferences) => { + pendingSave = { value: preferences }; + if (saveInFlight) { + return; + } + + const flushPromise = flush(); + flushPromise.catch((error) => { + params.onError("Failed to flush user preference persistence:", error); + }); + }; +} + +export function UserPreferencesProvider(props: { children: ReactNode }) { + const { api } = useAPI(); + const projectContext = useProjectContext(); + const workspaceContext = useWorkspaceContext(); + const currentPreferencesRef = useRef(undefined); + const dirtyKeysRef = useRef>(new Set()); + const savePreferencesRef = useRef<(preferences: UserPreferences | undefined) => void>( + () => undefined + ); + const hydratedRef = useRef(false); + const [hydrated, setHydrated] = useState(false); + + useEffect(() => { + if (!api) { + savePreferencesRef.current = () => undefined; + hydratedRef.current = false; + setHydrated(false); + return; + } + + // Treat every concrete API client identity as a fresh backend source. Electron normally + // reconnects through null, but direct client swaps should still rerun the initial backfill. + currentPreferencesRef.current = undefined; + dirtyKeysRef.current.clear(); + hydratedRef.current = false; + setHydrated(false); + + const storage = getLocalStorage(); + if (!storage) { + return; + } + + const abortController = new AbortController(); + const { signal } = abortController; + let iterator: AsyncIterator | null = null; + + const enqueueSave = createUserPreferenceSaveQueue({ + configClient: api.config, + signal, + getCurrentPreferences: () => currentPreferencesRef.current, + clearDirtyKeys: () => { + dirtyKeysRef.current.clear(); + }, + onError: (message, error) => { + console.warn(message, error); + }, + }); + + savePreferencesRef.current = enqueueSave; + + const applyBackendConfig = async () => { + const config = await api.config.getConfig(); + if (signal.aborted) { + return; + } + + const backendPreferences = normalizeUserPreferences(config.userPreferences); + const shouldBackfill = shouldBackfillLocalPreferences({ + backendPreferences, + userPreferencesInitialized: config.userPreferencesInitialized, + }); + mirrorBackendPreferences({ + backendPreferences, + dirtyKeys: dirtyKeysRef.current, + initial: shouldBackfill, + storage, + }); + + const withLocalBackfill = shouldBackfill + ? mergeMissingLocalPreferences(backendPreferences, storage) + : backendPreferences; + const nextPreferences = overlayDirtyLocalValues( + withLocalBackfill, + dirtyKeysRef.current, + storage + ); + + currentPreferencesRef.current = nextPreferences; + hydratedRef.current = true; + setHydrated(true); + + if ( + (shouldBackfill || dirtyKeysRef.current.size > 0) && + stableStringify(nextPreferences) !== stableStringify(backendPreferences) + ) { + enqueueSave(nextPreferences); + } + }; + + const unsubscribeWrites = subscribePersistedStateWrites((event) => { + if (event.source === "backend" || !isUserPreferenceStorageKey(event.key)) { + return; + } + + dirtyKeysRef.current.add(event.key); + currentPreferencesRef.current = applyLocalPreferenceWrite({ + preferences: currentPreferencesRef.current, + key: event.key, + newValue: event.newValue, + storage, + }); + + if (!hydratedRef.current) { + return; + } + + enqueueSave(currentPreferencesRef.current); + }); + + const initialSync = retryUserPreferenceHydration({ + signal, + applyBackendConfig, + onError: (message, error) => { + console.warn(message, error); + }, + }); + initialSync.catch((error) => { + console.warn("Failed to retry user preference hydration:", error); + }); + + const subscription = (async () => { + try { + const subscribedIterator = await api.config.onConfigChanged(undefined, { signal }); + if (signal.aborted) { + const cleanup = subscribedIterator.return?.(); + cleanup?.catch(() => undefined); + return; + } + + iterator = subscribedIterator; + for await (const _ of subscribedIterator) { + if (signal.aborted) { + break; + } + const refresh = applyBackendConfig(); + refresh.catch((error) => { + console.warn("Failed to refresh user preferences:", error); + }); + } + } catch { + // Config subscriptions are cancelled during unmounts and API reconnects. + } + })(); + + subscription.catch((error) => { + console.warn("Failed to subscribe to user preference changes:", error); + }); + + return () => { + abortController.abort(); + unsubscribeWrites(); + const cleanup = iterator?.return?.(); + cleanup?.catch(() => undefined); + savePreferencesRef.current = () => undefined; + }; + }, [api]); + + useEffect(() => { + if ( + !canPrunePreferenceScopes({ + hydrated, + projectLoading: projectContext.loading, + projectLoaded: projectContext.loaded, + projectLoadError: projectContext.loadError, + workspaceLoading: workspaceContext.loading, + workspaceLoaded: workspaceContext.loaded, + workspaceLoadError: workspaceContext.loadError, + }) + ) { + return; + } + + const projectPaths = new Set(projectContext.userProjects.keys()); + const workspaceIds = new Set(workspaceContext.workspaceMetadata.keys()); + const pruned = prunePreferenceScopes({ + preferences: currentPreferencesRef.current, + projectPaths, + workspaceIds, + userProjects: projectContext.userProjects, + }); + + if (stableStringify(pruned) === stableStringify(currentPreferencesRef.current)) { + return; + } + + currentPreferencesRef.current = pruned; + const storage = getLocalStorage(); + if (storage) { + for (const entry of entriesFromUserPreferences(pruned)) { + writeBackendEntryToLocalStorage(entry, storage); + } + } + + const prunedKeys = new Set(entriesFromUserPreferences(pruned).map((entry) => entry.key)); + if (storage) { + for (const key of getStoredUserPreferenceKeys(storage)) { + if (!prunedKeys.has(key)) { + removeBackendEntryFromLocalStorage(key, storage); + } + } + } + + savePreferencesRef.current(pruned); + }, [ + hydrated, + projectContext.loading, + projectContext.loaded, + projectContext.loadError, + projectContext.userProjects, + workspaceContext.loading, + workspaceContext.loaded, + workspaceContext.loadError, + workspaceContext.workspaceMetadata, + ]); + + return <>{props.children}; +} diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 91764ce1e9..8329d06f90 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -111,6 +111,21 @@ describe("WorkspaceContext", () => { ); }); + test("exposes workspace metadata load failures without marking metadata as loaded", async () => { + createMockAPI({ + workspace: { + list: () => Promise.reject(new Error("workspace metadata unavailable")), + }, + }); + + const ctx = await setup(); + + await waitFor(() => expect(ctx().loading).toBe(false)); + expect(ctx().loaded).toBe(false); + expect(ctx().loadError).toContain("workspace metadata unavailable"); + expect(ctx().workspaceMetadata.size).toBe(0); + }); + test("subscribes to new workspace immediately when metadata event fires", async () => { const { workspace: workspaceApi } = createMockAPI({ workspace: { diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index ac82319ce0..21ccb15a2f 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -410,6 +410,8 @@ function findExistingEmptyDraft( export interface WorkspaceMetadataContextValue { workspaceMetadata: Map; loading: boolean; + loaded: boolean; + loadError: string | null; } const WorkspaceMetadataContext = createContext( @@ -495,18 +497,18 @@ export interface WorkspaceContext extends WorkspaceMetadataContextValue { } const WorkspaceActionsContext = createContext< - Omit | undefined + Omit | undefined >(undefined); export const WorkspaceContext = { Provider(props: { value: WorkspaceContext; children: ReactNode }) { - const { workspaceMetadata, loading, ...actionsValue } = props.value; + const { workspaceMetadata, loading, loaded, loadError, ...actionsValue } = props.value; // Some focused tests only need to provide metadata. Route the public provider // shape into the split contexts so they avoid mounting WorkspaceProvider and // its API subscriptions. return ( - + {props.children} @@ -678,6 +680,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { [workspaceStore] ); const [loading, setLoading] = useState(true); + const [loaded, setLoaded] = useState(false); + const [loadError, setLoadError] = useState(null); const [workspaceDraftPromotionsByProject, setWorkspaceDraftPromotionsByProject] = useState({}); @@ -996,7 +1000,11 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { }, [workspaceMetadata]); const loadWorkspaceMetadata = useCallback(async () => { - if (!api) return false; // Return false to indicate metadata wasn't loaded + if (!api) { + setLoaded(false); + setLoadError("API not connected"); + return false; // Return false to indicate metadata wasn't attempted. + } try { const metadataList = await api.workspace.list(); @@ -1013,27 +1021,47 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { } setWorkspaceMetadata(metadataMap); - return true; // Return true to indicate metadata was loaded + setLoaded(true); + setLoadError(null); + return true; // Return true to indicate metadata was attempted. } catch (error) { console.error("Failed to load workspace metadata:", error); - setWorkspaceMetadata(new Map()); - return true; // Still return true - we tried to load, just got empty result + // Keep the previous metadata map on failure so scoped preferences are not pruned. + setLoadError(getErrorMessage(error)); + return true; // Still return true because the request completed with a failure. } }, [setWorkspaceMetadata, api]); // Load metadata once on mount (and again when api becomes available) useEffect(() => { - void (async () => { - const loaded = await loadWorkspaceMetadata(); - if (!loaded) { + let cancelled = false; + setLoading(true); + + const initialLoad = async () => { + const attempted = await loadWorkspaceMetadata(); + if (!attempted || cancelled) { // api not available yet - effect will run again when api connects return; } // After loading metadata (which may trigger migration), reload projects // to ensure frontend has the updated config with workspace IDs await refreshProjects(); - setLoading(false); - })(); + if (!cancelled) { + setLoading(false); + } + }; + + const loadPromise = initialLoad(); + loadPromise.catch((error) => { + if (!cancelled) { + setLoadError(getErrorMessage(error)); + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; }, [loadWorkspaceMetadata, refreshProjects]); // URL restoration is now handled by RouterContext which parses the URL on load @@ -1803,8 +1831,8 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { // Split into two context values so metadata-Map churn doesn't re-render // components that only need actions/selection/drafts. const metadataValue = useMemo( - () => ({ workspaceMetadata, loading }), - [workspaceMetadata, loading] + () => ({ workspaceMetadata, loading, loaded, loadError }), + [workspaceMetadata, loading, loaded, loadError] ); const actionsValue = useMemo( @@ -1885,7 +1913,10 @@ export function useWorkspaceMetadata(): WorkspaceMetadataContextValue { * stable across metadata-Map changes, so sidebar-like components that don't * need the full Map can avoid re-renders. */ -export function useWorkspaceActions(): Omit { +export function useWorkspaceActions(): Omit< + WorkspaceContext, + "workspaceMetadata" | "loading" | "loaded" | "loadError" +> { const context = useContext(WorkspaceActionsContext); if (!context) { throw new Error("useWorkspaceActions must be used within WorkspaceProvider"); diff --git a/src/browser/features/RightSidebar/CodeReview/HunkViewer.stories.tsx b/src/browser/features/RightSidebar/CodeReview/HunkViewer.stories.tsx index 4d2d808886..ec354ce008 100644 --- a/src/browser/features/RightSidebar/CodeReview/HunkViewer.stories.tsx +++ b/src/browser/features/RightSidebar/CodeReview/HunkViewer.stories.tsx @@ -74,6 +74,8 @@ function createStubWorkspaceContextValue(): WorkspaceContextValue { return { workspaceMetadata: new Map(), loading: false, + loaded: true, + loadError: null, workspaceDraftPromotionsByProject: {}, promoteWorkspaceDraft: () => undefined, createWorkspace: () => diff --git a/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx index 83f673291e..aa34b49510 100644 --- a/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx @@ -66,7 +66,9 @@ import { import { parseDiff, extractAllHunks, buildGitDiffCommand } from "@/common/utils/git/diffParser"; import { getReviewImmersiveKey, + getReviewDefaultBaseKey, getReviewSearchStateKey, + REVIEW_INCLUDE_UNCOMMITTED_KEY, REVIEW_SORT_ORDER_KEY, } from "@/common/constants/storage"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/Tooltip/Tooltip"; @@ -656,7 +658,7 @@ export const ReviewAssistedStatsReporter: React.FC( projectDefaultBaseKey, @@ -664,7 +666,7 @@ export const ReviewAssistedStatsReporter: React.FC = ({ selectedRepoRootProjectPath ); - const projectDefaultBaseKey = STORAGE_KEYS.reviewDefaultBase(projectPath); + const projectDefaultBaseKey = getReviewDefaultBaseKey(projectPath); const workspaceDiffBaseKey = STORAGE_KEYS.reviewDiffBase(workspaceId); // Per-project default base (shared across workspaces in the same project). @@ -863,8 +865,9 @@ export const ReviewPanel: React.FC = ({ // Persist includeUncommitted flag globally const [includeUncommitted, setIncludeUncommitted] = usePersistedState( - "review-include-uncommitted", - false + REVIEW_INCLUDE_UNCOMMITTED_KEY, + false, + { listener: true } ); // Persist showReadHunks flag globally diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 3a23d62a10..493d01da93 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -25,6 +25,8 @@ import { DEFAULT_BASH_COLLAPSED_SUMMARY_MODE, TRANSCRIPT_DENSITIES, normalizeBashCollapsedSummaryMode, + normalizeEditorConfig, + normalizeTerminalFontConfig, normalizeTranscriptDensity, type BashCollapsedSummaryMode, type TranscriptDensity, @@ -50,33 +52,6 @@ import { type WorktreeArchiveBehavior, } from "@/common/config/worktreeArchiveBehavior"; -// Guard against corrupted/old persisted settings (e.g. from a downgraded build). -const ALLOWED_EDITOR_TYPES: ReadonlySet = new Set([ - "vscode", - "cursor", - "zed", - "custom", -]); - -function normalizeEditorConfig(value: unknown): EditorConfig { - if (!value || typeof value !== "object") { - return DEFAULT_EDITOR_CONFIG; - } - - const record = value as { editor?: unknown; customCommand?: unknown }; - const editor = - typeof record.editor === "string" && ALLOWED_EDITOR_TYPES.has(record.editor as EditorType) - ? (record.editor as EditorType) - : DEFAULT_EDITOR_CONFIG.editor; - - const customCommand = - typeof record.customCommand === "string" && record.customCommand.trim() - ? record.customCommand - : undefined; - - return { editor, customCommand }; -} - function getTerminalFontAvailabilityWarning(config: TerminalFontConfig): string | undefined { if (typeof document === "undefined") { return undefined; @@ -117,27 +92,6 @@ function getTerminalFontAvailabilityWarning(config: TerminalFontConfig): string return undefined; } -function normalizeTerminalFontConfig(value: unknown): TerminalFontConfig { - if (!value || typeof value !== "object") { - return DEFAULT_TERMINAL_FONT_CONFIG; - } - - const record = value as { fontFamily?: unknown; fontSize?: unknown }; - - const fontFamily = - typeof record.fontFamily === "string" && record.fontFamily.trim() - ? record.fontFamily - : DEFAULT_TERMINAL_FONT_CONFIG.fontFamily; - - const fontSizeNumber = Number(record.fontSize); - const fontSize = - Number.isFinite(fontSizeNumber) && fontSizeNumber > 0 - ? fontSizeNumber - : DEFAULT_TERMINAL_FONT_CONFIG.fontSize; - - return { fontFamily, fontSize }; -} - const EDITOR_OPTIONS: Array<{ value: EditorType; label: string }> = [ { value: "vscode", label: "VS Code" }, { value: "cursor", label: "Cursor" }, diff --git a/src/browser/hooks/usePersistedState.test.tsx b/src/browser/hooks/usePersistedState.test.tsx new file mode 100644 index 0000000000..187850933a --- /dev/null +++ b/src/browser/hooks/usePersistedState.test.tsx @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { installDom } from "../../../tests/ui/dom"; + +import { + subscribePersistedStateWrites, + syncPersistedStateFromBackend, + updatePersistedState, + usePersistedState, + type PersistedStateWriteEvent, +} from "./usePersistedState"; + +describe("usePersistedState backend sync", () => { + let cleanupDom: (() => void) | null = null; + + beforeEach(() => { + cleanupDom = installDom(); + }); + + afterEach(() => { + cleanup(); + cleanupDom?.(); + cleanupDom = null; + }); + + test("backend cache hydration updates subscribers that did not opt into storage listening", () => { + const { result } = renderHook(() => usePersistedState("backend-synced-key", "initial")); + + expect(result.current[0]).toBe("initial"); + + act(() => { + syncPersistedStateFromBackend("backend-synced-key", "from-backend"); + }); + + expect(result.current[0]).toBe("from-backend"); + }); + + test("write observers receive local and backend source labels", () => { + const events: PersistedStateWriteEvent[] = []; + const unsubscribe = subscribePersistedStateWrites((event) => { + events.push(event); + }); + + updatePersistedState("observed-key", "local-value"); + syncPersistedStateFromBackend("observed-key", "backend-value"); + unsubscribe(); + + expect(events).toEqual([ + { key: "observed-key", newValue: "local-value", source: "local" }, + { key: "observed-key", newValue: "backend-value", source: "backend" }, + ]); + }); +}); diff --git a/src/browser/hooks/usePersistedState.ts b/src/browser/hooks/usePersistedState.ts index 95acecb3ce..9bd1f47d3d 100644 --- a/src/browser/hooks/usePersistedState.ts +++ b/src/browser/hooks/usePersistedState.ts @@ -10,6 +10,31 @@ interface Subscriber { listener: boolean; } +export type PersistedStateWriteSource = "local" | "backend"; + +export interface PersistedStateWriteEvent { + key: string; + newValue: unknown; + source: PersistedStateWriteSource; +} + +type PersistedStateWriteListener = (event: PersistedStateWriteEvent) => void; + +const writeListeners = new Set(); + +export function subscribePersistedStateWrites(listener: PersistedStateWriteListener): () => void { + writeListeners.add(listener); + return () => { + writeListeners.delete(listener); + }; +} + +function notifyWriteListeners(event: PersistedStateWriteEvent): void { + for (const listener of writeListeners) { + listener(event); + } +} + const subscribersByKey = new Map>(); function addSubscriber(key: string, subscriber: Subscriber): () => void { @@ -27,13 +52,13 @@ function addSubscriber(key: string, subscriber: Subscriber): () => void { }; } -function notifySubscribers(key: string, origin?: string) { +function notifySubscribers(key: string, origin?: string, includeNonListeners = false) { const subs = subscribersByKey.get(key); if (!subs) return; for (const sub of subs) { - // If listener=false, only react to updates originating from this hook instance. - if (!sub.listener) { + // If listener=false, only react to this hook instance or explicit cache hydration. + if (!includeNonListeners && !sub.listener) { if (!origin || origin !== sub.componentId) continue; } sub.callback(); @@ -143,6 +168,8 @@ export function updatePersistedState( window.localStorage.setItem(key, JSON.stringify(newValue)); } + notifyWriteListeners({ key, newValue, source: "local" }); + // Notify same-tab subscribers (usePersistedState) immediately. notifySubscribers(key); @@ -157,6 +184,30 @@ export function updatePersistedState( } } +export function syncPersistedStateFromBackend(key: string, newValue: unknown): void { + if (typeof window === "undefined" || !window.localStorage) { + return; + } + + try { + if (newValue === undefined || newValue === null) { + window.localStorage.removeItem(key); + } else { + window.localStorage.setItem(key, JSON.stringify(newValue)); + } + + notifyWriteListeners({ key, newValue, source: "backend" }); + notifySubscribers(key, undefined, true); + + const customEvent = new CustomEvent(getStorageChangeEvent(key), { + detail: { key, newValue, source: "backend" }, + }); + window.dispatchEvent(customEvent); + } catch (error) { + console.warn(`Error writing backend preference cache key "${key}":`, error); + } +} + interface UsePersistedStateOptions { /** Enable listening to storage changes from other components/tabs */ listener?: boolean; @@ -257,6 +308,8 @@ export function usePersistedState( window.localStorage.setItem(key, JSON.stringify(newValue)); } + notifyWriteListeners({ key, newValue, source: "local" }); + // Notify hook subscribers synchronously (keeps UI responsive). notifySubscribers(key, componentIdRef.current); diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index a53e39cc52..4542ac04d1 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -62,6 +62,10 @@ import type { } from "@/common/orpc/schemas/coder"; import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import type { WorktreeArchiveBehavior } from "@/common/config/worktreeArchiveBehavior"; +import { + normalizeUserPreferences, + type UserPreferences, +} from "@/common/config/schemas/userPreferences"; import type { z } from "zod"; import type { ProjectRemoveErrorSchema } from "@/common/orpc/schemas/errors"; import { isWorkspaceArchived } from "@/common/utils/archive"; @@ -116,6 +120,8 @@ export interface MockORPCClientOptions { projectGitStatusesByWorkspace?: Map; /** Pre-seeded workspace activity snapshots for sidebar status/streaming stories. */ workspaceActivitySnapshots?: Record; + /** Initial backend-synced user preferences for config.getConfig. */ + userPreferences?: UserPreferences; /** Initial task settings for config.getConfig (e.g., Settings → Tasks section) */ taskSettings?: Partial; /** Initial unified AI defaults for agents (plan/exec/compact + subagents) */ @@ -360,6 +366,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl mcpOverrides = new Map(), mcpTestResults = new Map(), mcpOauthAuthStatus = new Map(), + userPreferences: initialUserPreferences, taskSettings: initialTaskSettings, subagentAiDefaults: initialSubagentAiDefaults, agentAiDefaults: initialAgentAiDefaults, @@ -483,6 +490,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl }, ] satisfies AgentDefinitionDescriptor[]); + let userPreferences = normalizeUserPreferences(initialUserPreferences); + let userPreferencesInitialized = initialUserPreferences !== undefined; let taskSettings = normalizeTaskSettings(initialTaskSettings ?? DEFAULT_TASK_SETTINGS); let agentAiDefaults = normalizeAgentAiDefaults( @@ -685,6 +694,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl config: { getConfig: () => Promise.resolve({ + userPreferencesInitialized, + userPreferences, taskSettings, muxGatewayEnabled, muxGatewayModels, @@ -706,11 +717,19 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl llmDebugLogs: false, }), saveConfig: (input: { - taskSettings: unknown; + taskSettings?: unknown; + userPreferences?: unknown; agentAiDefaults?: unknown; subagentAiDefaults?: unknown; }) => { - taskSettings = normalizeTaskSettings(input.taskSettings); + if (input.taskSettings != null) { + taskSettings = normalizeTaskSettings(input.taskSettings); + } + + if (input.userPreferences !== undefined) { + userPreferences = normalizeUserPreferences(input.userPreferences); + userPreferencesInitialized = true; + } if (input.agentAiDefaults !== undefined) { agentAiDefaults = normalizeAgentAiDefaults(input.agentAiDefaults); diff --git a/src/browser/utils/openInEditor.ts b/src/browser/utils/openInEditor.ts index 9fe16b4bef..4a9e3621a6 100644 --- a/src/browser/utils/openInEditor.ts +++ b/src/browser/utils/openInEditor.ts @@ -9,6 +9,7 @@ import { import { DEFAULT_EDITOR_CONFIG, EDITOR_CONFIG_KEY, + normalizeEditorConfig, type EditorConfig, } from "@/common/constants/storage"; import type { RuntimeConfig } from "@/common/types/runtime"; @@ -96,7 +97,9 @@ export async function openInEditor(args: { */ isFile?: boolean; }): Promise { - const editorConfig = readPersistedState(EDITOR_CONFIG_KEY, DEFAULT_EDITOR_CONFIG); + const editorConfig = normalizeEditorConfig( + readPersistedState(EDITOR_CONFIG_KEY, DEFAULT_EDITOR_CONFIG) + ); const isSSH = isSSHRuntime(args.runtimeConfig); const isDocker = isDockerRuntime(args.runtimeConfig); diff --git a/src/common/config/schemas/appConfigOnDisk.test.ts b/src/common/config/schemas/appConfigOnDisk.test.ts index d2270db376..b4533f9a6c 100644 --- a/src/common/config/schemas/appConfigOnDisk.test.ts +++ b/src/common/config/schemas/appConfigOnDisk.test.ts @@ -22,6 +22,21 @@ describe("AppConfigOnDiskSchema", () => { ); }); + it("validates userPreferences", () => { + const valid = { + userPreferences: { + appearance: { theme: "dark" }, + sharing: { expiration: "24h", signing: false }, + }, + }; + + expect(AppConfigOnDiskSchema.safeParse(valid).success).toBe(true); + expect( + AppConfigOnDiskSchema.safeParse({ userPreferences: { appearance: { theme: "neon" } } }) + .success + ).toBe(false); + }); + it("validates taskSettings with limits", () => { const valid = { taskSettings: { diff --git a/src/common/config/schemas/appConfigOnDisk.ts b/src/common/config/schemas/appConfigOnDisk.ts index 66c7b48743..acebb81a98 100644 --- a/src/common/config/schemas/appConfigOnDisk.ts +++ b/src/common/config/schemas/appConfigOnDisk.ts @@ -6,12 +6,15 @@ import { RuntimeEnablementOverridesSchema } from "../../schemas/runtimeEnablemen import { ThinkingLevelSchema } from "../../types/thinking"; import { CODER_ARCHIVE_BEHAVIORS } from "../coderArchiveBehavior"; import { WORKTREE_ARCHIVE_BEHAVIORS } from "../worktreeArchiveBehavior"; +import { UserPreferencesSchema } from "./userPreferences"; import { TaskSettingsSchema } from "./taskSettings"; import { HEARTBEAT_MAX_INTERVAL_MS, HEARTBEAT_MIN_INTERVAL_MS } from "@/constants/heartbeat"; import { DEFAULT_GOAL_DEFAULTS } from "@/constants/goals"; export { RuntimeEnablementOverridesSchema } from "../../schemas/runtimeEnablement"; export type { RuntimeEnablementOverrides } from "../../schemas/runtimeEnablement"; +export { UserPreferencesSchema } from "./userPreferences"; +export type { UserPreferences } from "./userPreferences"; export { TaskSettingsSchema } from "./taskSettings"; export type { TaskSettings } from "./taskSettings"; @@ -50,6 +53,7 @@ export const GoalDefaultsSchema = z.object({ export const AppConfigMigrationsSchema = z.object({ execSubagentDefaultsSplit: z.boolean().optional(), + userPreferencesInitialized: z.boolean().optional(), }); export const FeatureFlagOverrideSchema = z.enum(["default", "on", "off"]); @@ -70,6 +74,7 @@ export const AppConfigOnDiskSchema = z viewedSplashScreens: z.array(z.string()).optional(), featureFlagOverrides: z.record(z.string(), FeatureFlagOverrideSchema).optional(), layoutPresets: z.unknown().optional(), + userPreferences: UserPreferencesSchema.optional(), taskSettings: TaskSettingsSchema.optional(), chatTranscriptFullWidth: z.boolean().optional(), muxGatewayEnabled: z.boolean().optional(), diff --git a/src/common/config/schemas/index.ts b/src/common/config/schemas/index.ts index f1922aad46..5d961f8bfc 100644 --- a/src/common/config/schemas/index.ts +++ b/src/common/config/schemas/index.ts @@ -3,3 +3,4 @@ export * from "./modelParameters"; export * from "./providersConfig"; export * from "./configOperations"; export * from "./taskSettings"; +export * from "./userPreferences"; diff --git a/src/common/config/schemas/userPreferences.test.ts b/src/common/config/schemas/userPreferences.test.ts new file mode 100644 index 0000000000..fda8aedf94 --- /dev/null +++ b/src/common/config/schemas/userPreferences.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; + +import { + UserPreferencesSchema, + normalizeUserPreferences, + pruneUserPreferences, +} from "./userPreferences"; + +describe("UserPreferencesSchema", () => { + test("accepts the semantic preference shape", () => { + const result = UserPreferencesSchema.safeParse({ + appearance: { + theme: "flexoki-dark", + transcriptDensity: "hyper", + bashCollapsedSummaryMode: "intent", + terminalFontConfig: { fontFamily: "Geist Mono", fontSize: 14 }, + editorConfig: { editor: "cursor" }, + vimEnabled: true, + }, + navigation: { + launchBehavior: "last-workspace", + projectOrder: ["/repo/a", "/repo/b"], + }, + ai: { + globalDefaults: { agentId: "exec", thinkingLevel: "high" }, + projectDefaults: { + "/repo/a": { agentId: "plan", model: "openai:gpt-4.1", thinkingLevel: "medium" }, + }, + providerOptions: { + anthropic: { disableBetaFeatures: true }, + google: { thinkingConfig: { includeThoughts: true } }, + }, + autoCompactionThresholdByModel: { "openai:gpt-4.1": 70 }, + }, + workspaceCreation: { + byProject: { + "/repo/a": { + trunkBranch: "origin/main", + lastRuntimeConfig: { ssh: { host: "devbox" } }, + notifyOnResponseAutoEnable: true, + }, + }, + }, + notifications: { + notifyOnResponseByWorkspace: { "ws-1": true }, + }, + sharing: { expiration: "7d", signing: false }, + review: { includeUncommitted: true, defaultBaseByProject: { "/repo/a": "origin/main" } }, + }); + + expect(result.success).toBe(true); + }); + + test("normalizes invalid nested values without dropping valid siblings", () => { + expect( + normalizeUserPreferences({ + appearance: { + theme: "legacy-dark", + transcriptDensity: "wide", + vimEnabled: true, + }, + ai: { + projectDefaults: { + "/repo": { + agentId: " Exec ", + model: "mux-gateway:openai", + thinkingLevel: "xhigh", + }, + }, + autoCompactionThresholdByModel: { + "openai:gpt-4.1": 75, + bad: 101, + }, + }, + }) + ).toEqual({ + appearance: { + theme: "dark", + vimEnabled: true, + }, + ai: { + projectDefaults: { + "/repo": { + agentId: "exec", + thinkingLevel: "xhigh", + }, + }, + autoCompactionThresholdByModel: { + "openai:gpt-4.1": 75, + }, + }, + }); + }); + + test("prunes empty nested preference groups", () => { + expect( + pruneUserPreferences({ + appearance: {}, + ai: { projectDefaults: { "/removed": {} } }, + navigation: { projectOrder: [] }, + }) + ).toBeUndefined(); + }); +}); diff --git a/src/common/config/schemas/userPreferences.ts b/src/common/config/schemas/userPreferences.ts new file mode 100644 index 0000000000..9ef663be2b --- /dev/null +++ b/src/common/config/schemas/userPreferences.ts @@ -0,0 +1,498 @@ +import { z } from "zod"; + +import { + AUTO_COMPACTION_THRESHOLD_MIN, + AUTO_COMPACTION_THRESHOLD_STORAGE_MAX, +} from "@/common/constants/ui"; +import { + BASH_COLLAPSED_SUMMARY_MODES, + EDITOR_TYPES, + TRANSCRIPT_DENSITIES, + normalizeEditorConfig, + normalizeTerminalFontConfig, + type BashCollapsedSummaryMode, + type EditorConfig, + type LaunchBehavior, + type TerminalFontConfig, + type TranscriptDensity, +} from "@/common/constants/storage"; +import { MuxProviderOptionsSchema } from "@/common/schemas/providerOptions"; +import { ThinkingLevelSchema } from "@/common/types/thinking"; +import { EXPIRATION_OPTIONS, type ExpirationValue } from "@/common/lib/shareExpiration"; +import { + isRecord, + parseAgentId, + parseBoolean, + parseEnum, + parseModelString, + parseNonEmptyString, + parseStringArray, + parseThinkingLevel, +} from "@/common/preferences/userPreferenceParsing"; + +const SHARE_EXPIRATION_VALUES = EXPIRATION_OPTIONS.map((option) => option.value) as [ + ExpirationValue, + ...ExpirationValue[], +]; + +export const ThemePreferenceSchema = z.enum([ + "auto", + "light", + "dark", + "flexoki-light", + "flexoki-dark", +]); +export type ThemePreferenceConfig = z.infer; + +export const LaunchBehaviorSchema = z.enum(["dashboard", "new-chat", "last-workspace"]); + +export const UserPreferencesSchema = z.object({ + appearance: z + .object({ + theme: ThemePreferenceSchema.optional(), + transcriptDensity: z.enum(TRANSCRIPT_DENSITIES).optional(), + bashCollapsedSummaryMode: z.enum(BASH_COLLAPSED_SUMMARY_MODES).optional(), + terminalFontConfig: z + .object({ + fontFamily: z.string().min(1), + fontSize: z.number().positive(), + }) + .optional(), + editorConfig: z + .object({ + editor: z.enum(EDITOR_TYPES), + customCommand: z.string().min(1).optional(), + }) + .optional(), + vimEnabled: z.boolean().optional(), + }) + .optional(), + navigation: z + .object({ + launchBehavior: LaunchBehaviorSchema.optional(), + projectOrder: z.array(z.string()).optional(), + }) + .optional(), + sharing: z + .object({ + expiration: z.enum(SHARE_EXPIRATION_VALUES).optional(), + signing: z.boolean().optional(), + }) + .optional(), + ai: z + .object({ + globalDefaults: z + .object({ + agentId: z.string().min(1).optional(), + thinkingLevel: ThinkingLevelSchema.optional(), + }) + .optional(), + projectDefaults: z + .record( + z.string(), + z.object({ + agentId: z.string().min(1).optional(), + model: z.string().min(1).optional(), + thinkingLevel: ThinkingLevelSchema.optional(), + }) + ) + .optional(), + providerOptions: z + .object({ + anthropic: MuxProviderOptionsSchema.shape.anthropic, + google: MuxProviderOptionsSchema.shape.google, + }) + .optional(), + autoCompactionThresholdByModel: z + .record( + z.string(), + z.number().min(AUTO_COMPACTION_THRESHOLD_MIN).max(AUTO_COMPACTION_THRESHOLD_STORAGE_MAX) + ) + .optional(), + }) + .optional(), + workspaceCreation: z + .object({ + byProject: z + .record( + z.string(), + z.object({ + trunkBranch: z.string().min(1).optional(), + lastRuntimeConfig: z.record(z.string(), z.unknown()).optional(), + notifyOnResponseAutoEnable: z.boolean().optional(), + }) + ) + .optional(), + }) + .optional(), + notifications: z + .object({ + notifyOnResponseByWorkspace: z.record(z.string(), z.boolean()).optional(), + }) + .optional(), + review: z + .object({ + includeUncommitted: z.boolean().optional(), + defaultBaseByProject: z.record(z.string(), z.string().min(1)).optional(), + }) + .optional(), +}); + +export type UserPreferences = z.infer; + +export function parseThemePreference(value: unknown): ThemePreferenceConfig | undefined { + const parsed = ThemePreferenceSchema.safeParse(value); + if (parsed.success) { + return parsed.data; + } + + if (typeof value === "string" && value.endsWith("-light")) { + return "light"; + } + + if (typeof value === "string" && value.endsWith("-dark")) { + return "dark"; + } + + return undefined; +} + +function parseTerminalFontConfig(value: unknown): TerminalFontConfig | undefined { + if (!isRecord(value)) { + return undefined; + } + + return normalizeTerminalFontConfig(value); +} + +function parseEditorConfig(value: unknown): EditorConfig | undefined { + if (!isRecord(value)) { + return undefined; + } + + return normalizeEditorConfig(value); +} + +function parseProviderOptions( + value: unknown +): NonNullable["providerOptions"]> | undefined { + if (!isRecord(value)) { + return undefined; + } + + const out: NonNullable["providerOptions"]> = {}; + const anthropic = MuxProviderOptionsSchema.shape.anthropic.safeParse(value.anthropic); + if (anthropic.success && anthropic.data && Object.keys(anthropic.data).length > 0) { + out.anthropic = anthropic.data; + } + + const google = MuxProviderOptionsSchema.shape.google.safeParse(value.google); + if (google.success && google.data && Object.keys(google.data).length > 0) { + out.google = google.data; + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseLastRuntimeConfig(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + + return Object.keys(value).length > 0 ? value : undefined; +} + +function parseAutoCompactionThresholds(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + + const out: Record = {}; + for (const [model, threshold] of Object.entries(value)) { + if ( + typeof threshold !== "number" || + !Number.isFinite(threshold) || + threshold < AUTO_COMPACTION_THRESHOLD_MIN || + threshold > AUTO_COMPACTION_THRESHOLD_STORAGE_MAX + ) { + continue; + } + + out[model] = threshold; + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseProjectDefaults( + value: unknown +): NonNullable["projectDefaults"] { + if (!isRecord(value)) { + return undefined; + } + + const out: NonNullable["projectDefaults"]> = {}; + for (const [projectPath, rawEntry] of Object.entries(value)) { + if (!isRecord(rawEntry)) { + continue; + } + + const entry: NonNullable["projectDefaults"]>[string] = {}; + const agentId = parseAgentId(rawEntry.agentId); + if (agentId) { + entry.agentId = agentId; + } + + const model = parseModelString(rawEntry.model); + if (model) { + entry.model = model; + } + + const thinkingLevel = parseThinkingLevel(rawEntry.thinkingLevel); + if (thinkingLevel) { + entry.thinkingLevel = thinkingLevel; + } + + if (Object.keys(entry).length > 0) { + out[projectPath] = entry; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseWorkspaceCreationByProject( + value: unknown +): NonNullable["byProject"] { + if (!isRecord(value)) { + return undefined; + } + + const out: NonNullable["byProject"]> = {}; + for (const [projectPath, rawEntry] of Object.entries(value)) { + if (!isRecord(rawEntry)) { + continue; + } + + const entry: NonNullable< + NonNullable["byProject"] + >[string] = {}; + const trunkBranch = parseNonEmptyString(rawEntry.trunkBranch); + if (trunkBranch) { + entry.trunkBranch = trunkBranch; + } + + const lastRuntimeConfig = parseLastRuntimeConfig(rawEntry.lastRuntimeConfig); + if (lastRuntimeConfig) { + entry.lastRuntimeConfig = lastRuntimeConfig; + } + + const autoEnable = parseBoolean(rawEntry.notifyOnResponseAutoEnable); + if (autoEnable !== undefined) { + entry.notifyOnResponseAutoEnable = autoEnable; + } + + if (Object.keys(entry).length > 0) { + out[projectPath] = entry; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseBooleanRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + + const out: Record = {}; + for (const [key, rawValue] of Object.entries(value)) { + if (typeof rawValue === "boolean") { + out[key] = rawValue; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseStringRecord(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + + const out: Record = {}; + for (const [key, rawValue] of Object.entries(value)) { + const parsed = parseNonEmptyString(rawValue); + if (parsed) { + out[key] = parsed; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +function pruneEmpty(value: unknown): unknown { + if (Array.isArray(value)) { + return value.length > 0 ? value : undefined; + } + + if (!isRecord(value)) { + return value; + } + + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + const pruned = pruneEmpty(child); + if (pruned !== undefined) { + out[key] = pruned; + } + } + + return Object.keys(out).length > 0 ? out : undefined; +} + +export function pruneUserPreferences( + value: UserPreferences | undefined +): UserPreferences | undefined { + const pruned = pruneEmpty(value); + return isRecord(pruned) ? (pruned as UserPreferences) : undefined; +} + +export function normalizeUserPreferences(value: unknown): UserPreferences | undefined { + if (!isRecord(value)) { + return undefined; + } + + const preferences: UserPreferences = {}; + + if (isRecord(value.appearance)) { + const appearance: NonNullable = {}; + const theme = parseThemePreference(value.appearance.theme); + if (theme) { + appearance.theme = theme; + } + + const transcriptDensity = parseEnum( + TRANSCRIPT_DENSITIES, + value.appearance.transcriptDensity + ); + if (transcriptDensity) { + appearance.transcriptDensity = transcriptDensity; + } + + const bashMode = parseEnum( + BASH_COLLAPSED_SUMMARY_MODES, + value.appearance.bashCollapsedSummaryMode + ); + if (bashMode) { + appearance.bashCollapsedSummaryMode = bashMode; + } + + const terminalFontConfig = parseTerminalFontConfig(value.appearance.terminalFontConfig); + if (terminalFontConfig) { + appearance.terminalFontConfig = terminalFontConfig; + } + + const editorConfig = parseEditorConfig(value.appearance.editorConfig); + if (editorConfig) { + appearance.editorConfig = editorConfig; + } + + const vimEnabled = parseBoolean(value.appearance.vimEnabled); + if (vimEnabled !== undefined) { + appearance.vimEnabled = vimEnabled; + } + + preferences.appearance = appearance; + } + + if (isRecord(value.navigation)) { + const navigation: NonNullable = {}; + const launchBehavior = parseEnum( + LaunchBehaviorSchema.options, + value.navigation.launchBehavior + ); + if (launchBehavior) { + navigation.launchBehavior = launchBehavior; + } + + const projectOrder = parseStringArray(value.navigation.projectOrder); + if (projectOrder) { + navigation.projectOrder = projectOrder; + } + + preferences.navigation = navigation; + } + + if (isRecord(value.sharing)) { + const sharing: NonNullable = {}; + const expiration = parseEnum( + EXPIRATION_OPTIONS.map((option) => option.value), + value.sharing.expiration + ); + if (expiration) { + sharing.expiration = expiration; + } + + const signing = parseBoolean(value.sharing.signing); + if (signing !== undefined) { + sharing.signing = signing; + } + + preferences.sharing = sharing; + } + + if (isRecord(value.ai)) { + const ai: NonNullable = {}; + if (isRecord(value.ai.globalDefaults)) { + const globalDefaults: NonNullable["globalDefaults"]> = {}; + const agentId = parseAgentId(value.ai.globalDefaults.agentId); + if (agentId) { + globalDefaults.agentId = agentId; + } + + const thinkingLevel = parseThinkingLevel(value.ai.globalDefaults.thinkingLevel); + if (thinkingLevel) { + globalDefaults.thinkingLevel = thinkingLevel; + } + + ai.globalDefaults = globalDefaults; + } + + ai.projectDefaults = parseProjectDefaults(value.ai.projectDefaults); + ai.providerOptions = parseProviderOptions(value.ai.providerOptions); + ai.autoCompactionThresholdByModel = parseAutoCompactionThresholds( + value.ai.autoCompactionThresholdByModel + ); + preferences.ai = ai; + } + + if (isRecord(value.workspaceCreation)) { + const workspaceCreation: NonNullable = {}; + workspaceCreation.byProject = parseWorkspaceCreationByProject( + value.workspaceCreation.byProject + ); + preferences.workspaceCreation = workspaceCreation; + } + + if (isRecord(value.notifications)) { + const notifications: NonNullable = {}; + notifications.notifyOnResponseByWorkspace = parseBooleanRecord( + value.notifications.notifyOnResponseByWorkspace + ); + preferences.notifications = notifications; + } + + if (isRecord(value.review)) { + const review: NonNullable = {}; + const includeUncommitted = parseBoolean(value.review.includeUncommitted); + if (includeUncommitted !== undefined) { + review.includeUncommitted = includeUncommitted; + } + + review.defaultBaseByProject = parseStringRecord(value.review.defaultBaseByProject); + preferences.review = review; + } + + return pruneUserPreferences(preferences); +} diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index c6440a2aaf..9233685109 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -87,6 +87,12 @@ export type LaunchBehavior = "dashboard" | "new-chat" | "last-workspace"; */ export const CHAT_TRANSCRIPT_FULL_WIDTH_KEY = "chatTranscriptFullWidth"; +/** + * Ordered project paths in the left sidebar. + * Format: "mux:projectOrder" + */ +export const PROJECT_ORDER_KEY = "mux:projectOrder"; + /** * Get the localStorage key for expanded projects in sidebar (global) * Format: "expandedProjects" @@ -336,6 +342,12 @@ export const HIDDEN_MODELS_KEY = "hidden-models"; */ export const AGENT_AI_DEFAULTS_KEY = "agentAiDefaults"; +/** + * Provider-specific AI options, synced through userPreferences. + */ +export const PROVIDER_OPTIONS_ANTHROPIC_KEY = "provider_options_anthropic"; +export const PROVIDER_OPTIONS_GOOGLE_KEY = "provider_options_google"; + /** * Get the localStorage key for vim mode preference (global) * Format: "vimEnabled" @@ -380,6 +392,27 @@ export const DEFAULT_EDITOR_CONFIG: EditorConfig = { editor: "vscode", }; +export const EDITOR_TYPES = ["vscode", "cursor", "zed", "custom"] as const; + +export function isEditorType(value: unknown): value is EditorType { + return typeof value === "string" && EDITOR_TYPES.includes(value as EditorType); +} + +export function normalizeEditorConfig(value: unknown): EditorConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return DEFAULT_EDITOR_CONFIG; + } + + const record = value as { editor?: unknown; customCommand?: unknown }; + const editor = isEditorType(record.editor) ? record.editor : DEFAULT_EDITOR_CONFIG.editor; + const customCommand = + typeof record.customCommand === "string" && record.customCommand.trim() + ? record.customCommand + : undefined; + + return { editor, customCommand }; +} + /** * Transcript density display preference (global) * Stores: "normal" | "hyper" @@ -439,6 +472,25 @@ export const DEFAULT_TERMINAL_FONT_CONFIG: TerminalFontConfig = { fontSize: 13, }; +export function normalizeTerminalFontConfig(value: unknown): TerminalFontConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return DEFAULT_TERMINAL_FONT_CONFIG; + } + + const record = value as { fontFamily?: unknown; fontSize?: unknown }; + const fontFamily = + typeof record.fontFamily === "string" && record.fontFamily.trim() + ? record.fontFamily + : DEFAULT_TERMINAL_FONT_CONFIG.fontFamily; + const fontSizeNumber = Number(record.fontSize); + const fontSize = + Number.isFinite(fontSizeNumber) && fontSizeNumber > 0 + ? fontSizeNumber + : DEFAULT_TERMINAL_FONT_CONFIG.fontSize; + + return { fontFamily, fontSize }; +} + /** * Tutorial state storage key (global) * Stores: { disabled: boolean, completed: { creation?: true, workspace?: true, review?: true } } @@ -475,6 +527,20 @@ export function getHunkFirstSeenKey(workspaceId: string): string { return `hunkFirstSeen:${workspaceId}`; } +/** + * Project-scoped default diff base for code review. + * Format: "review-default-base:{projectPath}" + */ +export function getReviewDefaultBaseKey(projectPath: string): string { + return `review-default-base:${projectPath}`; +} + +/** + * Global code review behavior for including uncommitted changes. + * Format: "review-include-uncommitted" + */ +export const REVIEW_INCLUDE_UNCOMMITTED_KEY = "review-include-uncommitted"; + /** * Get the localStorage key for review sort order preference (global) * Format: "review-sort-order" @@ -702,6 +768,7 @@ export function getPostCompactionStateKey(workspaceId: string): string { */ const EPHEMERAL_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> = [ getPendingWorkspaceSendErrorKey, + getNotifyOnResponseKey, getPlanContentKey, // Cache only, no need to preserve on fork getPostCompactionStateKey, // Cache only, no need to preserve on fork ]; diff --git a/src/common/constants/ui.ts b/src/common/constants/ui.ts index 9cf7f834e9..9a7a4c7443 100644 --- a/src/common/constants/ui.ts +++ b/src/common/constants/ui.ts @@ -10,6 +10,11 @@ export const AUTO_COMPACTION_THRESHOLD_MIN = 0; export const AUTO_COMPACTION_THRESHOLD_MAX = 90; +/** + * Stored threshold upper bound. A value of 100 disables auto-compaction. + */ +export const AUTO_COMPACTION_THRESHOLD_STORAGE_MAX = 100; + /** * Default auto-compaction threshold percentage (50-90 range) * Applied when creating new workspaces diff --git a/src/common/orpc/schemas/api.test.ts b/src/common/orpc/schemas/api.test.ts index 7e5c7a13b9..638629cabd 100644 --- a/src/common/orpc/schemas/api.test.ts +++ b/src/common/orpc/schemas/api.test.ts @@ -277,13 +277,13 @@ describe("workspace.createMultiProject schema", () => { }); describe("config.saveConfig schema", () => { - it("rejects payload missing taskSettings", () => { + it("accepts payloads that omit taskSettings", () => { const result = config.saveConfig.input.safeParse({ agentAiDefaults: {} }); - expect(result.success).toBe(false); + expect(result.success).toBe(true); }); - it("accepts payload with required taskSettings", () => { + it("accepts payload with taskSettings", () => { const result = config.saveConfig.input.safeParse({ taskSettings: { maxParallelAgentTasks: 2, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 8e20fd0d8a..c4536098e1 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -111,6 +111,7 @@ import { ServiceTierSchema, } from "../../config/schemas/providersConfig"; import { ProviderModelEntrySchema } from "../../config/schemas/providerModelEntry"; +import { UserPreferencesSchema } from "../../config/schemas/userPreferences"; import { TaskSettingsSchema } from "../../config/schemas/taskSettings"; import { ThinkingLevelSchema } from "../../types/thinking"; @@ -2060,6 +2061,8 @@ export const config = { getConfig: { input: z.void(), output: z.object({ + userPreferencesInitialized: z.boolean(), + userPreferences: UserPreferencesSchema.optional(), taskSettings: ResolvedTaskSettingsSchema, muxGatewayEnabled: z.boolean().optional(), muxGatewayModels: z.array(z.string()).optional(), @@ -2092,7 +2095,8 @@ export const config = { }, saveConfig: { input: z.object({ - taskSettings: ResolvedTaskSettingsSchema, + userPreferences: UserPreferencesSchema.nullish(), + taskSettings: ResolvedTaskSettingsSchema.nullish(), advisorModelString: AdvisorModelStringSchema.nullish(), advisorThinkingLevel: AdvisorThinkingLevelSchema.nullish(), advisorMaxUsesPerTurn: AdvisorMaxUsesPerTurnSchema.nullish(), diff --git a/src/common/orpc/schemas/providerOptions.ts b/src/common/orpc/schemas/providerOptions.ts index 4b4bb181c8..5038f7ae29 100644 --- a/src/common/orpc/schemas/providerOptions.ts +++ b/src/common/orpc/schemas/providerOptions.ts @@ -1,101 +1 @@ -import { z } from "zod"; - -import { CacheTtlSchema, ServiceTierSchema } from "../../config/schemas/providersConfig"; - -export const MuxProviderOptionsSchema = z.object({ - anthropic: z - .object({ - // Deprecated: prefer use1MContextModels for per-model control. - // Kept for backward compat with agentSession auto-retry which sets it directly. - use1MContext: z.boolean().optional().meta({ - description: - "Enable Anthropic's beta 1M context window globally (deprecated: use use1MContextModels)", - }), - use1MContextModels: z.array(z.string()).optional().meta({ - description: - "Model IDs with Anthropic beta 1M enabled (e.g. ['anthropic:claude-sonnet-4-20250514'])", - }), - // Anthropic prompt cache TTL. "5m" is the default (free refresh on hit). - // "1h" costs 2× base input for cache writes but keeps the cache alive longer — - // useful for agentic workflows where turns take >5 minutes. - // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration - cacheTtl: CacheTtlSchema.nullish().meta({ - description: - 'Anthropic prompt cache TTL: "5m" (default, free refresh) or "1h" (2× write cost, longer cache)', - }), - disableBetaFeatures: z.boolean().optional().meta({ - description: - "Disable Anthropic beta features (beta 1M context for older Sonnet models, prompt caching). Required for ZDR.", - }), - }) - .optional(), - openai: z - .object({ - serviceTier: ServiceTierSchema.optional().meta({ - description: - "OpenAI service tier: priority (low-latency), flex (50% cheaper, higher latency), auto/default (standard)", - }), - wireFormat: z.enum(["responses", "chatCompletions"]).optional().meta({ - description: - "OpenAI wire format: responses (default, persistence + built-in tools) or chatCompletions (legacy /chat/completions)", - }), - store: z.boolean().optional().meta({ - description: "Whether OpenAI stores responses. Set false for zero data retention (ZDR).", - }), - forceContextLimitError: z.boolean().optional().meta({ - description: "Force context limit error (used in integration tests to simulate overflow)", - }), - simulateToolPolicyNoop: z.boolean().optional().meta({ - description: - "Simulate successful response without executing tools (used in tool policy tests)", - }), - }) - .optional(), - google: z.record(z.string(), z.unknown()).optional(), - ollama: z.record(z.string(), z.unknown()).optional(), - openrouter: z.record(z.string(), z.unknown()).optional(), - xai: z - .object({ - searchParameters: z - .object({ - mode: z.enum(["auto", "off", "on"]), - returnCitations: z.boolean().optional(), - fromDate: z.string().optional(), - toDate: z.string().optional(), - maxSearchResults: z.number().optional(), - sources: z - .array( - z.discriminatedUnion("type", [ - z.object({ - type: z.literal("web"), - country: z.string().optional(), - excludedWebsites: z.array(z.string()).optional(), - allowedWebsites: z.array(z.string()).optional(), - safeSearch: z.boolean().optional(), - }), - z.object({ - type: z.literal("x"), - excludedXHandles: z.array(z.string()).optional(), - includedXHandles: z.array(z.string()).optional(), - postFavoriteCount: z.number().optional(), - postViewCount: z.number().optional(), - xHandles: z.array(z.string()).optional(), - }), - z.object({ - type: z.literal("news"), - country: z.string().optional(), - excludedWebsites: z.array(z.string()).optional(), - safeSearch: z.boolean().optional(), - }), - z.object({ - type: z.literal("rss"), - links: z.array(z.string()), - }), - ]) - ) - .optional(), - }) - .optional(), - }) - .optional(), -}); +export { MuxProviderOptionsSchema } from "@/common/schemas/providerOptions"; diff --git a/src/common/preferences/userPreferenceParsing.ts b/src/common/preferences/userPreferenceParsing.ts new file mode 100644 index 0000000000..60bc921757 --- /dev/null +++ b/src/common/preferences/userPreferenceParsing.ts @@ -0,0 +1,68 @@ +import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; +import { isValidModelFormat, normalizeSelectedModel } from "@/common/utils/ai/models"; +import { normalizeAgentId } from "@/common/utils/agentIds"; + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +export function parseBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +export function parseEnum(values: readonly T[], value: unknown): T | undefined { + return typeof value === "string" && values.includes(value as T) ? (value as T) : undefined; +} + +export function parseStringArray(value: unknown): string[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const out: string[] = []; + const seen = new Set(); + for (const item of value) { + const parsed = parseNonEmptyString(item); + if (!parsed || seen.has(parsed)) { + continue; + } + seen.add(parsed); + out.push(parsed); + } + + return out.length > 0 ? out : undefined; +} + +export function parseAgentId(value: unknown): string | undefined { + if (typeof value !== "string" || value.trim().length === 0) { + return undefined; + } + + return normalizeAgentId(value, ""); +} + +export function parseModelString(value: unknown): string | undefined { + const parsed = parseNonEmptyString(value); + if (!parsed) { + return undefined; + } + + if (parsed.startsWith("mux-gateway:") && !parsed.includes("/")) { + return undefined; + } + + const normalized = normalizeSelectedModel(parsed); + return isValidModelFormat(normalized) ? normalized : undefined; +} + +export function parseThinkingLevel(value: unknown): ThinkingLevel | undefined { + return coerceThinkingLevel(value); +} + +export function parseRecord(value: unknown): Record | undefined { + return isRecord(value) && Object.keys(value).length > 0 ? value : undefined; +} diff --git a/src/common/preferences/userPreferencesStorage.test.ts b/src/common/preferences/userPreferencesStorage.test.ts new file mode 100644 index 0000000000..445297b7c7 --- /dev/null +++ b/src/common/preferences/userPreferencesStorage.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, test } from "bun:test"; + +import { + applyStoredUserPreference, + entriesFromUserPreferences, + getStoredUserPreferenceEntries, + hasUserPreferenceEntry, + isUserPreferenceStorageKey, + removeStoredUserPreference, +} from "./userPreferencesStorage"; +import { + PROJECT_ORDER_KEY, + PROVIDER_OPTIONS_ANTHROPIC_KEY, + REVIEW_INCLUDE_UNCOMMITTED_KEY, + UI_THEME_KEY, + getAgentIdKey, + getAutoCompactionThresholdKey, + getLastRuntimeConfigKey, + getModelKey, + getNotifyOnResponseAutoEnableKey, + getNotifyOnResponseKey, + getProjectScopeId, + getReviewDefaultBaseKey, + getThinkingLevelKey, + getTrunkBranchKey, +} from "@/common/constants/storage"; +import type { UserPreferences } from "@/common/config/schemas/userPreferences"; + +class MemoryStorage { + private values = new Map(); + + get length() { + return this.values.size; + } + + key(index: number): string | null { + return Array.from(this.values.keys())[index] ?? null; + } + + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + + setJSON(key: string, value: unknown) { + this.values.set(key, JSON.stringify(value)); + } +} + +function collectForTest(storage: MemoryStorage) { + return getStoredUserPreferenceEntries(storage).reduce( + (preferences, entry) => applyStoredUserPreference(preferences, entry.key, entry.value), + undefined as Parameters[0] + ); +} + +describe("user preference localStorage registry", () => { + test("collects semantic preferences from legacy localStorage keys", () => { + const storage = new MemoryStorage(); + const projectScope = getProjectScopeId("/repo"); + storage.setJSON(UI_THEME_KEY, "dark"); + storage.setJSON(PROJECT_ORDER_KEY, ["/repo"]); + storage.setJSON(getAgentIdKey(projectScope), "plan"); + storage.setJSON(getModelKey(projectScope), "openai:gpt-4.1"); + storage.setJSON(getThinkingLevelKey(projectScope), "high"); + storage.setJSON(PROVIDER_OPTIONS_ANTHROPIC_KEY, { disableBetaFeatures: true }); + storage.setJSON(getAutoCompactionThresholdKey("openai:gpt-4.1"), 80); + storage.setJSON(getTrunkBranchKey("/repo"), "origin/main"); + storage.setJSON(getLastRuntimeConfigKey("/repo"), { ssh: { host: "devbox" } }); + storage.setJSON(getNotifyOnResponseAutoEnableKey("/repo"), true); + storage.setJSON(getNotifyOnResponseKey("ws-1"), true); + storage.setJSON(REVIEW_INCLUDE_UNCOMMITTED_KEY, true); + storage.setJSON(getReviewDefaultBaseKey("/repo"), "origin/main"); + + expect(collectForTest(storage)).toEqual({ + appearance: { theme: "dark" }, + navigation: { projectOrder: ["/repo"] }, + ai: { + projectDefaults: { + "/repo": { + agentId: "plan", + model: "openai:gpt-4.1", + thinkingLevel: "high", + }, + }, + providerOptions: { + anthropic: { disableBetaFeatures: true }, + }, + autoCompactionThresholdByModel: { + "openai:gpt-4.1": 80, + }, + }, + workspaceCreation: { + byProject: { + "/repo": { + trunkBranch: "origin/main", + lastRuntimeConfig: { ssh: { host: "devbox" } }, + notifyOnResponseAutoEnable: true, + }, + }, + }, + notifications: { + notifyOnResponseByWorkspace: { "ws-1": true }, + }, + review: { + includeUncommitted: true, + defaultBaseByProject: { "/repo": "origin/main" }, + }, + }); + }); + + test("migrates legacy theme names during localStorage collection", () => { + const storage = new MemoryStorage(); + storage.setJSON(UI_THEME_KEY, "solarized-dark"); + + expect(collectForTest(storage)).toEqual({ appearance: { theme: "dark" } }); + }); + + test("all flattened preference entries are recognized, applied, and removable", () => { + const preferences: UserPreferences = { + appearance: { + theme: "flexoki-dark", + transcriptDensity: "hyper", + bashCollapsedSummaryMode: "intent", + terminalFontConfig: { fontFamily: "Geist Mono", fontSize: 13 }, + editorConfig: { editor: "custom", customCommand: "code --goto" }, + vimEnabled: true, + }, + navigation: { launchBehavior: "new-chat", projectOrder: ["/repo"] }, + sharing: { expiration: "24h", signing: false }, + ai: { + globalDefaults: { agentId: "exec", thinkingLevel: "medium" }, + projectDefaults: { + "/repo": { + agentId: "plan", + model: "openai:gpt-4.1", + thinkingLevel: "high", + }, + }, + providerOptions: { + anthropic: { disableBetaFeatures: true }, + google: { safety: "off" }, + }, + autoCompactionThresholdByModel: { "openai:gpt-4.1": 100 }, + }, + workspaceCreation: { + byProject: { + "/repo": { + trunkBranch: "origin/main", + lastRuntimeConfig: { ssh: { host: "devbox" } }, + notifyOnResponseAutoEnable: true, + }, + }, + }, + notifications: { notifyOnResponseByWorkspace: { "ws-1": false } }, + review: { includeUncommitted: true, defaultBaseByProject: { "/repo": "origin/main" } }, + }; + + const entries = entriesFromUserPreferences(preferences); + expect(new Set(entries.map((entry) => entry.key)).size).toBe(entries.length); + + for (const entry of entries) { + expect(isUserPreferenceStorageKey(entry.key)).toBe(true); + const applied = applyStoredUserPreference(undefined, entry.key, entry.value); + expect(hasUserPreferenceEntry(applied, entry.key)).toBe(true); + expect( + hasUserPreferenceEntry(removeStoredUserPreference(applied, entry.key), entry.key) + ).toBe(false); + } + }); + + test("round trips backend preferences to localStorage entries", () => { + const preferences = { + appearance: { theme: "flexoki-light" as const }, + ai: { + globalDefaults: { agentId: "exec" }, + projectDefaults: { "/repo": { model: "anthropic:claude-sonnet-4-20250514" } }, + }, + notifications: { notifyOnResponseByWorkspace: { "ws-1": false } }, + }; + + expect(entriesFromUserPreferences(preferences)).toEqual([ + { key: UI_THEME_KEY, value: "flexoki-light" }, + { key: getAgentIdKey("__global__"), value: "exec" }, + { + key: getModelKey(getProjectScopeId("/repo")), + value: "anthropic:claude-sonnet-4-20250514", + }, + { key: getNotifyOnResponseKey("ws-1"), value: false }, + ]); + }); + + test("removes a single localStorage preference without dropping siblings", () => { + let preferences = applyStoredUserPreference(undefined, UI_THEME_KEY, "dark"); + preferences = applyStoredUserPreference(preferences, "vimEnabled", true); + preferences = removeStoredUserPreference(preferences, UI_THEME_KEY); + + expect(preferences).toEqual({ appearance: { vimEnabled: true } }); + expect(hasUserPreferenceEntry(preferences, UI_THEME_KEY)).toBe(false); + }); + + test("returns only valid entries for backfill", () => { + const storage = new MemoryStorage(); + storage.setJSON(UI_THEME_KEY, "dark"); + storage.setJSON(getAutoCompactionThresholdKey("bad"), 200); + + expect(getStoredUserPreferenceEntries(storage)).toEqual([{ key: UI_THEME_KEY, value: "dark" }]); + }); +}); diff --git a/src/common/preferences/userPreferencesStorage.ts b/src/common/preferences/userPreferencesStorage.ts new file mode 100644 index 0000000000..fbac391308 --- /dev/null +++ b/src/common/preferences/userPreferencesStorage.ts @@ -0,0 +1,673 @@ +import { + LaunchBehaviorSchema, + parseThemePreference, + pruneUserPreferences, + type UserPreferences, +} from "@/common/config/schemas/userPreferences"; +import { + AUTO_COMPACTION_THRESHOLD_MIN, + AUTO_COMPACTION_THRESHOLD_STORAGE_MAX, +} from "@/common/constants/ui"; +import { + BASH_COLLAPSED_SUMMARY_MODE_KEY, + BASH_COLLAPSED_SUMMARY_MODES, + EDITOR_CONFIG_KEY, + GLOBAL_SCOPE_ID, + LAUNCH_BEHAVIOR_KEY, + PROJECT_ORDER_KEY, + PROVIDER_OPTIONS_ANTHROPIC_KEY, + PROVIDER_OPTIONS_GOOGLE_KEY, + REVIEW_INCLUDE_UNCOMMITTED_KEY, + SHARE_EXPIRATION_KEY, + SHARE_SIGNING_KEY, + TERMINAL_FONT_CONFIG_KEY, + TRANSCRIPT_DENSITIES, + TRANSCRIPT_DENSITY_KEY, + UI_THEME_KEY, + VIM_ENABLED_KEY, + getAgentIdKey, + getAutoCompactionThresholdKey, + getLastRuntimeConfigKey, + getModelKey, + getNotifyOnResponseAutoEnableKey, + getNotifyOnResponseKey, + getProjectScopeId, + getReviewDefaultBaseKey, + getThinkingLevelKey, + getTrunkBranchKey, + normalizeEditorConfig, + normalizeTerminalFontConfig, + type BashCollapsedSummaryMode, + type LaunchBehavior, + type TranscriptDensity, +} from "@/common/constants/storage"; +import { EXPIRATION_OPTIONS, type ExpirationValue } from "@/common/lib/shareExpiration"; +import { MuxProviderOptionsSchema } from "@/common/schemas/providerOptions"; +import { + isRecord, + parseAgentId, + parseBoolean, + parseEnum, + parseModelString, + parseNonEmptyString, + parseRecord, + parseStringArray, + parseThinkingLevel, +} from "@/common/preferences/userPreferenceParsing"; + +export interface UserPreferenceStorageArea { + readonly length: number; + key(index: number): string | null; + getItem(key: string): string | null; +} + +export interface StoredUserPreferenceEntry { + key: string; + value: unknown; +} + +const PROJECT_SCOPE_PREFIX = "__project__/"; +const STATIC_USER_PREFERENCE_KEYS = new Set([ + UI_THEME_KEY, + TRANSCRIPT_DENSITY_KEY, + BASH_COLLAPSED_SUMMARY_MODE_KEY, + TERMINAL_FONT_CONFIG_KEY, + EDITOR_CONFIG_KEY, + VIM_ENABLED_KEY, + LAUNCH_BEHAVIOR_KEY, + PROJECT_ORDER_KEY, + PROVIDER_OPTIONS_ANTHROPIC_KEY, + PROVIDER_OPTIONS_GOOGLE_KEY, + SHARE_EXPIRATION_KEY, + SHARE_SIGNING_KEY, + REVIEW_INCLUDE_UNCOMMITTED_KEY, + getAgentIdKey(GLOBAL_SCOPE_ID), + getThinkingLevelKey(GLOBAL_SCOPE_ID), +]); + +const DYNAMIC_USER_PREFERENCE_PREFIXES = [ + getAgentIdKey(PROJECT_SCOPE_PREFIX), + getModelKey(PROJECT_SCOPE_PREFIX), + getThinkingLevelKey(PROJECT_SCOPE_PREFIX), + getAutoCompactionThresholdKey(""), + getTrunkBranchKey(""), + getLastRuntimeConfigKey(""), + getNotifyOnResponseAutoEnableKey(""), + getNotifyOnResponseKey(""), + getReviewDefaultBaseKey(""), +] as const; + +function cloneUserPreferences(preferences: UserPreferences | undefined): UserPreferences { + return preferences ? (JSON.parse(JSON.stringify(preferences)) as UserPreferences) : {}; +} + +function parseStoredValue(raw: string | null): unknown { + if (raw === null || raw === "undefined") { + return undefined; + } + + try { + return JSON.parse(raw) as unknown; + } catch { + return raw; + } +} + +function parseLaunchBehavior(value: unknown): LaunchBehavior | undefined { + return parseEnum(LaunchBehaviorSchema.options, value); +} + +function parseExpiration(value: unknown): ExpirationValue | undefined { + return typeof value === "string" && EXPIRATION_OPTIONS.some((option) => option.value === value) + ? (value as ExpirationValue) + : undefined; +} + +function parseThreshold(value: unknown): number | undefined { + return typeof value === "number" && + Number.isFinite(value) && + value >= AUTO_COMPACTION_THRESHOLD_MIN && + value <= AUTO_COMPACTION_THRESHOLD_STORAGE_MAX + ? value + : undefined; +} + +function parseProjectScope(key: string, prefix: string): string | undefined { + if (!key.startsWith(prefix)) { + return undefined; + } + + const scopeId = key.slice(prefix.length); + return scopeId.startsWith(PROJECT_SCOPE_PREFIX) && scopeId.length > PROJECT_SCOPE_PREFIX.length + ? scopeId.slice(PROJECT_SCOPE_PREFIX.length) + : undefined; +} + +function readSuffix(key: string, prefix: string): string | undefined { + if (!key.startsWith(prefix)) { + return undefined; + } + + const suffix = key.slice(prefix.length); + return suffix.length > 0 ? suffix : undefined; +} + +function getPreferenceKind(key: string): string | undefined { + if (STATIC_USER_PREFERENCE_KEYS.has(key)) { + return key; + } + + return DYNAMIC_USER_PREFERENCE_PREFIXES.find((prefix) => key.startsWith(prefix)); +} + +export function isUserPreferenceStorageKey(key: string): boolean { + return getPreferenceKind(key) !== undefined; +} + +function ensureAppearance( + preferences: UserPreferences +): NonNullable { + preferences.appearance ??= {}; + return preferences.appearance; +} + +function ensureNavigation( + preferences: UserPreferences +): NonNullable { + preferences.navigation ??= {}; + return preferences.navigation; +} + +function ensureSharing(preferences: UserPreferences): NonNullable { + preferences.sharing ??= {}; + return preferences.sharing; +} + +function ensureAi(preferences: UserPreferences): NonNullable { + preferences.ai ??= {}; + return preferences.ai; +} + +function ensureGlobalAiDefaults( + preferences: UserPreferences +): NonNullable["globalDefaults"]> { + const ai = ensureAi(preferences); + ai.globalDefaults ??= {}; + return ai.globalDefaults; +} + +function ensureProjectAiDefaults( + preferences: UserPreferences, + projectPath: string +): NonNullable["projectDefaults"]>[string] { + const ai = ensureAi(preferences); + ai.projectDefaults ??= {}; + ai.projectDefaults[projectPath] ??= {}; + return ai.projectDefaults[projectPath]; +} + +function ensureProviderOptions( + preferences: UserPreferences +): NonNullable["providerOptions"]> { + const ai = ensureAi(preferences); + ai.providerOptions ??= {}; + return ai.providerOptions; +} + +function ensureWorkspaceCreationProject( + preferences: UserPreferences, + projectPath: string +): NonNullable["byProject"]>[string] { + preferences.workspaceCreation ??= {}; + preferences.workspaceCreation.byProject ??= {}; + preferences.workspaceCreation.byProject[projectPath] ??= {}; + return preferences.workspaceCreation.byProject[projectPath]; +} + +function ensureNotifications( + preferences: UserPreferences +): NonNullable { + preferences.notifications ??= {}; + return preferences.notifications; +} + +function ensureReview(preferences: UserPreferences): NonNullable { + preferences.review ??= {}; + return preferences.review; +} + +export function applyStoredUserPreference( + preferences: UserPreferences | undefined, + key: string, + value: unknown +): UserPreferences | undefined { + const next = cloneUserPreferences(preferences); + + if (key === UI_THEME_KEY) { + const parsed = parseThemePreference(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureAppearance(next).theme = parsed; + return pruneUserPreferences(next); + } + + if (key === TRANSCRIPT_DENSITY_KEY) { + const parsed = parseEnum(TRANSCRIPT_DENSITIES, value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureAppearance(next).transcriptDensity = parsed; + return pruneUserPreferences(next); + } + + if (key === BASH_COLLAPSED_SUMMARY_MODE_KEY) { + const parsed = parseEnum(BASH_COLLAPSED_SUMMARY_MODES, value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureAppearance(next).bashCollapsedSummaryMode = parsed; + return pruneUserPreferences(next); + } + + if (key === TERMINAL_FONT_CONFIG_KEY) { + if (!isRecord(value)) { + return removeStoredUserPreference(next, key); + } + ensureAppearance(next).terminalFontConfig = normalizeTerminalFontConfig(value); + return pruneUserPreferences(next); + } + + if (key === EDITOR_CONFIG_KEY) { + if (!isRecord(value)) { + return removeStoredUserPreference(next, key); + } + ensureAppearance(next).editorConfig = normalizeEditorConfig(value); + return pruneUserPreferences(next); + } + + if (key === VIM_ENABLED_KEY) { + const parsed = parseBoolean(value); + if (parsed === undefined) { + return removeStoredUserPreference(next, key); + } + ensureAppearance(next).vimEnabled = parsed; + return pruneUserPreferences(next); + } + + if (key === LAUNCH_BEHAVIOR_KEY) { + const parsed = parseLaunchBehavior(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureNavigation(next).launchBehavior = parsed; + return pruneUserPreferences(next); + } + + if (key === PROJECT_ORDER_KEY) { + const parsed = parseStringArray(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureNavigation(next).projectOrder = parsed; + return pruneUserPreferences(next); + } + + if (key === SHARE_EXPIRATION_KEY) { + const parsed = parseExpiration(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureSharing(next).expiration = parsed; + return pruneUserPreferences(next); + } + + if (key === SHARE_SIGNING_KEY) { + const parsed = parseBoolean(value); + if (parsed === undefined) { + return removeStoredUserPreference(next, key); + } + ensureSharing(next).signing = parsed; + return pruneUserPreferences(next); + } + + if (key === getAgentIdKey(GLOBAL_SCOPE_ID)) { + const parsed = parseAgentId(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureGlobalAiDefaults(next).agentId = parsed; + return pruneUserPreferences(next); + } + + if (key === getThinkingLevelKey(GLOBAL_SCOPE_ID)) { + const parsed = parseThinkingLevel(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureGlobalAiDefaults(next).thinkingLevel = parsed; + return pruneUserPreferences(next); + } + + const projectAgentPath = parseProjectScope(key, "agentId:"); + if (projectAgentPath) { + const parsed = parseAgentId(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureProjectAiDefaults(next, projectAgentPath).agentId = parsed; + return pruneUserPreferences(next); + } + + const projectModelPath = parseProjectScope(key, "model:"); + if (projectModelPath) { + const parsed = parseModelString(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureProjectAiDefaults(next, projectModelPath).model = parsed; + return pruneUserPreferences(next); + } + + const projectThinkingPath = parseProjectScope(key, "thinkingLevel:"); + if (projectThinkingPath) { + const parsed = parseThinkingLevel(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureProjectAiDefaults(next, projectThinkingPath).thinkingLevel = parsed; + return pruneUserPreferences(next); + } + + if (key === PROVIDER_OPTIONS_ANTHROPIC_KEY) { + const parsed = MuxProviderOptionsSchema.shape.anthropic.safeParse(value); + if (!parsed.success || !parsed.data || Object.keys(parsed.data).length === 0) { + return removeStoredUserPreference(next, key); + } + ensureProviderOptions(next).anthropic = parsed.data; + return pruneUserPreferences(next); + } + + if (key === PROVIDER_OPTIONS_GOOGLE_KEY) { + const parsed = MuxProviderOptionsSchema.shape.google.safeParse(value); + if (!parsed.success || !parsed.data || Object.keys(parsed.data).length === 0) { + return removeStoredUserPreference(next, key); + } + ensureProviderOptions(next).google = parsed.data; + return pruneUserPreferences(next); + } + + const thresholdModel = readSuffix(key, getAutoCompactionThresholdKey("")); + if (thresholdModel) { + const parsed = parseThreshold(value); + if (parsed === undefined) { + return removeStoredUserPreference(next, key); + } + const ai = ensureAi(next); + ai.autoCompactionThresholdByModel ??= {}; + ai.autoCompactionThresholdByModel[thresholdModel] = parsed; + return pruneUserPreferences(next); + } + + const trunkProjectPath = readSuffix(key, getTrunkBranchKey("")); + if (trunkProjectPath) { + const parsed = parseNonEmptyString(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureWorkspaceCreationProject(next, trunkProjectPath).trunkBranch = parsed; + return pruneUserPreferences(next); + } + + const runtimeProjectPath = readSuffix(key, getLastRuntimeConfigKey("")); + if (runtimeProjectPath) { + const parsed = parseRecord(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + ensureWorkspaceCreationProject(next, runtimeProjectPath).lastRuntimeConfig = parsed; + return pruneUserPreferences(next); + } + + const autoNotifyProjectPath = readSuffix(key, getNotifyOnResponseAutoEnableKey("")); + if (autoNotifyProjectPath) { + const parsed = parseBoolean(value); + if (parsed === undefined) { + return removeStoredUserPreference(next, key); + } + ensureWorkspaceCreationProject(next, autoNotifyProjectPath).notifyOnResponseAutoEnable = parsed; + return pruneUserPreferences(next); + } + + const notifyWorkspaceId = readSuffix(key, getNotifyOnResponseKey("")); + if (notifyWorkspaceId) { + const parsed = parseBoolean(value); + if (parsed === undefined) { + return removeStoredUserPreference(next, key); + } + const notifications = ensureNotifications(next); + notifications.notifyOnResponseByWorkspace ??= {}; + notifications.notifyOnResponseByWorkspace[notifyWorkspaceId] = parsed; + return pruneUserPreferences(next); + } + + if (key === REVIEW_INCLUDE_UNCOMMITTED_KEY) { + const parsed = parseBoolean(value); + if (parsed === undefined) { + return removeStoredUserPreference(next, key); + } + ensureReview(next).includeUncommitted = parsed; + return pruneUserPreferences(next); + } + + const reviewDefaultProjectPath = readSuffix(key, getReviewDefaultBaseKey("")); + if (reviewDefaultProjectPath) { + const parsed = parseNonEmptyString(value); + if (!parsed) { + return removeStoredUserPreference(next, key); + } + const review = ensureReview(next); + review.defaultBaseByProject ??= {}; + review.defaultBaseByProject[reviewDefaultProjectPath] = parsed; + return pruneUserPreferences(next); + } + + return pruneUserPreferences(next); +} + +export function removeStoredUserPreference( + preferences: UserPreferences | undefined, + key: string +): UserPreferences | undefined { + const next = cloneUserPreferences(preferences); + + if (key === UI_THEME_KEY) delete next.appearance?.theme; + else if (key === TRANSCRIPT_DENSITY_KEY) delete next.appearance?.transcriptDensity; + else if (key === BASH_COLLAPSED_SUMMARY_MODE_KEY) + delete next.appearance?.bashCollapsedSummaryMode; + else if (key === TERMINAL_FONT_CONFIG_KEY) delete next.appearance?.terminalFontConfig; + else if (key === EDITOR_CONFIG_KEY) delete next.appearance?.editorConfig; + else if (key === VIM_ENABLED_KEY) delete next.appearance?.vimEnabled; + else if (key === LAUNCH_BEHAVIOR_KEY) delete next.navigation?.launchBehavior; + else if (key === PROJECT_ORDER_KEY) delete next.navigation?.projectOrder; + else if (key === SHARE_EXPIRATION_KEY) delete next.sharing?.expiration; + else if (key === SHARE_SIGNING_KEY) delete next.sharing?.signing; + else if (key === getAgentIdKey(GLOBAL_SCOPE_ID)) delete next.ai?.globalDefaults?.agentId; + else if (key === getThinkingLevelKey(GLOBAL_SCOPE_ID)) + delete next.ai?.globalDefaults?.thinkingLevel; + else if (key === PROVIDER_OPTIONS_ANTHROPIC_KEY) delete next.ai?.providerOptions?.anthropic; + else if (key === PROVIDER_OPTIONS_GOOGLE_KEY) delete next.ai?.providerOptions?.google; + else if (key === REVIEW_INCLUDE_UNCOMMITTED_KEY) delete next.review?.includeUncommitted; + else { + const projectAgentPath = parseProjectScope(key, "agentId:"); + const projectModelPath = parseProjectScope(key, "model:"); + const projectThinkingPath = parseProjectScope(key, "thinkingLevel:"); + const thresholdModel = readSuffix(key, getAutoCompactionThresholdKey("")); + const trunkProjectPath = readSuffix(key, getTrunkBranchKey("")); + const runtimeProjectPath = readSuffix(key, getLastRuntimeConfigKey("")); + const autoNotifyProjectPath = readSuffix(key, getNotifyOnResponseAutoEnableKey("")); + const notifyWorkspaceId = readSuffix(key, getNotifyOnResponseKey("")); + const reviewDefaultProjectPath = readSuffix(key, getReviewDefaultBaseKey("")); + + if (projectAgentPath) delete next.ai?.projectDefaults?.[projectAgentPath]?.agentId; + else if (projectModelPath) delete next.ai?.projectDefaults?.[projectModelPath]?.model; + else if (projectThinkingPath) + delete next.ai?.projectDefaults?.[projectThinkingPath]?.thinkingLevel; + else if (thresholdModel) delete next.ai?.autoCompactionThresholdByModel?.[thresholdModel]; + else if (trunkProjectPath) + delete next.workspaceCreation?.byProject?.[trunkProjectPath]?.trunkBranch; + else if (runtimeProjectPath) + delete next.workspaceCreation?.byProject?.[runtimeProjectPath]?.lastRuntimeConfig; + else if (autoNotifyProjectPath) + delete next.workspaceCreation?.byProject?.[autoNotifyProjectPath]?.notifyOnResponseAutoEnable; + else if (notifyWorkspaceId) + delete next.notifications?.notifyOnResponseByWorkspace?.[notifyWorkspaceId]; + else if (reviewDefaultProjectPath) + delete next.review?.defaultBaseByProject?.[reviewDefaultProjectPath]; + } + + return pruneUserPreferences(next); +} + +export function entriesFromUserPreferences( + preferences: UserPreferences | undefined +): StoredUserPreferenceEntry[] { + const entries: StoredUserPreferenceEntry[] = []; + if (!preferences) { + return entries; + } + + const appearance = preferences.appearance; + if (appearance?.theme !== undefined) entries.push({ key: UI_THEME_KEY, value: appearance.theme }); + if (appearance?.transcriptDensity !== undefined) + entries.push({ key: TRANSCRIPT_DENSITY_KEY, value: appearance.transcriptDensity }); + if (appearance?.bashCollapsedSummaryMode !== undefined) + entries.push({ + key: BASH_COLLAPSED_SUMMARY_MODE_KEY, + value: appearance.bashCollapsedSummaryMode, + }); + if (appearance?.terminalFontConfig !== undefined) + entries.push({ key: TERMINAL_FONT_CONFIG_KEY, value: appearance.terminalFontConfig }); + if (appearance?.editorConfig !== undefined) + entries.push({ key: EDITOR_CONFIG_KEY, value: appearance.editorConfig }); + if (appearance?.vimEnabled !== undefined) + entries.push({ key: VIM_ENABLED_KEY, value: appearance.vimEnabled }); + + const navigation = preferences.navigation; + if (navigation?.launchBehavior !== undefined) + entries.push({ key: LAUNCH_BEHAVIOR_KEY, value: navigation.launchBehavior }); + if (navigation?.projectOrder !== undefined) + entries.push({ key: PROJECT_ORDER_KEY, value: navigation.projectOrder }); + + const sharing = preferences.sharing; + if (sharing?.expiration !== undefined) + entries.push({ key: SHARE_EXPIRATION_KEY, value: sharing.expiration }); + if (sharing?.signing !== undefined) + entries.push({ key: SHARE_SIGNING_KEY, value: sharing.signing }); + + const ai = preferences.ai; + if (ai?.globalDefaults?.agentId !== undefined) + entries.push({ key: getAgentIdKey(GLOBAL_SCOPE_ID), value: ai.globalDefaults.agentId }); + if (ai?.globalDefaults?.thinkingLevel !== undefined) + entries.push({ + key: getThinkingLevelKey(GLOBAL_SCOPE_ID), + value: ai.globalDefaults.thinkingLevel, + }); + + for (const [projectPath, defaults] of Object.entries(ai?.projectDefaults ?? {})) { + const scopeId = getProjectScopeId(projectPath); + if (defaults.agentId !== undefined) + entries.push({ key: getAgentIdKey(scopeId), value: defaults.agentId }); + if (defaults.model !== undefined) + entries.push({ key: getModelKey(scopeId), value: defaults.model }); + if (defaults.thinkingLevel !== undefined) + entries.push({ key: getThinkingLevelKey(scopeId), value: defaults.thinkingLevel }); + } + + if (ai?.providerOptions?.anthropic !== undefined) + entries.push({ key: PROVIDER_OPTIONS_ANTHROPIC_KEY, value: ai.providerOptions.anthropic }); + if (ai?.providerOptions?.google !== undefined) + entries.push({ key: PROVIDER_OPTIONS_GOOGLE_KEY, value: ai.providerOptions.google }); + + for (const [model, threshold] of Object.entries(ai?.autoCompactionThresholdByModel ?? {})) { + entries.push({ key: getAutoCompactionThresholdKey(model), value: threshold }); + } + + for (const [projectPath, defaults] of Object.entries( + preferences.workspaceCreation?.byProject ?? {} + )) { + if (defaults.trunkBranch !== undefined) + entries.push({ key: getTrunkBranchKey(projectPath), value: defaults.trunkBranch }); + if (defaults.lastRuntimeConfig !== undefined) + entries.push({ + key: getLastRuntimeConfigKey(projectPath), + value: defaults.lastRuntimeConfig, + }); + if (defaults.notifyOnResponseAutoEnable !== undefined) + entries.push({ + key: getNotifyOnResponseAutoEnableKey(projectPath), + value: defaults.notifyOnResponseAutoEnable, + }); + } + + for (const [workspaceId, enabled] of Object.entries( + preferences.notifications?.notifyOnResponseByWorkspace ?? {} + )) { + entries.push({ key: getNotifyOnResponseKey(workspaceId), value: enabled }); + } + + if (preferences.review?.includeUncommitted !== undefined) + entries.push({ + key: REVIEW_INCLUDE_UNCOMMITTED_KEY, + value: preferences.review.includeUncommitted, + }); + for (const [projectPath, defaultBase] of Object.entries( + preferences.review?.defaultBaseByProject ?? {} + )) { + entries.push({ key: getReviewDefaultBaseKey(projectPath), value: defaultBase }); + } + + return entries; +} + +export function readStoredUserPreferenceValue( + storage: UserPreferenceStorageArea, + key: string +): unknown { + return parseStoredValue(storage.getItem(key)); +} + +export function getStoredUserPreferenceEntries( + storage: UserPreferenceStorageArea +): StoredUserPreferenceEntry[] { + const entries: StoredUserPreferenceEntry[] = []; + for (const key of getStoredUserPreferenceKeys(storage)) { + const next = applyStoredUserPreference(undefined, key, parseStoredValue(storage.getItem(key))); + const entry = entriesFromUserPreferences(next).find((candidate) => candidate.key === key); + if (entry) { + entries.push(entry); + } + } + return entries; +} + +export function getStoredUserPreferenceKeys(storage: UserPreferenceStorageArea): string[] { + const keys: string[] = []; + const seen = new Set(); + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key || seen.has(key) || !isUserPreferenceStorageKey(key)) { + continue; + } + seen.add(key); + keys.push(key); + } + return keys; +} + +export function hasUserPreferenceEntry( + preferences: UserPreferences | undefined, + key: string +): boolean { + return entriesFromUserPreferences(preferences).some((entry) => entry.key === key); +} diff --git a/src/common/schemas/providerOptions.ts b/src/common/schemas/providerOptions.ts new file mode 100644 index 0000000000..9dbe888bc9 --- /dev/null +++ b/src/common/schemas/providerOptions.ts @@ -0,0 +1,101 @@ +import { z } from "zod"; + +import { CacheTtlSchema, ServiceTierSchema } from "../config/schemas/providersConfig"; + +export const MuxProviderOptionsSchema = z.object({ + anthropic: z + .object({ + // Deprecated: prefer use1MContextModels for per-model control. + // Kept for backward compat with agentSession auto-retry which sets it directly. + use1MContext: z.boolean().optional().meta({ + description: + "Enable Anthropic's beta 1M context window globally (deprecated: use use1MContextModels)", + }), + use1MContextModels: z.array(z.string()).optional().meta({ + description: + "Model IDs with Anthropic beta 1M enabled (e.g. ['anthropic:claude-sonnet-4-20250514'])", + }), + // Anthropic prompt cache TTL. "5m" is the default (free refresh on hit). + // "1h" costs 2× base input for cache writes but keeps the cache alive longer, + // useful for agentic workflows where turns take >5 minutes. + // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration + cacheTtl: CacheTtlSchema.nullish().meta({ + description: + 'Anthropic prompt cache TTL: "5m" (default, free refresh) or "1h" (2× write cost, longer cache)', + }), + disableBetaFeatures: z.boolean().optional().meta({ + description: + "Disable Anthropic beta features (beta 1M context for older Sonnet models, prompt caching). Required for ZDR.", + }), + }) + .optional(), + openai: z + .object({ + serviceTier: ServiceTierSchema.optional().meta({ + description: + "OpenAI service tier: priority (low-latency), flex (50% cheaper, higher latency), auto/default (standard)", + }), + wireFormat: z.enum(["responses", "chatCompletions"]).optional().meta({ + description: + "OpenAI wire format: responses (default, persistence + built-in tools) or chatCompletions (legacy /chat/completions)", + }), + store: z.boolean().optional().meta({ + description: "Whether OpenAI stores responses. Set false for zero data retention (ZDR).", + }), + forceContextLimitError: z.boolean().optional().meta({ + description: "Force context limit error (used in integration tests to simulate overflow)", + }), + simulateToolPolicyNoop: z.boolean().optional().meta({ + description: + "Simulate successful response without executing tools (used in tool policy tests)", + }), + }) + .optional(), + google: z.record(z.string(), z.unknown()).optional(), + ollama: z.record(z.string(), z.unknown()).optional(), + openrouter: z.record(z.string(), z.unknown()).optional(), + xai: z + .object({ + searchParameters: z + .object({ + mode: z.enum(["auto", "off", "on"]), + returnCitations: z.boolean().optional(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + maxSearchResults: z.number().optional(), + sources: z + .array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("web"), + country: z.string().optional(), + excludedWebsites: z.array(z.string()).optional(), + allowedWebsites: z.array(z.string()).optional(), + safeSearch: z.boolean().optional(), + }), + z.object({ + type: z.literal("x"), + excludedXHandles: z.array(z.string()).optional(), + includedXHandles: z.array(z.string()).optional(), + postFavoriteCount: z.number().optional(), + postViewCount: z.number().optional(), + xHandles: z.array(z.string()).optional(), + }), + z.object({ + type: z.literal("news"), + country: z.string().optional(), + excludedWebsites: z.array(z.string()).optional(), + safeSearch: z.boolean().optional(), + }), + z.object({ + type: z.literal("rss"), + links: z.array(z.string()), + }), + ]) + ) + .optional(), + }) + .optional(), + }) + .optional(), +}); diff --git a/src/common/types/project.ts b/src/common/types/project.ts index dc10d4bc8f..67cd2124fc 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -10,6 +10,7 @@ import type { FeatureFlagOverride, UpdateChannel, } from "@/common/config/schemas/appConfigOnDisk"; +import type { UserPreferences } from "@/common/config/schemas/userPreferences"; import type { z } from "zod"; import type { ProjectConfigSchema, WorkspaceConfigSchema } from "../orpc/schemas"; import type { AgentAiDefaults } from "./agentAiDefaults"; @@ -77,6 +78,8 @@ export interface ProjectsConfig { viewedSplashScreens?: string[]; /** Cross-client feature flag overrides (shared via ~/.mux/config.json). */ featureFlagOverrides?: Record; + /** User preferences shared across local browser origins through ~/.mux/config.json. */ + userPreferences?: UserPreferences; /** Global task settings (agent sub-workspaces, queue limits, nesting depth) */ taskSettings?: TaskSettings; /** UI layout presets + hotkeys (shared via ~/.mux/config.json). */ diff --git a/src/common/utils/stableStringify.ts b/src/common/utils/stableStringify.ts new file mode 100644 index 0000000000..5e6af3014b --- /dev/null +++ b/src/common/utils/stableStringify.ts @@ -0,0 +1,23 @@ +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stableNormalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stableNormalize); + } + + if (!isRecord(value)) { + return value; + } + + const out: Record = {}; + for (const key of Object.keys(value).sort()) { + out[key] = stableNormalize(value[key]); + } + return out; +} + +export function stableStringify(value: unknown): string { + return JSON.stringify(stableNormalize(value)) ?? "undefined"; +} diff --git a/src/constants/workspaceDefaults.ts b/src/constants/workspaceDefaults.ts index 7a502b7600..79c0f90937 100644 --- a/src/constants/workspaceDefaults.ts +++ b/src/constants/workspaceDefaults.ts @@ -1,9 +1,13 @@ +import { DEFAULT_MODEL } from "@/common/constants/knownModels"; +import { getReviewDefaultBaseKey } from "@/common/constants/storage"; +import { THINKING_LEVEL_OFF } from "@/common/types/thinking"; + /** * Storage key helpers for persisted settings. */ export const STORAGE_KEYS = { /** Per-project default diff base for code review. Pass projectPath. */ - reviewDefaultBase: (projectPath: string) => `review-default-base:${projectPath}`, + reviewDefaultBase: getReviewDefaultBaseKey, /** Per-workspace diff base override. Pass workspaceId. */ reviewDiffBase: (workspaceId: string) => `review-diff-base:${workspaceId}`, } as const; @@ -31,9 +35,6 @@ Object.freeze(STORAGE_KEYS); * Do not modify these values at runtime - they serve as the single source of truth. */ -import { THINKING_LEVEL_OFF } from "@/common/types/thinking"; -import { DEFAULT_MODEL } from "@/common/constants/knownModels"; - /** * Hard-coded default values for workspace settings. * Type assertions ensure proper typing while maintaining immutability. diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 4a0c64f250..e8225c81fa 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -54,6 +54,90 @@ describe("Config", () => { }); }); + describe("userPreferences", () => { + it("loads and saves user preferences", async () => { + await config.editConfig((cfg) => ({ + ...cfg, + userPreferences: { + appearance: { theme: "dark" }, + navigation: { projectOrder: ["/repo"] }, + }, + })); + + const restartedConfig = new Config(tempDir); + expect(restartedConfig.loadConfigOrDefault().userPreferences).toEqual({ + appearance: { theme: "dark" }, + navigation: { projectOrder: ["/repo"] }, + }); + + const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { + migrations?: { userPreferencesInitialized?: unknown }; + userPreferences?: unknown; + }; + expect(raw.migrations?.userPreferencesInitialized).toBe(true); + expect(raw.userPreferences).toEqual({ + appearance: { theme: "dark" }, + navigation: { projectOrder: ["/repo"] }, + }); + }); + + it("preserves user preferences during unrelated saves", async () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + userPreferences: { + appearance: { theme: "flexoki-dark" }, + }, + }) + ); + + await config.editConfig((cfg) => ({ + ...cfg, + llmDebugLogs: true, + })); + + const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { + userPreferences?: unknown; + llmDebugLogs?: unknown; + }; + expect(raw.userPreferences).toEqual({ appearance: { theme: "flexoki-dark" } }); + expect(raw.llmDebugLogs).toBe(true); + }); + + it("treats existing user preferences as initialized for cross-origin sync", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + userPreferences: { + appearance: { theme: "flexoki-dark" }, + }, + }) + ); + + expect(config.loadConfigOrDefault().migrations?.userPreferencesInitialized).toBe(true); + }); + + it("normalizes invalid user preference values on load", () => { + fs.writeFileSync( + path.join(tempDir, "config.json"), + JSON.stringify({ + projects: [], + userPreferences: { + appearance: { theme: "legacy-light", transcriptDensity: "wide" }, + notifications: { notifyOnResponseByWorkspace: { "ws-1": true, "ws-2": "yes" } }, + }, + }) + ); + + expect(config.loadConfigOrDefault().userPreferences).toEqual({ + appearance: { theme: "light" }, + notifications: { notifyOnResponseByWorkspace: { "ws-1": true } }, + }); + }); + }); + describe("chat transcript settings", () => { it("persists the full-width transcript flag", async () => { await config.editConfig((cfg) => { diff --git a/src/node/config.ts b/src/node/config.ts index 36aa2de4f3..c05fa53ea2 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -32,6 +32,7 @@ import { normalizeTaskSettings, shouldMirrorAgentDefaultToLegacySubagent, } from "@/common/types/tasks"; +import { normalizeUserPreferences } from "@/common/config/schemas/userPreferences"; import { isLayoutPresetsConfigEmpty, normalizeLayoutPresetsConfig } from "@/common/types/uiLayouts"; import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { @@ -338,6 +339,7 @@ function normalizeConfigMigrations(value: unknown): AppConfigMigrations { const record = value as Record; return { ...(record.execSubagentDefaultsSplit === true ? { execSubagentDefaultsSplit: true } : {}), + ...(record.userPreferencesInitialized === true ? { userPreferencesInitialized: true } : {}), }; } @@ -884,6 +886,12 @@ export class Config { const runtimeEnablement = normalizeRuntimeEnablementOverrides(parsed.runtimeEnablement); const defaultRuntime = normalizeRuntimeEnablementId(parsed.defaultRuntime); + const userPreferences = normalizeUserPreferences(parsed.userPreferences); + const migrations = normalizeConfigMigrations(parsed.migrations); + if (parsed.userPreferences !== undefined) { + migrations.userPreferencesInitialized = true; + } + const layoutPresetsRaw = normalizeLayoutPresetsConfig(parsed.layoutPresets); const layoutPresets = isLayoutPresetsConfigEmpty(layoutPresetsRaw) ? undefined @@ -900,6 +908,7 @@ export class Config { serverAuthGithubOwner: parseOptionalNonEmptyString(parsed.serverAuthGithubOwner), defaultProjectDir: parseOptionalNonEmptyString(parsed.defaultProjectDir), viewedSplashScreens: parsed.viewedSplashScreens, + userPreferences, layoutPresets, taskSettings, chatTranscriptFullWidth: parseOptionalBoolean(parsed.chatTranscriptFullWidth), @@ -924,7 +933,7 @@ export class Config { // Subagent defaults: exec is canonical active storage, non-exec entries // support legacy mirror compatibility. subagentAiDefaults: legacySubagentAiDefaults, - migrations: normalizeConfigMigrations(parsed.migrations), + migrations, featureFlagOverrides: parsed.featureFlagOverrides, useSSH2Transport: parseOptionalBoolean(parsed.useSSH2Transport), muxGovernorUrl: parseOptionalNonEmptyString(parsed.muxGovernorUrl), @@ -1103,6 +1112,11 @@ export class Config { if (config.featureFlagOverrides) { data.featureFlagOverrides = config.featureFlagOverrides; } + const userPreferences = normalizeUserPreferences(config.userPreferences); + if (userPreferences) { + data.userPreferences = userPreferences; + } + if (config.layoutPresets) { const normalized = normalizeLayoutPresetsConfig(config.layoutPresets); if (!isLayoutPresetsConfigEmpty(normalized)) { @@ -1135,10 +1149,18 @@ export class Config { const migrations = normalizeConfigMigrations(config.migrations); if ( migrations.execSubagentDefaultsSplit === true || + migrations.userPreferencesInitialized === true || + config.userPreferences !== undefined || config.agentAiDefaults?.exec != null || config.subagentAiDefaults?.exec != null ) { - data.migrations = { ...migrations, execSubagentDefaultsSplit: true }; + data.migrations = { + ...migrations, + ...(config.userPreferences !== undefined ? { userPreferencesInitialized: true } : {}), + ...(config.agentAiDefaults?.exec != null || config.subagentAiDefaults?.exec != null + ? { execSubagentDefaultsSplit: true } + : {}), + }; } if (config.useSSH2Transport !== undefined) { diff --git a/src/node/orpc/router.test.ts b/src/node/orpc/router.test.ts index 9daa906a92..451b71ab30 100644 --- a/src/node/orpc/router.test.ts +++ b/src/node/orpc/router.test.ts @@ -466,6 +466,82 @@ describe("router config.saveConfig", () => { expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBeUndefined(); }); + test("getConfig and saveConfig round trip user preferences", async () => { + const client = createRouterClient(router(), { context: createContext() }); + + await client.config.saveConfig({ + taskSettings: DEFAULT_TASK_SETTINGS, + userPreferences: { + appearance: { theme: "dark" }, + notifications: { notifyOnResponseByWorkspace: { "ws-1": true } }, + }, + }); + + expect((await client.config.getConfig()).userPreferencesInitialized).toBe(true); + expect((await client.config.getConfig()).userPreferences).toEqual({ + appearance: { theme: "dark" }, + notifications: { notifyOnResponseByWorkspace: { "ws-1": true } }, + }); + expect(config.loadConfigOrDefault().userPreferences).toEqual({ + appearance: { theme: "dark" }, + notifications: { notifyOnResponseByWorkspace: { "ws-1": true } }, + }); + }); + + test("saveConfig preserves task settings when user preference saves omit them", async () => { + await config.editConfig((current) => ({ + ...current, + taskSettings: { + ...DEFAULT_TASK_SETTINGS, + maxParallelAgentTasks: 7, + preserveSubagentsUntilArchive: true, + }, + })); + const client = createRouterClient(router(), { context: createContext() }); + + await client.config.saveConfig({ + userPreferences: { appearance: { theme: "dark" } }, + }); + + expect(config.loadConfigOrDefault().taskSettings).toEqual({ + ...DEFAULT_TASK_SETTINGS, + maxParallelAgentTasks: 7, + preserveSubagentsUntilArchive: true, + }); + }); + + test("saveConfig clears user preferences when explicitly set to null", async () => { + await config.editConfig((current) => ({ + ...current, + userPreferences: { appearance: { theme: "flexoki-light" } }, + })); + const client = createRouterClient(router(), { context: createContext() }); + + await client.config.saveConfig({ + userPreferences: null, + }); + + expect((await client.config.getConfig()).userPreferencesInitialized).toBe(true); + expect(config.loadConfigOrDefault().userPreferences).toBeUndefined(); + }); + + test("saveConfig preserves existing user preferences when omitted", async () => { + await config.editConfig((current) => ({ + ...current, + userPreferences: { appearance: { theme: "flexoki-light" } }, + })); + const client = createRouterClient(router(), { context: createContext() }); + + await client.config.saveConfig({ + taskSettings: DEFAULT_TASK_SETTINGS, + advisorModelString: null, + }); + + expect(config.loadConfigOrDefault().userPreferences).toEqual({ + appearance: { theme: "flexoki-light" }, + }); + }); + test("preserves optional task settings when a save omits them", async () => { await config.editConfig((current) => ({ ...current, diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 6e3fbf5e66..9526b39885 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -52,6 +52,7 @@ import { isLayoutPresetsConfigEmpty, normalizeLayoutPresetsConfig, } from "@/common/types/uiLayouts"; +import { normalizeUserPreferences } from "@/common/config/schemas/userPreferences"; import { normalizeAgentAiDefaults } from "@/common/types/agentAiDefaults"; import { isValidModelFormat, normalizeSelectedModel } from "@/common/utils/ai/models"; import { @@ -816,6 +817,8 @@ export const router = (authToken?: string) => { const muxGovernorUrl = config.muxGovernorUrl ?? null; const muxGovernorEnrolled = Boolean(config.muxGovernorUrl && config.muxGovernorToken); return { + userPreferencesInitialized: config.migrations?.userPreferencesInitialized === true, + userPreferences: config.userPreferences, taskSettings: config.taskSettings ?? DEFAULT_TASK_SETTINGS, muxGatewayEnabled: config.muxGatewayEnabled, muxGatewayModels: config.muxGatewayModels, @@ -1150,11 +1153,22 @@ export const router = (authToken?: string) => { .output(schemas.config.saveConfig.output) .handler(async ({ context, input }) => { await context.config.editConfig((config) => { - const normalizedTaskSettings = mergeTaskSettingsForConfigSave( - config.taskSettings, - input.taskSettings - ); - const result = { ...config, taskSettings: normalizedTaskSettings }; + const result = { ...config }; + + if (input.taskSettings != null) { + result.taskSettings = mergeTaskSettingsForConfigSave( + config.taskSettings, + input.taskSettings + ); + } + + if (input.userPreferences !== undefined) { + result.userPreferences = normalizeUserPreferences(input.userPreferences); + result.migrations = { + ...(result.migrations ?? {}), + userPreferencesInitialized: true, + }; + } if (input.advisorModelString !== undefined) { result.advisorModelString = normalizeOptionalConfigString(input.advisorModelString); diff --git a/src/node/services/policyService.ts b/src/node/services/policyService.ts index 09e8ef9f21..ea99be2318 100644 --- a/src/node/services/policyService.ts +++ b/src/node/services/policyService.ts @@ -13,6 +13,7 @@ import { import type { RuntimeConfig } from "@/common/types/runtime"; import type { MCPServerTransport } from "@/common/types/mcp"; import { compareVersions } from "@/node/services/coderService"; +import { stableStringify } from "@/common/utils/stableStringify"; import packageJson from "../../../package.json"; import { getErrorMessage } from "@/common/utils/errors"; @@ -26,25 +27,6 @@ type ActivePolicySource = | { kind: "governor"; origin: string; token: string } | { kind: "none" }; -function stableNormalize(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(stableNormalize); - } - if (value && typeof value === "object") { - const obj = value as Record; - return Object.fromEntries( - Object.keys(obj) - .sort() - .map((key) => [key, stableNormalize(obj[key])]) - ); - } - return value; -} - -function stableStringify(value: unknown): string { - return JSON.stringify(stableNormalize(value)); -} - async function getClientVersion(): Promise { // Prefer Electron's app version when available (authoritative in packaged apps). if (process.versions.electron) {