diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index af8f5d6704b..bf3e3e8a2de 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -56,6 +56,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-fable-5", name: "Claude Fable 5", + shortName: "Fable 5", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ @@ -87,6 +88,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-opus-4-8", name: "Claude Opus 4.8", + shortName: "Opus 4.8", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ @@ -114,6 +116,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-opus-4-7", name: "Claude Opus 4.7", + shortName: "Opus 4.7", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ @@ -140,6 +143,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-opus-4-6", name: "Claude Opus 4.6", + shortName: "Opus 4.6", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ @@ -173,6 +177,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-opus-4-5", name: "Claude Opus 4.5", + shortName: "Opus 4.5", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ @@ -196,6 +201,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-sonnet-5", name: "Claude Sonnet 5", + shortName: "Sonnet 5", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ @@ -226,6 +232,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", + shortName: "Sonnet 4.6", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ @@ -255,6 +262,7 @@ const BUILT_IN_MODELS: ReadonlyArray = [ { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5", + shortName: "Haiku 4.5", isCustom: false, capabilities: createModelCapabilities({ optionDescriptors: [ diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0e21b76306b..46b4a2af18a 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,43 @@ import { assert, it } from "@effect/vitest"; -import { mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + mapCodexModelCapabilities, + parseCodexModelListResponse, + toCodexShortName, +} from "./CodexProvider.ts"; + +it("derives concise names for Codex-owned GPT models", () => { + assert.strictEqual(toCodexShortName("GPT-5.6-Sol"), "5.6 Sol"); + assert.strictEqual(toCodexShortName("GPT-5.6-Terra"), "5.6 Terra"); + assert.strictEqual(toCodexShortName("GPT-5.4-Mini"), "5.4 Mini"); + assert.strictEqual(toCodexShortName("GPT-5.5"), "5.5"); + assert.strictEqual(toCodexShortName("GPT-4o"), "4o"); + assert.strictEqual(toCodexShortName("GPT-4o-Mini"), "4o Mini"); + assert.strictEqual(toCodexShortName("o3"), "o3"); +}); + +it("derives name and shortName from a raw catalog display name", () => { + const [model] = parseCodexModelListResponse({ + data: [ + { + additionalSpeedTiers: [], + defaultReasoningEffort: "medium", + defaultServiceTier: null, + description: "Test model", + displayName: "gpt-5.4-mini", + hidden: false, + id: "gpt-5.4-mini", + isDefault: false, + model: "gpt-5.4-mini", + serviceTiers: [], + supportedReasoningEfforts: [], + }, + ], + }); + + assert.strictEqual(model?.name, "GPT-5.4-Mini"); + assert.strictEqual(model?.shortName, "5.4 Mini"); +}); 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 a2182cfb73c..1dfd3b75e30 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -181,15 +181,30 @@ const toDisplayName = (model: CodexSchema.V2ModelListResponse__Model): string => .replace(/-([a-z])/g, (_, c) => "-" + c.toUpperCase()); }; -function parseCodexModelListResponse( +export const toCodexShortName = (name: string): string => { + const match = /^GPT-(\d+(?:\.\d+)*[a-z]?)(?:(?:[-\s]+)(.+))?$/iu.exec(name.trim()); + const version = match?.[1]; + if (!version) { + return name; + } + const suffix = match[2]?.trim().replace(/[-_]+/gu, " "); + return suffix ? `${version} ${suffix}` : version; +}; + +export function parseCodexModelListResponse( response: CodexSchema.V2ModelListResponse, ): ReadonlyArray { - return response.data.map((model) => ({ - slug: model.model, - name: toDisplayName(model), - isCustom: false, - capabilities: mapCodexModelCapabilities(model), - })); + return response.data.map((model) => { + const name = toDisplayName(model); + const shortName = toCodexShortName(name); + return { + slug: model.model, + name, + ...(shortName !== name ? { shortName } : {}), + isCustom: false, + capabilities: mapCodexModelCapabilities(model), + }; + }); } function appendCustomCodexModels( diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5456a90bdf5..b1edd5d3583 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1500,6 +1500,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ); const fable5 = status.models.find((model) => model.slug === "claude-fable-5"); assert.strictEqual(fable5?.name, "Claude Fable 5"); + assert.strictEqual(fable5?.shortName, "Fable 5"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { diff --git a/apps/web/src/components/chat/ModelListRow.tsx b/apps/web/src/components/chat/ModelListRow.tsx index 3f8915e5d8b..f66938f4825 100644 --- a/apps/web/src/components/chat/ModelListRow.tsx +++ b/apps/web/src/components/chat/ModelListRow.tsx @@ -1,12 +1,8 @@ import { type ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; import { memo } from "react"; import { CheckIcon, StarIcon } from "lucide-react"; -import { - getDisplayModelName, - getTriggerDisplayModelLabel, - type ModelEsque, - PROVIDER_ICON_BY_PROVIDER, -} from "./providerIconUtils"; +import { getDisplayModelName, type ModelEsque } from "./modelDisplayNames"; +import { PROVIDER_ICON_BY_PROVIDER } from "./providerIconUtils"; import { ComboboxItem } from "../ui/combobox"; import { Button } from "../ui/button"; import { Kbd } from "../ui/kbd"; @@ -26,12 +22,10 @@ export const ModelListRow = memo(function ModelListRow(props: { * "Codex Personal" render with their user-authored label. */ providerDisplayName: string; - providerAccentColor?: string | undefined; isFavorite: boolean; isSelected: boolean; showProvider: boolean; preferShortName?: boolean; - useTriggerLabel?: boolean; showNewBadge?: boolean; jumpLabel?: string | null; disabledReason?: string | null; @@ -50,26 +44,24 @@ export const ModelListRow = memo(function ModelListRow(props: { disabled={Boolean(props.disabledReason)} contentClassName="flex w-full items-center gap-3" className={cn( - "group relative w-full !min-w-0 max-w-full cursor-pointer rounded-md px-2 py-2.5 transition-[background-color,box-shadow,color]", - "data-highlighted:bg-muted/56 data-selected:bg-transparent data-selected:text-foreground data-selected:ring-0", + "group relative my-0.5 w-full !min-w-0 max-w-full cursor-pointer rounded-sm px-2 py-2 transition-[background-color,box-shadow,color]", + "data-highlighted:bg-muted/56 data-selected:bg-blue-500/10 data-selected:text-foreground data-selected:ring-0 [&[data-highlighted][data-selected]]:bg-blue-500/15", props.disabledReason && "data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:hover:bg-transparent", )} >
-
- {props.useTriggerLabel - ? getTriggerDisplayModelLabel(props.model) - : getDisplayModelName( - props.model, - props.preferShortName ? { preferShortName: true } : undefined, - )} +
+ {getDisplayModelName( + props.model, + props.preferShortName ? { preferShortName: true } : undefined, + )}
{props.isSelected ? : null} {props.showNewBadge ? ( New @@ -97,7 +89,7 @@ export const ModelListRow = memo(function ModelListRow(props: { size="icon-xs" variant="ghost" className={cn( - "-mr-1 shrink-0 text-muted-foreground/70 opacity-64 transition-[color,opacity] hover:text-foreground hover:opacity-100 group-hover:opacity-100", + "-mr-1 shrink-0 rounded-sm text-muted-foreground/70 opacity-64 transition-[color,opacity] before:rounded-[calc(var(--radius-sm)-1px)] hover:text-foreground hover:opacity-100 group-hover:opacity-100", props.isFavorite && "text-foreground opacity-100", )} onClick={(event) => { diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index e0d6b9afc5e..3f38dcbb3a5 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -12,7 +12,7 @@ import { ModelPickerSidebar } from "./ModelPickerSidebar"; import { isModelPickerNewModel } from "./modelPickerModelHighlights"; import { buildModelPickerSearchText, scoreModelPickerSearch } from "./modelPickerSearch"; import { Combobox, ComboboxEmpty, ComboboxInput, ComboboxListVirtualized } from "../ui/combobox"; -import { ModelEsque } from "./providerIconUtils"; +import { ModelEsque } from "./modelDisplayNames"; import { modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, @@ -37,7 +37,6 @@ type ModelPickerItem = { instanceId: ProviderInstanceId; driverKind: ProviderDriverKind; instanceDisplayName: string; - instanceAccentColor?: string | undefined; continuationGroupKey?: string | undefined; }; @@ -210,7 +209,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { instanceId, driverKind: entry.driverKind, instanceDisplayName: entry.displayName, - ...(entry.accentColor ? { instanceAccentColor: entry.accentColor } : {}), ...(entry.continuationGroupKey ? { continuationGroupKey: entry.continuationGroupKey } : {}), @@ -472,6 +470,15 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return mapping.size > 0 ? mapping : EMPTY_MODEL_JUMP_LABELS; }, [keybindings, modelJumpCommandByKey, modelJumpShortcutContext]); + const showProviderForEveryRow = isSearching || selectedInstanceId === "favorites"; + // Rows with a sub-provider render the taller two-line layout even in + // single-provider tabs, so size the estimate for them or the list + // mis-measures on first paint. + const hasSubProviderRows = useMemo( + () => filteredModels.some((model) => Boolean(model.subProvider)), + [filteredModels], + ); + useEffect(() => { const onWindowKeyDown = (event: globalThis.KeyboardEvent) => { if (event.defaultPrevented || event.repeat) { @@ -504,6 +511,34 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; }, [handleModelSelect, keybindings, modelJumpModelKeys, modelJumpShortcutContext]); + // LegendList only re-renders a row when its data item or `extraData` + // changes — a new `renderItem` closure alone is ignored. Everything the + // row render reads from surrounding state must therefore be represented + // here, or rows that keep their list position render stale output. + const listExtraData = useMemo( + () => ({ + favoritesSet, + isSearching, + selectedInstanceId, + activeModelKey: `${props.activeInstanceId}:${props.model}`, + filteredModelByKey, + getModelDisabledReason, + modelJumpLabelByKey, + toggleFavorite, + }), + [ + favoritesSet, + isSearching, + selectedInstanceId, + props.activeInstanceId, + props.model, + filteredModelByKey, + getModelDisabledReason, + modelJumpLabelByKey, + toggleFavorite, + ], + ); + useLayoutEffect(() => { setShowTopScrollFade(false); setShowBottomScrollFade(filteredModelKeys.length > 5); @@ -619,11 +654,11 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { {/* Model list */}
- + ref={modelListRef} data={filteredModelKeys} - extraData={favoritesSet} + extraData={listExtraData} keyExtractor={(modelKey) => modelKey} renderItem={({ item: modelKey, index }) => { const model = filteredModelByKey.get(modelKey); @@ -640,12 +675,18 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { instanceId={model.instanceId} driverKind={model.driverKind} providerDisplayName={model.instanceDisplayName} - providerAccentColor={model.instanceAccentColor} isFavorite={favoritesSet.has(modelKey)} - isSelected={modelKey === `${props.activeInstanceId}:${props.model}`} - showProvider - preferShortName={!isLocked} - useTriggerLabel={false} + isSelected={modelKey === listExtraData.activeModelKey} + showProvider={ + // When browsing a single provider's tab the sidebar + // already says which provider these models belong + // to, so the per-row provider line is redundant. + // Keep it in mixed-provider views (favorites, + // search) and for models that carry a sub-provider + // distinction (e.g. "Claude · Bedrock"). + showProviderForEveryRow || Boolean(model.subProvider) + } + preferShortName showNewBadge={isModelPickerNewModel(model.driverKind, model.slug)} jumpLabel={modelJumpLabelByKey.get(modelKey) ?? null} disabledReason={disabledReason} @@ -653,13 +694,13 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { /> ); }} - estimatedItemSize={60} + estimatedItemSize={showProviderForEveryRow || hasSubProviderRows ? 60 : 40} drawDistance={480} recycleItems onLayout={updateModelListScrollFades} onScroll={updateModelListScrollFades} className={cn( - "scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", + "model-picker-list scrollbar-gutter-both h-full overflow-x-hidden overscroll-y-contain py-1.5 [--fade-size:1.5rem]", showTopScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", showBottomScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", )} diff --git a/apps/web/src/components/chat/ModelPickerSidebar.tsx b/apps/web/src/components/chat/ModelPickerSidebar.tsx index e5555cb0115..c0a479e62ea 100644 --- a/apps/web/src/components/chat/ModelPickerSidebar.tsx +++ b/apps/web/src/components/chat/ModelPickerSidebar.tsx @@ -102,10 +102,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: { data-model-picker-sidebar="true" >
-
+
{selectedIndicatorTop !== null ? (
handleSelect("favorites")} type="button" @@ -172,7 +169,7 @@ export const ModelPickerSidebar = memo(function ModelPickerSidebar(props: {