diff --git a/scripts/check-test-seam-comments.allowlist.json b/scripts/check-test-seam-comments.allowlist.json index 5041f3fe39a..678d2258b97 100644 --- a/scripts/check-test-seam-comments.allowlist.json +++ b/scripts/check-test-seam-comments.allowlist.json @@ -6,7 +6,6 @@ { "file": "src/browser/stores/WorkspaceStore.ts", "symbol": "workspaceStore.addWorkspace", "reason": "production caller: App.tsx (workspace creation/restore paths)" }, { "file": "src/browser/stores/WorkspaceStore.ts", "symbol": "workspaceStore.setActiveWorkspaceId", "reason": "production caller: WorkspaceContext.tsx active-workspace effect" }, { "file": "src/browser/stores/WorkspaceStore.ts", "symbol": "findRenderedRefineProposalHash", "reason": "production caller: getDisplayedRefineProposalHash in the same file" }, - { "file": "src/browser/utils/aiSelectionIntent.ts", "symbol": "resetAiSelectionIntentForTests", "reason": "module-singleton reset hook named ...ForTests" }, { "file": "src/browser/utils/highlighting/highlightWorkerClient.ts", "symbol": "highlightWithBudget", "reason": "production caller: enqueueHighlightWithBudget in the same file" }, { "file": "src/browser/utils/highlighting/highlightWorkerClient.ts", "symbol": "enqueueHighlightWithBudget", "reason": "production caller: highlightCode in the same file" }, { "file": "src/browser/utils/highlighting/highlightWorkerClient.ts", "symbol": "__resetForTests", "reason": "module-singleton reset hook named ...ForTests" }, diff --git a/src/browser/App.tsx b/src/browser/App.tsx index a9df99b825e..c8e5c6ddb81 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -84,7 +84,6 @@ import { LEFT_SIDEBAR_WIDTH_KEY, } from "@/common/constants/storage"; import { normalizeToCanonical } from "@/common/utils/ai/models"; -import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import type { BranchListResult } from "@/common/orpc/types"; import type { UpdateChannel } from "@/common/types/project"; import { useTelemetry } from "./hooks/useTelemetry"; @@ -92,7 +91,7 @@ import { getRuntimeTypeForTelemetry } from "@/common/telemetry"; import { useStartWorkspaceCreation } from "./hooks/useStartWorkspaceCreation"; import { useAPI } from "@/browser/contexts/API"; import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking"; -import { resolveEffectiveComposerModel } from "@/browser/utils/workspaceAiSettingsSync"; +import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import { AuthTokenModal } from "@/browser/components/AuthTokenModal/AuthTokenModal"; import { ScratchPage } from "@/browser/components/ScratchPage/ScratchPage"; @@ -125,7 +124,6 @@ import { RosettaBanner } from "./components/RosettaBanner/RosettaBanner"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; import { getAutoRoutingKey, setAutoRoutingChoice } from "@/browser/utils/modelChange"; -import { readScopedAiDefault } from "@/browser/utils/scopedAiDefaults"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { useRouting } from "@/browser/hooks/useRouting"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; @@ -498,25 +496,23 @@ function AppInner() { } = useCommandRegistry(); /** - * Get the selected model for a workspace, preserving explicit gateway prefixes. + * The workspace's effective AI selection, preserving explicit gateway prefixes. A sub-agent + * resolves for its own agent. */ - const getModelForWorkspace = useCallback( - (workspaceId: string): string => { - const defaultModel = getDefaultModel(); - const preferredModel = readScopedAiDefault(workspaceId, "model") ?? null; + const getAiSelectionForWorkspace = useCallback( + (workspaceId: string) => { const metadata = workspaceMetadata.get(workspaceId); - const persistedAgentId = - (readScopedAiDefault(workspaceId, "agentId") ?? WORKSPACE_DEFAULTS.agentId) - .trim() - .toLowerCase() || WORKSPACE_DEFAULTS.agentId; - const agentId = - metadata?.parentWorkspaceId != null && metadata.agentId - ? metadata.agentId - : persistedAgentId; - return resolveEffectiveComposerModel(preferredModel, metadata, agentId, defaultModel); + return getWorkspaceAiSelection( + workspaceId, + metadata?.parentWorkspaceId != null && metadata.agentId ? metadata.agentId : undefined + ); }, [workspaceMetadata] ); + const getModelForWorkspace = useCallback( + (workspaceId: string): string => getAiSelectionForWorkspace(workspaceId).model, + [getAiSelectionForWorkspace] + ); const getThinkingLevelForWorkspace = useCallback( (workspaceId: string): ThinkingLevel => { diff --git a/src/browser/components/TitleBar/TitleBar.tsx b/src/browser/components/TitleBar/TitleBar.tsx index 9510f1c7ac9..f8a77a2779f 100644 --- a/src/browser/components/TitleBar/TitleBar.tsx +++ b/src/browser/components/TitleBar/TitleBar.tsx @@ -14,7 +14,6 @@ import { useAboutDialog } from "@/browser/contexts/AboutDialogContext"; import { useRouter } from "@/browser/contexts/RouterContext"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { useRouting } from "@/browser/hooks/useRouting"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { formatMuxGatewayBalance, @@ -26,7 +25,7 @@ import { DESKTOP_TITLEBAR_HEIGHT_CLASS, } from "@/browser/hooks/useDesktopTitlebar"; -import { getModelKey } from "@/common/constants/storage"; +import { useWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import { useAppConfig } from "@/browser/stores/AppConfigStore"; import { PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers"; import { @@ -77,17 +76,8 @@ export function TitleBar(props: TitleBarProps) { useRouter(); const routing = useRouting(); const defaultModel = useAppConfig((config) => config.defaultModel) ?? WORKSPACE_DEFAULTS.model; - const workspaceModelKey = getModelKey(currentWorkspaceId ?? "__titlebar__"); - const [workspaceModelOverride] = usePersistedState(workspaceModelKey, null, { - listener: true, - }); - const rawActiveModel = - currentWorkspaceId && - typeof workspaceModelOverride === "string" && - workspaceModelOverride.trim().length > 0 - ? workspaceModelOverride.trim() - : defaultModel; - const activeModel = normalizeSelectedModel(rawActiveModel); + const workspaceModel = useWorkspaceAiSelection(currentWorkspaceId ?? "").model; + const activeModel = normalizeSelectedModel(currentWorkspaceId ? workspaceModel : defaultModel); const explicitGateway = getExplicitGatewayPrefix(activeModel); const canonicalActiveModel = normalizeToCanonical(activeModel); // Explicit gateway selections short-circuit route resolution: the user diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx index b7398fc7810..a4063b37f3d 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.test.tsx @@ -200,36 +200,29 @@ describe("WorkspaceModeAISync", () => { test("restores workspace-by-agent override on explicit agent switch when defaults inherit", async () => { const workspaceId = nextWorkspaceId(); - const planModel = "anthropic:claude-sonnet-4-5"; const planThinking = "high"; - const execWorkspaceModel = "openai:gpt-5.2-pro"; const execWorkspaceThinking = "medium"; setTestAgentAiDefaults({}); updatePersistedState(getWorkspaceAISettingsByAgentKey(workspaceId), { - exec: { model: execWorkspaceModel, thinkingLevel: execWorkspaceThinking }, + exec: { model: "openai:gpt-5.2-pro", thinkingLevel: execWorkspaceThinking }, }); - updatePersistedState(getModelKey(workspaceId), planModel); updatePersistedState(getThinkingLevelKey(workspaceId), planThinking); const { rerender } = renderSync({ workspaceId, agentId: "plan" }); await waitFor(() => { - expect(readPersistedState(getModelKey(workspaceId), "")).toBe(planModel); expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe(planThinking); }); rerender(); await waitFor(() => { - expect(readPersistedState(getModelKey(workspaceId), "")).toBe(execWorkspaceModel); expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe( execWorkspaceThinking ); }); - - expect(consumeWorkspaceModelChange(workspaceId, execWorkspaceModel)).toBe("agent"); }); test("ignores same-agent workspace overrides when agent defaults are missing", async () => { @@ -307,7 +300,6 @@ describe("WorkspaceModeAISync", () => { exec: { thinkingLevel: baseConfiguredThinking }, }); - updatePersistedState(getModelKey(workspaceId), "some-legacy-model"); updatePersistedState(getThinkingLevelKey(workspaceId), "high"); // Unknown non-plan agent IDs still use exec as fallback agent; this verifies @@ -315,7 +307,6 @@ describe("WorkspaceModeAISync", () => { renderSync({ workspaceId, agentId: "custom" }); await waitFor(() => { - expect(readPersistedState(getModelKey(workspaceId), "")).toBe(customConfiguredModel); expect(readPersistedState(getThinkingLevelKey(workspaceId), "off")).toBe("high"); }); }); @@ -354,7 +345,6 @@ describe("WorkspaceModeAISync", () => { await waitFor(() => { expect(readAuto(workspaceId)).toEqual({ model: true, thinkingLevel: true }); }); - expect(readPersistedState(getModelKey(workspaceId), "")).toBe("openai:gpt-5.2"); switchTo(rerender, workspaceId, "plan"); await waitFor(() => { diff --git a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx index c6cb0ac761e..448c8ddbd8c 100644 --- a/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx +++ b/src/browser/components/WorkspaceModeAISync/WorkspaceModeAISync.tsx @@ -4,7 +4,6 @@ import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePer import { useAgentAiDefaults } from "@/browser/stores/AppConfigStore"; import { getAutoRoutingChoiceByAgentKey, - getModelKey, getReasoningModeKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, @@ -12,7 +11,7 @@ import { import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { applyAutoRoutingOutcome, - setWorkspaceModelWithOrigin, + recordWorkspaceModelChange, setWorkspaceThinkingLevelWithOrigin, } from "@/browser/utils/modelChange"; import { @@ -21,6 +20,7 @@ import { type AutoRoutingChoiceByAgent, type WorkspaceAISettingsCache, } from "@/browser/utils/workspaceModeAi"; +import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import type { OpenAIReasoningMode, ThinkingLevel } from "@/common/types/thinking"; @@ -41,15 +41,15 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { useEffect(() => { const fallbackModel = getDefaultModel(); - const modelKey = getModelKey(workspaceId); const thinkingKey = getThinkingLevelKey(workspaceId); const normalizedAgentId = normalizeAgentId(agentId, "exec"); + const previousAgentId = prevAgentIdRef.current; const isExplicitAgentSwitch = - prevAgentIdRef.current !== null && + previousAgentId !== null && prevWorkspaceIdRef.current === workspaceId && - prevAgentIdRef.current !== normalizedAgentId; + previousAgentId !== normalizedAgentId; // Update refs for the next run (even if no model changes). prevAgentIdRef.current = normalizedAgentId; @@ -63,27 +63,41 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { {} ); - const existingModel = readPersistedState(modelKey, fallbackModel); const existingThinking = readPersistedState(thinkingKey, "off"); const reasoningKey = getReasoningModeKey(workspaceId); const existingReasoning = readPersistedState(reasoningKey, "standard"); const agentBaseById = new Map(agents.map((agent) => [agent.id, agent.base])); - const { resolvedModel, resolvedThinking, resolvedReasoningMode } = - resolveWorkspaceAiSettingsForAgent({ - agentId: normalizedAgentId, - agentAiDefaults, - // Keep deterministic handoff behavior: background sync should trust the - // currently active workspace model, but explicit mode switches should - // restore the selected agent's per-workspace override (if any). - workspaceByAgent, - useWorkspaceByAgentFallback: isExplicitAgentSwitch, - fallbackModel, - existingModel, - existingThinking, - existingReasoningMode: existingReasoning, - agentBaseById, - }); + const resolvedModel = getWorkspaceAiSelection( + workspaceId, + normalizedAgentId, + agentBaseById + ).model; + if (isExplicitAgentSwitch) { + // Each agent resolves its own model, so the switch itself is the explicit model change. + recordWorkspaceModelChange( + workspaceId, + resolvedModel, + "agent", + getWorkspaceAiSelection(workspaceId, previousAgentId, agentBaseById).model + ); + } + + // The resolver owns the model; only thinking and reasoning come from here. + const { resolvedThinking, resolvedReasoningMode } = resolveWorkspaceAiSettingsForAgent({ + agentId: normalizedAgentId, + agentAiDefaults, + // Keep deterministic handoff behavior: background sync should trust the + // currently active workspace settings, but explicit mode switches should + // restore the selected agent's per-workspace override (if any). + workspaceByAgent, + useWorkspaceByAgentFallback: isExplicitAgentSwitch, + fallbackModel, + existingModel: resolvedModel, + existingThinking, + existingReasoningMode: existingReasoning, + agentBaseById, + }); const autoRoutingOutcome = resolveAutoRoutingForAgent({ agentId: normalizedAgentId, agentAiDefaults, @@ -97,14 +111,6 @@ export function WorkspaceModeAISync(props: { workspaceId: string }): null { workspaceByAgent, }); - if (existingModel !== resolvedModel) { - setWorkspaceModelWithOrigin( - workspaceId, - resolvedModel, - isExplicitAgentSwitch ? "agent" : "sync" - ); - } - if (existingThinking !== resolvedThinking) { setWorkspaceThinkingLevelWithOrigin( workspaceId, diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index 7c6761ec85e..61a6630a9aa 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -15,7 +15,6 @@ import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { ThinkingLevel } from "@/common/types/thinking"; import { getAutoThinkingLevelKey, - getModelKey, getProjectScopeId, getReasoningModeKey, getThinkingLevelByModelKey, @@ -174,11 +173,7 @@ function applyWorkspaceStorageOverrides(props: { modelOverride?: string | null; thinkingOverride?: "off" | null; }) { - if (props.modelOverride === null) { - window.localStorage.removeItem(getModelKey(props.workspaceId)); - } if (props.modelOverride != null) { - updatePersistedState(getModelKey(props.workspaceId), props.modelOverride); markAiSelectionIntent(props.workspaceId, "model", props.modelOverride); } @@ -569,7 +564,6 @@ describe("ThinkingContext", () => { test("switching models does not remount children", async () => { const workspaceId = "ws-1"; - updatePersistedState(getModelKey(workspaceId), "openai:gpt-5.2"); markAiSelectionIntent(workspaceId, "model", "openai:gpt-5.2"); updatePersistedState(getThinkingLevelKey(workspaceId), "high"); @@ -597,7 +591,6 @@ describe("ThinkingContext", () => { }); act(() => { - updatePersistedState(getModelKey(workspaceId), "anthropic:claude-3.5"); markAiSelectionIntent(workspaceId, "model", "anthropic:claude-3.5"); }); @@ -611,7 +604,6 @@ describe("ThinkingContext", () => { test("migrates legacy per-model thinking to the workspace-scoped key", async () => { const workspaceId = "ws-1"; - updatePersistedState(getModelKey(workspaceId), "openai:gpt-5.2"); markAiSelectionIntent(workspaceId, "model", "openai:gpt-5.2"); updatePersistedState(getThinkingLevelByModelKey("openai:gpt-5.2"), "low"); @@ -632,7 +624,6 @@ describe("ThinkingContext", () => { // Switching models should not change the workspace-scoped value. act(() => { - updatePersistedState(getModelKey(workspaceId), "anthropic:claude-3.5"); markAiSelectionIntent(workspaceId, "model", "anthropic:claude-3.5"); }); diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 155a2162906..ac5c020ea96 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -26,7 +26,11 @@ import { useMinThinkingLevels } from "@/browser/hooks/useMinThinkingLevels"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { useAPI } from "@/browser/contexts/API"; import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking"; -import { getWorkspaceAiSettingsFromMetadata } from "@/browser/utils/workspaceAiSettingsSync"; +import { + getWorkspaceAiSettingsFromMetadata, + useWorkspaceAiSelection, +} from "@/browser/utils/workspaceAiSettingsSync"; +import { useOptionalAgent } from "@/browser/contexts/AgentContext"; import { useOptionalWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { KEYBINDS, matchesKeybind } from "@/browser/utils/ui/keybinds"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; @@ -58,37 +62,28 @@ 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 = readScopedAiDefault(scopeId, "model") ?? fallbackModel; - return normalizeToCanonical(rawModel || fallbackModel); -} - -function getModelForThinkingUpdate( - scopeId: string, - metadataModel: string | undefined, - fallbackModel: string -): string { - const persistedModel = readScopedAiDefault(scopeId, "model"); - // Prefer the scoped preference, then metadata, then the default model to avoid clobbering - // startup metadata. - // normalizeSelectedModel (not normalizeToCanonical): this value is persisted - // back via persistAgentAiSettings, and explicit gateway identities must - // survive — normalizeToCanonical rewrites coder:openai/ (a valid - // cross-typed instance) to openai: from the name alone, so merely - // changing the thinking level would silently reroute the workspace to - // direct OpenAI. Thinking policy lookups resolve gateway-scoped strings - // through resolveModelForMetadata, so capability behavior is unchanged. - return normalizeSelectedModel(persistedModel ?? metadataModel ?? fallbackModel); -} - export const ThinkingProvider: React.FC = (props) => { const { api } = useAPI(); const workspaceContext = useOptionalWorkspaceContext(); const { getMinimum } = useMinThinkingLevels(); // Resolve mapped aliases so keybind stepping walks the target model's ladder. const { config: providersConfig } = useProvidersConfig(); + const workspaceId = props.workspaceId; const defaultModel = useDefaultModel(); - const scopeId = getScopeId(props.workspaceId, props.projectPath); + const scopeId = getScopeId(workspaceId, props.projectPath); + // Hooks run unconditionally; a workspace mount reads the resolver instead of scope defaults. + const defaultsScopeId = workspaceId != null ? GLOBAL_SCOPE_ID : scopeId; + const agentContext = useOptionalAgent(); + const selection = useWorkspaceAiSelection( + workspaceId ?? "", + agentContext?.agentId, + agentContext && new Map(agentContext.agents.map((agent) => [agent.id, agent.base])) + ); + const scopedModel = useScopedAiDefault(defaultsScopeId, "model"); + // normalizeSelectedModel (not normalizeToCanonical): explicit gateway identities must + // survive; thinking policy lookups resolve gateway-scoped strings themselves. + const model = + workspaceId != null ? selection.model : normalizeSelectedModel(scopedModel ?? defaultModel); const metadataAgentId = readScopedAiDefault(scopeId, "agentId") ?? WORKSPACE_DEFAULTS.agentId; const metadataSettings = getWorkspaceAiSettingsFromMetadata( props.workspaceId ? workspaceContext?.workspaceMetadata.get(props.workspaceId) : undefined, @@ -124,15 +119,14 @@ export const ThinkingProvider: React.FC = (props) => { return; } - const model = getCanonicalModelForScope(scopeId, defaultModel); - const legacyKey = getThinkingLevelByModelKey(model); + const legacyKey = getThinkingLevelByModelKey(normalizeToCanonical(model)); const legacy = readPersistedState(legacyKey, undefined); if (legacy === undefined) { return; } updatePersistedState(thinkingKey, legacy); - }, [defaultModel, props.workspaceId, scopeId]); + }, [model, props.workspaceId]); // Keep picker choices local until a user message sends the full settings. const persistAgentAiSettings = useCallback( @@ -197,8 +191,6 @@ export const ThinkingProvider: React.FC = (props) => { const setThinkingLevel = useCallback( (level: ThinkingLevel) => { - const model = getModelForThinkingUpdate(scopeId, metadataSettings.model, defaultModel); - writeScopedAiDefault(scopeId, "thinkingLevel", level); // Deliberate pick: pins thinking on a sub-agent once a message sends it. if (props.workspaceId) { @@ -219,21 +211,11 @@ export const ThinkingProvider: React.FC = (props) => { requestActiveTurnThinkingLevel(api, props.workspaceId, level); } }, - [ - api, - defaultModel, - getCurrentReasoningMode, - metadataSettings.model, - persistAgentAiSettings, - props.workspaceId, - scopeId, - ] + [api, getCurrentReasoningMode, model, persistAgentAiSettings, props.workspaceId, scopeId] ); const setReasoningMode = useCallback( (mode: OpenAIReasoningMode) => { - const model = getModelForThinkingUpdate(scopeId, metadataSettings.model, defaultModel); - setReasoningModeInternal(mode); if (props.workspaceId) { markAiSelectionIntent(props.workspaceId, "reasoningMode", mode); @@ -245,12 +227,10 @@ export const ThinkingProvider: React.FC = (props) => { }); }, [ - defaultModel, getCurrentThinkingLevel, - metadataSettings.model, + model, persistAgentAiSettings, props.workspaceId, - scopeId, setReasoningModeInternal, ] ); @@ -270,8 +250,6 @@ export const ThinkingProvider: React.FC = (props) => { e.preventDefault(); - // Keep stepping aligned with setThinkingLevel so startup metadata uses the matching policy. - const model = getModelForThinkingUpdate(scopeId, metadataSettings.model, defaultModel); // Step only within levels at or above the model's minimum floor. const minimum = getMinimum(model); const allowed = getAvailableThinkingLevels(model, minimum, providersConfig); @@ -301,15 +279,7 @@ export const ThinkingProvider: React.FC = (props) => { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [ - defaultModel, - getMinimum, - metadataSettings.model, - providersConfig, - scopeId, - thinkingLevel, - setThinkingLevel, - ]); + }, [getMinimum, model, providersConfig, thinkingLevel, setThinkingLevel]); // Memoize context value to prevent unnecessary re-renders of consumers. const contextValue = useMemo( diff --git a/src/browser/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index 287d8cfec65..45eff3c941f 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -34,7 +34,6 @@ import { deleteWorkspaceStorage, migrateWorkspaceStorage } from "@/browser/utils import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import { useAPI } from "@/browser/contexts/API"; -import { setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; import { useWorkspaceStoreRaw } from "@/browser/stores/WorkspaceStore"; @@ -180,7 +179,7 @@ export function seedWorkspaceLocalStorageFromBackend( const modelKey = getModelKey(workspaceId); const existingModel = readPersistedState(modelKey, undefined); if (existingModel !== active.model && !keepsUnsentPick("model", existingModel)) { - setWorkspaceModelWithOrigin(workspaceId, active.model, "sync"); + updatePersistedState(modelKey, active.model); } const thinkingKey = getThinkingLevelKey(workspaceId); diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index b66a868cadf..a7fd971c4e7 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -766,10 +766,6 @@ const ChatInputInner: React.FC = (props) => { ensureModelInSettings(selectedModel); // Ensure model exists in Settings // A concrete pick (selector, /model, or the cycle shortcut) always leaves Auto. setAutoModelRoutingActive(false); - // Deliberate pick: pins the model on a sub-agent once a message sends it. - if (variant === "workspace" && workspaceId) { - markAiSelectionIntent(workspaceId, "model", selectedModel); - } if (onModelChange) { // Notify parent of model change (for context switch warning + persisted model metadata). @@ -786,6 +782,9 @@ const ChatInputInner: React.FC = (props) => { if (variant !== "workspace" || !workspaceId) { return; } + // Marked after onModelChange so its explicit-switch record still sees the previous model; + // this also pins a same-model pick on a sub-agent once a message sends it. + markAiSelectionIntent(workspaceId, "model", selectedModel); const normalizedAgentId = normalizeAgentId(agentId, "exec"); diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index a1a135f92ec..f73ba03f7d6 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -18,7 +18,6 @@ import { useDraftWorkspaceSettings } from "@/browser/hooks/useDraftWorkspaceSett import { getAutoRoutingKey, recordAutoRoutingChoiceForAgent, - setWorkspaceModelWithOrigin, type AutoRoutingDimension, } from "@/browser/utils/modelChange"; import { resolveConfiguredAiDefaults } from "@/browser/utils/workspaceModeAi"; @@ -144,12 +143,6 @@ function syncCreationPreferences( const aiPreferences = getUserPreferences().ai; const projectDefaults = aiPreferences?.projectDefaults?.[projectPath]; - // Sync model from project scope to workspace scope - // This ensures the model used for creation is persisted for future resumes - const projectModel = projectDefaults?.model; - if (projectModel) { - setWorkspaceModelWithOrigin(workspaceId, projectModel, "sync"); - } const projectAgentId = projectDefaults?.agentId; const globalDefaultAgentId = aiPreferences?.globalDefaults?.agentId ?? WORKSPACE_DEFAULTS.agentId; const effectiveAgentId = resolveCreationAgentId( @@ -201,6 +194,7 @@ function syncCreationPreferences( updatePersistedState(getReasoningModeKey(workspaceId), projectReasoningMode); } + const projectModel = projectDefaults?.model; if (projectModel) { const effectiveThinking: ThinkingLevel = projectThinkingLevel ?? "off"; diff --git a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx index a8c5a3421c7..db2ae112820 100644 --- a/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx +++ b/src/browser/features/Messages/ChatBarrier/StreamingBarrier.tsx @@ -2,8 +2,7 @@ import React from "react"; import { StreamingBarrierView } from "./StreamingBarrierView"; import { getModelName } from "@/common/utils/ai/models"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; -import { getModelKey } from "@/common/constants/storage"; -import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import { getAppConfigStore, getUserPreferences } from "@/browser/stores/AppConfigStore"; import { useWorkspaceState, @@ -11,7 +10,6 @@ import { useWorkspaceStoreRaw, useWorkspaceStreamingStats, } from "@/browser/stores/WorkspaceStore"; -import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { useSettings } from "@/browser/contexts/SettingsContext"; import { useAPI } from "@/browser/contexts/API"; import { stopStream } from "@/browser/utils/stopStream"; @@ -285,13 +283,11 @@ export const StreamingBarrierContent: React.FC = ( const tps = showTokenCount ? streamingStats?.tps : undefined; // Model to display: - // - "starting" phase: prefer pendingStreamModel (from muxMetadata), then localStorage + // - "starting" phase: prefer pendingStreamModel (from muxMetadata), then the composer model // - Otherwise: use currentModel from active stream const model = phase === "starting" - ? (pendingStreamModel ?? - readPersistedState(getModelKey(workspaceId), null) ?? - getDefaultModel()) + ? (pendingStreamModel ?? getWorkspaceAiSelection(workspaceId).model) : currentModel; const modelName = model ? getModelName(model) : null; diff --git a/src/browser/features/Settings/Sections/TasksSection.tsx b/src/browser/features/Settings/Sections/TasksSection.tsx index b6b1ed68a81..e6877ff9e04 100644 --- a/src/browser/features/Settings/Sections/TasksSection.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.tsx @@ -17,10 +17,9 @@ import { import { copyToClipboard } from "@/browser/utils/clipboard"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; import { getDefaultModel, useModelsFromSettings } from "@/browser/hooks/useModelsFromSettings"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; import { resolveAdvisorEnabledForAgent } from "@/common/constants/advisor"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; -import { getModelKey } from "@/common/constants/storage"; +import { useWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import { getAppConfigStore, updateUserPreferences, @@ -449,15 +448,8 @@ export function TasksSection() { // "Inherit", we show thinking levels for the workspace model (falling back to // the global default). This mirrors the workspace model resolution chain used when sending messages. const selectedWorkspaceId = selectedWorkspace?.workspaceId ?? null; - const defaultModel = getDefaultModel(); - const workspaceModelStorageKey = selectedWorkspaceId - ? getModelKey(selectedWorkspaceId) - : "__tasks_workspace_model_fallback__"; - const [workspaceModelRaw] = usePersistedState(workspaceModelStorageKey, defaultModel, { - listener: true, - }); - const inheritedEffectiveModel = - (typeof workspaceModelRaw === "string" ? workspaceModelRaw.trim() : "") || defaultModel; + const workspaceModel = useWorkspaceAiSelection(selectedWorkspaceId ?? "").model; + const inheritedEffectiveModel = selectedWorkspaceId ? workspaceModel : getDefaultModel(); const lastSyncedTaskSettingsRef = useRef(null); const lastSyncedAgentAiDefaultsRef = useRef(null); diff --git a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx index 94016e4861f..76e9b281a85 100644 --- a/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.ui.test.tsx @@ -20,8 +20,6 @@ import { restoreModulesAfterSuite } from "../../../../../tests/ui/moduleMocks"; import type { AgentAiDefaults } from "@/common/types/agentAiDefaults"; import type { AgentDefinitionDescriptor } from "@/common/types/agentDefinition"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; -import { getModelKey } from "@/common/constants/storage"; -import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { markAiSelectionIntent, resetAiSelectionIntentForTests, @@ -174,7 +172,6 @@ function renderTasksSection(options: RenderTasksSectionOptions = {}) { options.agents || options.workspaceModel ? { projectPath: "/proj", workspaceId: "ws-1" } : null; resetAiSelectionIntentForTests(); if (options.workspaceModel) { - updatePersistedState(getModelKey("ws-1"), options.workspaceModel); markAiSelectionIntent("ws-1", "model", options.workspaceModel); } diff --git a/src/browser/features/Tools/ProposePlanToolCall.test.tsx b/src/browser/features/Tools/ProposePlanToolCall.test.tsx index e9d20d95851..eabf78d4f55 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.test.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.test.tsx @@ -19,11 +19,12 @@ import { getAgentIdKey, getAutoModelRoutingKey, getAutoThinkingLevelKey, - getModelKey, getThinkingLevelKey, getWorkspaceAISettingsByAgentKey, } from "@/common/constants/storage"; import { + getPendingAiSelection, + markAiSelectionIntent, resetAiSelectionIntentForTests, setWorkspaceAiMetadata, } from "@/browser/utils/aiSelectionIntent"; @@ -330,7 +331,6 @@ function startInPlanMode( thinkingLevel?: ThinkingLevel ) { window.localStorage.setItem(getAgentIdKey(workspaceId), JSON.stringify("plan")); - if (model) updatePersistedState(getModelKey(workspaceId), model); if (model) { setWorkspaceAiMetadata(workspaceId, { aiSettingsByAgent: { plan: { model, thinkingLevel: thinkingLevel ?? "off" } }, @@ -607,22 +607,40 @@ describe("ProposePlanToolCall", () => { // Note: some tests in this repo mock the `usePersistedState` module globally. In that case, // `updatePersistedState` won't actually write to localStorage here, so we assert the call. const agentKey = getAgentIdKey(WORKSPACE_ID); - const modelKey = getModelKey(WORKSPACE_ID); const thinkingKey = getThinkingLevelKey(WORKSPACE_ID); const updatePersistedStateMaybeMock = updatePersistedState as unknown as { mock?: { calls: unknown[][] }; }; if (updatePersistedStateMaybeMock.mock) { expect(updatePersistedState).toHaveBeenCalledWith(agentKey, "exec"); - expect(updatePersistedState).toHaveBeenCalledWith(modelKey, execModel); expect(updatePersistedState).toHaveBeenCalledWith(thinkingKey, execThinking); } else { expect(JSON.parse(window.localStorage.getItem(agentKey)!)).toBe("exec"); - expect(JSON.parse(window.localStorage.getItem(modelKey)!)).toBe(execModel); expect(JSON.parse(window.localStorage.getItem(thinkingKey)!)).toBe(execThinking); } }); + test("Implement sends the unsent Exec pick and ends it once it is saved", async () => { + const pickedModel = "openai:gpt-5.2-pro"; + window.localStorage.setItem(getAgentIdKey(WORKSPACE_ID), JSON.stringify("exec")); + markAiSelectionIntent(WORKSPACE_ID, "model", pickedModel); + startInPlanMode(WORKSPACE_ID, "anthropic:claude-sonnet-4-5", "high"); + + const sendMessageCalls: SendMessageArgs[] = []; + mockApi = createMockApi({ sendMessage: recordSendMessage(sendMessageCalls) }); + const view = renderCompletedPlan(); + fireEvent.click(view.getByRole("button", { name: "Implement" })); + + await waitFor(() => expect(sendMessageCalls.length).toBe(1)); + expect(sendMessageCalls[0]?.options.model).toBe(pickedModel); + setWorkspaceAiMetadata(WORKSPACE_ID, { + aiSettingsByAgent: { exec: { model: pickedModel, thinkingLevel: "high" } }, + }); + await waitFor(() => + expect(getPendingAiSelection(WORKSPACE_ID, "exec", "model")).toBeUndefined() + ); + }); + test("Implement keeps the exec model when the composer has Auto routing selected", async () => { // Same model in plan and exec: the agent switch persists nothing, so only the send can // drop the Auto flag. diff --git a/src/browser/features/Tools/ProposePlanToolCall.tsx b/src/browser/features/Tools/ProposePlanToolCall.tsx index e1e4bbadcb3..bb10e4c25a5 100644 --- a/src/browser/features/Tools/ProposePlanToolCall.tsx +++ b/src/browser/features/Tools/ProposePlanToolCall.tsx @@ -43,7 +43,6 @@ import { usePopoverError } from "@/browser/hooks/usePopoverError"; import { PopoverError } from "@/browser/components/PopoverError/PopoverError"; import { getAgentIdKey, - getModelKey, getReasoningModeKey, getAutoRoutingChoiceByAgentKey, getThinkingLevelKey, @@ -52,7 +51,12 @@ import { import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getSendOptionsFromStorage } from "@/browser/utils/messages/sendOptions"; -import { applyAutoRoutingOutcome, setWorkspaceModelWithOrigin } from "@/browser/utils/modelChange"; +import { + consumeAiSelectionIntent, + getAiSelectionIntentForSendOptions, +} from "@/browser/utils/aiSelectionIntent"; +import { applyAutoRoutingOutcome } from "@/browser/utils/modelChange"; +import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import { useExperimentValue } from "@/browser/hooks/useExperiments"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { @@ -166,10 +170,8 @@ function isTypingInNonEmptyField(element: Element | null): boolean { /** Resolved (not yet persisted) AI settings for a plan action's target agent. */ interface TargetAgentSettings { - resolvedModel: string; resolvedThinking: ThinkingLevel; resolvedReasoningMode: OpenAIReasoningMode; - existingModel: string; existingThinking: ThinkingLevel; existingReasoning: OpenAIReasoningMode; autoRouting: AutoRoutingOutcome; @@ -525,7 +527,7 @@ export const ProposePlanToolCall: React.FC = (props) = }; // User request: propose_plan primary actions send immediately after agent switch. - // Resolve and persist model/thinking synchronously here so the follow-up message + // Resolve and persist thinking and reasoning synchronously here so the follow-up message // uses the target agent defaults instead of stale planning-mode preferences. /** * The AI settings a plan action switches the workspace to. Pure read: nothing is persisted @@ -536,12 +538,10 @@ export const ProposePlanToolCall: React.FC = (props) = workspaceId: string; targetAgentId: "auto" | "exec"; }): TargetAgentSettings => { - const modelKey = getModelKey(args.workspaceId); const thinkingKey = getThinkingLevelKey(args.workspaceId); const reasoningKey = getReasoningModeKey(args.workspaceId); const fallbackModel = getDefaultModel(); - const existingModel = readPersistedState(modelKey, fallbackModel); const existingThinking = readPersistedState(thinkingKey, "off"); const existingReasoning = readPersistedState(reasoningKey, "standard"); const agentAiDefaults: AgentAiDefaults = @@ -552,20 +552,21 @@ export const ProposePlanToolCall: React.FC = (props) = ); const agentBaseById = new Map(agents.map((agent) => [agent.id, agent.base])); - const { resolvedModel, resolvedThinking, resolvedReasoningMode } = - resolveWorkspaceAiSettingsForAgent({ - agentId: args.targetAgentId, - agentAiDefaults, - // Propose-plan actions are explicit mode switches; honor any per-agent - // workspace override before inheriting the previously active plan settings. - workspaceByAgent, - useWorkspaceByAgentFallback: true, - fallbackModel, - existingModel, - existingThinking, - existingReasoningMode: existingReasoning, - agentBaseById, - }); + // The resolver owns the model; only thinking and reasoning come from here. + const { resolvedThinking, resolvedReasoningMode } = resolveWorkspaceAiSettingsForAgent({ + agentId: args.targetAgentId, + agentAiDefaults, + // Propose-plan actions are explicit mode switches; honor any per-agent + // workspace override before inheriting the previously active plan settings. + workspaceByAgent, + useWorkspaceByAgentFallback: true, + fallbackModel, + existingModel: getWorkspaceAiSelection(args.workspaceId, args.targetAgentId, agentBaseById) + .model, + existingThinking, + existingReasoningMode: existingReasoning, + agentBaseById, + }); const autoRouting = resolveAutoRoutingForAgent({ agentId: args.targetAgentId, agentAiDefaults, @@ -579,10 +580,8 @@ export const ProposePlanToolCall: React.FC = (props) = }); return { - resolvedModel, resolvedThinking, resolvedReasoningMode, - existingModel, existingThinking, existingReasoning, autoRouting, @@ -598,9 +597,6 @@ export const ProposePlanToolCall: React.FC = (props) = const { settings } = args; updatePersistedState(getAgentIdKey(args.workspaceId), args.targetAgentId); - if (settings.existingModel !== settings.resolvedModel) { - setWorkspaceModelWithOrigin(args.workspaceId, settings.resolvedModel, "agent"); - } if (settings.existingThinking !== settings.resolvedThinking) { updatePersistedState(getThinkingLevelKey(args.workspaceId), settings.resolvedThinking); } @@ -655,23 +651,33 @@ export const ProposePlanToolCall: React.FC = (props) = }); } persistTargetAgentSettings({ workspaceId, targetAgentId: args.targetAgentId, settings }); + // The target agent's resolved model (its saved or configured settings). const sendMessageOptions = getSendOptionsFromStorage(workspaceId); + const options = { + ...sendMessageOptions, + agentId: args.targetAgentId, + thinkingLevel: settings.resolvedThinking, + // The target agent's model and thinking level are explicit; classifying + // "Implement the plan" would reroute them based on a prompt that says nothing + // about the plan's difficulty. + autoModelRouting: false, + autoThinkingLevel: false, + }; + // Picks the send carries, consumed like a composer send's. + const { attachedTokens } = getAiSelectionIntentForSendOptions( + workspaceId, + args.targetAgentId, + options + ); const sendResult = await api.workspace.sendMessage({ workspaceId, message: "Implement the plan", - options: { - ...sendMessageOptions, - agentId: args.targetAgentId, - model: settings.resolvedModel, - thinkingLevel: settings.resolvedThinking, - // The target agent's model and thinking level are explicit; classifying - // "Implement the plan" would reroute them based on a prompt that says nothing - // about the plan's difficulty. - autoModelRouting: false, - autoThinkingLevel: false, - }, + options, }); - if (sendResult.success) return null; + if (sendResult.success) { + consumeAiSelectionIntent(workspaceId, args.targetAgentId, attachedTokens); + return null; + } const formatted = formatSendMessageError(sendResult.error); return formatted.resolutionHint ? `${formatted.message} ${formatted.resolutionHint}` diff --git a/src/browser/hooks/useContextSwitchWarning.test.ts b/src/browser/hooks/useContextSwitchWarning.test.ts index 199581441c3..f53a4c6b63a 100644 --- a/src/browser/hooks/useContextSwitchWarning.test.ts +++ b/src/browser/hooks/useContextSwitchWarning.test.ts @@ -13,8 +13,10 @@ import { recordWorkspaceModelChange, setWorkspaceModelWithOrigin, } from "@/browser/utils/modelChange"; -import { getModelKey } from "@/common/constants/storage"; -import { readPersistedState } from "@/browser/hooks/usePersistedState"; +import { + getPendingAiSelection, + resetAiSelectionIntentForTests, +} from "@/browser/utils/aiSelectionIntent"; import { createTestApiClient } from "@/browser/testUtils"; async function* emptyStream() { @@ -371,16 +373,14 @@ describe("useContextSwitchWarning", () => { result.current.handleModelChange(coderModel); }); - expect(readPersistedState(getModelKey(props.workspaceId), null)).toBe( - coderModel - ); + expect(getPendingAiSelection(props.workspaceId, "exec", "model")).toBe(coderModel); // Re-selecting the identical selection stays a no-op. - window.localStorage.clear(); + resetAiSelectionIntentForTests(); act(() => { result.current.handleModelChange(directModel); }); - expect(readPersistedState(getModelKey(props.workspaceId), null)).toBeNull(); + expect(getPendingAiSelection(props.workspaceId, "exec", "model")).toBeUndefined(); }); test("warns when gateway model strings are normalized for explicit switches", async () => { diff --git a/src/browser/hooks/useSendMessageOptions.ts b/src/browser/hooks/useSendMessageOptions.ts index 205a09f6576..029f9c2849f 100644 --- a/src/browser/hooks/useSendMessageOptions.ts +++ b/src/browser/hooks/useSendMessageOptions.ts @@ -1,20 +1,13 @@ -import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { useReasoningMode } from "./useReasoningMode"; import { useThinkingLevel } from "./useThinkingLevel"; import { useAgent } from "@/browser/contexts/AgentContext"; import { usePersistedState } from "./usePersistedState"; -import { - buildSendMessageOptions, - normalizeModelPreference, -} from "@/browser/utils/messages/buildSendMessageOptions"; -import { useScopedAiDefault } from "@/browser/utils/scopedAiDefaults"; +import { buildSendMessageOptions } from "@/browser/utils/messages/buildSendMessageOptions"; import type { SendMessageOptions } from "@/common/orpc/types"; import { useProviderOptions } from "./useProviderOptions"; import { useExperimentValue } from "./useExperiments"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; -import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; -import { useAppConfig } from "@/browser/stores/AppConfigStore"; -import { resolveEffectiveComposerModel } from "@/browser/utils/workspaceAiSettingsSync"; +import { useWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import { getAutoRoutingKey, setAutoRoutingChoice, @@ -55,27 +48,18 @@ export function useAutoRoutingSelection( export function useSendMessageOptions(workspaceId: string): SendMessageOptionsWithBase { const [thinkingLevel] = useThinkingLevel(); const [reasoningMode] = useReasoningMode(); - const { agentId, disableWorkspaceAgents } = useAgent(); - const { workspaceMetadata } = useWorkspaceContext(); + const { agentId, agents, disableWorkspaceAgents } = useAgent(); const { options: providerOptions } = useProviderOptions(); - const defaultModelPref = useAppConfig((config) => config.defaultModel); - const defaultModel = normalizeModelPreference(defaultModelPref, WORKSPACE_DEFAULTS.model); - - // Workspace-scoped model preference. If unset, fall back to metadata, then global default. - const preferredModel = useScopedAiDefault(workspaceId, "model") ?? null; + const baseModel = useWorkspaceAiSelection( + workspaceId, + agentId, + new Map(agents.map((agent) => [agent.id, agent.base])) + ).model; const [autoModelRouting] = useAutoRoutingSelection(workspaceId, "model"); const [autoThinkingLevel] = useAutoRoutingSelection(workspaceId, "thinkingLevel"); - // Prefer metadata over the global default until workspace localStorage seeding catches up. - const baseModel = resolveEffectiveComposerModel( - preferredModel, - workspaceMetadata.get(workspaceId), - agentId, - defaultModel - ); - const options = buildSendMessageOptions({ agentId, thinkingLevel, diff --git a/src/browser/hooks/useWorkspaceFallbackModel.ts b/src/browser/hooks/useWorkspaceFallbackModel.ts index 61256b08618..a0727cbec28 100644 --- a/src/browser/hooks/useWorkspaceFallbackModel.ts +++ b/src/browser/hooks/useWorkspaceFallbackModel.ts @@ -1,29 +1,7 @@ -import { usePersistedState } from "./usePersistedState"; import { normalizeToCanonical } from "@/common/utils/ai/models"; -import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; -import { getModelKey } from "@/common/constants/storage"; -import { useAppConfig } from "@/browser/stores/AppConfigStore"; +import { useWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; -/** - * Resolves the effective model for a workspace by combining the global default - * model preference with the workspace-scoped preference. - * - * This subscribes to both storage keys with `{ listener: true }` so changes - * (including backend-seeded values on fresh origins) propagate immediately. - */ +/** The workspace's effective model for its selected agent, in canonical form. */ export function useWorkspaceFallbackModel(workspaceId: string): string { - const defaultModelPref = useAppConfig((config) => config.defaultModel) ?? ""; - const defaultModel = normalizeToCanonical(defaultModelPref).trim() || WORKSPACE_DEFAULTS.model; - - // Workspace-scoped model preference. If unset, fall back to the global default model. - // Note: we intentionally *don't* pass defaultModel as the usePersistedState initialValue; - // initialValue is sticky and would lock in the fallback before startup seeding. - const [preferredModel] = usePersistedState(getModelKey(workspaceId), null, { - listener: true, - }); - - if (typeof preferredModel === "string" && preferredModel.trim().length > 0) { - return normalizeToCanonical(preferredModel.trim()); - } - return defaultModel; + return normalizeToCanonical(useWorkspaceAiSelection(workspaceId).model); } diff --git a/src/browser/stories/meta.tsx b/src/browser/stories/meta.tsx index a0e31574450..d8065c48255 100644 --- a/src/browser/stories/meta.tsx +++ b/src/browser/stories/meta.tsx @@ -13,6 +13,7 @@ import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip"; import type { APIClient } from "@/browser/contexts/API"; import { ThemeProvider } from "@/browser/contexts/ThemeContext"; import { getAppConfigStore } from "@/browser/stores/AppConfigStore"; +import { resetAiSelectionIntentForTests } from "@/browser/utils/aiSelectionIntent"; import { SELECTED_WORKSPACE_KEY } from "@/common/constants/storage"; // ═══════════════════════════════════════════════════════════════════════════════ @@ -84,6 +85,8 @@ function resetStorybookPersistedStateForStory(): void { } // Startup waits for this story's own snapshot instead of the previous story's. getAppConfigStore().updateOptimistically({ experiments: undefined, userPreferences: undefined }); + // Unsent AI picks live in memory, and the test-runner renders all stories of a file in one page. + resetAiSelectionIntentForTests(); } function getStorybookRenderKey(): string | null { if (typeof window === "undefined") { diff --git a/src/browser/utils/aiSelectionIntent.test.ts b/src/browser/utils/aiSelectionIntent.test.ts index 88887dc58c3..b43219e8d0b 100644 --- a/src/browser/utils/aiSelectionIntent.test.ts +++ b/src/browser/utils/aiSelectionIntent.test.ts @@ -7,9 +7,11 @@ import { consumeAiSelectionIntent, getAiSelectionIntentForSend, getAiSelectionIntentForSendOptions, + getPendingAiSelection, hasPendingAiSelectionIntent, markAiSelectionIntent, resetAiSelectionIntentForTests, + setWorkspaceAiMetadata, } from "@/browser/utils/aiSelectionIntent"; import { getAgentIdKey } from "@/common/constants/storage"; @@ -17,6 +19,10 @@ const WS = "intent-ws"; const MODEL_A = "openai:gpt-5.2"; const MODEL_B = "anthropic:claude-sonnet-4-5"; +function saveExecModel(model: string): void { + setWorkspaceAiMetadata(WS, { aiSettingsByAgent: { exec: { model, thinkingLevel: "off" } } }); +} + describe("aiSelectionIntent", () => { beforeEach(() => { saveDomGlobals(); @@ -68,10 +74,29 @@ describe("aiSelectionIntent", () => { markAiSelectionIntent(WS, "model", MODEL_A); const latest = getAiSelectionIntentForSend(WS, "exec", { model: MODEL_A }); expect(latest.attachedTokens.model).not.toBe(staleTokens.model); + saveExecModel(MODEL_A); consumeAiSelectionIntent(WS, "exec", latest.attachedTokens); expect(getAiSelectionIntentForSend(WS, "exec", { model: MODEL_A }).intent).toBeUndefined(); }); + test("a sent pick lasts until the saved bucket holds it; an unsent pick outlasts it", () => { + saveExecModel(MODEL_A); + markAiSelectionIntent(WS, "model", MODEL_B); + consumeAiSelectionIntent( + WS, + "exec", + getAiSelectionIntentForSend(WS, "exec", { model: MODEL_B }).attachedTokens + ); + // The save is in flight or failed: the composer keeps the sent pick. + expect(getPendingAiSelection(WS, "exec", "model")).toBe(MODEL_B); + saveExecModel(MODEL_B); + expect(getPendingAiSelection(WS, "exec", "model")).toBeUndefined(); + + markAiSelectionIntent(WS, "model", MODEL_A); + saveExecModel(MODEL_A); + expect(getPendingAiSelection(WS, "exec", "model")).toBe(MODEL_A); + }); + test("a pick scoped to Plan does not apply to Exec", () => { updatePersistedState(getAgentIdKey(WS), "plan"); markAiSelectionIntent(WS, "model", MODEL_A); @@ -97,6 +122,9 @@ describe("aiSelectionIntent", () => { }); expect(autoModel.intent).toEqual({ thinkingLevel: true }); // Only attached fields are consumed; the model pick stays pending. + setWorkspaceAiMetadata(WS, { + aiSettingsByAgent: { exec: { model: MODEL_B, thinkingLevel: "high" } }, + }); consumeAiSelectionIntent(WS, "exec", autoModel.attachedTokens); expect(getAiSelectionIntentForSend(WS, "exec", sent).intent).toEqual({ model: true }); diff --git a/src/browser/utils/aiSelectionIntent.ts b/src/browser/utils/aiSelectionIntent.ts index 4c71579dd01..780acc17876 100644 --- a/src/browser/utils/aiSelectionIntent.ts +++ b/src/browser/utils/aiSelectionIntent.ts @@ -24,6 +24,8 @@ export type AiSelectionTokens = Partial>; interface PendingSelection { value: string; token: number; + /** A successful send carried it: it ends once the agent's saved bucket holds it. */ + sent?: true; } export type WorkspaceAiMetadata = Pick< @@ -57,6 +59,19 @@ export function setWorkspaceAiMetadata(workspaceId: string, metadata: WorkspaceA const previous = metadataByWorkspace.get(workspaceId); if (JSON.stringify(previous) === JSON.stringify(metadata)) return; metadataByWorkspace.set(workspaceId, metadata); + for (const agentId of Object.keys(metadata.aiSettingsByAgent ?? {})) { + const key = scopeKey(workspaceId, agentId); + const pending = pendingByScope.get(key); + if (pending == null) continue; + for (const field of Object.keys(pending) as AiSelectionField[]) { + const selection = pending[field]; + // An unsent pick stays the composer's value even when it equals the saved one. + if (selection?.sent === true && isSavedPick(workspaceId, agentId, field, selection.value)) { + delete pending[field]; + } + } + if (Object.keys(pending).length === 0) pendingByScope.delete(key); + } notify(); } @@ -89,6 +104,16 @@ function normalizeAgent(agentId: string): string { return agentId.trim().toLowerCase() || WORKSPACE_DEFAULTS.agentId; } +function isSavedPick( + workspaceId: string, + agentId: string, + field: AiSelectionField, + value: string +): boolean { + const saved = metadataByWorkspace.get(workspaceId)?.aiSettingsByAgent?.[normalizeAgent(agentId)]; + return saved != null && comparable(field, saved[field]) === value; +} + function scopeKey(workspaceId: string, agentId: string): string { assert(workspaceId.length > 0, "aiSelectionIntent: workspaceId must be non-empty"); return `${workspaceId}\u0000${normalizeAgent(agentId)}`; @@ -193,7 +218,10 @@ export function getAiSelectionIntentForSendOptions( }; } -/** Clears attached picks after a successful send, unless the user re-picked meanwhile. */ +/** + * After a successful send: an attached pick ends once the saved bucket holds it, so a save + * still in flight or failed keeps it. A re-pick made meanwhile has a new token and survives. + */ export function consumeAiSelectionIntent( workspaceId: string, agentId: string, @@ -204,7 +232,13 @@ export function consumeAiSelectionIntent( if (pending == null) return; const next = { ...pending }; for (const field of Object.keys(attachedTokens) as AiSelectionField[]) { - if (next[field]?.token === attachedTokens[field]) delete next[field]; + const selection = next[field]; + if (selection == null || selection.token !== attachedTokens[field]) continue; + if (isSavedPick(workspaceId, agentId, field, selection.value)) { + delete next[field]; + } else { + next[field] = { ...selection, sent: true }; + } } if (Object.keys(next).length === 0) { pendingByScope.delete(key); @@ -243,10 +277,12 @@ export function copyPendingAiSelection(sourceWorkspaceId: string, destWorkspaceI notify(); } -/** Test-only: forget all pending picks and metadata. */ -export function resetAiSelectionIntentForTests(): void { +/** Forgets all pending picks and metadata: another server can reuse a workspace ID. */ +export function clearAiSelectionState(): void { pendingByScope.clear(); metadataByWorkspace.clear(); agentBasesByScope.clear(); notify(); } + +export const resetAiSelectionIntentForTests = clearAiSelectionState; diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 1ef495e57a3..8c103182ddd 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -8,7 +8,6 @@ import type { ProjectConfig } from "@/node/config"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { GlobalWindow } from "happy-dom"; -import { getModelKey } from "@/common/constants/storage"; import { markAiSelectionIntent, resetAiSelectionIntentForTests, @@ -1165,7 +1164,6 @@ test("goal set objective prompt blocks budgeted goals on unpriced selected model const originalDocument = globalThis.document; globalThis.window = testWindow as unknown as Window & typeof globalThis; globalThis.document = testWindow.document as unknown as Document; - window.localStorage.setItem(getModelKey("w1"), JSON.stringify("custom:unpriced-model")); markAiSelectionIntent("w1", "model", "custom:unpriced-model"); try { diff --git a/src/browser/utils/messages/sendOptions.test.ts b/src/browser/utils/messages/sendOptions.test.ts index 9c12a667377..d3e898e7a93 100644 --- a/src/browser/utils/messages/sendOptions.test.ts +++ b/src/browser/utils/messages/sendOptions.test.ts @@ -6,7 +6,6 @@ import { updatePersistedState } from "@/browser/hooks/usePersistedState"; import { getAutoModelRoutingKey, getAutoThinkingLevelKey, - getModelKey, getProjectScopeId, getThinkingLevelByModelKey, getThinkingLevelKey, @@ -79,7 +78,6 @@ describe("getSendOptionsFromStorage", () => { const workspaceId = "ws-1"; const rawModel = "mux-gateway:anthropic/claude-haiku-4-5"; - window.localStorage.setItem(getModelKey(workspaceId), JSON.stringify(rawModel)); setWorkspaceAiMetadata(workspaceId, { aiSettingsByAgent: { exec: { model: rawModel, thinkingLevel: "off" } }, }); diff --git a/src/browser/utils/messages/sendOptions.ts b/src/browser/utils/messages/sendOptions.ts index a9f3d2649fc..76a8b02a2a5 100644 --- a/src/browser/utils/messages/sendOptions.ts +++ b/src/browser/utils/messages/sendOptions.ts @@ -7,11 +7,7 @@ import { getDisableWorkspaceAgentsKey, } from "@/common/constants/storage"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { getDefaultModel } from "@/browser/hooks/useModelsFromSettings"; -import { - buildSendMessageOptions, - normalizeModelPreference, -} from "@/browser/utils/messages/buildSendMessageOptions"; +import { buildSendMessageOptions } from "@/browser/utils/messages/buildSendMessageOptions"; import type { SendMessageOptions } from "@/common/orpc/types"; import { coerceOpenAIReasoningMode, @@ -22,6 +18,7 @@ import type { MuxProviderOptions } from "@/common/types/providerOptions"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { getAppConfigStore, getUserPreferences } from "@/browser/stores/AppConfigStore"; import { getServerScope, readScopedAiDefault } from "@/browser/utils/scopedAiDefaults"; +import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { migrateGlobalToPerModel } from "@/browser/contexts/ProviderOptionsContext"; @@ -38,9 +35,8 @@ function getProviderOptions(): MuxProviderOptions { * Used by compaction, resume, idle-compaction, and plan execution outside React context. */ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptions { - const defaultModel = getDefaultModel(); - const rawModel = readScopedAiDefault(workspaceId, "model") ?? defaultModel; - const baseModel = normalizeModelPreference(rawModel, defaultModel); + const agentId = readScopedAiDefault(workspaceId, "agentId") ?? WORKSPACE_DEFAULTS.agentId; + const baseModel = getWorkspaceAiSelection(workspaceId, agentId).model; // Read thinking level (workspace-scoped). // Migration: if the workspace-scoped value is missing, fall back to legacy per-model storage @@ -63,8 +59,6 @@ export function getSendOptionsFromStorage(workspaceId: string): SendMessageOptio updatePersistedState(scopedKey, thinkingLevel); } - const agentId = readScopedAiDefault(workspaceId, "agentId") ?? WORKSPACE_DEFAULTS.agentId; - // OpenAI pro reasoning mode (workspace-scoped); absent = standard. // Coerce untrusted persisted values so corrupt entries self-heal to "standard" // instead of failing SendMessageOptionsSchema on retry/resume/creation flows. diff --git a/src/browser/utils/modelChange.test.ts b/src/browser/utils/modelChange.test.ts index aedf99c4090..b7a2f202dec 100644 --- a/src/browser/utils/modelChange.test.ts +++ b/src/browser/utils/modelChange.test.ts @@ -3,7 +3,7 @@ import { restoreDomGlobals, saveDomGlobals } from "../../../tests/ui/domGlobals" import { GlobalWindow } from "happy-dom"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; -import { markAiSelectionIntent } from "@/browser/utils/aiSelectionIntent"; +import { getPendingAiSelection, markAiSelectionIntent } from "@/browser/utils/aiSelectionIntent"; import { consumeWorkspaceModelChange, recordAutoRoutingChoiceForAgent, @@ -53,6 +53,12 @@ describe("modelChange", () => { expect(readPersistedState(autoKey, false)).toBe(false); }); + test("a legacy workspace id that starts with __ records the pick as unsent", () => { + const legacyId = "__proj-main"; + setWorkspaceModelWithOrigin(legacyId, "openai:gpt-5.2-codex", "user"); + expect(getPendingAiSelection(legacyId, "exec", "model")).toBe("openai:gpt-5.2-codex"); + }); + test("records workspace routing picks per agent without a local experiment override", () => { const workspaceId = nextWorkspaceId(); updatePersistedState(getAgentIdKey(workspaceId), "plan"); @@ -70,7 +76,6 @@ describe("modelChange", () => { const model = "openai:gpt-5.2-codex"; const otherModel = "anthropic:claude-sonnet-4-5"; - setWorkspaceModelWithOrigin(workspaceId, model, "sync"); markAiSelectionIntent(workspaceId, "model", model); // Simulate user selecting the already-active model. diff --git a/src/browser/utils/modelChange.ts b/src/browser/utils/modelChange.ts index 478322b050b..d5362bbb47c 100644 --- a/src/browser/utils/modelChange.ts +++ b/src/browser/utils/modelChange.ts @@ -4,6 +4,7 @@ import { getAutoModelRoutingKey, getAutoRoutingChoiceByAgentKey, getAutoThinkingLevelKey, + isNonWorkspaceScopeId, } from "@/common/constants/storage"; import { modelSelectionEqualityKey } from "@/common/utils/ai/models"; import { normalizeAgentId } from "@/common/utils/agentIds"; @@ -11,6 +12,8 @@ import type { ThinkingLevel } from "@/common/types/thinking"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { readScopedAiDefault, writeScopedAiDefault } from "@/browser/utils/scopedAiDefaults"; +import { markAiSelectionIntent } from "@/browser/utils/aiSelectionIntent"; +import { getWorkspaceAiSelection } from "@/browser/utils/workspaceAiSettingsSync"; import type { AutoRoutingChoiceByAgent, AutoRoutingOutcome } from "@/browser/utils/workspaceModeAi"; import { withRecordEntry } from "@/browser/utils/boundedPersistedValue"; @@ -30,15 +33,19 @@ const pendingExplicitChanges = new Map(); // aliases (mux-gateway:openai/x) still collapse so persisted rewrites keep matching. const normalizeExplicitModel = (model: string): string => modelSelectionEqualityKey(model); +const isWorkspaceScope = (scopeId: string): boolean => !isNonWorkspaceScopeId(scopeId); + export function recordWorkspaceModelChange( workspaceId: string, model: string, - origin: ModelChangeOrigin + origin: ModelChangeOrigin, + current = isWorkspaceScope(workspaceId) + ? getWorkspaceAiSelection(workspaceId).model + : readScopedAiDefault(workspaceId, "model") ): void { if (origin === "sync") return; const normalized = normalizeExplicitModel(model); - const current = readScopedAiDefault(workspaceId, "model"); const normalizedCurrent = current ? normalizeExplicitModel(current) : null; // Avoid leaving stale explicit-change entries when the effective model doesn't change @@ -87,7 +94,12 @@ export function setWorkspaceModelWithOrigin( origin: ModelChangeOrigin ): void { recordWorkspaceModelChange(workspaceId, model, origin); - writeScopedAiDefault(workspaceId, "model", model); + // A workspace pick stays in memory until a send persists it into workspace metadata. + if (!isWorkspaceScope(workspaceId)) { + writeScopedAiDefault(workspaceId, "model", model); + } else if (origin === "user") { + markAiSelectionIntent(workspaceId, "model", model); + } if (origin === "user") { setAutoRoutingChoice(workspaceId, "model", false); } else if (origin === "agent") { diff --git a/src/browser/utils/modelPreferenceRepair.test.ts b/src/browser/utils/modelPreferenceRepair.test.ts index 0b5edd95e4b..50591581ccc 100644 --- a/src/browser/utils/modelPreferenceRepair.test.ts +++ b/src/browser/utils/modelPreferenceRepair.test.ts @@ -12,6 +12,11 @@ import { getWorkspaceAISettingsByAgentKey, } from "@/common/constants/storage"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; +import { + getPendingAiSelection, + markAiSelectionIntent, + resetAiSelectionIntentForTests, +} from "@/browser/utils/aiSelectionIntent"; import { repairLocalModelPreferencesForRemovedProvider } from "./modelPreferenceRepair"; const REMOVED_PROVIDER = "removed-provider"; @@ -57,6 +62,7 @@ describe("repairLocalModelPreferencesForRemovedProvider", () => { updatePersistedState(key, undefined); } touchedKeys.clear(); + resetAiSelectionIntentForTests(); cleanupDom?.(); cleanupDom = null; }); @@ -81,6 +87,17 @@ describe("repairLocalModelPreferencesForRemovedProvider", () => { expect(readString(unaffectedKey)).toBe(unaffectedModel); }); + test("drops unsent model picks only when they belong to the removed provider", () => { + const unaffectedModel = `${OTHER_PROVIDER}:workspace-model`; + markAiSelectionIntent("affected", "model", `${REMOVED_PROVIDER}:workspace-model`); + markAiSelectionIntent("unaffected", "model", unaffectedModel); + + repairLocalModelPreferencesForRemovedProvider(REMOVED_PROVIDER, []); + + expect(getPendingAiSelection("affected", "exec", "model")).toBeUndefined(); + expect(getPendingAiSelection("unaffected", "exec", "model")).toBe(unaffectedModel); + }); + test("clears last custom model provider only when it matches the removed provider", () => { writeState(LAST_CUSTOM_MODEL_PROVIDER_KEY, REMOVED_PROVIDER); diff --git a/src/browser/utils/modelPreferenceRepair.ts b/src/browser/utils/modelPreferenceRepair.ts index ec8023d1519..a68878d6779 100644 --- a/src/browser/utils/modelPreferenceRepair.ts +++ b/src/browser/utils/modelPreferenceRepair.ts @@ -11,6 +11,7 @@ import { } from "@/common/constants/storage"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { modelStringStartsWithProvider } from "@/common/utils/providers/modelString"; +import { dropPendingModelPicks } from "@/browser/utils/aiSelectionIntent"; // Browser repair only: removing a custom provider updates config on the backend, // but per-origin persisted browser preferences can still reference provider-owned models. @@ -83,4 +84,5 @@ export function repairLocalModelPreferencesForRemovedProvider( repairPersistedModelString(getModelKey(workspaceId), provider, WORKSPACE_DEFAULTS.model); repairWorkspaceAISettingsByAgent(workspaceId, provider); } + dropPendingModelPicks((model) => modelStringStartsWithProvider(model, provider)); } diff --git a/tests/ui/rightSidebar/childGoalBudget.test.ts b/tests/ui/rightSidebar/childGoalBudget.test.ts index 10b13fab89d..070d3462a5b 100644 --- a/tests/ui/rightSidebar/childGoalBudget.test.ts +++ b/tests/ui/rightSidebar/childGoalBudget.test.ts @@ -22,13 +22,10 @@ import { } from "../../ipc/helpers"; import { detectDefaultTrunkBranch } from "@/node/git"; -import { - RIGHT_SIDEBAR_TAB_KEY, - getModelKey, - getRightSidebarLayoutKey, -} from "@/common/constants/storage"; +import { RIGHT_SIDEBAR_TAB_KEY, getRightSidebarLayoutKey } from "@/common/constants/storage"; import { UNPRICED_CURRENT_MODEL_GOAL_MESSAGE } from "@/common/utils/goals/budgetPricing"; import { updatePersistedState } from "@/browser/hooks/usePersistedState"; +import { markAiSelectionIntent } from "@/browser/utils/aiSelectionIntent"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { installDom } from "../dom"; @@ -100,7 +97,7 @@ describe("RightSidebar child goal budget (UI)", () => { cleanupDom = installDom(); updatePersistedState(RIGHT_SIDEBAR_TAB_KEY, "goal"); updatePersistedState(getRightSidebarLayoutKey(target.id), null); - updatePersistedState(getModelKey(target.id), UNPRICED_COMPOSER_MODEL); + markAiSelectionIntent(target.id, "model", UNPRICED_COMPOSER_MODEL); view = renderApp({ apiClient: env.orpc, metadata: target }); await setupWorkspaceView(view, target, target.id); const rendered = view; diff --git a/vscode/src/webview/App.test.tsx b/vscode/src/webview/App.test.tsx index 45af66e1247..faf36f0b6e3 100644 --- a/vscode/src/webview/App.test.tsx +++ b/vscode/src/webview/App.test.tsx @@ -1808,7 +1808,7 @@ describe("vscode webview explicit AI-setting persistence", () => { await reply(bridge, OK); }); - test("persists an explicit model pick once, at the next send", async () => { + test("persists an explicit model pick until the host's metadata holds it", async () => { // "low" is below Opus 5.5's built-in minimum (MED): the companion thinking level must be sent // (and so persisted) as stored, not raised to a client-side floor. const { bridge, view } = await open([ @@ -1816,23 +1816,62 @@ describe("vscode webview explicit AI-setting persistence", () => { ]); await pickModel(view, "Opus 5.5"); - const first = await send(bridge, view); - expect(first).toMatchObject({ + const picked = { agentId: "plan", model: "anthropic:claude-opus-5-5", thinkingLevel: "low", skipAiSettingsPersistence: false, aiSelectionIntent: { model: true }, - }); + }; + expect(await send(bridge, view)).toMatchObject(picked); + await reply(bridge, OK); + // The metadata pump has not delivered the saved model yet, so the pick still applies. + expect(await send(bridge, view)).toMatchObject(picked); await reply(bridge, OK); - const second = await send(bridge, view); - expect(second.skipAiSettingsPersistence).toBe(true); - expect(second.aiSelectionIntent).toBeUndefined(); + await bridge.emit({ + type: "workspaces", + workspaces: [mainWorkspace({ model: "anthropic:claude-opus-5-5", thinkingLevel: "low" })], + }); + const saved = await send(bridge, view); + expect(saved.model).toBe("anthropic:claude-opus-5-5"); + expect(saved.skipAiSettingsPersistence).toBe(true); + expect(saved.aiSelectionIntent).toBeUndefined(); await reply(bridge, OK); expect(bridge.orpcCalls("workspace.updateAgentAISettings")).toHaveLength(0); }); + test("drops an unsent pick on a server switch but keeps it across a reconnect", async () => { + // The host posts the server's workspaces, and the chat catches up again, after each connection. + async function reconnect(bridge: TestBridge, baseUrl: string) { + await bridge.emit({ type: "connectionStatus", status: { mode: "api", baseUrl } }); + await bridge.emit({ type: "workspaces", workspaces: [mainWorkspace(TERRA_HIGH)] }); + await bridge.emit({ + type: "chatEvent", + workspaceId: WORKSPACE.id, + event: { type: "caught-up" }, + }); + await emitBackgroundBashes(bridge, WORKSPACE.id); + } + const { bridge, view } = await open([mainWorkspace(TERRA_HIGH)]); + await pickModel(view, "Opus 5.5"); + + await bridge.emit({ type: "connectionStatus", status: { mode: "file" } }); + await reconnect(bridge, "http://x"); + expect(await send(bridge, view)).toMatchObject({ + model: "anthropic:claude-opus-5-5", + aiSelectionIntent: { model: true }, + }); + await reply(bridge, OK); + + // Another server with a workspace of the same ID saves another model. + await reconnect(bridge, "http://y"); + const options = await send(bridge, view); + expect(options.model).toBe(TERRA_HIGH.model); + expect(options.aiSelectionIntent).toBeUndefined(); + await reply(bridge, OK); + }); + test("persists only the last of several rapid picks", async () => { const { bridge, view } = await open([mainWorkspace(TERRA_HIGH)]); await pickModel(view, "Sonnet 5.5"); diff --git a/vscode/src/webview/App.tsx b/vscode/src/webview/App.tsx index 36c0d3be86c..2682ce31d0b 100644 --- a/vscode/src/webview/App.tsx +++ b/vscode/src/webview/App.tsx @@ -38,6 +38,7 @@ import { import { useChatViewDataReadyDeadline } from "xum/browser/components/ChatPane/useChatViewDataReady"; import { mergeConsecutiveStreamErrors } from "xum/browser/utils/messages/messageUtils"; import { seedWorkspaceLocalStorageFromBackend } from "xum/browser/contexts/WorkspaceContext"; +import { clearAiSelectionState } from "xum/browser/utils/aiSelectionIntent"; import { WorkspaceModeAISync } from "xum/browser/components/WorkspaceModeAISync/WorkspaceModeAISync"; import { resolvePersistedAgentId } from "xum/common/utils/agentIds"; import { @@ -301,6 +302,7 @@ function WebviewApp(props: { bridge: VscodeBridge; apiClient: APIClient }): JSX. const activeWorkspaceIdRef = useRef(null); const connectionKeyRef = useRef(null); + const lastApiConnectionKeyRef = useRef(null); activeWorkspaceIdRef.current = selectedWorkspaceId; const chatReplayStateRef = useRef(null); @@ -533,6 +535,15 @@ function WebviewApp(props: { bridge: VscodeBridge; apiClient: APIClient }): JSX. backgroundBashStore.setClient(null); backgroundBashStore.clearCachedState(); } + // AI picks and metadata belong to one server: a switch to another server drops them + // before its workspaces arrive, a reconnect to the same server keeps them. + if (nextConnectionKey !== null) { + const lastApiConnectionKey = lastApiConnectionKeyRef.current; + if (lastApiConnectionKey !== null && lastApiConnectionKey !== nextConnectionKey) { + clearAiSelectionState(); + } + lastApiConnectionKeyRef.current = nextConnectionKey; + } setConnectionStatus(msg.status); return; } diff --git a/vscode/src/webview/ChatComposer.tsx b/vscode/src/webview/ChatComposer.tsx index ef3724f905b..0c23dcf6806 100644 --- a/vscode/src/webview/ChatComposer.tsx +++ b/vscode/src/webview/ChatComposer.tsx @@ -13,6 +13,7 @@ import { useThinkingLevel } from "xum/browser/hooks/useThinkingLevel"; import { useReasoningMode } from "xum/browser/hooks/useReasoningMode"; import type { WorkspaceAISettingsCache } from "xum/browser/utils/workspaceModeAi"; import { normalizeAgentId } from "xum/common/utils/agentIds"; +import { useWorkspaceAiSelection } from "xum/browser/utils/workspaceAiSettingsSync"; import { ThinkingProvider } from "xum/browser/contexts/ThinkingContext"; import { usePersistedState, updatePersistedState } from "xum/browser/hooks/usePersistedState"; import { useUserPreferences } from "xum/browser/stores/AppConfigStore"; @@ -42,11 +43,7 @@ import { COMPOSER_CONTROL_HEIGHT_CLASS, COMPOSER_WORKSPACE_ICON_ONLY_HIDE_CLASS, } from "xum/constants/layout"; -import { - getInputKey, - getModelKey, - getWorkspaceAISettingsByAgentKey, -} from "xum/common/constants/storage"; +import { getInputKey, getWorkspaceAISettingsByAgentKey } from "xum/common/constants/storage"; const SEND_MESSAGE_TIMEOUT_MS = 30_000; @@ -128,16 +125,9 @@ function ChatComposerInner(props: { setDefaultModel, } = useModelsFromSettings(); - const modelKey = getModelKey(props.workspaceId); - // Not defaultModel as the initial value: it is sticky, and the default arrives with the config, - // which can load after the composer mounts. Sends read the live default too. - const [preferredModel, setPreferredModel] = usePersistedState(modelKey, null, { - listener: true, - }); - // Gateway-preserving, like the desktop composer: an explicit gateway pick (e.g. // openrouter:openai/gpt-5) stays selected instead of showing as its direct-provider model. - const storedModel = normalizeSelectedModel(preferredModel ?? defaultModel); + const storedModel = useWorkspaceAiSelection(props.workspaceId, agentId).model; const inputKey = getInputKey(props.workspaceId); const [input, setInput] = usePersistedState(inputKey, "", { listener: true }); @@ -202,10 +192,9 @@ function ChatComposerInner(props: { const selectedModel = normalizeSelectedModel(model); ensureModelInSettings(selectedModel); markAiSelectionIntent(props.workspaceId, "model", selectedModel); - setPreferredModel(selectedModel); - // Like the desktop composer, record the pick in the active agent's cache so - // WorkspaceModeAISync restores it (not the seeded model) after switching agents and back. + // Like the desktop composer, keep the active agent's cache current: WorkspaceModeAISync + // still restores thinking and reasoning from it after switching agents and back. updatePersistedState( getWorkspaceAISettingsByAgentKey(props.workspaceId), (prev) => ({