diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 182b8bc5b1fdf..97c9ca0483ab8 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -4,7 +4,7 @@ import { type WorktreeSubmodules, managedRuntimeModeDenial, } from "@t3tools/contracts"; -import { createModelSelection } from "@t3tools/shared/model"; +import { createModelSelection, pickModelSelection } from "@t3tools/shared/model"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { useNavigate } from "@tanstack/react-router"; @@ -172,7 +172,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting }); }} onInstanceModelChange={(instanceId, model) => - setModel(createModelSelection(instanceId, model)) + setModel(pickModelSelection(mixedModel ? null : selection, instanceId, model)) } /> {!mixedModel && !presentation.providerNote && !effortDisabledReason && activeEntry ? ( @@ -288,7 +288,13 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting }); return; } - updateSettings({ fallbackModelSelection: createModelSelection(instanceId, model) }); + updateSettings({ + fallbackModelSelection: pickModelSelection( + mixedFallback ? null : settings.fallbackModelSelection, + instanceId, + model, + ), + }); }} /> {settings.fallbackModelSelection !== null && fallbackEntry && !mixedFallback ? ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index ea874c2b18c30..633e18a567b2f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -27,7 +27,7 @@ import { ToolCallVisibility, } from "@t3tools/contracts/settings"; import { desktopUpdateChannelOf } from "@t3tools/shared/cliRelease"; -import { createModelSelection } from "@t3tools/shared/model"; +import { createModelSelection, pickModelSelection } from "@t3tools/shared/model"; import { resolvePastedPath } from "@t3tools/shared/path"; import * as Equal from "effect/Equal"; import * as Schema from "effect/Schema"; @@ -2046,7 +2046,11 @@ export function ThreadsSettingsPanel() { textGenerationModelSelection: resolveAppModelSelectionState( { ...settings, - textGenerationModelSelection: createModelSelection(instanceId, model), + textGenerationModelSelection: pickModelSelection( + textGenerationModelSelection, + instanceId, + model, + ), }, textGenerationProviders, ), diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 26cf6eb3dcbb2..6270639f4c167 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -7,7 +7,7 @@ import { textGeneratorForSelection, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; -import { createModelSelection } from "@t3tools/shared/model"; +import { createModelSelection, pickModelSelection } from "@t3tools/shared/model"; import { resolveReviewGuideModelSelection, resolveSourceControlWriterModelSelection, @@ -365,7 +365,11 @@ export function SourceControlWritingSettingsSection() { return; } updateSettings({ - reviewGuideModelSelection: createModelSelection(instanceId, model), + reviewGuideModelSelection: pickModelSelection( + mixedReviewGuideModel ? null : reviewGuideSelection, + instanceId, + model, + ), }); }} /> @@ -564,7 +568,11 @@ export function SourceControlWritingSettingsSection() { return; } updateSettings({ - sourceControlWriterModelSelection: createModelSelection(instanceId, model), + sourceControlWriterModelSelection: pickModelSelection( + mixedWriterModel ? null : activeSelection, + instanceId, + model, + ), }); }} /> diff --git a/apps/web/src/components/settings/TextGeneratorsSettings.tsx b/apps/web/src/components/settings/TextGeneratorsSettings.tsx index ce0e729309bf2..76bf4f6b41e58 100644 --- a/apps/web/src/components/settings/TextGeneratorsSettings.tsx +++ b/apps/web/src/components/settings/TextGeneratorsSettings.tsx @@ -7,7 +7,7 @@ import { TextGeneratorConfig, type TextGeneratorModel, } from "@t3tools/contracts"; -import { createModelSelection } from "@t3tools/shared/model"; +import { pickModelSelection } from "@t3tools/shared/model"; import { AsyncResult } from "effect/reactivity"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -571,7 +571,7 @@ function TextGenerationTaskPicker({ )} triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} onInstanceModelChange={(instanceId, model) => - updateSettings({ [settingKey]: createModelSelection(instanceId, model) }) + updateSettings({ [settingKey]: pickModelSelection(selection, instanceId, model) }) } /> diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx index 22ae59e55d31e..66cacf6eb7ee9 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx @@ -28,7 +28,7 @@ import { type SpeechModelUnloadTimeout, type VoiceShortcutMode, } from "@t3tools/contracts"; -import { createModelSelection } from "@t3tools/shared/model"; +import { createModelSelection, pickModelSelection } from "@t3tools/shared/model"; import { resolveSpeechPostProcessingModelSelection } from "@t3tools/shared/serverSettings"; import { cloudSpeechLanguages } from "@t3tools/shared/speech"; import { @@ -576,7 +576,7 @@ function CleanupModelRow() { triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} getModelDisabledReason={modelDisabledReason} onInstanceModelChange={(instanceId, model) => - setModel(createModelSelection(instanceId, model)) + setModel(pickModelSelection(selection, instanceId, model)) } /> { ).toBe(false); expect(modelSelectionsEqual(left, { ...reordered, model: "gpt-5.5" })).toBe(false); }); + + it("keeps the options when the current model is picked again", () => { + const codex = ProviderInstanceId.make("codex"); + const current = createModelSelection(codex, "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]); + + expect(pickModelSelection(current, codex, "gpt-5.4")).toBe(current); + expect(pickModelSelection(current, codex, "gpt-5.5")).toEqual({ + instanceId: "codex", + model: "gpt-5.5", + }); + expect(pickModelSelection(current, ProviderInstanceId.make("codex_work"), "gpt-5.4")).toEqual({ + instanceId: "codex_work", + model: "gpt-5.4", + }); + expect(pickModelSelection(null, codex, "gpt-5.4")).toEqual({ + instanceId: "codex", + model: "gpt-5.4", + }); + }); }); describe("applyClaudePromptEffortPrefix", () => { diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 6714bae9b8de1..babe1347cc056 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -538,6 +538,17 @@ export function createModelSelection( return selections.length > 0 ? { ...base, options: selections } : base; } +/** A model picker's pick: re-picking the current model keeps its options, another model starts without any. */ +export function pickModelSelection( + current: ModelSelection | null | undefined, + instanceId: ProviderInstanceId, + model: string, +): ModelSelection { + return current?.instanceId === instanceId && current.model === model + ? current + : createModelSelection(instanceId, model); +} + export function resolvePromptInjectedEffort( caps: ModelCapabilities, rawEffort: string | null | undefined,