diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 40130ecb5a39c5d5086a357100326d62130dafbf..6c6cbac495f68b77cad8cf4b43189d606bc4dce1 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { getAppModelOptions, getSlashModelOptions, normalizeCustomModelSlugs } from "./appSettings"; +import { + getAppModelOptions, + getSlashModelOptions, + normalizeCustomModelSlugs, + resolveAppModelSelection, +} from "./appSettings"; describe("normalizeCustomModelSlugs", () => { it("normalizes aliases, removes built-ins, and deduplicates values", () => { @@ -42,6 +47,18 @@ describe("getAppModelOptions", () => { }); }); +describe("resolveAppModelSelection", () => { + it("preserves saved custom model slugs instead of falling back to the default", () => { + expect(resolveAppModelSelection("codex", ["galapagos-alpha"], "galapagos-alpha")).toBe( + "galapagos-alpha", + ); + }); + + it("falls back to the provider default when no model is selected", () => { + expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); + }); +}); + describe("getSlashModelOptions", () => { it("includes saved custom model slugs for /model command suggestions", () => { const options = getSlashModelOptions( diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 876153ce778bd867dc629851739665f080ae6518..5ed218fb254e751012d2bd5e5d00ca7e8e02469e 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,7 +1,7 @@ import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; import { type ProviderKind } from "@t3tools/contracts"; -import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; @@ -111,6 +111,38 @@ export function getAppModelOptions( return options; } +export function resolveAppModelSelection( + provider: ProviderKind, + customModels: readonly string[], + selectedModel: string | null | undefined, +): string { + const options = getAppModelOptions(provider, customModels, selectedModel); + const trimmedSelectedModel = selectedModel?.trim(); + if (trimmedSelectedModel) { + const direct = options.find((option) => option.slug === trimmedSelectedModel); + if (direct) { + return direct.slug; + } + + const byName = options.find( + (option) => option.name.toLowerCase() === trimmedSelectedModel.toLowerCase(), + ); + if (byName) { + return byName.slug; + } + } + + const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider); + if (!normalizedSelectedModel) { + return getDefaultModel(provider); + } + + return ( + options.find((option) => option.slug === normalizedSelectedModel)?.slug ?? + getDefaultModel(provider) + ); +} + export function getSlashModelOptions( provider: ProviderKind, customModels: readonly string[], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7c346c726c29dbb139c6cabd616b75a42f65520b..94d10ee5bc2ce3c2ced1cb6bc2d4fbe9197ca763 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -196,7 +196,7 @@ import { Toggle } from "./ui/toggle"; import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { getAppModelOptions, useAppSettings } from "../appSettings"; +import { getAppModelOptions, resolveAppModelSelection, useAppSettings } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadEnvMode, @@ -762,30 +762,18 @@ export default function ChatView({ threadId }: ChatViewProps) { selectedProvider, activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider), ); + const customModelsForSelectedProvider = settings.customCodexModels; const selectedModel = useMemo(() => { const draftModel = composerDraft.model; if (!draftModel) { return baseThreadModel; } - - const providerOptions = getCustomModelOptionsByProvider(settings)[selectedProvider]; - const directMatch = providerOptions.find((option) => option.slug === draftModel); - if (directMatch) { - return directMatch.slug as ModelSlug; - } - - const normalizedDraftModel = normalizeModelSlug(draftModel, selectedProvider); - if (normalizedDraftModel) { - const normalizedMatch = providerOptions.find( - (option) => option.slug === normalizedDraftModel, - ); - if (normalizedMatch) { - return normalizedMatch.slug as ModelSlug; - } - } - - return resolveModelSlugForProvider(selectedProvider, draftModel); - }, [baseThreadModel, composerDraft.model, selectedProvider, settings]); + return resolveAppModelSelection( + selectedProvider, + customModelsForSelectedProvider, + draftModel, + ) as ModelSlug; + }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]); const reasoningOptions = getReasoningEffortOptions(selectedProvider); const supportsReasoningEffort = reasoningOptions.length > 0; const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider); @@ -2979,9 +2967,11 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus(); return; } - const resolvedModel = resolveModelSlugForProvider(provider, model); setComposerDraftProvider(activeThread.id, provider); - setComposerDraftModel(activeThread.id, resolvedModel); + setComposerDraftModel( + activeThread.id, + resolveAppModelSelection(provider, settings.customCodexModels, model), + ); scheduleComposerFocus(); }, [ @@ -2990,6 +2980,7 @@ export default function ChatView({ threadId }: ChatViewProps) { scheduleComposerFocus, setComposerDraftModel, setComposerDraftProvider, + settings.customCodexModels, ], ); const onEffortSelect = useCallback(