diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e920fddb6cb..6d204e3a8e6 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -43,7 +43,7 @@ import { type ComposerDraft, } from "../../state/use-composer-drafts"; import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; -import { resolveSelectableModelSelection } from "../../lib/modelOptions"; +import { buildModelMenuActions, resolveSelectableModelSelection } from "../../lib/modelOptions"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -543,27 +543,7 @@ export function NewTaskDraftScreen(props: { ); const modelMenuActions = useMemo( - () => - flow.providerGroups.map((group) => ({ - id: `provider:${group.providerKey}`, - title: group.providerLabel, - subtitle: group.models.find( - (model) => - flow.selectedModel && - model.selection.instanceId === flow.selectedModel.instanceId && - model.selection.model === flow.selectedModel.model, - )?.label, - subactions: group.models.map((option) => ({ - id: `model:${option.key}`, - title: option.label, - state: - flow.selectedModel && - option.selection.instanceId === flow.selectedModel.instanceId && - option.selection.model === flow.selectedModel.model - ? ("on" as const) - : undefined, - })), - })), + () => buildModelMenuActions(flow.providerGroups, flow.selectedModel), [flow.providerGroups, flow.selectedModel], ); const providerOptionDescriptors = useMemo( diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 76f4231ad65..9bba613eea0 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -53,7 +53,7 @@ import { import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; -import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; +import { buildModelMenuActions, buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; import { @@ -606,25 +606,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer [providerOptionDescriptors], ); const modelMenuActions = useMemo( - () => - providerGroups.map((group) => ({ - id: `provider:${group.providerKey}`, - title: group.providerLabel, - subtitle: group.models.find( - (model) => - model.selection.instanceId === currentModelSelection.instanceId && - model.selection.model === currentModelSelection.model, - )?.label, - subactions: group.models.map((option) => ({ - id: `model:${option.key}`, - title: option.label, - state: - option.selection.instanceId === currentModelSelection.instanceId && - option.selection.model === currentModelSelection.model - ? ("on" as const) - : undefined, - })), - })), + () => buildModelMenuActions(providerGroups, currentModelSelection), [providerGroups, currentModelSelection], ); diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index f9e1e25787a..2ec8566b4e4 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -2,9 +2,92 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; -import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions"; +import { + buildModelMenuActions, + buildModelOptions, + groupByProvider, + resolveSelectableModelSelection, +} from "./modelOptions"; describe("mobile model options", () => { + it("folds legacy models into a provider-scoped menu", () => { + const config = { + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + isCustom: false, + capabilities: null, + }, + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + isLegacy: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + const actions = buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null); + + expect(actions).toMatchObject([ + { + title: "Codex", + subactions: [{ id: "model:codex:gpt-5.6-sol", title: "GPT-5.6 Sol" }], + }, + { + id: "legacy-models:codex", + title: "Codex legacy models", + subactions: [{ id: "model:codex:gpt-5.4", title: "GPT-5.4" }], + }, + ]); + }); + + it("omits an empty provider menu when every model is legacy", () => { + const config = { + providers: [ + { + instanceId: "codex", + driver: "codex", + displayName: "Codex", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + isLegacy: true, + capabilities: null, + }, + ], + }, + ], + } as unknown as ServerConfig; + + expect( + buildModelMenuActions(groupByProvider(buildModelOptions(config, null)), null), + ).toMatchObject([ + { + id: "legacy-models:codex", + title: "Codex legacy models", + subactions: [{ id: "model:codex:gpt-5.4" }], + }, + ]); + }); + it("normalizes a legacy fallback selection against current capabilities", () => { const config = { providers: [ diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index b51fa915dea..951b74f7d51 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -3,6 +3,7 @@ import type { ModelSelection, ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; +import type { MenuAction } from "@react-native-menu/menu"; import { buildProviderOptionSelectionsFromDescriptors, getProviderOptionDescriptors, @@ -16,6 +17,7 @@ export type ModelOption = { readonly providerLabel: string; readonly providerDriver: string; readonly isDefault: boolean; + readonly isLegacy: boolean; readonly capabilities: ModelCapabilities | null; readonly selection: ModelSelection; }; @@ -105,6 +107,7 @@ export function buildModelOptions( providerLabel, providerDriver: provider.driver, isDefault: model.isDefault === true, + isLegacy: model.isLegacy === true, capabilities: model.capabilities, selection: normalizeSelectionOptions( { @@ -135,6 +138,7 @@ export function buildModelOptions( providerLabel, providerDriver: fallbackModelSelection.instanceId, isDefault: false, + isLegacy: false, capabilities: null, selection: fallbackModelSelection, }); @@ -164,3 +168,53 @@ export function groupByProvider(options: ReadonlyArray): ReadonlyAr models: group.models, })); } + +function modelMenuAction(option: ModelOption, selectedModel: ModelSelection | null): MenuAction { + return { + id: `model:${option.key}`, + title: option.label, + state: + option.selection.instanceId === selectedModel?.instanceId && + option.selection.model === selectedModel.model + ? "on" + : undefined, + }; +} + +export function buildModelMenuActions( + groups: ReadonlyArray, + selectedModel: ModelSelection | null, +): MenuAction[] { + return groups.flatMap((group) => { + const currentModels = group.models.filter((model) => !model.isLegacy); + const legacyModels = group.models.filter((model) => model.isLegacy); + const selected = group.models.find( + (model) => + model.selection.instanceId === selectedModel?.instanceId && + model.selection.model === selectedModel.model, + ); + + return [ + ...(currentModels.length > 0 + ? [ + { + id: `provider:${group.providerKey}`, + title: group.providerLabel, + subtitle: selected && !selected.isLegacy ? selected.label : undefined, + subactions: currentModels.map((option) => modelMenuAction(option, selectedModel)), + }, + ] + : []), + ...(legacyModels.length > 0 + ? [ + { + id: `legacy-models:${group.providerKey}`, + title: `${group.providerLabel} legacy models`, + subtitle: selected?.isLegacy ? selected.label : undefined, + subactions: legacyModels.map((option) => modelMenuAction(option, selectedModel)), + }, + ] + : []), + ]; + }); +} diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index ba3199921b2..e2bbc28cb2f 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -57,6 +57,7 @@ it("installs under Marcode's own service identity", () => { const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( platform: NodeJS.Platform = "linux", + usePinnedLauncher = false, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -68,6 +69,10 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( const runtime = pinnedRuntimePaths(path, baseDir, "1.2.3"); yield* fs.makeDirectory(path.dirname(runtime.entryPath), { recursive: true }); yield* fs.writeFileString(runtime.entryPath, "export {};\n"); + yield* fs.writeFileString( + path.join(path.dirname(runtime.entryPath), "service-launcher.mjs"), + "export const source = 'pinned runtime';\n", + ); yield* fs.writeFileString(runtime.sentinelPath, "1.2.3\n"); const commands: string[] = []; @@ -93,8 +98,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* ( cliVersion: "1.2.3", host: { execPath: "/usr/bin/node", - cliEntryPath: path.join(home, "bin.mjs"), - launcherSourcePath: sourceLauncher, + ...(usePinnedLauncher ? {} : { launcherSourcePath: sourceLauncher }), }, }).pipe( Effect.provideService(ProcessRunner.ProcessRunner, runner), @@ -133,6 +137,17 @@ it.layer(NodeServices.layer)("boot service install", (it) => { }), ); + it.effect("copies the launcher from the prepared pinned runtime", () => + Effect.gen(function* () { + const { service, fs } = yield* makeHarness("linux", true); + const plan = yield* service.install; + + expect(yield* fs.readFileString(plan.launcherPath)).toBe( + "export const source = 'pinned runtime';\n", + ); + }), + ); + it.effect("restarts an installed service when repair fails", () => Effect.gen(function* () { const { service, commands, control } = yield* makeHarness(); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index e49619d5cd6..bf3ebbb6db7 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -1,8 +1,4 @@ -import { - HostProcessArguments, - HostProcessExecutablePath, - HostProcessPlatform, -} from "@t3tools/shared/hostProcess"; +import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -140,7 +136,6 @@ export class BootService extends Context.Service< export interface BootServiceHost { readonly execPath: string; - readonly cliEntryPath: string; readonly launcherSourcePath?: string; } @@ -150,26 +145,23 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { readonly cliVersion: string; readonly host?: BootServiceHost; }) { - const hostArguments = yield* HostProcessArguments; const hostExecPath = yield* HostProcessExecutablePath; const platform = yield* HostProcessPlatform; const homeDir = yield* Config.string("HOME").pipe(Config.withDefault("")); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runner = yield* ProcessRunner.ProcessRunner; - const host = input.host ?? { - execPath: hostExecPath, - cliEntryPath: hostArguments[1] ?? "", - }; + const host = input.host ?? { execPath: hostExecPath }; const unitDir = path.join(homeDir, ".config", "systemd", "user"); const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE); const logPath = path.join(input.logsDir, "boot-service.log"); const launcherPath = path.join(input.baseDir, "runtime", SERVICE_LAUNCHER_FILE); const statePath = path.join(input.baseDir, "runtime", SERVICE_STATE_FILE); - const launcherSourcePath = - host.launcherSourcePath ?? path.join(path.dirname(host.cliEntryPath), SERVICE_LAUNCHER_FILE); const runtimePaths = pinnedRuntimePaths(path, input.baseDir, input.cliVersion); + const launcherSourcePath = + host.launcherSourcePath ?? + path.join(path.dirname(runtimePaths.entryPath), SERVICE_LAUNCHER_FILE); const writeDurably = (filePath: string, contents: string) => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index ab6e5992990..19907ece888 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -9,11 +9,27 @@ import * as Schema from "effect/Schema"; import { buildClaudeCapabilitiesProbeQueryOptions, CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES, + isLegacyClaudeModel, probeClaudeCapabilities, } from "./ClaudeProvider.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); +it("keeps only the Claude 5 family out of legacy models", () => { + assert.deepStrictEqual( + ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ + model, + isLegacyClaudeModel(model), + ]), + [ + ["claude-fable-5", false], + ["claude-opus-5", false], + ["claude-sonnet-5", false], + ["claude-opus-4-8", true], + ], + ); +}); + it("isolates Claude capability probes without dropping workspace setting sources", () => { const abortController = new AbortController(); const options = buildClaudeCapabilitiesProbeQueryOptions({ diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index ee3feaf0052..5e887843026 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -56,7 +56,13 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; -const BUILT_IN_MODELS: ReadonlyArray = [ +const CURRENT_CLAUDE_MODELS = new Set(["claude-fable-5", "claude-opus-5", "claude-sonnet-5"]); + +export function isLegacyClaudeModel(model: string): boolean { + return !CURRENT_CLAUDE_MODELS.has(model); +} + +const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ { slug: "claude-fable-5", name: "Claude Fable 5", @@ -309,6 +315,10 @@ const BUILT_IN_MODELS: ReadonlyArray = [ }, ]; +const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG.map((model) => + isLegacyClaudeModel(model.slug) ? { ...model, isLegacy: true } : model, +); + function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; } diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 2aeebdb2ccd..26e77f82a79 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,25 @@ import { assert, it } from "@effect/vitest"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyPreferredCodexDefaultModel, + isLegacyCodexModel, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; + +it("keeps only the GPT-5.6 Codex family out of legacy models", () => { + assert.deepStrictEqual( + ["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol", "gpt-5.4"].map((model) => [ + model, + isLegacyCodexModel(model), + ]), + [ + ["gpt-5.6-luna", false], + ["gpt-5.6-terra", false], + ["gpt-5.6-sol", false], + ["gpt-5.4", true], + ], + ); +}); it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 76cc13bbf67..dd33a807d6c 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -62,6 +62,11 @@ const REASONING_EFFORT_LABELS: Readonly> = { }; const DEFAULT_SERVICE_TIER_ID = "default"; +const CURRENT_CODEX_MODELS = new Set(["gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.6-sol"]); + +export function isLegacyCodexModel(model: string): boolean { + return !CURRENT_CODEX_MODELS.has(model); +} function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; @@ -190,6 +195,7 @@ function parseCodexModelListResponse( name: toDisplayName(model), isCustom: false, ...(model.isDefault ? { isDefault: true } : {}), + ...(isLegacyCodexModel(model.model) ? { isLegacy: true } : {}), capabilities: mapCodexModelCapabilities(model), })); } diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index e86435561a2..a74a4ebf8c2 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -12,6 +12,7 @@ import { Button } from "../ui/button"; import { Kbd } from "../ui/kbd"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { cn } from "~/lib/utils"; +import { modelPickerModelKey } from "./modelPickerKeys"; export const ModelListRow = memo(function ModelListRow(props: { index: number; @@ -46,7 +47,7 @@ export const ModelListRow = memo(function ModelListRow(props: { (); @@ -47,20 +60,6 @@ function ModelListSeparator() { return
; } -// Split a `${instanceId}:${slug}` combobox key back into its pieces. Slugs -// can contain colons (e.g. some vendor model ids), so we only split on the -// first colon — anything after that is the slug. -function splitInstanceModelKey(key: string): { instanceId: ProviderInstanceId; slug: string } { - const colonIndex = key.indexOf(":"); - if (colonIndex === -1) { - return { instanceId: key as ProviderInstanceId, slug: "" }; - } - return { - instanceId: key.slice(0, colonIndex) as ProviderInstanceId, - slug: key.slice(colonIndex + 1), - }; -} - export const ModelPickerContent = memo(function ModelPickerContent(props: { /** The instance currently selected in the composer (combobox "value"). */ activeInstanceId: ProviderInstanceId; @@ -117,6 +116,16 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return favorites.length > 0 ? "favorites" : props.activeInstanceId; }, ); + const [expandedLegacyInstances, setExpandedLegacyInstances] = useState( + () => + new Set( + modelOptionsByInstance + .get(props.activeInstanceId) + ?.some((model) => model.slug === props.model && model.isLegacy) + ? [props.activeInstanceId] + : [], + ), + ); const keybindings = useMemo( () => providedKeybindings ?? [], [providedKeybindings], @@ -211,6 +220,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { name: model.name, ...(model.shortName ? { shortName: model.shortName } : {}), ...(model.subProvider ? { subProvider: model.subProvider } : {}), + ...(model.isLegacy ? { isLegacy: true } : {}), instanceId, driverKind: entry.driverKind, instanceDisplayName: entry.displayName, @@ -366,6 +376,45 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { selectedInstanceId, ]); + const legacySection = useMemo(() => { + if (isSearching || selectedInstanceId === "favorites") { + return null; + } + const currentModels = filteredModels.filter((model) => !model.isLegacy); + const legacyModels = filteredModels.filter((model) => model.isLegacy); + if (legacyModels.length === 0) { + return null; + } + return { + key: modelPickerLegacySectionKey(selectedInstanceId), + currentModels, + legacyModels, + isExpanded: expandedLegacyInstances.has(selectedInstanceId), + }; + }, [expandedLegacyInstances, filteredModels, isSearching, selectedInstanceId]); + + const visibleModels = useMemo(() => { + if (!legacySection) { + return filteredModels; + } + return [ + ...legacySection.currentModels, + ...(legacySection.isExpanded ? legacySection.legacyModels : []), + ]; + }, [filteredModels, legacySection]); + + const toggleLegacySection = useCallback((instanceId: ProviderInstanceId) => { + setExpandedLegacyInstances((expanded) => { + const next = new Set(expanded); + if (next.has(instanceId)) { + next.delete(instanceId); + } else { + next.add(instanceId); + } + return next; + }); + }, []); + const handleModelSelect = useCallback( (modelSlug: string, instanceId: ProviderInstanceId) => { if (getModelDisabledReason?.(instanceId, modelSlug)) { @@ -410,7 +459,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { NonNullable> >(); let selectableModelIndex = 0; - for (const model of filteredModels) { + for (const model of visibleModels) { if (getModelDisabledReason?.(model.instanceId, model.slug)) { continue; } @@ -418,27 +467,44 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!jumpCommand) { return mapping; } - mapping.set(`${model.instanceId}:${model.slug}`, jumpCommand); + mapping.set(modelPickerModelKey(model.instanceId, model.slug), jumpCommand); selectableModelIndex += 1; } return mapping; - }, [filteredModels, getModelDisabledReason]); + }, [getModelDisabledReason, visibleModels]); const modelJumpModelKeys = useMemo( () => [...modelJumpCommandByKey.keys()], [modelJumpCommandByKey], ); - const allModelKeys = useMemo( - (): string[] => flatModels.map((model) => `${model.instanceId}:${model.slug}`), + const allItemKeys = useMemo( + (): string[] => [ + ...flatModels.map((model) => modelPickerModelKey(model.instanceId, model.slug)), + ...new Set( + flatModels + .filter((model) => model.isLegacy) + .map((model) => modelPickerLegacySectionKey(model.instanceId)), + ), + ], [flatModels], ); - const filteredModelKeys = useMemo( - (): string[] => filteredModels.map((model) => `${model.instanceId}:${model.slug}`), - [filteredModels], - ); + const filteredItemKeys = useMemo((): string[] => { + const modelKeys = visibleModels.map((model) => + modelPickerModelKey(model.instanceId, model.slug), + ); + if (!legacySection) { + return modelKeys; + } + modelKeys.splice(legacySection.currentModels.length, 0, legacySection.key); + return modelKeys; + }, [legacySection, visibleModels]); const filteredModelByKey = useMemo( (): ReadonlyMap => - new Map(filteredModels.map((model) => [`${model.instanceId}:${model.slug}`, model] as const)), - [filteredModels], + new Map( + visibleModels.map( + (model) => [modelPickerModelKey(model.instanceId, model.slug), model] as const, + ), + ), + [visibleModels], ); const updateModelListScrollFades = useCallback(() => { const scrollElement = modelListRef.current?.getScrollableNode(); @@ -495,10 +561,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (!targetModelKey) { return; } - const { instanceId, slug } = splitInstanceModelKey(targetModelKey); + const model = parseModelPickerModelKey(targetModelKey); + if (!model) { + return; + } event.preventDefault(); event.stopPropagation(); - handleModelSelect(slug, instanceId); + handleModelSelect(model.slug, model.instanceId); }; window.addEventListener("keydown", onWindowKeyDown, true); @@ -510,7 +579,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { useLayoutEffect(() => { setShowTopScrollFade(false); - setShowBottomScrollFade(filteredModelKeys.length > 5); + setShowBottomScrollFade(filteredItemKeys.length > 5); let nestedFrame = 0; const frame = window.requestAnimationFrame(() => { updateModelListScrollFades(); @@ -520,7 +589,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { window.cancelAnimationFrame(frame); window.cancelAnimationFrame(nestedFrame); }; - }, [filteredModelKeys, updateModelListScrollFades]); + }, [filteredItemKeys, updateModelListScrollFades]); return ( @@ -548,13 +617,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { {/* Main content area */} { highlightedModelKeyRef.current = typeof modelKey === "string" ? modelKey : null; if (eventDetails.reason === "keyboard" && eventDetails.index >= 0) { @@ -568,8 +637,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (typeof modelKey !== "string") { return; } - const { instanceId, slug } = splitInstanceModelKey(modelKey); - handleModelSelect(slug, instanceId); + const legacyInstanceId = parseModelPickerLegacySectionKey(modelKey); + if (legacyInstanceId) { + toggleLegacySection(legacyInstanceId); + return; + } + const model = parseModelPickerModelKey(modelKey); + if (model) { + handleModelSelect(model.slug, model.instanceId); + } }} >
ref={modelListRef} - data={filteredModelKeys} + data={filteredItemKeys} extraData={favoritesSet} keyExtractor={(modelKey) => modelKey} renderItem={({ item: modelKey, index }) => { + if (legacySection?.key === modelKey) { + return ( + +
+
Legacy models
+
+ {legacySection.legacyModels.length} models +
+
+ +
+ ); + } const model = filteredModelByKey.get(modelKey); if (!model) { return null; @@ -645,8 +753,12 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { driverKind={model.driverKind} providerDisplayName={model.instanceDisplayName} providerAccentColor={model.instanceAccentColor} - isFavorite={favoritesSet.has(modelKey)} - isSelected={modelKey === `${props.activeInstanceId}:${props.model}`} + isFavorite={favoritesSet.has( + providerModelKey(model.instanceId, model.slug), + )} + isSelected={ + modelKey === modelPickerModelKey(props.activeInstanceId, props.model) + } showProvider preferShortName={!isLocked} useTriggerLabel={false} @@ -657,7 +769,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { /> ); }} - estimatedItemSize={60} + estimatedItemSize={52} drawDistance={480} recycleItems contentContainerClassName="pl-2 pr-px" diff --git a/apps/web/src/components/chat/modelPickerKeys.test.ts b/apps/web/src/components/chat/modelPickerKeys.test.ts new file mode 100644 index 00000000000..e56fcb3d414 --- /dev/null +++ b/apps/web/src/components/chat/modelPickerKeys.test.ts @@ -0,0 +1,31 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { + modelPickerLegacySectionKey, + modelPickerModelKey, + parseModelPickerLegacySectionKey, + parseModelPickerModelKey, +} from "./modelPickerKeys"; + +describe("model picker item keys", () => { + it("keeps model and legacy section keys distinct for colliding instance names", () => { + const modelKey = modelPickerModelKey(ProviderInstanceId.make("legacy-models"), "codex"); + const sectionKey = modelPickerLegacySectionKey(ProviderInstanceId.make("codex")); + + expect(modelKey).not.toBe(sectionKey); + expect(parseModelPickerLegacySectionKey(modelKey)).toBeNull(); + expect(parseModelPickerModelKey(modelKey)).toEqual({ + instanceId: "legacy-models", + slug: "codex", + }); + }); + + it("round-trips arbitrary strings without throwing", () => { + const instanceId = ProviderInstanceId.make("custom"); + const slug = "model:\udfff"; + + const key = modelPickerModelKey(instanceId, slug); + + expect(parseModelPickerModelKey(key)).toEqual({ instanceId, slug }); + }); +}); diff --git a/apps/web/src/components/chat/modelPickerKeys.ts b/apps/web/src/components/chat/modelPickerKeys.ts new file mode 100644 index 00000000000..0b4f0845e11 --- /dev/null +++ b/apps/web/src/components/chat/modelPickerKeys.ts @@ -0,0 +1,47 @@ +import type { ProviderInstanceId } from "@t3tools/contracts"; + +const MODEL_KEY_PREFIX = "model:"; +const LEGACY_SECTION_KEY_PREFIX = "legacy-models:"; + +export function modelPickerModelKey(instanceId: ProviderInstanceId, slug: string): string { + return `${MODEL_KEY_PREFIX}${instanceId.length}:${instanceId}${slug}`; +} + +export function parseModelPickerModelKey( + key: string, +): { instanceId: ProviderInstanceId; slug: string } | null { + if (!key.startsWith(MODEL_KEY_PREFIX)) { + return null; + } + const encoded = key.slice(MODEL_KEY_PREFIX.length); + const separatorIndex = encoded.indexOf(":"); + if (separatorIndex === -1) { + return null; + } + + const instanceIdLengthText = encoded.slice(0, separatorIndex); + if (!/^\d+$/.test(instanceIdLengthText)) { + return null; + } + + const instanceIdLength = Number(instanceIdLengthText); + const value = encoded.slice(separatorIndex + 1); + if (!Number.isSafeInteger(instanceIdLength) || instanceIdLength > value.length) { + return null; + } + + return { + instanceId: value.slice(0, instanceIdLength) as ProviderInstanceId, + slug: value.slice(instanceIdLength), + }; +} + +export function modelPickerLegacySectionKey(instanceId: ProviderInstanceId): string { + return `${LEGACY_SECTION_KEY_PREFIX}${instanceId}`; +} + +export function parseModelPickerLegacySectionKey(key: string): ProviderInstanceId | null { + return key.startsWith(LEGACY_SECTION_KEY_PREFIX) + ? (key.slice(LEGACY_SECTION_KEY_PREFIX.length) as ProviderInstanceId) + : null; +} diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index f9e7a700716..842c616fe1f 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -26,6 +26,7 @@ export type ModelEsque = { name: string; shortName?: string | undefined; subProvider?: string | undefined; + isLegacy?: boolean | undefined; }; function escapeRegExp(value: string): string { diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index e8fc8a244d0..a35fb752b44 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -55,6 +55,24 @@ function settingsWithProviderInstances(): UnifiedSettings { } describe("instance-scoped model selection", () => { + it("preserves server-provided legacy model metadata", () => { + const baseProvider = provider({ + instanceId: "claudeAgent", + models: ["claude-opus-4-8"], + }); + const providers = [ + { + ...baseProvider, + models: [{ ...baseProvider.models[0]!, isLegacy: true }], + }, + ]; + const stock = deriveProviderInstanceEntries(providers)[0]!; + + expect(getAppModelOptionsForInstance(settingsWithProviderInstances(), stock)[0]?.isLegacy).toBe( + true, + ); + }); + it("keeps custom models on the provider instance that declared them", () => { const providers = [ provider({ diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 72c5eb14cc2..2763245299d 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -76,6 +76,7 @@ export interface AppModelOption { subProvider?: string; isCustom: boolean; isDefault?: boolean; + isLegacy?: boolean; } function toAppModelOption(model: ServerProvider["models"][number]): AppModelOption { @@ -87,6 +88,7 @@ function toAppModelOption(model: ServerProvider["models"][number]): AppModelOpti if (model.shortName) option.shortName = model.shortName; if (model.subProvider) option.subProvider = model.subProvider; if (model.isDefault) option.isDefault = true; + if (model.isLegacy) option.isLegacy = true; return option; } diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 078e9fcbf33..eaf1578aae7 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -73,6 +73,30 @@ describe("ServerProvider", () => { expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); }); + + it("decodes optional legacy model metadata", () => { + const parsed = decodeServerProvider({ + instanceId: "codex", + driver: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [ + { + slug: "gpt-5.4", + name: "GPT-5.4", + isCustom: false, + isLegacy: true, + capabilities: null, + }, + ], + }); + + expect(parsed.models[0]?.isLegacy).toBe(true); + }); }); describe("server config forward compatibility", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index bb139c9782d..fc322db6206 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -68,6 +68,7 @@ export const ServerProviderModel = Schema.Struct({ subProvider: Schema.optional(TrimmedNonEmptyString), isCustom: Schema.Boolean, isDefault: Schema.optional(Schema.Boolean), + isLegacy: Schema.optional(Schema.Boolean), capabilities: Schema.NullOr(ModelCapabilities), }); export type ServerProviderModel = typeof ServerProviderModel.Type;