From 393b30b432dfada24af524ce721f504cad6a6ff0 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 20 Dec 2025 12:34:19 -0600 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20restore=20per-model?= =?UTF-8?q?=20thinking=20levels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore the per-model thinking behavior that was changed in #1203. While that PR added valuable backend persistence for AI settings, it also changed thinking levels from per-model to per-workspace scoping. This was an unintended UX regression - users expect different models to remember their individual thinking preferences. ### Changes - **ThinkingContext**: Use per-model localStorage keys (`thinkingLevel:model:{model}`) instead of workspace-scoped keys - **WorkspaceContext**: Seed per-model thinking from backend metadata - **Storage**: Remove workspace-scoped thinking key from persistent keys (thinking is global per-model, not workspace-specific) - **sendOptions**: Simplify to read per-model thinking directly - **useCreationWorkspace**: Remove workspace-scoped thinking sync - **ThinkingSlider**: Update tooltip to "Saved per model" ### Backend persistence preserved Backend still stores `aiSettings.thinkingLevel` per workspace (the last-used value) and seeds it to new devices via the per-model key. This maintains cross-device sync while restoring the expected per-model UX. ### Tests - Updated ThinkingContext tests to verify per-model behavior - Updated WorkspaceContext test to seed per-model key - Updated useCreationWorkspace test to remove migration assertion --- src/browser/App.tsx | 29 +++------ .../ChatInput/useCreationWorkspace.test.tsx | 6 +- .../ChatInput/useCreationWorkspace.ts | 10 +--- src/browser/components/ThinkingSlider.tsx | 2 +- src/browser/contexts/ThinkingContext.test.tsx | 38 ++++++------ src/browser/contexts/ThinkingContext.tsx | 60 ++++++------------- .../contexts/WorkspaceContext.test.tsx | 11 ++-- src/browser/contexts/WorkspaceContext.tsx | 8 +-- src/browser/utils/messages/sendOptions.ts | 29 +++------ src/common/constants/storage.ts | 14 +---- .../orpc/schemas/workspaceAiSettings.ts | 5 +- 11 files changed, 74 insertions(+), 138 deletions(-) diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 448dd5bcc2..f54f54450b 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -34,11 +34,7 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; -import { - getThinkingLevelByModelKey, - getThinkingLevelKey, - getModelKey, -} from "@/common/constants/storage"; +import { getThinkingLevelByModelKey, getModelKey } from "@/common/constants/storage"; import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; @@ -307,21 +303,12 @@ function AppInner() { return "off"; } - const scopedKey = getThinkingLevelKey(workspaceId); - const scoped = readPersistedState(scopedKey, undefined); - if (scoped !== undefined) { - return THINKING_LEVELS.includes(scoped) ? scoped : "off"; - } - - // Migration: fall back to legacy per-model thinking and seed the workspace-scoped key. + // Per-model thinking level. const model = getModelForWorkspace(workspaceId); - const legacy = readPersistedState( - getThinkingLevelByModelKey(model), - undefined - ); - if (legacy !== undefined && THINKING_LEVELS.includes(legacy)) { - updatePersistedState(scopedKey, legacy); - return legacy; + const thinkingKey = getThinkingLevelByModelKey(model); + const level = readPersistedState(thinkingKey, undefined); + if (level !== undefined && THINKING_LEVELS.includes(level)) { + return level; } return "off"; @@ -338,11 +325,11 @@ function AppInner() { const normalized = THINKING_LEVELS.includes(level) ? level : "off"; const model = getModelForWorkspace(workspaceId); const effective = enforceThinkingPolicy(model, normalized); - const key = getThinkingLevelKey(workspaceId); + const thinkingKey = getThinkingLevelByModelKey(model); // Use the utility function which handles localStorage and event dispatch // ThinkingProvider will pick this up via its listener - updatePersistedState(key, effective); + updatePersistedState(thinkingKey, effective); // Persist to backend so the palette change follows the workspace across devices. if (api) { diff --git a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx index 9534665824..bf64141ad9 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.test.tsx +++ b/src/browser/components/ChatInput/useCreationWorkspace.test.tsx @@ -7,7 +7,6 @@ import { getModeKey, getPendingScopeId, getProjectScopeId, - getThinkingLevelKey, } from "@/common/constants/storage"; import type { SendMessageError as _SendMessageError } from "@/common/types/errors"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; @@ -538,9 +537,8 @@ describe("useCreationWorkspace", () => { await waitFor(() => expect(getHook().toast?.message).toBe("backend exploded")); await waitFor(() => expect(getHook().isSending).toBe(false)); - // Side effect: send-options reader may migrate thinking level into the project scope. - const thinkingKey = getThinkingLevelKey(getProjectScopeId(TEST_PROJECT_PATH)); - expect(updatePersistedStateCalls).toEqual([[thinkingKey, "off"]]); + // No side-effect writes expected (thinking level is per-model, not workspace-scoped). + expect(updatePersistedStateCalls).toEqual([]); }); }); diff --git a/src/browser/components/ChatInput/useCreationWorkspace.ts b/src/browser/components/ChatInput/useCreationWorkspace.ts index 0d86fa700d..b0969e3f51 100644 --- a/src/browser/components/ChatInput/useCreationWorkspace.ts +++ b/src/browser/components/ChatInput/useCreationWorkspace.ts @@ -1,7 +1,6 @@ import { useState, useEffect, useCallback } from "react"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { RuntimeConfig, RuntimeMode } from "@/common/types/runtime"; -import type { ThinkingLevel } from "@/common/types/thinking"; import type { UIMode } from "@/common/types/mode"; import { parseRuntimeString } from "@/browser/utils/chatCommands"; import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSettings"; @@ -12,7 +11,6 @@ import { getInputImagesKey, getModelKey, getModeKey, - getThinkingLevelKey, getPendingScopeId, getProjectScopeId, } from "@/common/constants/storage"; @@ -47,13 +45,7 @@ function syncCreationPreferences(projectPath: string, workspaceId: string): void updatePersistedState(getModeKey(workspaceId), projectMode); } - const projectThinkingLevel = readPersistedState( - getThinkingLevelKey(projectScopeId), - null - ); - if (projectThinkingLevel !== null) { - updatePersistedState(getThinkingLevelKey(workspaceId), projectThinkingLevel); - } + // Note: thinking level is per-model (global), no workspace sync needed } interface UseCreationWorkspaceReturn { diff --git a/src/browser/components/ThinkingSlider.tsx b/src/browser/components/ThinkingSlider.tsx index bae8c41b97..047f8ac180 100644 --- a/src/browser/components/ThinkingSlider.tsx +++ b/src/browser/components/ThinkingSlider.tsx @@ -199,7 +199,7 @@ export const ThinkingSliderComponent: React.FC = ({ modelS - Thinking: {formatKeybind(KEYBINDS.TOGGLE_THINKING)} to cycle. Saved per workspace. + Thinking: {formatKeybind(KEYBINDS.TOGGLE_THINKING)} to cycle. Saved per model. ); diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 3978c02154..008eead494 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -1,16 +1,17 @@ import { GlobalWindow } from "happy-dom"; -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { act, cleanup, render, waitFor } from "@testing-library/react"; import React from "react"; import { ThinkingProvider } from "./ThinkingContext"; import { useThinkingLevel } from "@/browser/hooks/useThinkingLevel"; -import { - getModelKey, - getThinkingLevelByModelKey, - getThinkingLevelKey, -} from "@/common/constants/storage"; +import { getModelKey, getThinkingLevelByModelKey } from "@/common/constants/storage"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +// Mock useAPI to avoid requiring APIProvider in tests +void mock.module("@/browser/contexts/API", () => ({ + useAPI: () => ({ api: null, status: "disconnected" as const, error: null }), +})); + // Setup basic DOM environment for testing-library const dom = new GlobalWindow(); /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ @@ -53,7 +54,8 @@ describe("ThinkingContext", () => { const workspaceId = "ws-1"; updatePersistedState(getModelKey(workspaceId), "openai:gpt-5.2"); - updatePersistedState(getThinkingLevelKey(workspaceId), "high"); + updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "high"); + updatePersistedState(getThinkingLevelByModelKey("anthropic:claude-3.5"), "low"); let unmounts = 0; @@ -82,18 +84,19 @@ describe("ThinkingContext", () => { updatePersistedState(getModelKey(workspaceId), "anthropic:claude-3.5"); }); - // Thinking is workspace-scoped (not per-model), so switching models should not change it. + // Thinking is per-model, so switching models changes the thinking level. await waitFor(() => { - expect(view.getByTestId("child").textContent).toBe("high"); + expect(view.getByTestId("child").textContent).toBe("low"); }); expect(unmounts).toBe(0); }); - test("migrates legacy per-model thinking to the workspace-scoped key", async () => { + + test("thinking level is per-model", async () => { const workspaceId = "ws-1"; updatePersistedState(getModelKey(workspaceId), "openai:gpt-5.2"); - updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "low"); + updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "high"); const view = render( @@ -102,21 +105,22 @@ describe("ThinkingContext", () => { ); await waitFor(() => { - expect(view.getByTestId("thinking").textContent).toBe("low:ws-1"); + expect(view.getByTestId("thinking").textContent).toBe("high:ws-1"); }); - // Migration should have populated the new workspace-scoped key. - const persisted = window.localStorage.getItem(getThinkingLevelKey(workspaceId)); + // The per-model key should be used. + const persisted = window.localStorage.getItem(getThinkingLevelByModelKey("openai:gpt-5.2")); expect(persisted).toBeTruthy(); - expect(JSON.parse(persisted!)).toBe("low"); + expect(JSON.parse(persisted!)).toBe("high"); - // Switching models should not change the workspace-scoped value. + // Switching models should change thinking level to the new model's value. act(() => { updatePersistedState(getModelKey(workspaceId), "anthropic:claude-3.5"); }); + // New model has no saved thinking level, should default to "off". await waitFor(() => { - expect(view.getByTestId("thinking").textContent).toBe("low:ws-1"); + expect(view.getByTestId("thinking").textContent).toBe("off:ws-1"); }); }); }); diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 1e7847b1b5..f12008da3e 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -1,21 +1,15 @@ import type { ReactNode } from "react"; -import React, { createContext, useContext, useEffect, useMemo, useCallback } from "react"; +import React, { createContext, useContext, useMemo, useCallback } from "react"; import type { ThinkingLevel } from "@/common/types/thinking"; -import { - readPersistedState, - updatePersistedState, - usePersistedState, -} from "@/browser/hooks/usePersistedState"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { getModelKey, getProjectScopeId, getThinkingLevelByModelKey, - getThinkingLevelKey, GLOBAL_SCOPE_ID, } from "@/common/constants/storage"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; -import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; import { useAPI } from "@/browser/contexts/API"; interface ThinkingContextType { @@ -26,8 +20,8 @@ interface ThinkingContextType { const ThinkingContext = createContext(undefined); interface ThinkingProviderProps { - workspaceId?: string; // Workspace-scoped storage (highest priority) - projectPath?: string; // Project-scoped storage (fallback if no workspaceId) + workspaceId?: string; // For existing workspaces + projectPath?: string; // For workspace creation (uses project-scoped model key) children: ReactNode; } @@ -35,48 +29,32 @@ function getScopeId(workspaceId: string | undefined, projectPath: string | undef return workspaceId ?? (projectPath ? getProjectScopeId(projectPath) : GLOBAL_SCOPE_ID); } -function getCanonicalModelForScope(scopeId: string, fallbackModel: string): string { - const rawModel = readPersistedState(getModelKey(scopeId), fallbackModel); - return migrateGatewayModel(rawModel || fallbackModel); -} - export const ThinkingProvider: React.FC = (props) => { const { api } = useAPI(); const defaultModel = getDefaultModel(); const scopeId = getScopeId(props.workspaceId, props.projectPath); - const thinkingKey = getThinkingLevelKey(scopeId); - // Workspace-scoped thinking. (No longer per-model.) + // Subscribe to model changes so we update thinking level when model changes. + const [rawModel] = usePersistedState(getModelKey(scopeId), defaultModel, { + listener: true, + }); + + const model = useMemo( + () => migrateGatewayModel(rawModel || defaultModel), + [rawModel, defaultModel] + ); + + // Per-model thinking level (restored behavior). + const thinkingKey = useMemo(() => getThinkingLevelByModelKey(model), [model]); const [thinkingLevel, setThinkingLevelInternal] = usePersistedState( thinkingKey, "off", { listener: true } ); - // One-time migration: if the new workspace-scoped key is missing, seed from the legacy per-model key. - useEffect(() => { - const existing = readPersistedState(thinkingKey, undefined); - if (existing !== undefined) { - return; - } - - const model = getCanonicalModelForScope(scopeId, defaultModel); - const legacyKey = getThinkingLevelByModelKey(model); - const legacy = readPersistedState(legacyKey, undefined); - if (legacy === undefined) { - return; - } - - const effective = enforceThinkingPolicy(model, legacy); - updatePersistedState(thinkingKey, effective); - }, [defaultModel, scopeId, thinkingKey]); - const setThinkingLevel = useCallback( (level: ThinkingLevel) => { - const model = getCanonicalModelForScope(scopeId, defaultModel); - const effective = enforceThinkingPolicy(model, level); - - setThinkingLevelInternal(effective); + setThinkingLevelInternal(level); // Workspace variant: persist to backend so settings follow the workspace across devices. if (!props.workspaceId || !api) { @@ -86,13 +64,13 @@ export const ThinkingProvider: React.FC = (props) => { api.workspace .updateAISettings({ workspaceId: props.workspaceId, - aiSettings: { model, thinkingLevel: effective }, + aiSettings: { model, thinkingLevel: level }, }) .catch(() => { // Best-effort only. If offline or backend is old, the next sendMessage will persist. }); }, - [api, defaultModel, props.workspaceId, scopeId, setThinkingLevelInternal] + [api, model, props.workspaceId, setThinkingLevelInternal] ); // Memoize context value to prevent unnecessary re-renders of consumers. diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index cdc07b2ae1..2777300a4b 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -9,7 +9,7 @@ import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/W import { SELECTED_WORKSPACE_KEY, getModelKey, - getThinkingLevelKey, + getThinkingLevelByModelKey, } from "@/common/constants/storage"; import type { RecursivePartial } from "@/browser/testUtils"; @@ -111,7 +111,7 @@ describe("WorkspaceContext", () => { localStorage: { // Seed with different values; backend should win. [getModelKey("ws-ai")]: JSON.stringify("anthropic:claude-3.5"), - [getThinkingLevelKey("ws-ai")]: JSON.stringify("low"), + [getThinkingLevelByModelKey("openai:gpt-5.2")]: JSON.stringify("low"), }, }); @@ -122,9 +122,10 @@ describe("WorkspaceContext", () => { expect(JSON.parse(globalThis.localStorage.getItem(getModelKey("ws-ai"))!)).toBe( "openai:gpt-5.2" ); - expect(JSON.parse(globalThis.localStorage.getItem(getThinkingLevelKey("ws-ai"))!)).toBe( - "xhigh" - ); + // Thinking level is seeded per-model (to the model from aiSettings) + expect( + JSON.parse(globalThis.localStorage.getItem(getThinkingLevelByModelKey("openai:gpt-5.2"))!) + ).toBe("xhigh"); }); test("loads workspace metadata on mount", async () => { const initialWorkspaces: FrontendWorkspaceMetadata[] = [ diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 5665b1cd61..e093ffa033 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -15,7 +15,7 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import { deleteWorkspaceStorage, getModelKey, - getThinkingLevelKey, + getThinkingLevelByModelKey, SELECTED_WORKSPACE_KEY, } from "@/common/constants/storage"; import { useAPI } from "@/browser/contexts/API"; @@ -49,9 +49,9 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat } } - // Seed thinking level. - if (ai.thinkingLevel) { - const thinkingKey = getThinkingLevelKey(metadata.id); + // Seed thinking level for the model (per-model thinking). + if (ai.thinkingLevel && ai.model) { + const thinkingKey = getThinkingLevelByModelKey(ai.model); const existingThinking = readPersistedState(thinkingKey, undefined); if (existingThinking !== ai.thinkingLevel) { updatePersistedState(thinkingKey, ai.thinkingLevel); diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index f6de8b6193..3dd296f87c 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -1,11 +1,6 @@ -import { - getModelKey, - getThinkingLevelByModelKey, - getThinkingLevelKey, - getModeKey, -} from "@/common/constants/storage"; +import { getModelKey, getThinkingLevelByModelKey, getModeKey } from "@/common/constants/storage"; import { modeToToolPolicy } from "@/common/utils/ui/modeUtils"; -import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { toGatewayModel, migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; import type { SendMessageOptions } from "@/common/orpc/types"; @@ -52,21 +47,11 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio // Transform to gateway format if gateway is enabled for this model const model = toGatewayModel(baseModel); - // Read thinking level (workspace-scoped). - // Migration: if the workspace-scoped value is missing, fall back to legacy per-model storage - // once, then persist into the workspace-scoped key. - const scopedKey = getThinkingLevelKey(workspaceId); - const existingScoped = readPersistedState(scopedKey, undefined); - const thinkingLevel = - existingScoped ?? - readPersistedState( - getThinkingLevelByModelKey(baseModel), - WORKSPACE_DEFAULTS.thinkingLevel - ); - if (existingScoped === undefined) { - // Best-effort: avoid losing a user's existing per-model preference. - updatePersistedState(scopedKey, thinkingLevel); - } + // Read thinking level (per-model). + const thinkingLevel = readPersistedState( + getThinkingLevelByModelKey(baseModel), + WORKSPACE_DEFAULTS.thinkingLevel + ); // Read mode (workspace-specific) const mode = readPersistedState(getModeKey(workspaceId), WORKSPACE_DEFAULTS.mode); diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index c4de82c6ce..445c48c690 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -58,18 +58,8 @@ export function getMCPTestResultsKey(projectPath: string): string { } /** - * Get the localStorage key for thinking level preference per scope (workspace/project). - * Format: "thinkingLevel:{scopeId}" - */ -export function getThinkingLevelKey(scopeId: string): string { - return `thinkingLevel:${scopeId}`; -} - -/** - * LEGACY: Get the localStorage key for thinking level preference per model (global). + * Get the localStorage key for thinking level preference per model (global). * Format: "thinkingLevel:model:{modelName}" - * - * Kept for one-time migration to per-workspace thinking. */ export function getThinkingLevelByModelKey(modelName: string): string { return `thinkingLevel:model:${modelName}`; @@ -326,7 +316,6 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getInputKey, getInputImagesKey, getModeKey, - getThinkingLevelKey, getAutoRetryKey, getRetryStateKey, getReviewStateKey, @@ -336,6 +325,7 @@ const PERSISTENT_WORKSPACE_KEY_FUNCTIONS: Array<(workspaceId: string) => string> getReviewsKey, getAutoCompactionEnabledKey, getStatusStateKey, + // Note: thinking level is per-model (global), not per-workspace // Note: auto-compaction threshold is per-model, not per-workspace ]; diff --git a/src/common/orpc/schemas/workspaceAiSettings.ts b/src/common/orpc/schemas/workspaceAiSettings.ts index 275c7734ea..9345ca4e33 100644 --- a/src/common/orpc/schemas/workspaceAiSettings.ts +++ b/src/common/orpc/schemas/workspaceAiSettings.ts @@ -1,11 +1,12 @@ import { z } from "zod"; /** - * Workspace-scoped AI settings that should persist across devices. + * AI settings that should persist across devices. * * Notes: * - `model` must be canonical "provider:model" (NOT mux-gateway:provider/model). - * - `thinkingLevel` is workspace-scoped (saved per workspace, not per-model). + * - `thinkingLevel` is per-model on the frontend (global). Backend stores the + * last-used level per workspace to seed new devices. */ export const WorkspaceAISettingsSchema = z.object({ model: z.string().meta({ description: 'Canonical model id in the form "provider:model"' }), From 9b1a277b8646a11b04b3afde3c81a2c9a6f4dfe7 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 20 Dec 2025 13:32:27 -0600 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=A4=96=20feat:=20store=20per-model=20?= =?UTF-8?q?thinking=20in=20backend=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/App.tsx | 42 ++-- src/browser/contexts/ThinkingContext.tsx | 59 +++-- .../contexts/WorkspaceContext.test.tsx | 15 +- src/browser/contexts/WorkspaceContext.tsx | 13 +- .../stores/PersistedSettingsStore.test.ts | 80 +++++++ src/browser/stores/PersistedSettingsStore.ts | 206 ++++++++++++++++++ src/browser/utils/messages/sendOptions.ts | 11 +- src/cli/cli.test.ts | 1 + src/cli/server.test.ts | 1 + src/cli/server.ts | 1 + src/common/orpc/schemas.ts | 2 + src/common/orpc/schemas/api.ts | 22 +- src/common/orpc/schemas/persistedSettings.ts | 23 ++ .../orpc/schemas/workspaceAiSettings.ts | 8 +- src/common/orpc/types.ts | 1 + src/common/types/project.ts | 9 +- src/desktop/main.ts | 1 + src/node/config.ts | 49 +++-- src/node/orpc/context.ts | 2 + src/node/orpc/router.ts | 51 +++++ .../services/persistedSettingsService.test.ts | 66 ++++++ src/node/services/persistedSettingsService.ts | 102 +++++++++ src/node/services/serviceContainer.ts | 3 + tests/ipc/setup.ts | 1 + 24 files changed, 659 insertions(+), 110 deletions(-) create mode 100644 src/browser/stores/PersistedSettingsStore.test.ts create mode 100644 src/browser/stores/PersistedSettingsStore.ts create mode 100644 src/common/orpc/schemas/persistedSettings.ts create mode 100644 src/node/services/persistedSettingsService.test.ts create mode 100644 src/node/services/persistedSettingsService.ts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index f54f54450b..75be50a347 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -7,11 +7,7 @@ import { LeftSidebar } from "./components/LeftSidebar"; import { ProjectCreateModal } from "./components/ProjectCreateModal"; import { AIView } from "./components/AIView"; import { ErrorBoundary } from "./components/ErrorBoundary"; -import { - usePersistedState, - updatePersistedState, - readPersistedState, -} from "./hooks/usePersistedState"; +import { usePersistedState, readPersistedState } from "./hooks/usePersistedState"; import { matchesKeybind, KEYBINDS } from "./utils/ui/keybinds"; import { buildSortedWorkspacesByProject } from "./utils/ui/workspaceFiltering"; import { useResumeManager } from "./hooks/useResumeManager"; @@ -34,7 +30,7 @@ import { buildCoreSources, type BuildSourcesParams } from "./utils/commands/sour import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS } from "@/common/constants/events"; import { isWorkspaceForkSwitchEvent } from "./utils/workspaceEvents"; -import { getThinkingLevelByModelKey, getModelKey } from "@/common/constants/storage"; +import { getModelKey } from "@/common/constants/storage"; import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; @@ -42,6 +38,7 @@ import type { BranchListResult } from "@/common/orpc/types"; import { useTelemetry } from "./hooks/useTelemetry"; import { getRuntimeTypeForTelemetry } from "@/common/telemetry"; import { useStartWorkspaceCreation, getFirstProjectPath } from "./hooks/useStartWorkspaceCreation"; +import { persistedSettingsStore } from "@/browser/stores/PersistedSettingsStore"; import { useAPI } from "@/browser/contexts/API"; import { AuthTokenModal } from "@/browser/components/AuthTokenModal"; @@ -85,6 +82,10 @@ function AppInner() { ); const { api, status, error, authenticate } = useAPI(); + useEffect(() => { + persistedSettingsStore.init(api); + }, [api]); + const { projects, removeProject, @@ -303,15 +304,8 @@ function AppInner() { return "off"; } - // Per-model thinking level. const model = getModelForWorkspace(workspaceId); - const thinkingKey = getThinkingLevelByModelKey(model); - const level = readPersistedState(thinkingKey, undefined); - if (level !== undefined && THINKING_LEVELS.includes(level)) { - return level; - } - - return "off"; + return persistedSettingsStore.getThinkingLevelForModel(model); }, [getModelForWorkspace] ); @@ -325,20 +319,10 @@ function AppInner() { const normalized = THINKING_LEVELS.includes(level) ? level : "off"; const model = getModelForWorkspace(workspaceId); const effective = enforceThinkingPolicy(model, normalized); - const thinkingKey = getThinkingLevelByModelKey(model); - - // Use the utility function which handles localStorage and event dispatch - // ThinkingProvider will pick this up via its listener - updatePersistedState(thinkingKey, effective); - - // Persist to backend so the palette change follows the workspace across devices. - if (api) { - api.workspace - .updateAISettings({ workspaceId, aiSettings: { model, thinkingLevel: effective } }) - .catch(() => { - // Best-effort only. - }); - } + + persistedSettingsStore.setAIThinkingLevel(model, effective).catch(() => { + // Best-effort only. + }); // Dispatch toast notification event for UI feedback if (typeof window !== "undefined") { @@ -349,7 +333,7 @@ function AppInner() { ); } }, - [api, getModelForWorkspace] + [getModelForWorkspace] ); const registerParamsRef = useRef(null); diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index f12008da3e..600f744dd2 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -1,16 +1,19 @@ import type { ReactNode } from "react"; -import React, { createContext, useContext, useMemo, useCallback } from "react"; +import React, { + createContext, + useContext, + useMemo, + useCallback, + useEffect, + useSyncExternalStore, +} from "react"; import type { ThinkingLevel } from "@/common/types/thinking"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; -import { - getModelKey, - getProjectScopeId, - getThinkingLevelByModelKey, - GLOBAL_SCOPE_ID, -} from "@/common/constants/storage"; +import { getModelKey, getProjectScopeId, GLOBAL_SCOPE_ID } from "@/common/constants/storage"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; import { useAPI } from "@/browser/contexts/API"; +import { persistedSettingsStore } from "@/browser/stores/PersistedSettingsStore"; interface ThinkingContextType { thinkingLevel: ThinkingLevel; @@ -31,6 +34,18 @@ function getScopeId(workspaceId: string | undefined, projectPath: string | undef export const ThinkingProvider: React.FC = (props) => { const { api } = useAPI(); + + useEffect(() => { + persistedSettingsStore.init(api); + }, [api]); + + // Subscribe to persisted settings so we update thinking when the backend changes. + const persistedSnapshot = useSyncExternalStore( + (callback) => persistedSettingsStore.subscribe(callback), + () => persistedSettingsStore.getSnapshot(), + () => persistedSettingsStore.getSnapshot() + ); + const defaultModel = getDefaultModel(); const scopeId = getScopeId(props.workspaceId, props.projectPath); @@ -44,33 +59,17 @@ export const ThinkingProvider: React.FC = (props) => { [rawModel, defaultModel] ); - // Per-model thinking level (restored behavior). - const thinkingKey = useMemo(() => getThinkingLevelByModelKey(model), [model]); - const [thinkingLevel, setThinkingLevelInternal] = usePersistedState( - thinkingKey, - "off", - { listener: true } - ); + const thinkingLevel = + persistedSnapshot.settings.ai?.thinkingLevelByModel?.[model] ?? + persistedSettingsStore.getThinkingLevelForModel(model); const setThinkingLevel = useCallback( (level: ThinkingLevel) => { - setThinkingLevelInternal(level); - - // Workspace variant: persist to backend so settings follow the workspace across devices. - if (!props.workspaceId || !api) { - return; - } - - api.workspace - .updateAISettings({ - workspaceId: props.workspaceId, - aiSettings: { model, thinkingLevel: level }, - }) - .catch(() => { - // Best-effort only. If offline or backend is old, the next sendMessage will persist. - }); + persistedSettingsStore.setAIThinkingLevel(model, level).catch(() => { + // Best-effort. Store will heal on next refresh. + }); }, - [api, model, props.workspaceId, setThinkingLevelInternal] + [model] ); // Memoize context value to prevent unnecessary re-renders of consumers. diff --git a/src/browser/contexts/WorkspaceContext.test.tsx b/src/browser/contexts/WorkspaceContext.test.tsx index 2777300a4b..ef6133a03e 100644 --- a/src/browser/contexts/WorkspaceContext.test.tsx +++ b/src/browser/contexts/WorkspaceContext.test.tsx @@ -6,11 +6,7 @@ import type { WorkspaceContext } from "./WorkspaceContext"; import { WorkspaceProvider, useWorkspaceContext } from "./WorkspaceContext"; import { ProjectProvider } from "@/browser/contexts/ProjectContext"; import { useWorkspaceStoreRaw as getWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; -import { - SELECTED_WORKSPACE_KEY, - getModelKey, - getThinkingLevelByModelKey, -} from "@/common/constants/storage"; +import { SELECTED_WORKSPACE_KEY, getModelKey } from "@/common/constants/storage"; import type { RecursivePartial } from "@/browser/testUtils"; import type { APIClient } from "@/browser/contexts/API"; @@ -96,7 +92,7 @@ describe("WorkspaceContext", () => { expect(workspaceApi.onMetadata).toHaveBeenCalled(); }); - test("seeds model + thinking localStorage from backend metadata", async () => { + test("seeds model localStorage from backend metadata", async () => { const initialWorkspaces: FrontendWorkspaceMetadata[] = [ createWorkspaceMetadata({ id: "ws-ai", @@ -109,9 +105,8 @@ describe("WorkspaceContext", () => { list: () => Promise.resolve(initialWorkspaces), }, localStorage: { - // Seed with different values; backend should win. + // Seed with different value; backend should win. [getModelKey("ws-ai")]: JSON.stringify("anthropic:claude-3.5"), - [getThinkingLevelByModelKey("openai:gpt-5.2")]: JSON.stringify("low"), }, }); @@ -122,10 +117,6 @@ describe("WorkspaceContext", () => { expect(JSON.parse(globalThis.localStorage.getItem(getModelKey("ws-ai"))!)).toBe( "openai:gpt-5.2" ); - // Thinking level is seeded per-model (to the model from aiSettings) - expect( - JSON.parse(globalThis.localStorage.getItem(getThinkingLevelByModelKey("openai:gpt-5.2"))!) - ).toBe("xhigh"); }); test("loads workspace metadata on mount", async () => { const initialWorkspaces: FrontendWorkspaceMetadata[] = [ diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index e093ffa033..08375a6df6 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -9,13 +9,11 @@ import { type SetStateAction, } from "react"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import type { ThinkingLevel } from "@/common/types/thinking"; import type { WorkspaceSelection } from "@/browser/components/ProjectSidebar"; import type { RuntimeConfig } from "@/common/types/runtime"; import { deleteWorkspaceStorage, getModelKey, - getThinkingLevelByModelKey, SELECTED_WORKSPACE_KEY, } from "@/common/constants/storage"; import { useAPI } from "@/browser/contexts/API"; @@ -32,7 +30,7 @@ import { EXPERIMENT_IDS } from "@/common/constants/experiments"; /** * Seed per-workspace localStorage from backend workspace metadata. * - * This keeps a workspace's model/thinking consistent across devices/browsers. + * Model selection is workspace-scoped in localStorage; thinking is stored via persistedSettings. */ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadata): void { const ai = metadata.aiSettings; @@ -48,15 +46,6 @@ function seedWorkspaceLocalStorageFromBackend(metadata: FrontendWorkspaceMetadat updatePersistedState(modelKey, ai.model); } } - - // Seed thinking level for the model (per-model thinking). - if (ai.thinkingLevel && ai.model) { - const thinkingKey = getThinkingLevelByModelKey(ai.model); - const existingThinking = readPersistedState(thinkingKey, undefined); - if (existingThinking !== ai.thinkingLevel) { - updatePersistedState(thinkingKey, ai.thinkingLevel); - } - } } /** diff --git a/src/browser/stores/PersistedSettingsStore.test.ts b/src/browser/stores/PersistedSettingsStore.test.ts new file mode 100644 index 0000000000..603499a7e4 --- /dev/null +++ b/src/browser/stores/PersistedSettingsStore.test.ts @@ -0,0 +1,80 @@ +import { GlobalWindow } from "happy-dom"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; +import { waitFor } from "@testing-library/react"; +import { persistedSettingsStore } from "./PersistedSettingsStore"; +import type { APIClient } from "@/browser/contexts/API"; + +const dom = new GlobalWindow(); +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ +(global as any).window = dom.window; +(global as any).document = dom.window.document; +(globalThis as any).StorageEvent = dom.window.StorageEvent; +(globalThis as any).CustomEvent = dom.window.CustomEvent; +/* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access */ + +describe("PersistedSettingsStore", () => { + beforeEach(() => { + window.localStorage.clear(); + persistedSettingsStore.dispose(); + }); + + afterEach(() => { + persistedSettingsStore.dispose(); + }); + + it("falls back to localStorage when backend has no value", () => { + window.localStorage.setItem("thinkingLevel:model:openai:gpt-5.2", JSON.stringify("high")); + + expect(persistedSettingsStore.getThinkingLevelForModel("openai:gpt-5.2")).toBe("high"); + }); + + it("updates snapshot optimistically when setting thinking", async () => { + await persistedSettingsStore.setAIThinkingLevel("openai:gpt-5.2", "low"); + + const snapshot = persistedSettingsStore.getSnapshot(); + expect(snapshot.settings.ai?.thinkingLevelByModel?.["openai:gpt-5.2"]).toBe("low"); + }); + + it("refreshes from backend and reacts to onChanged", async () => { + let callCount = 0; + + const get = mock(() => { + callCount += 1; + return Promise.resolve( + callCount === 1 + ? { ai: { thinkingLevelByModel: { "openai:gpt-5.2": "high" } } } + : { ai: { thinkingLevelByModel: { "openai:gpt-5.2": "low" } } } + ); + }); + + const onChanged = mock(() => { + async function* iter() { + await Promise.resolve(); + // Yield once to trigger a refresh. + yield undefined; + } + return Promise.resolve(iter()); + }); + + const api = { + persistedSettings: { + get, + onChanged, + setAIThinkingLevel: () => Promise.resolve({ success: true, data: undefined }), + }, + } as unknown as APIClient; + + persistedSettingsStore.init(api); + + await waitFor(() => { + expect(persistedSettingsStore.getThinkingLevelForModel("openai:gpt-5.2")).toBe("high"); + }); + + await waitFor(() => { + expect(persistedSettingsStore.getThinkingLevelForModel("openai:gpt-5.2")).toBe("low"); + }); + + expect(get).toHaveBeenCalled(); + expect(onChanged).toHaveBeenCalled(); + }); +}); diff --git a/src/browser/stores/PersistedSettingsStore.ts b/src/browser/stores/PersistedSettingsStore.ts new file mode 100644 index 0000000000..060f2e4499 --- /dev/null +++ b/src/browser/stores/PersistedSettingsStore.ts @@ -0,0 +1,206 @@ +import type { APIClient } from "@/browser/contexts/API"; +import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { getThinkingLevelByModelKey } from "@/common/constants/storage"; +import type { PersistedSettings } from "@/common/orpc/types"; +import type { ThinkingLevel } from "@/common/types/thinking"; +import { normalizeGatewayModel } from "@/common/utils/ai/models"; +import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; + +const THINKING_LEVELS: ThinkingLevel[] = ["off", "low", "medium", "high", "xhigh"]; + +export interface PersistedSettingsSnapshot { + loading: boolean; + settings: PersistedSettings; +} + +type Subscriber = () => void; + +const subscribers = new Set(); + +let api: APIClient | null = null; +let abortController: AbortController | null = null; + +let snapshot: PersistedSettingsSnapshot = { loading: true, settings: {} }; + +function emitChange(): void { + for (const subscriber of subscribers) { + subscriber(); + } +} + +function setSnapshot(next: PersistedSettingsSnapshot): void { + snapshot = next; + emitChange(); +} + +function getThinkingLevelFromLocalStorage(model: string): ThinkingLevel { + const key = getThinkingLevelByModelKey(model); + const stored = readPersistedState(key, undefined); + if (stored !== undefined && THINKING_LEVELS.includes(stored)) { + return stored; + } + return WORKSPACE_DEFAULTS.thinkingLevel; +} + +function isEmptyThinkingMap(settings: PersistedSettings): boolean { + const byModel = settings.ai?.thinkingLevelByModel; + return !byModel || Object.keys(byModel).length === 0; +} + +async function maybeSeedThinkingFromLocalStorage(apiClient: APIClient): Promise { + if (typeof window === "undefined" || !window.localStorage) { + return; + } + + const prefix = "thinkingLevel:model:"; + const toPersist: Array<{ model: string; thinkingLevel: ThinkingLevel }> = []; + + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if (!key?.startsWith(prefix)) continue; + + const rawModel = key.slice(prefix.length); + const model = normalizeGatewayModel(rawModel); + + const level = readPersistedState(key, undefined); + if (level !== undefined && THINKING_LEVELS.includes(level)) { + toPersist.push({ model, thinkingLevel: level }); + } + } + + if (toPersist.length === 0) { + return; + } + + await Promise.all( + toPersist.map(({ model, thinkingLevel }) => + apiClient.persistedSettings.setAIThinkingLevel({ model, thinkingLevel }) + ) + ); +} + +async function refresh(): Promise { + if (!api) { + return; + } + + try { + const settings = await api.persistedSettings.get(); + setSnapshot({ loading: false, settings }); + + // One-time migration: if the backend has no thinking map yet, seed from localStorage. + if (isEmptyThinkingMap(settings)) { + try { + await maybeSeedThinkingFromLocalStorage(api); + const refreshed = await api.persistedSettings.get(); + setSnapshot({ loading: false, settings: refreshed }); + } catch { + // Best-effort only. + } + } + } catch { + // Old server/client mismatch or offline. Keep local fallback behavior. + setSnapshot({ loading: false, settings: snapshot.settings }); + } +} + +export const persistedSettingsStore = { + subscribe(subscriber: Subscriber): () => void { + subscribers.add(subscriber); + return () => { + subscribers.delete(subscriber); + }; + }, + + getSnapshot(): PersistedSettingsSnapshot { + return snapshot; + }, + + init(apiClient: APIClient | null): void { + if (api === apiClient) { + return; + } + + abortController?.abort(); + abortController = null; + + api = apiClient; + + if (!api) { + return; + } + + abortController = new AbortController(); + const signal = abortController.signal; + + void refresh(); + + (async () => { + try { + const iterator = await api.persistedSettings.onChanged(undefined, { signal }); + for await (const _ of iterator) { + if (signal.aborted) break; + void refresh(); + } + } catch { + // Expected on shutdown / disconnect. + } + })(); + }, + + async setAIThinkingLevel(model: string, thinkingLevel: ThinkingLevel | null): Promise { + const normalizedModel = normalizeGatewayModel(model); + + // Optimistic update for instant UI feedback. + const current = snapshot.settings; + const currentByModel = current.ai?.thinkingLevelByModel ?? {}; + const nextByModel = { ...currentByModel }; + if (thinkingLevel === null) { + delete nextByModel[normalizedModel]; + } else { + nextByModel[normalizedModel] = thinkingLevel; + } + + const hasAnyThinking = Object.keys(nextByModel).length > 0; + const nextSettings: PersistedSettings = { + ...current, + ai: hasAnyThinking ? { ...(current.ai ?? {}), thinkingLevelByModel: nextByModel } : undefined, + }; + + setSnapshot({ loading: snapshot.loading, settings: nextSettings }); + + if (!api) { + return; + } + + try { + const result = await api.persistedSettings.setAIThinkingLevel({ + model: normalizedModel, + thinkingLevel, + }); + if (!result.success) { + throw new Error(result.error); + } + } catch { + await refresh(); + } + }, + + getThinkingLevelForModel(model: string): ThinkingLevel { + const normalizedModel = normalizeGatewayModel(model); + const stored = snapshot.settings.ai?.thinkingLevelByModel?.[normalizedModel]; + if (stored !== undefined && THINKING_LEVELS.includes(stored)) { + return stored; + } + + return getThinkingLevelFromLocalStorage(normalizedModel); + }, + + dispose(): void { + abortController?.abort(); + abortController = null; + api = null; + subscribers.clear(); + snapshot = { loading: true, settings: {} }; + }, +}; diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index 3dd296f87c..19a822b862 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -1,13 +1,13 @@ -import { getModelKey, getThinkingLevelByModelKey, getModeKey } from "@/common/constants/storage"; +import { getModelKey, getModeKey } from "@/common/constants/storage"; import { modeToToolPolicy } from "@/common/utils/ui/modeUtils"; import { readPersistedState } from "@/browser/hooks/usePersistedState"; import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { toGatewayModel, migrateGatewayModel } from "@/browser/hooks/useGatewayModels"; import type { SendMessageOptions } from "@/common/orpc/types"; import type { UIMode } from "@/common/types/mode"; -import type { ThinkingLevel } from "@/common/types/thinking"; import { enforceThinkingPolicy } from "@/browser/utils/thinking/policy"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; +import { persistedSettingsStore } from "@/browser/stores/PersistedSettingsStore"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { isExperimentEnabled } from "@/browser/hooks/useExperiments"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; @@ -47,11 +47,8 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio // Transform to gateway format if gateway is enabled for this model const model = toGatewayModel(baseModel); - // Read thinking level (per-model). - const thinkingLevel = readPersistedState( - getThinkingLevelByModelKey(baseModel), - WORKSPACE_DEFAULTS.thinkingLevel - ); + // Read thinking level (per-model, backend-authoritative with local fallback). + const thinkingLevel = persistedSettingsStore.getThinkingLevelForModel(baseModel); // Read mode (workspace-specific) const mode = readPersistedState(getModeKey(workspaceId), WORKSPACE_DEFAULTS.mode); diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 5eac460d0a..b6da47669e 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -61,6 +61,7 @@ async function createTestServer(authToken?: string): Promise { aiService: services.aiService, projectService: services.projectService, workspaceService: services.workspaceService, + persistedSettingsService: services.persistedSettingsService, providerService: services.providerService, terminalService: services.terminalService, editorService: services.editorService, diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index 2b0fa4d36e..3499451bb7 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -64,6 +64,7 @@ async function createTestServer(): Promise { aiService: services.aiService, projectService: services.projectService, workspaceService: services.workspaceService, + persistedSettingsService: services.persistedSettingsService, providerService: services.providerService, terminalService: services.terminalService, editorService: services.editorService, diff --git a/src/cli/server.ts b/src/cli/server.ts index f8f11051da..6cecebc96a 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -80,6 +80,7 @@ const mockWindow: BrowserWindow = { aiService: serviceContainer.aiService, projectService: serviceContainer.projectService, workspaceService: serviceContainer.workspaceService, + persistedSettingsService: serviceContainer.persistedSettingsService, providerService: serviceContainer.providerService, terminalService: serviceContainer.terminalService, editorService: serviceContainer.editorService, diff --git a/src/common/orpc/schemas.ts b/src/common/orpc/schemas.ts index d61eead7a2..dd7f6b7dbf 100644 --- a/src/common/orpc/schemas.ts +++ b/src/common/orpc/schemas.ts @@ -11,6 +11,7 @@ export { RuntimeConfigSchema, RuntimeModeSchema } from "./schemas/runtime"; export { ProjectConfigSchema, WorkspaceConfigSchema } from "./schemas/project"; // Workspace schemas +export { PersistedSettingsSchema } from "./schemas/persistedSettings"; export { WorkspaceAISettingsSchema } from "./schemas/workspaceAiSettings"; export { FrontendWorkspaceMetadataSchema, @@ -121,6 +122,7 @@ export { nameGeneration, projects, ProviderConfigInfoSchema, + persistedSettings, providers, ProvidersConfigMapSchema, server, diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 6e25b954ef..8a1560c6a7 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -16,7 +16,8 @@ import { import { BashToolResultSchema, FileTreeNodeSchema } from "./tools"; import { WorkspaceStatsSnapshotSchema } from "./workspaceStats"; import { FrontendWorkspaceMetadataSchema, WorkspaceActivitySnapshotSchema } from "./workspace"; -import { WorkspaceAISettingsSchema } from "./workspaceAiSettings"; +import { PersistedSettingsSchema } from "./persistedSettings"; +import { ThinkingLevelSchema, WorkspaceAISettingsSchema } from "./workspaceAiSettings"; import { MCPAddParamsSchema, MCPRemoveParamsSchema, @@ -131,6 +132,25 @@ export const providers = { }, }; +// Persisted settings (cross-device preferences) +export const persistedSettings = { + get: { + input: z.void(), + output: PersistedSettingsSchema, + }, + setAIThinkingLevel: { + input: z.object({ + model: z.string(), + thinkingLevel: ThinkingLevelSchema.nullable(), + }), + output: ResultSchema(z.void(), z.string()), + }, + onChanged: { + input: z.void(), + output: eventIterator(z.void()), + }, +}; + // Projects export const projects = { create: { diff --git a/src/common/orpc/schemas/persistedSettings.ts b/src/common/orpc/schemas/persistedSettings.ts new file mode 100644 index 0000000000..ce4246f777 --- /dev/null +++ b/src/common/orpc/schemas/persistedSettings.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { ThinkingLevelSchema } from "./workspaceAiSettings"; + +export const PersistedSettingsSchema = z + .object({ + ai: z + .object({ + thinkingLevelByModel: z.record(z.string(), ThinkingLevelSchema).optional(), + }) + .optional(), + projectDefaults: z + .record( + z.string(), + z + .object({ + model: z.string().optional(), + mode: z.enum(["plan", "exec"]).optional(), + }) + .strict() + ) + .optional(), + }) + .strict(); diff --git a/src/common/orpc/schemas/workspaceAiSettings.ts b/src/common/orpc/schemas/workspaceAiSettings.ts index 9345ca4e33..1a48e813c6 100644 --- a/src/common/orpc/schemas/workspaceAiSettings.ts +++ b/src/common/orpc/schemas/workspaceAiSettings.ts @@ -8,9 +8,11 @@ import { z } from "zod"; * - `thinkingLevel` is per-model on the frontend (global). Backend stores the * last-used level per workspace to seed new devices. */ +export const ThinkingLevelSchema = z.enum(["off", "low", "medium", "high", "xhigh"]).meta({ + description: "Thinking/reasoning effort level", +}); + export const WorkspaceAISettingsSchema = z.object({ model: z.string().meta({ description: 'Canonical model id in the form "provider:model"' }), - thinkingLevel: z.enum(["off", "low", "medium", "high", "xhigh"]).meta({ - description: "Thinking/reasoning effort level", - }), + thinkingLevel: ThinkingLevelSchema, }); diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts index d7cb32553d..46a27801fd 100644 --- a/src/common/orpc/types.ts +++ b/src/common/orpc/types.ts @@ -21,6 +21,7 @@ export type SendMessageOptions = z.infer; export type ProviderConfigInfo = z.infer; +export type PersistedSettings = z.infer; export type ProvidersConfigMap = z.infer; export type ImagePart = z.infer; export type WorkspaceChatMessage = z.infer; diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 800966b257..759ee22131 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -4,15 +4,22 @@ */ import type { z } from "zod"; -import type { ProjectConfigSchema, WorkspaceConfigSchema } from "../orpc/schemas"; +import type { + PersistedSettingsSchema, + ProjectConfigSchema, + WorkspaceConfigSchema, +} from "../orpc/schemas"; export type Workspace = z.infer; +export type PersistedSettings = z.infer; export type ProjectConfig = z.infer; export type FeatureFlagOverride = "default" | "on" | "off"; export interface ProjectsConfig { + /** Cross-client persisted settings (shared via ~/.mux/config.json). */ + persistedSettings?: PersistedSettings; projects: Map; /** SSH hostname/alias for this machine (used for editor deep links in browser mode) */ serverSshHost?: string; diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 548310fa00..a79ddea021 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -327,6 +327,7 @@ async function loadServices(): Promise { projectService: services.projectService, workspaceService: services.workspaceService, providerService: services.providerService, + persistedSettingsService: services.persistedSettingsService, terminalService: services.terminalService, editorService: services.editorService, windowService: services.windowService, diff --git a/src/node/config.ts b/src/node/config.ts index 5243bdb71a..de0faa94e2 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -16,6 +16,7 @@ import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { getMuxHome } from "@/common/constants/paths"; import { PlatformPaths } from "@/common/utils/paths"; +import { PersistedSettingsSchema } from "@/common/orpc/schemas"; import { stripTrailingSlashes } from "@/node/utils/pathUtils"; // Re-export project types from dedicated types file (for preload usage) @@ -60,27 +61,40 @@ export class Config { const data = fs.readFileSync(this.configFile, "utf-8"); const parsed = JSON.parse(data) as { projects?: unknown; + persistedSettings?: unknown; serverSshHost?: string; viewedSplashScreens?: string[]; featureFlagOverrides?: Record; }; + const persistedSettings = (() => { + if (parsed.persistedSettings === undefined) { + return undefined; + } + const result = PersistedSettingsSchema.safeParse(parsed.persistedSettings); + return result.success ? result.data : undefined; + })(); + // Config is stored as array of [path, config] pairs - if (parsed.projects && Array.isArray(parsed.projects)) { - const rawPairs = parsed.projects as Array<[string, ProjectConfig]>; - // Migrate: normalize project paths by stripping trailing slashes - // This fixes configs created with paths like "/home/user/project/" - const normalizedPairs = rawPairs.map(([projectPath, projectConfig]) => { - return [stripTrailingSlashes(projectPath), projectConfig] as [string, ProjectConfig]; - }); - const projectsMap = new Map(normalizedPairs); - return { - projects: projectsMap, - serverSshHost: parsed.serverSshHost, - viewedSplashScreens: parsed.viewedSplashScreens, - featureFlagOverrides: parsed.featureFlagOverrides, - }; - } + const rawPairs = + parsed.projects && Array.isArray(parsed.projects) + ? (parsed.projects as Array<[string, ProjectConfig]>) + : []; + + // Migrate: normalize project paths by stripping trailing slashes + // This fixes configs created with paths like "/home/user/project/" + const normalizedPairs = rawPairs.map(([projectPath, projectConfig]) => { + return [stripTrailingSlashes(projectPath), projectConfig] as [string, ProjectConfig]; + }); + const projectsMap = new Map(normalizedPairs); + + return { + persistedSettings, + projects: projectsMap, + serverSshHost: parsed.serverSshHost, + viewedSplashScreens: parsed.viewedSplashScreens, + featureFlagOverrides: parsed.featureFlagOverrides, + }; } } catch (error) { log.error("Error loading config:", error); @@ -100,12 +114,17 @@ export class Config { const data: { projects: Array<[string, ProjectConfig]>; + persistedSettings?: ProjectsConfig["persistedSettings"]; serverSshHost?: string; viewedSplashScreens?: string[]; featureFlagOverrides?: ProjectsConfig["featureFlagOverrides"]; } = { projects: Array.from(config.projects.entries()), }; + if (config.persistedSettings) { + data.persistedSettings = config.persistedSettings; + } + if (config.serverSshHost) { data.serverSshHost = config.serverSshHost; } diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 6a7ee4935a..8d38b854df 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -3,6 +3,7 @@ import type { Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; import type { ProjectService } from "@/node/services/projectService"; import type { WorkspaceService } from "@/node/services/workspaceService"; +import type { PersistedSettingsService } from "@/node/services/persistedSettingsService"; import type { ProviderService } from "@/node/services/providerService"; import type { TerminalService } from "@/node/services/terminalService"; import type { EditorService } from "@/node/services/editorService"; @@ -25,6 +26,7 @@ export interface ORPCContext { aiService: AIService; projectService: ProjectService; workspaceService: WorkspaceService; + persistedSettingsService: PersistedSettingsService; providerService: ProviderService; terminalService: TerminalService; editorService: EditorService; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index d2ceabf328..06711707f5 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -177,6 +177,57 @@ export const router = (authToken?: string) => { } }), }, + persistedSettings: { + get: t + .input(schemas.persistedSettings.get.input) + .output(schemas.persistedSettings.get.output) + .handler(({ context }) => context.persistedSettingsService.get()), + setAIThinkingLevel: t + .input(schemas.persistedSettings.setAIThinkingLevel.input) + .output(schemas.persistedSettings.setAIThinkingLevel.output) + .handler(({ context, input }) => + context.persistedSettingsService.setAIThinkingLevel(input.model, input.thinkingLevel) + ), + onChanged: t + .input(schemas.persistedSettings.onChanged.input) + .output(schemas.persistedSettings.onChanged.output) + .handler(async function* ({ context }) { + let resolveNext: (() => void) | null = null; + let pendingNotification = false; + let ended = false; + + const push = () => { + if (ended) return; + if (resolveNext) { + const resolve = resolveNext; + resolveNext = null; + resolve(); + } else { + pendingNotification = true; + } + }; + + const unsubscribe = context.persistedSettingsService.onChanged(push); + + try { + while (!ended) { + if (pendingNotification) { + pendingNotification = false; + yield undefined; + continue; + } + await new Promise((resolve) => { + resolveNext = resolve; + }); + yield undefined; + } + } finally { + ended = true; + unsubscribe(); + } + }), + }, + general: { listDirectory: t .input(schemas.general.listDirectory.input) diff --git a/src/node/services/persistedSettingsService.test.ts b/src/node/services/persistedSettingsService.test.ts new file mode 100644 index 0000000000..fc93d69335 --- /dev/null +++ b/src/node/services/persistedSettingsService.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "bun:test"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { Config } from "@/node/config"; +import { PersistedSettingsService } from "./persistedSettingsService"; + +async function readConfigFile(rootDir: string): Promise> { + const raw = await fs.promises.readFile(path.join(rootDir, "config.json"), "utf-8"); + return JSON.parse(raw) as Record; +} + +describe("PersistedSettingsService", () => { + it("persists and returns thinking level per model", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-persisted-settings-")); + try { + const config = new Config(tmpDir); + const service = new PersistedSettingsService(config); + + const result = await service.setAIThinkingLevel("openai:gpt-5.2", "high"); + expect(result.success).toBe(true); + + const settings = service.get(); + expect(settings.ai?.thinkingLevelByModel?.["openai:gpt-5.2"]).toBe("high"); + + const raw = await readConfigFile(tmpDir); + expect(raw.persistedSettings).toBeTruthy(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("normalizes mux-gateway models", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-persisted-settings-")); + try { + const config = new Config(tmpDir); + const service = new PersistedSettingsService(config); + + const result = await service.setAIThinkingLevel("mux-gateway:openai/gpt-5.2", "low"); + expect(result.success).toBe(true); + + const settings = service.get(); + expect(settings.ai?.thinkingLevelByModel?.["openai:gpt-5.2"]).toBe("low"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("deletes persistedSettings when last value is removed", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-persisted-settings-")); + try { + const config = new Config(tmpDir); + const service = new PersistedSettingsService(config); + + expect((await service.setAIThinkingLevel("openai:gpt-5.2", "high")).success).toBe(true); + expect((await service.setAIThinkingLevel("openai:gpt-5.2", null)).success).toBe(true); + + expect(service.get()).toEqual({}); + + const raw = await readConfigFile(tmpDir); + expect(raw.persistedSettings).toBeUndefined(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/node/services/persistedSettingsService.ts b/src/node/services/persistedSettingsService.ts new file mode 100644 index 0000000000..374c6aa3d2 --- /dev/null +++ b/src/node/services/persistedSettingsService.ts @@ -0,0 +1,102 @@ +import { EventEmitter } from "events"; +import type { Config } from "@/node/config"; +import { PersistedSettingsSchema } from "@/common/orpc/schemas"; +import type { PersistedSettings } from "@/common/orpc/types"; +import type { ThinkingLevel } from "@/common/types/thinking"; +import { normalizeGatewayModel } from "@/common/utils/ai/models"; +import { Err, Ok, type Result } from "@/common/types/result"; +import { log } from "@/node/services/log"; + +export class PersistedSettingsService { + private readonly emitter = new EventEmitter(); + + constructor(private readonly config: Config) {} + + onChanged(callback: () => void): () => void { + this.emitter.on("changed", callback); + return () => this.emitter.off("changed", callback); + } + + private emitChanged(): void { + this.emitter.emit("changed"); + } + + get(): PersistedSettings { + const cfg = this.config.loadConfigOrDefault(); + + // Validate before exposing over RPC so corrupted config doesn’t crash the UI. + const result = PersistedSettingsSchema.safeParse(cfg.persistedSettings ?? {}); + if (!result.success) { + log.warn("Invalid persistedSettings in config.json; ignoring:", result.error); + return {}; + } + + return result.data; + } + + async setAIThinkingLevel( + model: string, + thinkingLevel: ThinkingLevel | null + ): Promise> { + try { + const normalizedModel = normalizeGatewayModel(model); + + let changed = false; + + await this.config.editConfig((config) => { + const parsed = PersistedSettingsSchema.safeParse(config.persistedSettings ?? {}); + const currentSettings = parsed.success ? parsed.data : {}; + const currentByModel = currentSettings.ai?.thinkingLevelByModel ?? {}; + + const prev = currentByModel[normalizedModel]; + if (thinkingLevel === prev || (thinkingLevel === null && prev === undefined)) { + return config; + } + + changed = true; + + const nextByModel = { ...currentByModel }; + if (thinkingLevel === null) { + delete nextByModel[normalizedModel]; + } else { + nextByModel[normalizedModel] = thinkingLevel; + } + + const hasAnyThinking = Object.keys(nextByModel).length > 0; + const nextAI = hasAnyThinking + ? { ...(currentSettings.ai ?? {}), thinkingLevelByModel: nextByModel } + : (() => { + if (!currentSettings.ai) { + return undefined; + } + const { thinkingLevelByModel: _removed, ...rest } = currentSettings.ai; + return Object.keys(rest).length > 0 ? rest : undefined; + })(); + + const nextSettings: PersistedSettings = { + ...currentSettings, + ai: nextAI, + }; + + const isEmpty = + nextSettings.ai === undefined && + (nextSettings.projectDefaults === undefined || + Object.keys(nextSettings.projectDefaults).length === 0); + + return { + ...config, + persistedSettings: isEmpty ? undefined : nextSettings, + }; + }); + + if (changed) { + this.emitChanged(); + } + + return Ok(undefined); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return Err(`Failed to set thinking level: ${message}`); + } + } +} diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index cc8832ab7a..6868b213f5 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -9,6 +9,7 @@ import { PTYService } from "@/node/services/ptyService"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; import { ProjectService } from "@/node/services/projectService"; import { WorkspaceService } from "@/node/services/workspaceService"; +import { PersistedSettingsService } from "@/node/services/persistedSettingsService"; import { ProviderService } from "@/node/services/providerService"; import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; import { TerminalService } from "@/node/services/terminalService"; @@ -52,6 +53,7 @@ export class ServiceContainer { public readonly aiService: AIService; public readonly projectService: ProjectService; public readonly workspaceService: WorkspaceService; + public readonly persistedSettingsService: PersistedSettingsService; public readonly providerService: ProviderService; public readonly terminalService: TerminalService; public readonly editorService: EditorService; @@ -116,6 +118,7 @@ export class ServiceContainer { (workspaceId) => this.workspaceService.emitIdleCompactionNeeded(workspaceId) ); this.providerService = new ProviderService(config); + this.persistedSettingsService = new PersistedSettingsService(config); // Terminal services - PTYService is cross-platform this.ptyService = new PTYService(); this.terminalService = new TerminalService(config, this.ptyService); diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index 3eed6b912e..df981bb8a9 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -73,6 +73,7 @@ export async function createTestEnvironment(): Promise { aiService: services.aiService, projectService: services.projectService, workspaceService: services.workspaceService, + persistedSettingsService: services.persistedSettingsService, providerService: services.providerService, terminalService: services.terminalService, editorService: services.editorService, From 18adc909c3e0d95b958cc6d09258789996e5041b Mon Sep 17 00:00:00 2001 From: Ammar Date: Sat, 20 Dec 2025 13:51:22 -0600 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=A4=96=20fix:=20stabilize=20unit=20te?= =?UTF-8?q?sts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/browser/stores/PersistedSettingsStore.test.ts | 12 +++++++++++- src/node/services/serverService.test.ts | 15 ++++++--------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/browser/stores/PersistedSettingsStore.test.ts b/src/browser/stores/PersistedSettingsStore.test.ts index 603499a7e4..69b2d09be9 100644 --- a/src/browser/stores/PersistedSettingsStore.test.ts +++ b/src/browser/stores/PersistedSettingsStore.test.ts @@ -47,9 +47,17 @@ describe("PersistedSettingsStore", () => { ); }); + const change = (() => { + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = () => res(); + }); + return { promise, resolve }; + })(); + const onChanged = mock(() => { async function* iter() { - await Promise.resolve(); + await change.promise; // Yield once to trigger a refresh. yield undefined; } @@ -70,6 +78,8 @@ describe("PersistedSettingsStore", () => { expect(persistedSettingsStore.getThinkingLevelForModel("openai:gpt-5.2")).toBe("high"); }); + change.resolve(); + await waitFor(() => { expect(persistedSettingsStore.getThinkingLevelForModel("openai:gpt-5.2")).toBe("low"); }); diff --git a/src/node/services/serverService.test.ts b/src/node/services/serverService.test.ts index 25676aaac8..66ec613d68 100644 --- a/src/node/services/serverService.test.ts +++ b/src/node/services/serverService.test.ts @@ -77,22 +77,19 @@ describe("ServerService.startServer", () => { } test("cleans up server when lockfile acquisition fails", async () => { - // Skip on Windows where chmod doesn't work the same way - if (process.platform === "win32") { - return; - } - const service = new ServerService(); - // Make muxHome read-only so lockfile.acquire() will fail - await fs.chmod(tempDir, 0o444); + // Make muxHome a file so lockfile.acquire() fails deterministically + // (chmod-based tests don’t work reliably when running as root). + const muxHomeFile = path.join(tempDir, "mux-home-file"); + await fs.writeFile(muxHomeFile, "not-a-dir"); let thrownError: Error | null = null; try { // Start server - this should fail when trying to write lockfile await service.startServer({ - muxHome: tempDir, + muxHome: muxHomeFile, context: stubContext as ORPCContext, authToken: "test-token", port: 0, // random port @@ -103,7 +100,7 @@ describe("ServerService.startServer", () => { // Verify that an error was thrown expect(thrownError).not.toBeNull(); - expect(thrownError!.message).toMatch(/EACCES|permission denied/i); + expect(thrownError!.message).toMatch(/ENOTDIR|not a directory/i); // Verify the server is NOT left running expect(service.isServerRunning()).toBe(false);