authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-05 22:29:31-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-05 23:00:41-08:00
log9de85ea16e5228ae68ceb3b52b03ab66f8f1b17c
treef216e130564597ecc9115e8b3c3089a7cb7bac61
parent10dde452f90fc3d4d1aea0add67d13cdfb2567ab

fix: preserve custom model picker selections

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 64 insertions(+), 24 deletions(-)

apps/web/src/appSettings.test.ts+18-1
...@@ -1,6 +1,11 @@...@@ -1,6 +1,11 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
22
3import { getAppModelOptions, getSlashModelOptions, normalizeCustomModelSlugs } from "./appSettings";3import {
4 getAppModelOptions,
5 getSlashModelOptions,
6 normalizeCustomModelSlugs,
7 resolveAppModelSelection,
8} from "./appSettings";
49
5describe("normalizeCustomModelSlugs", () => {10describe("normalizeCustomModelSlugs", () => {
6 it("normalizes aliases, removes built-ins, and deduplicates values", () => {11 it("normalizes aliases, removes built-ins, and deduplicates values", () => {
...@@ -42,6 +47,18 @@ describe("getAppModelOptions", () => {...@@ -42,6 +47,18 @@ describe("getAppModelOptions", () => {
42 });47 });
43});48});
4449
50describe("resolveAppModelSelection", () => {
51 it("preserves saved custom model slugs instead of falling back to the default", () => {
52 expect(resolveAppModelSelection("codex", ["galapagos-alpha"], "galapagos-alpha")).toBe(
53 "galapagos-alpha",
54 );
55 });
56
57 it("falls back to the provider default when no model is selected", () => {
58 expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4");
59 });
60});
61
45describe("getSlashModelOptions", () => {62describe("getSlashModelOptions", () => {
46 it("includes saved custom model slugs for /model command suggestions", () => {63 it("includes saved custom model slugs for /model command suggestions", () => {
47 const options = getSlashModelOptions(64 const options = getSlashModelOptions(
apps/web/src/appSettings.ts+33-1
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import { useCallback, useSyncExternalStore } from "react";1import { useCallback, useSyncExternalStore } from "react";
2import { Option, Schema } from "effect";2import { Option, Schema } from "effect";
3import { type ProviderKind } from "@t3tools/contracts";3import { type ProviderKind } from "@t3tools/contracts";
4import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";4import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
55
6const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";6const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
7const MAX_CUSTOM_MODEL_COUNT = 32;7const MAX_CUSTOM_MODEL_COUNT = 32;
...@@ -111,6 +111,38 @@ export function getAppModelOptions(...@@ -111,6 +111,38 @@ export function getAppModelOptions(
111 return options;111 return options;
112}112}
113113
114export function resolveAppModelSelection(
115 provider: ProviderKind,
116 customModels: readonly string[],
117 selectedModel: string | null | undefined,
118): string {
119 const options = getAppModelOptions(provider, customModels, selectedModel);
120 const trimmedSelectedModel = selectedModel?.trim();
121 if (trimmedSelectedModel) {
122 const direct = options.find((option) => option.slug === trimmedSelectedModel);
123 if (direct) {
124 return direct.slug;
125 }
126
127 const byName = options.find(
128 (option) => option.name.toLowerCase() === trimmedSelectedModel.toLowerCase(),
129 );
130 if (byName) {
131 return byName.slug;
132 }
133 }
134
135 const normalizedSelectedModel = normalizeModelSlug(selectedModel, provider);
136 if (!normalizedSelectedModel) {
137 return getDefaultModel(provider);
138 }
139
140 return (
141 options.find((option) => option.slug === normalizedSelectedModel)?.slug ??
142 getDefaultModel(provider)
143 );
144}
145
114export function getSlashModelOptions(146export function getSlashModelOptions(
115 provider: ProviderKind,147 provider: ProviderKind,
116 customModels: readonly string[],148 customModels: readonly string[],
apps/web/src/components/ChatView.tsx+13-22
...@@ -196,7 +196,7 @@ import { Toggle } from "./ui/toggle";...@@ -196,7 +196,7 @@ import { Toggle } from "./ui/toggle";
196import { SidebarTrigger } from "./ui/sidebar";196import { SidebarTrigger } from "./ui/sidebar";
197import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";197import { newCommandId, newMessageId, newThreadId } from "~/lib/utils";
198import { readNativeApi } from "~/nativeApi";198import { readNativeApi } from "~/nativeApi";
199import { getAppModelOptions, useAppSettings } from "../appSettings";199import { getAppModelOptions, resolveAppModelSelection, useAppSettings } from "../appSettings";
200import {200import {
201 type ComposerImageAttachment,201 type ComposerImageAttachment,
202 type DraftThreadEnvMode,202 type DraftThreadEnvMode,
...@@ -762,30 +762,18 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -762,30 +762,18 @@ export default function ChatView({ threadId }: ChatViewProps) {
762 selectedProvider,762 selectedProvider,
763 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),763 activeThread?.model ?? activeProject?.model ?? getDefaultModel(selectedProvider),
764 );764 );
765 const customModelsForSelectedProvider = settings.customCodexModels;
765 const selectedModel = useMemo(() => {766 const selectedModel = useMemo(() => {
766 const draftModel = composerDraft.model;767 const draftModel = composerDraft.model;
767 if (!draftModel) {768 if (!draftModel) {
768 return baseThreadModel;769 return baseThreadModel;
769 }770 }
770771 return resolveAppModelSelection(
771 const providerOptions = getCustomModelOptionsByProvider(settings)[selectedProvider];772 selectedProvider,
772 const directMatch = providerOptions.find((option) => option.slug === draftModel);773 customModelsForSelectedProvider,
773 if (directMatch) {774 draftModel,
774 return directMatch.slug as ModelSlug;775 ) as ModelSlug;
775 }776 }, [baseThreadModel, composerDraft.model, customModelsForSelectedProvider, selectedProvider]);
776
777 const normalizedDraftModel = normalizeModelSlug(draftModel, selectedProvider);
778 if (normalizedDraftModel) {
779 const normalizedMatch = providerOptions.find(
780 (option) => option.slug === normalizedDraftModel,
781 );
782 if (normalizedMatch) {
783 return normalizedMatch.slug as ModelSlug;
784 }
785 }
786
787 return resolveModelSlugForProvider(selectedProvider, draftModel);
788 }, [baseThreadModel, composerDraft.model, selectedProvider, settings]);
789 const reasoningOptions = getReasoningEffortOptions(selectedProvider);777 const reasoningOptions = getReasoningEffortOptions(selectedProvider);
790 const supportsReasoningEffort = reasoningOptions.length > 0;778 const supportsReasoningEffort = reasoningOptions.length > 0;
791 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);779 const selectedEffort = composerDraft.effort ?? getDefaultReasoningEffort(selectedProvider);
...@@ -2979,9 +2967,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2979,9 +2967,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
2979 scheduleComposerFocus();2967 scheduleComposerFocus();
2980 return;2968 return;
2981 }2969 }
2982 const resolvedModel = resolveModelSlugForProvider(provider, model);
2983 setComposerDraftProvider(activeThread.id, provider);2970 setComposerDraftProvider(activeThread.id, provider);
2984 setComposerDraftModel(activeThread.id, resolvedModel);2971 setComposerDraftModel(
2972 activeThread.id,
2973 resolveAppModelSelection(provider, settings.customCodexModels, model),
2974 );
2985 scheduleComposerFocus();2975 scheduleComposerFocus();
2986 },2976 },
2987 [2977 [
...@@ -2990,6 +2980,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2990,6 +2980,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2990 scheduleComposerFocus,2980 scheduleComposerFocus,
2991 setComposerDraftModel,2981 setComposerDraftModel,
2992 setComposerDraftProvider,2982 setComposerDraftProvider,
2983 settings.customCodexModels,
2993 ],2984 ],
2994 );2985 );
2995 const onEffortSelect = useCallback(2986 const onEffortSelect = useCallback(