diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 8014f1125719a..07eebd9e891c4 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -69,6 +69,7 @@ import { serverConfigForCwd, type ModelOption, type ProviderGroup, + withStoredThreadModelOptions, } from "../../lib/modelOptions"; import { applyProviderOptionSelection } from "../../lib/providerOptions"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; @@ -1409,7 +1410,14 @@ export function ExistingThreadSettingsRouteScreen({ prompt={prompt} onPromptChange={(text) => setComposerDraftText(draftKey, text)} reportedModelSelection={reportedModelSelection} - onSelectModel={(option) => updateModelSelection(withRememberedModelOptions(option.selection))} + onSelectModel={(option) => + updateModelSelection( + withStoredThreadModelOptions( + withRememberedModelOptions(option.selection), + selectedThread.modelSelection, + ), + ) + } restoreSelection={withRememberedModelOptions} optionDescriptors={resolveProviderOptionDescriptors({ capabilities: currentModelOption?.capabilities, diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index bf03fed351076..73ce33f99dbf1 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -18,6 +18,7 @@ import { resolveDefaultableModelSelection, resolveNewTaskModelSelection, resolveSelectableModelSelection, + withStoredThreadModelOptions, type ModelOption, } from "./modelOptions"; @@ -144,6 +145,27 @@ describe("mobile model options", () => { expect(explicitOption?.selection.options).toEqual([{ id: "serviceTier", value: "priority" }]); }); + it("carries a thread's stored options onto an optionless pick of its own model", () => { + const claude = ProviderInstanceId.make("claudeAgent"); + const stored: ModelSelection = { + instanceId: claude, + model: "claude-opus-5-5", + options: [{ id: "effort", value: "high" }], + }; + expect( + withStoredThreadModelOptions({ instanceId: claude, model: stored.model }, stored), + ).toEqual(stored); + const remembered: ModelSelection = { ...stored, options: [{ id: "effort", value: "low" }] }; + expect(withStoredThreadModelOptions(remembered, stored)).toBe(remembered); + const otherInstance: ModelSelection = { + instanceId: ProviderInstanceId.make("claude_work"), + model: stored.model, + }; + expect(withStoredThreadModelOptions(otherInstance, stored)).toBe(otherInstance); + const otherModel: ModelSelection = { instanceId: claude, model: "claude-sonnet-5-5" }; + expect(withStoredThreadModelOptions(otherModel, stored)).toBe(otherModel); + }); + it("limits existing threads to their provider while new tasks keep every provider", () => { const providers = ["codex", "claudeAgent"].map((instanceId) => ({ instanceId, diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index 222d63a010408..e68a84b585417 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -287,6 +287,22 @@ export function resolveNewTaskModelSelection(input: { ); } +// Rows built after a pick on another instance carry no options for the thread's own model. +export function withStoredThreadModelOptions( + selection: ModelSelection, + stored: ModelSelection, +): ModelSelection { + if ( + (selection.options?.length ?? 0) > 0 || + !stored.options?.length || + selection.instanceId !== stored.instanceId || + selection.model !== stored.model + ) { + return selection; + } + return { ...selection, options: stored.options }; +} + export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, diff --git a/apps/web/src/components/ChatView.modelPick.browser.test.tsx b/apps/web/src/components/ChatView.modelPick.browser.test.tsx index 384d83009d38f..c157e7972b14a 100644 --- a/apps/web/src/components/ChatView.modelPick.browser.test.tsx +++ b/apps/web/src/components/ChatView.modelPick.browser.test.tsx @@ -1,8 +1,9 @@ import { createMemoryHistory } from "@tanstack/react-router"; +import { ProviderInstanceId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; import { expect, it, vi } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; import { AppRoot } from "~/AppRoot"; // The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines) @@ -36,8 +37,17 @@ vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ // Its own file: the app's shell subscription outlives a test, and it must list this thread. it("a model picked for an existing thread reaches the server before any message is sent", async () => { - const thread = threadWithHistory("thread-model-pick", 1); - using _environment = installFakeEnvironment([thread], { acceptCommands: true }); + const stored = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const thread = { ...threadWithHistory("thread-model-pick", 1), modelSelection: stored }; + const aliasThread = { + ...threadWithHistory("thread-model-alias", 1), + modelSelection: { ...stored, model: "gpt-5-codex" }, + }; + using _environment = installFakeEnvironment([thread, aliasThread], { acceptCommands: true }); __setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt: "2026-01-01T00:00:00.000Z", @@ -52,16 +62,29 @@ it("a model picked for an existing thread reaches the server before any message }); await expect.element(page.getByText("Answer 0 for thread-model-pick.")).toBeVisible(); + const modelSets = () => + dispatchedCommands().filter((command) => command.type === "thread.model-selection.set"); + await page.getByRole("button", { name: "GPT-5.4", exact: true }).click(); await page.getByRole("option", { name: /GPT-5\.5/ }).click(); await vi.waitFor(() => - expect( - dispatchedCommands().filter((command) => command.type === "thread.model-selection.set"), - ).toEqual([ + expect(modelSets()).toEqual([ expect.objectContaining({ threadId: thread.id, - modelSelection: expect.objectContaining({ instanceId: "codex", model: "gpt-5.5" }), + modelSelection: { instanceId: "codex", model: "gpt-5.5" }, }), ]), ); + + // A thread stored under an alias keeps its options when its canonical model is picked. + await userEvent.keyboard("{Escape}"); + await router.navigate({ href: `/${ENVIRONMENT_ID}/${aliasThread.id}` }); + await expect.element(page.getByText("Answer 0 for thread-model-alias.")).toBeVisible(); + await page.getByRole("button", { name: "GPT-5.4", exact: true }).click(); + await page.getByRole("option", { name: /GPT-5\.4/ }).click(); + await vi.waitFor(() => + expect(modelSets().at(-1)).toEqual( + expect.objectContaining({ threadId: aliasThread.id, modelSelection: stored }), + ), + ); }); diff --git a/apps/web/src/components/chat/useComposerSelection.ts b/apps/web/src/components/chat/useComposerSelection.ts index 6641bf0903ff0..01562f0cf536d 100644 --- a/apps/web/src/components/chat/useComposerSelection.ts +++ b/apps/web/src/components/chat/useComposerSelection.ts @@ -23,7 +23,11 @@ import { resolveNewDraftStartFromOrigin, threadHasStarted, } from "../../lib/chatThreadActions"; -import { type DraftThreadEnvMode, useComposerDraftStore } from "../../composerDraftStore"; +import { + type DraftThreadEnvMode, + isSameModel, + useComposerDraftStore, +} from "../../composerDraftStore"; import { getStartedThreadModelChangeBlockReason, deriveLockedProvider, @@ -188,9 +192,23 @@ export function useComposerSelection(input: ComposerSelectionInput) { } const rememberedOptions = useComposerDraftStore.getState().stickyOptionsByModelByProvider[instanceId]?.[resolvedModel]; - const nextModelSelection: ModelSelection = + const threadSelection = activeThread.modelSelection; + // This pick replaces the stored options, so the thread's own model carries them over. + const pickOptions = rememberedOptions !== undefined && rememberedOptions.length > 0 - ? { instanceId, model: resolvedModel, options: [...rememberedOptions] } + ? rememberedOptions + : threadSelection.instanceId === instanceId && + entry !== undefined && + isSameModel( + { providers: providerStatuses, selectedProvider: entry.driver, settings }, + threadSelection, + { instanceId, model: resolvedModel }, + ) + ? threadSelection.options + : undefined; + const nextModelSelection: ModelSelection = + pickOptions !== undefined && pickOptions.length > 0 + ? { instanceId, model: resolvedModel, options: [...pickOptions] } : { instanceId, model: resolvedModel }; const modelChangeBlockReason = getStartedThreadModelChangeBlockReason({ hasStartedSession: activeRuntime !== null, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 0bf2b690b4696..e45d2facbdf78 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1338,23 +1338,32 @@ export function deriveEffectiveComposerModelState(input: { baseSelection?.instanceId ?? activeSelectionInstanceId, baseSelection?.model, ); - // Draft options win; otherwise an existing thread's options, even none, outrank the project's. - // Selections stay with them, so a fallback instance can check which model the options belong to. - const optionSelections = - [ - draft?.modelSelectionByProvider, - draftPicksOtherModel ? null : selectionsByInstance(baseSelection), - ].find((selections) => modelSelectionByProviderToOptions(selections) !== null) ?? null; - const modelOptions = modelSelectionByProviderToOptions(optionSelections); - - return { - selectedModel, - modelOptions: withFallbackInstanceOptions({ + // An existing thread's options, even none, outrank the project's. Next to draft options, they + // fill in only for the model they were stored with. Each source keeps its selections so a + // fallback instance can check which model its options belong to. + const optionsFrom = (optionSelections: Partial> | null) => + withFallbackInstanceOptions({ ...input, - modelOptions, + modelOptions: modelSelectionByProviderToOptions(optionSelections), optionSelections, selectedModel, - }), + }); + const draftOptions = optionsFrom(draft?.modelSelectionByProvider ?? null); + const storedIsShownModel = + baseSelection != null && + isSameModel(input, baseSelection, { + instanceId: input.selectedInstanceId ?? baseSelection.instanceId, + model: selectedModel, + }); + const storedOptions = optionsFrom( + !draftPicksOtherModel && (draftOptions === null || storedIsShownModel) + ? selectionsByInstance(baseSelection) + : null, + ); + + return { + selectedModel, + modelOptions: draftOptions || storedOptions ? { ...storedOptions, ...draftOptions } : null, }; } @@ -1387,17 +1396,41 @@ function withFallbackInstanceOptions(input: { const requestedProvider = input.providers.find( (provider) => provider.instanceId === requestedInstanceId, ); - const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; - const sameModel = - requestedModel === input.selectedModel || - composerModelIdentity(input, requestedInstanceId, requestedModel) === - composerModelIdentity(input, selectedInstanceId, input.selectedModel); + const sameModel = isSameModel( + input, + { + instanceId: requestedInstanceId, + model: input.optionSelections?.[requestedInstanceId]?.model, + }, + { instanceId: selectedInstanceId, model: input.selectedModel }, + ); if (!requestedOptions || requestedProvider?.driver !== input.selectedProvider || !sameModel) { return modelOptions; } return { ...modelOptions, [selectedInstanceId]: requestedOptions }; } +interface InstanceModel { + readonly instanceId: ProviderInstanceId; + readonly model: string | undefined; +} + +export function isSameModel( + input: { + readonly providers: ReadonlyArray; + readonly selectedProvider: ProviderDriverKind; + readonly settings: UnifiedSettings; + }, + a: InstanceModel, + b: InstanceModel, +): boolean { + return ( + a.model === b.model || + composerModelIdentity(input, a.instanceId, a.model) === + composerModelIdentity(input, b.instanceId, b.model) + ); +} + function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 62a74204dd179..70a763f379bab 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -863,6 +863,68 @@ describe("instance-scoped model selection", () => { ).toEqual(createModelSelection(instanceId, "openai/gpt-5.5")); }); + it("keeps a thread's stored options while the draft holds options for another provider", () => { + const codex = ProviderInstanceId.make("codex"); + const claude = ProviderInstanceId.make("claudeAgent"); + const providers = [ + provider({ instanceId: "codex", models: ["gpt-5.5", "gpt-5.4"] }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: "claudeAgent", + models: ["claude-opus-5-5"], + }), + ]; + const stored = createModelSelection(codex, "gpt-5.5", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: true }, + ]); + const claudePick = createModelSelection(claude, "claude-opus-5-5", [ + { id: "effort", value: "max" }, + ]); + const optionsFor = ( + draftSelections: Record>, + threadModelSelection = stored, + ) => + deriveEffectiveComposerModelState({ + threadStarted: true, + draft: { + activeProvider: codex, + modelSelectionByProvider: draftSelections, + modelSelectionExplicit: true, + }, + providers, + selectedProvider: ProviderDriverKind.make("codex"), + selectedInstanceId: codex, + requestedInstanceId: claude, + threadModelSelection, + projectModelSelection: null, + settings: DEFAULT_UNIFIED_SETTINGS, + }).modelOptions; + + expect( + optionsFor({ [claude]: claudePick, [codex]: createModelSelection(codex, "gpt-5.5") }), + ).toEqual({ [claude]: claudePick.options, [codex]: stored.options }); + // Claude turned off after an unsent pick: the composer is back on the thread's instance. + expect(optionsFor({ [claude]: claudePick })?.[codex]).toEqual(stored.options); + const fastOff = createModelSelection(codex, "gpt-5.5", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: false }, + ]); + expect(optionsFor({ [claude]: claudePick, [codex]: fastOff })?.[codex]).toEqual( + fastOff.options, + ); + // A stored alias of the shown model still counts as that model. + const storedAlias = createModelSelection(codex, "gpt-5-codex", stored.options); + expect(optionsFor({ [claude]: claudePick }, storedAlias)?.[codex]).toEqual(stored.options); + const storedOtherModel = createModelSelection(codex, "gpt-5.4", stored.options); + expect( + optionsFor( + { [claude]: claudePick, [codex]: createModelSelection(codex, "gpt-5.5") }, + storedOtherModel, + )?.[codex], + ).toBeUndefined(); + }); + it("keeps the default instance's draft model off a custom instance of the same driver", () => { const driver = ProviderDriverKind.make("opencode"); const defaultInstanceId = ProviderInstanceId.make("opencode");