Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
00fc14b
fix(web): composer draft options override only their own provider
only21mil Nov 8, 2026
43e3a51
Merge upstream main ab9df1d6ff into fix/composer-draft-options-own-pr…
only21mil Nov 11, 2026
3cbfffd
fix(web): picking a thread's model again keeps its stored effort
only21mil Nov 11, 2026
384fdd1
fix(web): draft options for another provider no longer hide the threa…
only21mil Nov 11, 2026
612f5d1
fix(web): stored options fill in only for the model they were stored …
only21mil Nov 11, 2026
fa16e57
fix(web): a stored model alias still counts as the shown model
only21mil Nov 11, 2026
ccc7558
fix(web): picking the canonical model of a thread's stored alias keep…
only21mil Nov 11, 2026
42c305b
Merge upstream main cd8fc91a57 into fix/composer-draft-options-own-pr…
only21mil Nov 12, 2026
cf8beeb
Merge branch 'main' into fix/composer-draft-options-own-provider
only21mil Nov 12, 2026
b16f6a5
Merge branch 'main' into fix/composer-draft-options-own-provider
only21mil Nov 13, 2026
b22cdb7
test(web): re-picking the shown model saves nothing
only21mil Nov 13, 2026
5d33b23
Merge branch 'main' into fix/composer-draft-options-own-provider
only21mil Nov 14, 2026
d874dfd
Merge branch 'main' into fix/composer-draft-options-own-provider
only21mil Nov 14, 2026
e8df06c
Merge remote-tracking branch 'origin/main' into fix/composer-draft-op…
juliusmarminge Nov 15, 2026
f2113e6
test(web): drop the model-pick re-pick step main already covers
juliusmarminge Nov 15, 2026
c7cd8e4
fix(mobile): switching providers no longer drops the thread's stored …
juliusmarminge Nov 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/mobile/src/features/threads/ThreadSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
serverConfigForCwd,
type ModelOption,
type ProviderGroup,
withStoredThreadModelOptions,
} from "../../lib/modelOptions";
import { applyProviderOptionSelection } from "../../lib/providerOptions";
import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
resolveDefaultableModelSelection,
resolveNewTaskModelSelection,
resolveSelectableModelSelection,
withStoredThreadModelOptions,
type ModelOption,
} from "./modelOptions";

Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 30 additions & 7 deletions apps/web/src/components/ChatView.modelPick.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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",
Expand All @@ -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 }),
),
);
});
24 changes: 21 additions & 3 deletions apps/web/src/components/chat/useComposerSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
71 changes: 52 additions & 19 deletions apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, ModelSelection>> | 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,
};
}

Expand Down Expand Up @@ -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<ServerProvider>;
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;
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/modelSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ReturnType<typeof createModelSelection>>,
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");
Expand Down
Loading