From 00fc14b6387b06947232a40e480ad7fe79f6a3f1 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 8 Nov 2026 21:30:53 +0000 Subject: [PATCH 1/9] fix(web): composer draft options override only their own provider A draft that held options for any provider hid the thread's and project's stored options for every provider, so switching providers in the composer dropped the stored effort and other options of the thread's own provider. Draft options now win only for their own instance, and the stored options fill the rest. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composerDraftStore.ts | 26 +++++++++-------- apps/web/src/modelSelection.test.ts | 43 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 12 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index aef9e88a38572..1a1ffab9db8a7 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1163,24 +1163,26 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, )) : baseModel; - // Options come from the first of the draft, thread and project that has any. Their selections - // stay with them, so a fallback instance can check which model the options belong to. - const optionSelections = + // Draft options win for their own instance; the thread's, else the project's, fill the rest. + // Each source keeps its selections so a fallback instance can check which model they belong to. + const optionsFrom = (optionSelections: Partial> | null) => + withFallbackInstanceOptions({ + ...input, + modelOptions: modelSelectionByProviderToOptions(optionSelections), + optionSelections, + selectedModel, + }); + const draftOptions = optionsFrom(input.draft?.modelSelectionByProvider ?? null); + const storedOptions = optionsFrom( [ - input.draft?.modelSelectionByProvider, selectionsByInstance(input.threadModelSelection), selectionsByInstance(input.projectModelSelection), - ].find((selections) => modelSelectionByProviderToOptions(selections) !== null) ?? null; - const modelOptions = modelSelectionByProviderToOptions(optionSelections); + ].find((selections) => modelSelectionByProviderToOptions(selections) !== null) ?? null, + ); return { selectedModel, - modelOptions: withFallbackInstanceOptions({ - ...input, - modelOptions, - optionSelections, - selectedModel, - }), + modelOptions: draftOptions || storedOptions ? { ...storedOptions, ...draftOptions } : null, }; } diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 435854d54b393..23c00df358319 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -752,6 +752,49 @@ 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"] }), + 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 = (codexDraft: ReturnType) => + deriveEffectiveComposerModelState({ + draft: { + activeProvider: codex, + modelSelectionByProvider: { [claude]: claudePick, [codex]: codexDraft }, + }, + providers, + selectedProvider: ProviderDriverKind.make("codex"), + selectedInstanceId: codex, + threadModelSelection: stored, + projectModelSelection: null, + settings: DEFAULT_UNIFIED_SETTINGS, + }).modelOptions; + + expect(optionsFor(createModelSelection(codex, "gpt-5.5"))).toEqual({ + [claude]: claudePick.options, + [codex]: stored.options, + }); + const fastOff = createModelSelection(codex, "gpt-5.5", [ + { id: "reasoningEffort", value: "high" }, + { id: "fastMode", value: false }, + ]); + expect(optionsFor(fastOff)?.[codex]).toEqual(fastOff.options); + }); + 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"); From 3cbfffdc4b5e5c935bb2371979cfacbda2128fc1 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Wed, 11 Nov 2026 12:19:20 +0000 Subject: [PATCH 2/9] fix(web): picking a thread's model again keeps its stored effort Main now saves a pick of the thread's own provider straight to the thread. When this browser remembers no options for that model, the pick went out with none and replaced the thread's stored effort. The pick now carries the thread's own options for its own model. The earlier fix in deriveEffectiveComposerModelState no longer has a flow that reaches it, so it is dropped. Co-Authored-By: Claude Opus 5.5 --- .../ChatView.modelPick.browser.test.tsx | 28 +++++++++--- .../components/chat/useComposerSelection.ts | 12 +++++- apps/web/src/composerDraftStore.ts | 28 ++++++------ apps/web/src/modelSelection.test.ts | 43 ------------------- apps/web/test/fakeEnvironment.ts | 4 +- 5 files changed, 49 insertions(+), 66 deletions(-) diff --git a/apps/web/src/components/ChatView.modelPick.browser.test.tsx b/apps/web/src/components/ChatView.modelPick.browser.test.tsx index 384d83009d38f..65e530dd1430e 100644 --- a/apps/web/src/components/ChatView.modelPick.browser.test.tsx +++ b/apps/web/src/components/ChatView.modelPick.browser.test.tsx @@ -1,4 +1,5 @@ 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"; @@ -36,7 +37,12 @@ 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); + const stored = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + options: [{ id: "reasoningEffort", value: "high" }], + }; + const thread = { ...threadWithHistory("thread-model-pick", 1), modelSelection: stored }; using _environment = installFakeEnvironment([thread], { acceptCommands: true }); __setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, @@ -52,16 +58,26 @@ 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"); + + // With no options remembered in this browser, the thread's own model keeps its stored ones. 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()).toEqual([ + expect.objectContaining({ threadId: thread.id, modelSelection: stored }), + ]), + ); + + // The picker stays open beside its effort column. 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().at(-1)).toEqual( expect.objectContaining({ threadId: thread.id, - modelSelection: expect.objectContaining({ instanceId: "codex", model: "gpt-5.5" }), + modelSelection: { instanceId: "codex", model: "gpt-5.5" }, }), - ]), + ), ); }); diff --git a/apps/web/src/components/chat/useComposerSelection.ts b/apps/web/src/components/chat/useComposerSelection.ts index 1563c8c56b68c..dbd24f10e9849 100644 --- a/apps/web/src/components/chat/useComposerSelection.ts +++ b/apps/web/src/components/chat/useComposerSelection.ts @@ -164,9 +164,17 @@ 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 && threadSelection.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 08bdca13518cb..60c485562d0fe 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1165,23 +1165,23 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, )) : baseModel; - // An existing thread's options, even none, outrank the project's. Each source keeps its own - // selections so a fallback instance can check which model its options belong to. - const optionsFrom = (optionSelections: Partial> | null) => - withFallbackInstanceOptions({ - ...input, - modelOptions: modelSelectionByProviderToOptions(optionSelections), - optionSelections, - selectedModel, - }); - const draftOptions = optionsFrom(input.draft?.modelSelectionByProvider ?? null); - const storedOptions = optionsFrom( - selectionsByInstance(input.threadModelSelection ?? input.projectModelSelection), - ); + // 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 = + [ + input.draft?.modelSelectionByProvider, + selectionsByInstance(input.threadModelSelection ?? input.projectModelSelection), + ].find((selections) => modelSelectionByProviderToOptions(selections) !== null) ?? null; + const modelOptions = modelSelectionByProviderToOptions(optionSelections); return { selectedModel, - modelOptions: draftOptions || storedOptions ? { ...storedOptions, ...draftOptions } : null, + modelOptions: withFallbackInstanceOptions({ + ...input, + modelOptions, + optionSelections, + selectedModel, + }), }; } diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 511e7242dab9f..1f112c48e01d7 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -820,49 +820,6 @@ 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"] }), - 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 = (codexDraft: ReturnType) => - deriveEffectiveComposerModelState({ - draft: { - activeProvider: codex, - modelSelectionByProvider: { [claude]: claudePick, [codex]: codexDraft }, - }, - providers, - selectedProvider: ProviderDriverKind.make("codex"), - selectedInstanceId: codex, - threadModelSelection: stored, - projectModelSelection: null, - settings: DEFAULT_UNIFIED_SETTINGS, - }).modelOptions; - - expect(optionsFor(createModelSelection(codex, "gpt-5.5"))).toEqual({ - [claude]: claudePick.options, - [codex]: stored.options, - }); - const fastOff = createModelSelection(codex, "gpt-5.5", [ - { id: "reasoningEffort", value: "high" }, - { id: "fastMode", value: false }, - ]); - expect(optionsFor(fastOff)?.[codex]).toEqual(fastOff.options); - }); - 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"); diff --git a/apps/web/test/fakeEnvironment.ts b/apps/web/test/fakeEnvironment.ts index e878a39653432..ec5e8e7191023 100644 --- a/apps/web/test/fakeEnvironment.ts +++ b/apps/web/test/fakeEnvironment.ts @@ -17,6 +17,7 @@ import { TurnItemId, WS_METHODS, WsRpcGroup, + type ModelSelection, type VcsStatusStreamEvent, type OrchestrationProjectShell, type OrchestrationV2Command, @@ -196,6 +197,7 @@ export interface FakeThread { readonly sideChatOf?: ThreadId; /** Makes this an older settled thread the shell leaves out; only its family read returns it. */ readonly olderSettled?: true; + readonly modelSelection?: ModelSelection; readonly id: ThreadId; readonly title: string; readonly runs: ReadonlyArray; @@ -232,7 +234,7 @@ function shellOf(thread: FakeThread): OrchestrationV2ThreadShell { projectId: PROJECT_ID, title: thread.title, providerInstanceId: CODEX, - modelSelection: MODEL, + modelSelection: thread.modelSelection ?? MODEL, runtimeMode: "full-access", interactionMode: "default", branch: "main", From 384fdd1a8b07f12d025f30915a203517c91c70fa Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Wed, 11 Nov 2026 12:51:05 +0000 Subject: [PATCH 3/9] fix(web): draft options for another provider no longer hide the thread's stored options Brings back the per-instance rule for draft options. An unsent pick on a provider that is then turned off leaves the composer on the thread's instance without a new pick, and the other provider's draft options hid the thread's stored effort. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composerDraftStore.ts | 28 +++++++++--------- apps/web/src/modelSelection.test.ts | 44 +++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 60c485562d0fe..08bdca13518cb 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1165,23 +1165,23 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, )) : baseModel; - // 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 = - [ - input.draft?.modelSelectionByProvider, - selectionsByInstance(input.threadModelSelection ?? input.projectModelSelection), - ].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. Each source keeps its own + // 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(input.draft?.modelSelectionByProvider ?? null); + const storedOptions = optionsFrom( + selectionsByInstance(input.threadModelSelection ?? input.projectModelSelection), + ); + + return { + selectedModel, + modelOptions: draftOptions || storedOptions ? { ...storedOptions, ...draftOptions } : null, }; } diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 1f112c48e01d7..60445c285b720 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -820,6 +820,50 @@ 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"] }), + 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>) => + deriveEffectiveComposerModelState({ + draft: { activeProvider: codex, modelSelectionByProvider: draftSelections }, + providers, + selectedProvider: ProviderDriverKind.make("codex"), + selectedInstanceId: codex, + requestedInstanceId: claude, + threadModelSelection: stored, + 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, + ); + }); + 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"); From 612f5d1caf70b4de55f523c9416332793edd2d48 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Wed, 11 Nov 2026 13:22:14 +0000 Subject: [PATCH 4/9] fix(web): stored options fill in only for the model they were stored with Next to another provider's draft options, a draft that picks a different model of the thread's provider no longer takes the stored model's options. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composerDraftStore.ts | 10 +++++++--- apps/web/src/modelSelection.test.ts | 16 +++++++++++++--- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 08bdca13518cb..2f44ad776475f 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1165,8 +1165,9 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, )) : baseModel; - // An existing thread's options, even none, outrank the project's. Each source keeps its own - // selections so a fallback instance can check which model its options belong to. + // 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, @@ -1175,8 +1176,11 @@ export function deriveEffectiveComposerModelState(input: { selectedModel, }); const draftOptions = optionsFrom(input.draft?.modelSelectionByProvider ?? null); + const storedSelection = input.threadModelSelection ?? input.projectModelSelection; const storedOptions = optionsFrom( - selectionsByInstance(input.threadModelSelection ?? input.projectModelSelection), + draftOptions === null || storedSelection?.model === selectedModel + ? selectionsByInstance(storedSelection) + : null, ); return { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 60445c285b720..d3b466a51b8b2 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -824,7 +824,7 @@ describe("instance-scoped model selection", () => { const codex = ProviderInstanceId.make("codex"); const claude = ProviderInstanceId.make("claudeAgent"); const providers = [ - provider({ instanceId: "codex", models: ["gpt-5.5"] }), + provider({ instanceId: "codex", models: ["gpt-5.5", "gpt-5.4"] }), provider({ provider: ProviderDriverKind.make("claudeAgent"), instanceId: "claudeAgent", @@ -838,14 +838,17 @@ describe("instance-scoped model selection", () => { const claudePick = createModelSelection(claude, "claude-opus-5-5", [ { id: "effort", value: "max" }, ]); - const optionsFor = (draftSelections: Record>) => + const optionsFor = ( + draftSelections: Record>, + threadModelSelection = stored, + ) => deriveEffectiveComposerModelState({ draft: { activeProvider: codex, modelSelectionByProvider: draftSelections }, providers, selectedProvider: ProviderDriverKind.make("codex"), selectedInstanceId: codex, requestedInstanceId: claude, - threadModelSelection: stored, + threadModelSelection, projectModelSelection: null, settings: DEFAULT_UNIFIED_SETTINGS, }).modelOptions; @@ -862,6 +865,13 @@ describe("instance-scoped model selection", () => { expect(optionsFor({ [claude]: claudePick, [codex]: fastOff })?.[codex]).toEqual( fastOff.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", () => { From fa16e572e4d97f4ccb7bcc4253b74a6d2b1212d5 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Wed, 11 Nov 2026 13:44:33 +0000 Subject: [PATCH 5/9] fix(web): a stored model alias still counts as the shown model The stored-options check next to draft options now uses the same model identity as the fallback-instance check: static aliases match, custom slugs only match exactly. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/composerDraftStore.ts | 50 +++++++++++++++++++++-------- apps/web/src/modelSelection.test.ts | 3 ++ 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 2f44ad776475f..1ae68de2eb2f9 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1177,10 +1177,14 @@ export function deriveEffectiveComposerModelState(input: { }); const draftOptions = optionsFrom(input.draft?.modelSelectionByProvider ?? null); const storedSelection = input.threadModelSelection ?? input.projectModelSelection; + const storedIsShownModel = + storedSelection != null && + isSameModel(input, storedSelection, { + instanceId: input.selectedInstanceId ?? storedSelection.instanceId, + model: selectedModel, + }); const storedOptions = optionsFrom( - draftOptions === null || storedSelection?.model === selectedModel - ? selectionsByInstance(storedSelection) - : null, + draftOptions === null || storedIsShownModel ? selectionsByInstance(storedSelection) : null, ); return { @@ -1218,9 +1222,36 @@ function withFallbackInstanceOptions(input: { const requestedProvider = input.providers.find( (provider) => provider.instanceId === requestedInstanceId, ); - const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; + 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; +} + +function isSameModel( + input: { + readonly providers: ReadonlyArray; + readonly selectedProvider: ProviderDriverKind; + readonly settings: UnifiedSettings; + }, + a: InstanceModel, + b: InstanceModel, +): boolean { // Static aliases are shared across instances; exact custom slugs and catalog aliases are not. - const modelIdentity = (instanceId: ProviderInstanceId, model: string | undefined) => { + const modelIdentity = ({ instanceId, model }: InstanceModel) => { const isCustom = input.providers .find((provider) => provider.instanceId === instanceId) @@ -1230,14 +1261,7 @@ function withFallbackInstanceOptions(input: { ); return isCustom ? model : normalizeModelSlug(model, input.selectedProvider); }; - const sameModel = - requestedModel === input.selectedModel || - modelIdentity(requestedInstanceId, requestedModel) === - modelIdentity(selectedInstanceId, input.selectedModel); - if (!requestedOptions || requestedProvider?.driver !== input.selectedProvider || !sameModel) { - return modelOptions; - } - return { ...modelOptions, [selectedInstanceId]: requestedOptions }; + return a.model === b.model || modelIdentity(a) === modelIdentity(b); } function revokeObjectPreviewUrl(previewUrl: string): void { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index d3b466a51b8b2..a9ccd91e715a9 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -865,6 +865,9 @@ describe("instance-scoped model selection", () => { 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( From ccc755848a1f1cb24c1702c3f2b9f3539c478890 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Wed, 11 Nov 2026 14:13:26 +0000 Subject: [PATCH 6/9] fix(web): picking the canonical model of a thread's stored alias keeps its options The pick builder now matches the thread's model with the same alias-aware check as the composer's stored options, so a thread stored as gpt-5-codex keeps its effort when GPT-5.4 is picked. Custom model slugs still match only exactly. Co-Authored-By: Claude Opus 5.5 --- .../ChatView.modelPick.browser.test.tsx | 20 +++++++++++++++++-- .../components/chat/useComposerSelection.ts | 14 +++++++++++-- apps/web/src/composerDraftStore.ts | 2 +- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.modelPick.browser.test.tsx b/apps/web/src/components/ChatView.modelPick.browser.test.tsx index 65e530dd1430e..6c99081744fe7 100644 --- a/apps/web/src/components/ChatView.modelPick.browser.test.tsx +++ b/apps/web/src/components/ChatView.modelPick.browser.test.tsx @@ -3,7 +3,7 @@ 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) @@ -43,7 +43,11 @@ it("a model picked for an existing thread reaches the server before any message options: [{ id: "reasoningEffort", value: "high" }], }; const thread = { ...threadWithHistory("thread-model-pick", 1), modelSelection: stored }; - using _environment = installFakeEnvironment([thread], { acceptCommands: true }); + 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", @@ -80,4 +84,16 @@ it("a model picked for an existing thread reaches the server before any message }), ), ); + + // 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 dbd24f10e9849..ddb44a45350a1 100644 --- a/apps/web/src/components/chat/useComposerSelection.ts +++ b/apps/web/src/components/chat/useComposerSelection.ts @@ -19,7 +19,11 @@ import { useNavigate } from "@tanstack/react-router"; import { toastManager } from "../ui/toast"; import { resolveAppModelSelectionForInstance } from "../../modelSelection"; import { resolveNewDraftStartFromOrigin } from "../../lib/chatThreadActions"; -import { type DraftThreadEnvMode, useComposerDraftStore } from "../../composerDraftStore"; +import { + type DraftThreadEnvMode, + isSameModel, + useComposerDraftStore, +} from "../../composerDraftStore"; import { getStartedThreadModelChangeBlockReason, deriveLockedProvider, @@ -169,7 +173,13 @@ export function useComposerSelection(input: ComposerSelectionInput) { const pickOptions = rememberedOptions !== undefined && rememberedOptions.length > 0 ? rememberedOptions - : threadSelection.instanceId === instanceId && threadSelection.model === resolvedModel + : threadSelection.instanceId === instanceId && + entry !== undefined && + isSameModel( + { providers: providerStatuses, selectedProvider: entry.driver, settings }, + threadSelection, + { instanceId, model: resolvedModel }, + ) ? threadSelection.options : undefined; const nextModelSelection: ModelSelection = diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 1ae68de2eb2f9..c9f44ccb6ccab 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1241,7 +1241,7 @@ interface InstanceModel { readonly model: string | undefined; } -function isSameModel( +export function isSameModel( input: { readonly providers: ReadonlyArray; readonly selectedProvider: ProviderDriverKind; From b22cdb76b4b362748e4ed4a9b8b379799d15d41a Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Fri, 13 Nov 2026 09:33:37 +0000 Subject: [PATCH 7/9] test(web): re-picking the shown model saves nothing --- .../components/ChatView.modelPick.browser.test.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.modelPick.browser.test.tsx b/apps/web/src/components/ChatView.modelPick.browser.test.tsx index 6c99081744fe7..6e5df54be77a0 100644 --- a/apps/web/src/components/ChatView.modelPick.browser.test.tsx +++ b/apps/web/src/components/ChatView.modelPick.browser.test.tsx @@ -65,24 +65,19 @@ it("a model picked for an existing thread reaches the server before any message const modelSets = () => dispatchedCommands().filter((command) => command.type === "thread.model-selection.set"); - // With no options remembered in this browser, the thread's own model keeps its stored ones. + // Re-picking the model the composer shows saves nothing, so the thread keeps its stored options. 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()).toEqual([ - expect.objectContaining({ threadId: thread.id, modelSelection: stored }), - ]), - ); // The picker stays open beside its effort column. await page.getByRole("option", { name: /GPT-5\.5/ }).click(); await vi.waitFor(() => - expect(modelSets().at(-1)).toEqual( + expect(modelSets()).toEqual([ expect.objectContaining({ threadId: thread.id, modelSelection: { instanceId: "codex", model: "gpt-5.5" }, }), - ), + ]), ); // A thread stored under an alias keeps its options when its canonical model is picked. From f2113e61171106b5d0b500e541d414b86165e555 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 05:37:53 +0000 Subject: [PATCH 8/9] test(web): drop the model-pick re-pick step main already covers Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ChatView.modelPick.browser.test.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/apps/web/src/components/ChatView.modelPick.browser.test.tsx b/apps/web/src/components/ChatView.modelPick.browser.test.tsx index 6e5df54be77a0..c157e7972b14a 100644 --- a/apps/web/src/components/ChatView.modelPick.browser.test.tsx +++ b/apps/web/src/components/ChatView.modelPick.browser.test.tsx @@ -65,11 +65,7 @@ it("a model picked for an existing thread reaches the server before any message const modelSets = () => dispatchedCommands().filter((command) => command.type === "thread.model-selection.set"); - // Re-picking the model the composer shows saves nothing, so the thread keeps its stored options. await page.getByRole("button", { name: "GPT-5.4", exact: true }).click(); - await page.getByRole("option", { name: /GPT-5\.4/ }).click(); - - // The picker stays open beside its effort column. await page.getByRole("option", { name: /GPT-5\.5/ }).click(); await vi.waitFor(() => expect(modelSets()).toEqual([ From c7cd8e41e43565f7b1aed2fa6a87594c8ae0528f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 05:51:38 +0000 Subject: [PATCH 9/9] fix(mobile): switching providers no longer drops the thread's stored effort Co-Authored-By: Claude Opus 5.5 --- .../features/threads/ThreadSettingsSheet.tsx | 10 ++++++++- apps/mobile/src/lib/modelOptions.test.ts | 22 +++++++++++++++++++ apps/mobile/src/lib/modelOptions.ts | 16 ++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) 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,