authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-02 00:45:05-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-02 00:45:05-07:00
loge60821f0e0d82a5d671ca3b94719c49d333921c8
treedcf6fbf3dc97bb657e179a0b701d63b188e4ecc7
parent64bf0161919ec0e41470af3304928d9fc8711bf4
signature Signed by PGP key B5690EEEBB952194

feat: fold legacy models into separate menus (#5190)


17 files changed, 475 insertions(+), 88 deletions(-)

apps/mobile/src/features/threads/NewTaskDraftScreen.tsx+2-22
......@@ -43,7 +43,7 @@ import {
4343 type ComposerDraft,
4444} from "../../state/use-composer-drafts";
4545import { useEnvironmentServerConfig, useProjects } from "../../state/entities";
46import { resolveSelectableModelSelection } from "../../lib/modelOptions";
46import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions";
4747import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn";
4848import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
4949import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox";
......@@ -543,27 +543,7 @@ export function NewTaskDraftScreen(props: {
543543 );
544544
545545 const modelMenuActions = useMemo(
546 () =>
547 flow.providerGroups.map((group) => ({
548 id: `provider:${group.providerKey}`,
549 title: group.providerLabel,
550 subtitle: group.models.find(
551 (model) =>
552 flow.selectedModel &&
553 model.selection.instanceId === flow.selectedModel.instanceId &&
554 model.selection.model === flow.selectedModel.model,
555 )?.label,
556 subactions: group.models.map((option) => ({
557 id: `model:${option.key}`,
558 title: option.label,
559 state:
560 flow.selectedModel &&
561 option.selection.instanceId === flow.selectedModel.instanceId &&
562 option.selection.model === flow.selectedModel.model
563 ? ("on" as const)
564 : undefined,
565 })),
566 })),
546 () => buildModelMenuActions(flow.providerGroups, flow.selectedModel),
567547 [flow.providerGroups, flow.selectedModel],
568548 );
569549 const providerOptionDescriptors = useMemo(
apps/mobile/src/features/threads/ThreadComposer.tsx+2-20
......@@ -53,7 +53,7 @@ import {
5353import { ControlPill, ControlPillMenu } from "../../components/ControlPill";
5454import { ProviderIcon } from "../../components/ProviderIcon";
5555import type { DraftComposerImageAttachment } from "../../lib/composerImages";
56import { buildModelOptions, groupByProvider } from "../../lib/modelOptions";
56import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions";
5757import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
5858import type { RemoteClientConnectionState } from "../../lib/connection";
5959import {
......@@ -606,25 +606,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
606606 [providerOptionDescriptors],
607607 );
608608 const modelMenuActions = useMemo(
609 () =>
610 providerGroups.map((group) => ({
611 id: `provider:${group.providerKey}`,
612 title: group.providerLabel,
613 subtitle: group.models.find(
614 (model) =>
615 model.selection.instanceId === currentModelSelection.instanceId &&
616 model.selection.model === currentModelSelection.model,
617 )?.label,
618 subactions: group.models.map((option) => ({
619 id: `model:${option.key}`,
620 title: option.label,
621 state:
622 option.selection.instanceId === currentModelSelection.instanceId &&
623 option.selection.model === currentModelSelection.model
624 ? ("on" as const)
625 : undefined,
626 })),
627 })),
609 () => buildModelMenuActions(providerGroups, currentModelSelection),
628610 [providerGroups, currentModelSelection],
629611 );
630612
apps/mobile/src/lib/modelOptions.test.ts+84-1
......@@ -2,9 +2,92 @@ import { describe, expect, it } from "vite-plus/test";
22
33import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts";
44
5import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions";
5import {
6 buildModelMenuActions,
7 buildModelOptions,
8 groupByProvider,
9 resolveSelectableModelSelection,
10} from "./modelOptions";
611
712describe("mobile model options", () => {
13 it("folds legacy models into a provider-scoped menu", () => {
14 const config = {
15 providers: [
16 {
17 instanceId: "codex",
18 driver: "codex",
19 displayName: "Codex",
20 enabled: true,
21 installed: true,
22 auth: { status: "authenticated" },
23 models: [
24 {
25 slug: "gpt-5.6-sol",
26 name: "GPT-5.6 Sol",
27 isCustom: false,
28 capabilities: null,
29 },
30 {
31 slug: "gpt-5.4",
32 name: "GPT-5.4",
33 isCustom: false,
34 isLegacy: true,
35 capabilities: null,
36 },
37 ],
38 },
39 ],
40 } as unknown as ServerConfig;
41
42 const actions = buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null);
43
44 expect(actions).toMatchObject([
45 {
46 title: "Codex",
47 subactions: [{ id: "model:codex:gpt-5.6-sol", title: "GPT-5.6 Sol" }],
48 },
49 {
50 id: "legacy-models:codex",
51 title: "Codex legacy models",
52 subactions: [{ id: "model:codex:gpt-5.4", title: "GPT-5.4" }],
53 },
54 ]);
55 });
56
57 it("omits an empty provider menu when every model is legacy", () => {
58 const config = {
59 providers: [
60 {
61 instanceId: "codex",
62 driver: "codex",
63 displayName: "Codex",
64 enabled: true,
65 installed: true,
66 auth: { status: "authenticated" },
67 models: [
68 {
69 slug: "gpt-5.4",
70 name: "GPT-5.4",
71 isCustom: false,
72 isLegacy: true,
73 capabilities: null,
74 },
75 ],
76 },
77 ],
78 } as unknown as ServerConfig;
79
80 expect(
81 buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null),
82 ).toMatchObject([
83 {
84 id: "legacy-models:codex",
85 title: "Codex legacy models",
86 subactions: [{ id: "model:codex:gpt-5.4" }],
87 },
88 ]);
89 });
90
891 it("normalizes a legacy fallback selection against current capabilities", () => {
992 const config = {
1093 providers: [
apps/mobile/src/lib/modelOptions.ts+54
......@@ -3,6 +3,7 @@ import type {
33 ModelSelection,
44 ServerConfig as T3ServerConfig,
55} from "@t3tools/contracts";
6import type { MenuAction } from "@react-native-menu/menu";
67import {
78 buildProviderOptionSelectionsFromDescriptors,
89 getProviderOptionDescriptors,
......@@ -16,6 +17,7 @@ export type ModelOption = {
1617 readonly providerLabel: string;
1718 readonly providerDriver: string;
1819 readonly isDefault: boolean;
20 readonly isLegacy: boolean;
1921 readonly capabilities: ModelCapabilities | null;
2022 readonly selection: ModelSelection;
2123};
......@@ -105,6 +107,7 @@ export function buildModelOptions(
105107 providerLabel,
106108 providerDriver: provider.driver,
107109 isDefault: model.isDefault === true,
110 isLegacy: model.isLegacy === true,
108111 capabilities: model.capabilities,
109112 selection: normalizeSelectionOptions(
110113 {
......@@ -135,6 +138,7 @@ export function buildModelOptions(
135138 providerLabel,
136139 providerDriver: fallbackModelSelection.instanceId,
137140 isDefault: false,
141 isLegacy: false,
138142 capabilities: null,
139143 selection: fallbackModelSelection,
140144 });
......@@ -164,3 +168,53 @@ export function groupByProvider(options: ReadonlyArray<ModelOption>): ReadonlyAr
164168 models: group.models,
165169 }));
166170}
171
172function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction {
173 return {
174 id: `model:${option.key}`,
175 title: option.label,
176 state:
177 option.selection.instanceId === selectedModel?.instanceId &&
178 option.selection.model === selectedModel.model
179 ? "on"
180 : undefined,
181 };
182}
183
184export function buildModelMenuActions(
185 groups: ReadonlyArray<ProviderGroup>,
186 selectedModel: ModelSelection | null,
187): MenuAction[] {
188 return groups.flatMap((group) => {
189 const currentModels = group.models.filter((model) => !model.isLegacy);
190 const legacyModels = group.models.filter((model) => model.isLegacy);
191 const selected = group.models.find(
192 (model) =>
193 model.selection.instanceId === selectedModel?.instanceId &&
194 model.selection.model === selectedModel.model,
195 );
196
197 return [
198 ...(currentModels.length > 0
199 ? [
200 {
201 id: `provider:${group.providerKey}`,
202 title: group.providerLabel,
203 subtitle: selected && !selected.isLegacy ? selected.label : undefined,
204 subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)),
205 },
206 ]
207 : []),
208 ...(legacyModels.length > 0
209 ? [
210 {
211 id: `legacy-models:${group.providerKey}`,
212 title: `${group.providerLabel} legacy models`,
213 subtitle: selected?.isLegacy ? selected.label : undefined,
214 subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)),
215 },
216 ]
217 : []),
218 ];
219 });
220}
apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts+16
......@@ -9,11 +9,27 @@ import * as Schema from "effect/Schema";
99import {
1010 buildClaudeCapabilitiesProbeQueryOptions,
1111 CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
12 isLegacyClaudeModel,
1213 probeClaudeCapabilities,
1314} from "./ClaudeProvider.ts";
1415
1516const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
1617
18it("keeps only the Claude 5 family out of legacy models", () => {
19 assert.deepStrictEqual(
20 ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [
21 model,
22 isLegacyClaudeModel(model),
23 ]),
24 [
25 ["claude-fable-5", false],
26 ["claude-opus-5", false],
27 ["claude-sonnet-5", false],
28 ["claude-opus-4-8", true],
29 ],
30 );
31});
32
1733it("isolates Claude capability probes without dropping workspace setting sources", () => {
1834 const abortController = new AbortController();
1935 const options = buildClaudeCapabilitiesProbeQueryOptions({
apps/server/src/provider/Layers/ClaudeProvider.ts+11-1
......@@ -56,7 +56,13 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169";
5656const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154";
5757const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111";
5858
59const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
59const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]);
60
61export function isLegacyClaudeModel(model: string): boolean {
62 return !CURRENT_CLAUDE_MODELS.has(model);
63}
64
65const CLAUDE_MODEL_CATALOG: ReadonlyArray<ServerProviderModel> = [
6066 {
6167 slug: "claude-fable-5",
6268 name: "Claude Fable 5",
......@@ -309,6 +315,10 @@ const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = [
309315 },
310316];
311317
318const BUILT_IN_MODELS: ReadonlyArray<ServerProviderModel> = CLAUDE_MODEL_CATALOG.map((model) =>
319 isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model,
320);
321
312322function supportsClaudeOpus5(version: string | null | undefined): boolean {
313323 return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false;
314324}
apps/server/src/provider/Layers/CodexProvider.test.ts+20-1
......@@ -1,6 +1,25 @@
11import { assert, it } from "@effect/vitest";
22
3import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts";
3import {
4 applyPreferredCodexDefaultModel,
5 isLegacyCodexModel,
6 mapCodexModelCapabilities,
7} from "./CodexProvider.ts";
8
9it("keeps only the GPT-5.6 Codex family out of legacy models", () => {
10 assert.deepStrictEqual(
11 ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.4"].map((model) => [
12 model,
13 isLegacyCodexModel(model),
14 ]),
15 [
16 ["gpt-5.6-luna", false],
17 ["gpt-5.6-terra", false],
18 ["gpt-5.6-sol", false],
19 ["gpt-5.4", true],
20 ],
21 );
22});
423
524it("maps current Codex model capability fields", () => {
625 const capabilities = mapCodexModelCapabilities({
apps/server/src/provider/Layers/CodexProvider.ts+6
......@@ -62,6 +62,11 @@ const REASONING_EFFORT_LABELS: Readonly<Record<string, string>> = {
6262};
6363
6464const DEFAULT_SERVICE_TIER_ID = "default";
65const CURRENT_CODEX_MODELS = new Set(["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]);
66
67export function isLegacyCodexModel(model: string): boolean {
68 return !CURRENT_CODEX_MODELS.has(model);
69}
6570
6671function reasoningEffortLabel(reasoningEffort: string): string {
6772 return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort;
......@@ -190,6 +195,7 @@ function parseCodexModelListResponse(
190195 name: toDisplayName(model),
191196 isCustom: false,
192197 ...(model.isDefault ? { isDefault: true } : {}),
198 ...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}),
193199 capabilities: mapCodexModelCapabilities(model),
194200 }));
195201}
apps/web/src/components/chat/ModelListRow.tsx+2-1
......@@ -12,6 +12,7 @@ import { Button } from "../ui/button";
1212import { Kbd } from "../ui/kbd";
1313import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
1414import { cn } from "~/lib/utils";
15import { modelPickerModelKey } from "./modelPickerKeys";
1516
1617export const ModelListRow = memo(function ModelListRow(props: {
1718 index: number;
......@@ -46,7 +47,7 @@ export const ModelListRow = memo(function ModelListRow(props: {
4647 <ComboboxItem
4748 hideIndicator
4849 index={props.index}
49 value={`${props.instanceId}:${props.model.slug}`}
50 value={modelPickerModelKey(props.instanceId, props.model.slug)}
5051 disabled={Boolean(props.disabledReason)}
5152 contentClassName="flex w-full items-center gap-3"
5253 className={cn(
apps/web/src/components/chat/ModelPickerContent.tsx+154-42
......@@ -6,12 +6,24 @@ import {
66import { resolveSelectableModel } from "@t3tools/shared/model";
77import { LegendList, type LegendListRef } from "@legendapp/list/react";
88import { memo, useMemo, useState, useCallback, useEffect, useLayoutEffect, useRef } from "react";
9import { SearchIcon } from "lucide-react";
9import { ChevronRightIcon, SearchIcon } from "lucide-react";
1010import { ModelListRow } from "./ModelListRow";
1111import { ModelPickerSidebar } from "./ModelPickerSidebar";
12import {
13 modelPickerLegacySectionKey,
14 modelPickerModelKey,
15 parseModelPickerLegacySectionKey,
16 parseModelPickerModelKey,
17} from "./modelPickerKeys";
1218import { isModelPickerNewModel } from "./modelPickerModelHighlights";
1319import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch";
14import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxListVirtualized } from "../ui/combobox";
20import {
21 Combobox,
22 ComboboxEmpty,
23 ComboboxInput,
24 ComboboxItem,
25 ComboboxListVirtualized,
26} from "../ui/combobox";
1527import { ModelEsque } from "./providerIconUtils";
1628import {
1729 modelPickerJumpCommandForIndex,
......@@ -39,6 +51,7 @@ type ModelPickerItem = {
3951 instanceDisplayName: string;
4052 instanceAccentColor?: string | undefined;
4153 continuationGroupKey?: string | undefined;
54 isLegacy?: boolean | undefined;
4255};
4356
4457const EMPTY_MODEL_JUMP_LABELS = new Map<string, string>();
......@@ -47,20 +60,6 @@ function ModelListSeparator() {
4760 return <div className="h-0.5" />;
4861}
4962
50// Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs
51// can contain colons (e.g. some vendor model ids), so we only split on the
52// first colon — anything after that is the slug.
53function splitInstanceModelKey(key: string): { instanceId: ProviderInstanceId; slug: string } {
54 const colonIndex = key.indexOf(":");
55 if (colonIndex === -1) {
56 return { instanceId: key as ProviderInstanceId, slug: "" };
57 }
58 return {
59 instanceId: key.slice(0, colonIndex) as ProviderInstanceId,
60 slug: key.slice(colonIndex + 1),
61 };
62}
63
6463export const ModelPickerContent = memo(function ModelPickerContent(props: {
6564 /** The instance currently selected in the composer (combobox "value"). */
6665 activeInstanceId: ProviderInstanceId;
......@@ -117,6 +116,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
117116 return favorites.length > 0 ? "favorites" : props.activeInstanceId;
118117 },
119118 );
119 const [expandedLegacyInstances, setExpandedLegacyInstances] = useState(
120 () =>
121 new Set<ProviderInstanceId>(
122 modelOptionsByInstance
123 .get(props.activeInstanceId)
124 ?.some((model) => model.slug === props.model && model.isLegacy)
125 ? [props.activeInstanceId]
126 : [],
127 ),
128 );
120129 const keybindings = useMemo<ResolvedKeybindingsConfig>(
121130 () => providedKeybindings ?? [],
122131 [providedKeybindings],
......@@ -211,6 +220,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
211220 name: model.name,
212221 ...(model.shortName ? { shortName: model.shortName } : {}),
213222 ...(model.subProvider ? { subProvider: model.subProvider } : {}),
223 ...(model.isLegacy ? { isLegacy: true } : {}),
214224 instanceId,
215225 driverKind: entry.driverKind,
216226 instanceDisplayName: entry.displayName,
......@@ -366,6 +376,45 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
366376 selectedInstanceId,
367377 ]);
368378
379 const legacySection = useMemo(() => {
380 if (isSearching || selectedInstanceId === "favorites") {
381 return null;
382 }
383 const currentModels = filteredModels.filter((model) => !model.isLegacy);
384 const legacyModels = filteredModels.filter((model) => model.isLegacy);
385 if (legacyModels.length === 0) {
386 return null;
387 }
388 return {
389 key: modelPickerLegacySectionKey(selectedInstanceId),
390 currentModels,
391 legacyModels,
392 isExpanded: expandedLegacyInstances.has(selectedInstanceId),
393 };
394 }, [expandedLegacyInstances, filteredModels, isSearching, selectedInstanceId]);
395
396 const visibleModels = useMemo(() => {
397 if (!legacySection) {
398 return filteredModels;
399 }
400 return [
401 ...legacySection.currentModels,
402 ...(legacySection.isExpanded ? legacySection.legacyModels : []),
403 ];
404 }, [filteredModels, legacySection]);
405
406 const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => {
407 setExpandedLegacyInstances((expanded) => {
408 const next = new Set(expanded);
409 if (next.has(instanceId)) {
410 next.delete(instanceId);
411 } else {
412 next.add(instanceId);
413 }
414 return next;
415 });
416 }, []);
417
369418 const handleModelSelect = useCallback(
370419 (modelSlug: string, instanceId: ProviderInstanceId) => {
371420 if (getModelDisabledReason?.(instanceId, modelSlug)) {
......@@ -410,7 +459,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
410459 NonNullable<ReturnType<typeof modelPickerJumpCommandForIndex>>
411460 >();
412461 let selectableModelIndex = 0;
413 for (const model of filteredModels) {
462 for (const model of visibleModels) {
414463 if (getModelDisabledReason?.(model.instanceId, model.slug)) {
415464 continue;
416465 }
......@@ -418,27 +467,44 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
418467 if (!jumpCommand) {
419468 return mapping;
420469 }
421 mapping.set(`${model.instanceId}:${model.slug}`, jumpCommand);
470 mapping.set(modelPickerModelKey(model.instanceId, model.slug), jumpCommand);
422471 selectableModelIndex += 1;
423472 }
424473 return mapping;
425 }, [filteredModels, getModelDisabledReason]);
474 }, [getModelDisabledReason, visibleModels]);
426475 const modelJumpModelKeys = useMemo(
427476 () => [...modelJumpCommandByKey.keys()],
428477 [modelJumpCommandByKey],
429478 );
430 const allModelKeys = useMemo(
431 (): string[] => flatModels.map((model) => `${model.instanceId}:${model.slug}`),
479 const allItemKeys = useMemo(
480 (): string[] => [
481 ...flatModels.map((model) => modelPickerModelKey(model.instanceId, model.slug)),
482 ...new Set(
483 flatModels
484 .filter((model) => model.isLegacy)
485 .map((model) => modelPickerLegacySectionKey(model.instanceId)),
486 ),
487 ],
432488 [flatModels],
433489 );
434 const filteredModelKeys = useMemo(
435 (): string[] => filteredModels.map((model) => `${model.instanceId}:${model.slug}`),
436 [filteredModels],
437 );
490 const filteredItemKeys = useMemo((): string[] => {
491 const modelKeys = visibleModels.map((model) =>
492 modelPickerModelKey(model.instanceId, model.slug),
493 );
494 if (!legacySection) {
495 return modelKeys;
496 }
497 modelKeys.splice(legacySection.currentModels.length, 0, legacySection.key);
498 return modelKeys;
499 }, [legacySection, visibleModels]);
438500 const filteredModelByKey = useMemo(
439501 (): ReadonlyMap<string, ModelPickerItem> =>
440 new Map(filteredModels.map((model) => [`${model.instanceId}:${model.slug}`, model] as const)),
441 [filteredModels],
502 new Map(
503 visibleModels.map(
504 (model) => [modelPickerModelKey(model.instanceId, model.slug), model] as const,
505 ),
506 ),
507 [visibleModels],
442508 );
443509 const updateModelListScrollFades = useCallback(() => {
444510 const scrollElement = modelListRef.current?.getScrollableNode();
......@@ -495,10 +561,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
495561 if (!targetModelKey) {
496562 return;
497563 }
498 const { instanceId, slug } = splitInstanceModelKey(targetModelKey);
564 const model = parseModelPickerModelKey(targetModelKey);
565 if (!model) {
566 return;
567 }
499568 event.preventDefault();
500569 event.stopPropagation();
501 handleModelSelect(slug, instanceId);
570 handleModelSelect(model.slug, model.instanceId);
502571 };
503572
504573 window.addEventListener("keydown", onWindowKeyDown, true);
......@@ -510,7 +579,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
510579
511580 useLayoutEffect(() => {
512581 setShowTopScrollFade(false);
513 setShowBottomScrollFade(filteredModelKeys.length > 5);
582 setShowBottomScrollFade(filteredItemKeys.length > 5);
514583 let nestedFrame = 0;
515584 const frame = window.requestAnimationFrame(() => {
516585 updateModelListScrollFades();
......@@ -520,7 +589,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
520589 window.cancelAnimationFrame(frame);
521590 window.cancelAnimationFrame(nestedFrame);
522591 };
523 }, [filteredModelKeys, updateModelListScrollFades]);
592 }, [filteredItemKeys, updateModelListScrollFades]);
524593
525594 return (
526595 <TooltipProvider delay={0}>
......@@ -548,13 +617,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
548617 {/* Main content area */}
549618 <Combobox
550619 inline
551 items={allModelKeys}
552 filteredItems={filteredModelKeys}
620 items={allItemKeys}
621 filteredItems={filteredItemKeys}
553622 filter={null}
554623 autoHighlight
555624 open
556625 virtualized
557 value={`${props.activeInstanceId}:${props.model}`}
626 value={modelPickerModelKey(props.activeInstanceId, props.model)}
558627 onItemHighlighted={(modelKey, eventDetails) => {
559628 highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null;
560629 if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) {
......@@ -568,8 +637,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
568637 if (typeof modelKey !== "string") {
569638 return;
570639 }
571 const { instanceId, slug } = splitInstanceModelKey(modelKey);
572 handleModelSelect(slug, instanceId);
640 const legacyInstanceId = parseModelPickerLegacySectionKey(modelKey);
641 if (legacyInstanceId) {
642 toggleLegacySection(legacyInstanceId);
643 return;
644 }
645 const model = parseModelPickerModelKey(modelKey);
646 if (model) {
647 handleModelSelect(model.slug, model.instanceId);
648 }
573649 }}
574650 >
575651 <div
......@@ -605,10 +681,17 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
605681 ).preventBaseUIHandler?.();
606682 e.preventDefault();
607683 e.stopPropagation();
608 const { instanceId, slug } = splitInstanceModelKey(
684 const legacyInstanceId = parseModelPickerLegacySectionKey(
609685 highlightedModelKeyRef.current,
610686 );
611 handleModelSelect(slug, instanceId);
687 if (legacyInstanceId) {
688 toggleLegacySection(legacyInstanceId);
689 return;
690 }
691 const model = parseModelPickerModelKey(highlightedModelKeyRef.current);
692 if (model) {
693 handleModelSelect(model.slug, model.instanceId);
694 }
612695 return;
613696 }
614697 e.stopPropagation();
......@@ -626,10 +709,35 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
626709 <ComboboxListVirtualized className="size-full min-w-0 p-0 not-empty:p-0">
627710 <LegendList<string>
628711 ref={modelListRef}
629 data={filteredModelKeys}
712 data={filteredItemKeys}
630713 extraData={favoritesSet}
631714 keyExtractor={(modelKey) => modelKey}
632715 renderItem={({ item: modelKey, index }) => {
716 if (legacySection?.key === modelKey) {
717 return (
718 <ComboboxItem
719 hideIndicator
720 index={index}
721 value={modelKey}
722 aria-expanded={legacySection.isExpanded}
723 className="group w-full cursor-pointer rounded-md px-2 py-2"
724 contentClassName="flex w-full items-center gap-3"
725 >
726 <div className="min-w-0 flex-1 text-left">
727 <div className="text-xs font-medium leading-snug">Legacy models</div>
728 <div className="mt-1 text-xs font-normal leading-snug text-muted-foreground/70">
729 {legacySection.legacyModels.length} models
730 </div>
731 </div>
732 <ChevronRightIcon
733 className={cn(
734 "size-4 transition-transform",
735 legacySection.isExpanded && "rotate-90",
736 )}
737 />
738 </ComboboxItem>
739 );
740 }
633741 const model = filteredModelByKey.get(modelKey);
634742 if (!model) {
635743 return null;
......@@ -645,8 +753,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
645753 driverKind={model.driverKind}
646754 providerDisplayName={model.instanceDisplayName}
647755 providerAccentColor={model.instanceAccentColor}
648 isFavorite={favoritesSet.has(modelKey)}
649 isSelected={modelKey === `${props.activeInstanceId}:${props.model}`}
756 isFavorite={favoritesSet.has(
757 providerModelKey(model.instanceId, model.slug),
758 )}
759 isSelected={
760 modelKey === modelPickerModelKey(props.activeInstanceId, props.model)
761 }
650762 showProvider
651763 preferShortName={!isLocked}
652764 useTriggerLabel={false}
......@@ -657,7 +769,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
657769 />
658770 );
659771 }}
660 estimatedItemSize={60}
772 estimatedItemSize={52}
661773 drawDistance={480}
662774 recycleItems
663775 contentContainerClassName="pl-2 pr-px"
apps/web/src/components/chat/modelPickerKeys.test.ts created+31
......@@ -0,0 +1,31 @@
1import { ProviderInstanceId } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3import {
4 modelPickerLegacySectionKey,
5 modelPickerModelKey,
6 parseModelPickerLegacySectionKey,
7 parseModelPickerModelKey,
8} from "./modelPickerKeys";
9
10describe("model picker item keys", () => {
11 it("keeps model and legacy section keys distinct for colliding instance names", () => {
12 const modelKey = modelPickerModelKey(ProviderInstanceId.make("legacy-models"), "codex");
13 const sectionKey = modelPickerLegacySectionKey(ProviderInstanceId.make("codex"));
14
15 expect(modelKey).not.toBe(sectionKey);
16 expect(parseModelPickerLegacySectionKey(modelKey)).toBeNull();
17 expect(parseModelPickerModelKey(modelKey)).toEqual({
18 instanceId: "legacy-models",
19 slug: "codex",
20 });
21 });
22
23 it("round-trips arbitrary strings without throwing", () => {
24 const instanceId = ProviderInstanceId.make("custom");
25 const slug = "model:\udfff";
26
27 const key = modelPickerModelKey(instanceId, slug);
28
29 expect(parseModelPickerModelKey(key)).toEqual({ instanceId, slug });
30 });
31});
apps/web/src/components/chat/modelPickerKeys.ts created+47
......@@ -0,0 +1,47 @@
1import type { ProviderInstanceId } from "@t3tools/contracts";
2
3const MODEL_KEY_PREFIX = "model:";
4const LEGACY_SECTION_KEY_PREFIX = "legacy-models:";
5
6export function modelPickerModelKey(instanceId: ProviderInstanceId, slug: string): string {
7 return `${MODEL_KEY_PREFIX}${instanceId.length}:${instanceId}${slug}`;
8}
9
10export function parseModelPickerModelKey(
11 key: string,
12): { instanceId: ProviderInstanceId; slug: string } | null {
13 if (!key.startsWith(MODEL_KEY_PREFIX)) {
14 return null;
15 }
16 const encoded = key.slice(MODEL_KEY_PREFIX.length);
17 const separatorIndex = encoded.indexOf(":");
18 if (separatorIndex === -1) {
19 return null;
20 }
21
22 const instanceIdLengthText = encoded.slice(0, separatorIndex);
23 if (!/^\d+$/.test(instanceIdLengthText)) {
24 return null;
25 }
26
27 const instanceIdLength = Number(instanceIdLengthText);
28 const value = encoded.slice(separatorIndex + 1);
29 if (!Number.isSafeInteger(instanceIdLength) || instanceIdLength > value.length) {
30 return null;
31 }
32
33 return {
34 instanceId: value.slice(0, instanceIdLength) as ProviderInstanceId,
35 slug: value.slice(instanceIdLength),
36 };
37}
38
39export function modelPickerLegacySectionKey(instanceId: ProviderInstanceId): string {
40 return `${LEGACY_SECTION_KEY_PREFIX}${instanceId}`;
41}
42
43export function parseModelPickerLegacySectionKey(key: string): ProviderInstanceId | null {
44 return key.startsWith(LEGACY_SECTION_KEY_PREFIX)
45 ? (key.slice(LEGACY_SECTION_KEY_PREFIX.length) as ProviderInstanceId)
46 : null;
47}
apps/web/src/components/chat/providerIconUtils.ts+1
......@@ -26,6 +26,7 @@ export type ModelEsque = {
2626 name: string;
2727 shortName?: string | undefined;
2828 subProvider?: string | undefined;
29 isLegacy?: boolean | undefined;
2930};
3031
3132function escapeRegExp(value: string): string {
apps/web/src/modelSelection.test.ts+18
......@@ -55,6 +55,24 @@ function settingsWithProviderInstances(): UnifiedSettings {
5555}
5656
5757describe("instance-scoped model selection", () => {
58 it("preserves server-provided legacy model metadata", () => {
59 const baseProvider = provider({
60 instanceId: "claudeAgent",
61 models: ["claude-opus-4-8"],
62 });
63 const providers = [
64 {
65 ...baseProvider,
66 models: [{ ...baseProvider.models[0]!, isLegacy: true }],
67 },
68 ];
69 const stock = deriveProviderInstanceEntries(providers)[0]!;
70
71 expect(getAppModelOptionsForInstance(settingsWithProviderInstances(), stock)[0]?.isLegacy).toBe(
72 true,
73 );
74 });
75
5876 it("keeps custom models on the provider instance that declared them", () => {
5977 const providers = [
6078 provider({
apps/web/src/modelSelection.ts+2
......@@ -76,6 +76,7 @@ export interface AppModelOption {
7676 subProvider?: string;
7777 isCustom: boolean;
7878 isDefault?: boolean;
79 isLegacy?: boolean;
7980}
8081
8182function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption {
......@@ -87,6 +88,7 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti
8788 if (model.shortName) option.shortName = model.shortName;
8889 if (model.subProvider) option.subProvider = model.subProvider;
8990 if (model.isDefault) option.isDefault = true;
91 if (model.isLegacy) option.isLegacy = true;
9092 return option;
9193}
9294
packages/contracts/src/server.test.ts+24
......@@ -73,6 +73,30 @@ describe("ServerProvider", () => {
7373
7474 expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex");
7575 });
76
77 it("decodes optional legacy model metadata", () => {
78 const parsed = decodeServerProvider({
79 instanceId: "codex",
80 driver: "codex",
81 enabled: true,
82 installed: true,
83 version: "1.0.0",
84 status: "ready",
85 auth: { status: "authenticated" },
86 checkedAt: "2026-04-10T00:00:00.000Z",
87 models: [
88 {
89 slug: "gpt-5.4",
90 name: "GPT-5.4",
91 isCustom: false,
92 isLegacy: true,
93 capabilities: null,
94 },
95 ],
96 });
97
98 expect(parsed.models[0]?.isLegacy).toBe(true);
99 });
76100});
77101
78102describe("server config forward compatibility", () => {
packages/contracts/src/server.ts+1
......@@ -68,6 +68,7 @@ export const ServerProviderModel = Schema.Struct({
6868 subProvider: Schema.optional(TrimmedNonEmptyString),
6969 isCustom: Schema.Boolean,
7070 isDefault: Schema.optional(Schema.Boolean),
71 isLegacy: Schema.optional(Schema.Boolean),
7172 capabilities: Schema.NullOr(ModelCapabilities),
7273});
7374export type ServerProviderModel = typeof ServerProviderModel.Type;