diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index 5ee3743a33ee..3cdb4d26df44 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -45,7 +45,11 @@ import { isProviderInstancePickerVisible, type ProviderInstanceEntry, } from "../../providerInstances"; -import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; +import { + partitionLegacyModels, + providerModelKey, + sortProviderModelItems, +} from "../../modelOrdering"; type ModelPickerItem = { slug: string; @@ -238,16 +242,30 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { return favorites.length > 0 ? "favorites" : props.activeInstanceId; }, ); - const [expandedLegacyInstances, setExpandedLegacyInstances] = useState( - () => - new Set( + // Create a Set for efficient lookup. Favorites are keyed by + // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind + // to ProviderInstanceId so pre-migration favorites keyed by driver slugs + // (e.g. `"codex:gpt-5"`) still resolve — the default instance id equals + // the driver slug. + const favoritesSet = useMemo(() => { + return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model))); + }, [favorites]); + const [expandedLegacyInstances, setExpandedLegacyInstances] = useState(() => { + // Auto-expand the legacy group only when the active model actually lives + // there. A favorited legacy model is hoisted into the main list, so + // expanding the group for it would open an unrelated section. + const activeIsFavorite = favoritesSet.has( + providerModelKey(props.activeInstanceId, activeModelSlug), + ); + return new Set( + !activeIsFavorite && modelOptionsByInstance .get(props.activeInstanceId) ?.some((model) => model.slug === activeModelSlug && model.isLegacy) - ? [props.activeInstanceId] - : [], - ), - ); + ? [props.activeInstanceId] + : [], + ); + }); const serverKeybindings = useAtomValue(primaryServerKeybindingsAtom); const keybindings = providedKeybindings ?? serverKeybindings; const updateSettings = useUpdateClientSettings(); @@ -280,15 +298,6 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { }; }, [focusSearchInput]); - // Create a Set for efficient lookup. Favorites are keyed by - // `${instanceId}:${slug}`; the storage schema widened from ProviderDriverKind - // to ProviderInstanceId so pre-migration favorites keyed by driver slugs - // (e.g. `"codex:gpt-5"`) still resolve — the default instance id equals - // the driver slug. - const favoritesSet = useMemo(() => { - return new Set(favorites.map((fav) => providerModelKey(fav.provider, fav.model))); - }, [favorites]); - /** * Lookup table keyed by `instanceId`. Used for display name + driver * kind enrichment and for `ready`/enabled filtering before flattening @@ -522,8 +531,14 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { if (isSearching || selectedInstanceId === "favorites") { return null; } - const currentModels = filteredModels.filter((model) => !model.isLegacy); - const legacyModels = filteredModels.filter((model) => model.isLegacy); + // Favorited models are hoisted to the top, and that hoist has to win over + // the legacy split: a favorited legacy model stays in the main list rather + // than being buried in the collapsed legacy group. Unfavoriting it drops it + // back into the legacy section on the next render. + const { current: currentModels, legacy: legacyModels } = partitionLegacyModels( + filteredModels, + (model) => favoritesSet.has(providerModelKey(model.instanceId, model.slug)), + ); if (legacyModels.length === 0) { return null; } @@ -533,7 +548,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { legacyModels, isExpanded: expandedLegacyInstances.has(selectedInstanceId), }; - }, [expandedLegacyInstances, filteredModels, isSearching, selectedInstanceId]); + }, [expandedLegacyInstances, favoritesSet, filteredModels, isSearching, selectedInstanceId]); const visibleModels = useMemo(() => { if (!legacySection) { diff --git a/apps/web/src/modelOrdering.test.ts b/apps/web/src/modelOrdering.test.ts index 852d3b0e13dc..4d15b3b9fb64 100644 --- a/apps/web/src/modelOrdering.test.ts +++ b/apps/web/src/modelOrdering.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId } from "@t3tools/contracts"; import { + partitionLegacyModels, providerModelKey, sortModelsForProviderInstance, sortProviderModelItems, @@ -49,4 +50,59 @@ describe("model ordering", () => { }).map((item) => item.slug), ).toEqual(["gpt-5.4-mini", "gpt-5.5", "crest-alpha", "claude-opus-4-6"]); }); + + describe("partitionLegacyModels", () => { + const models = [ + { slug: "opus-5" }, + { slug: "sonnet-5" }, + { slug: "opus-4-8", isLegacy: true }, + { slug: "haiku-3", isLegacy: true }, + ]; + + it("keeps a favorited legacy model in the main list instead of the legacy group", () => { + const { current, legacy } = partitionLegacyModels( + models, + (model) => model.slug === "opus-4-8", + ); + + expect(current.map((model) => model.slug)).toEqual(["opus-5", "sonnet-5", "opus-4-8"]); + expect(legacy.map((model) => model.slug)).toEqual(["haiku-3"]); + }); + + it("returns legacy models to the legacy group once unfavorited", () => { + const { current, legacy } = partitionLegacyModels(models, () => false); + + expect(current.map((model) => model.slug)).toEqual(["opus-5", "sonnet-5"]); + expect(legacy.map((model) => model.slug)).toEqual(["opus-4-8", "haiku-3"]); + }); + + it("returns empty partitions for an empty list", () => { + const { current, legacy } = partitionLegacyModels([], () => false); + + expect(current).toEqual([]); + expect(legacy).toEqual([]); + }); + + it("treats an explicit isLegacy: false as a current model", () => { + const { current, legacy } = partitionLegacyModels( + [{ slug: "opus-5", isLegacy: false }], + () => false, + ); + + expect(current.map((model) => model.slug)).toEqual(["opus-5"]); + expect(legacy).toEqual([]); + }); + + it("leaves the legacy group empty when every legacy model is favorited", () => { + const { current, legacy } = partitionLegacyModels(models, (model) => model.isLegacy === true); + + expect(current.map((model) => model.slug)).toEqual([ + "opus-5", + "sonnet-5", + "opus-4-8", + "haiku-3", + ]); + expect(legacy).toEqual([]); + }); + }); }); diff --git a/apps/web/src/modelOrdering.ts b/apps/web/src/modelOrdering.ts index c8b5d9cec643..69b5399ce152 100644 --- a/apps/web/src/modelOrdering.ts +++ b/apps/web/src/modelOrdering.ts @@ -55,6 +55,32 @@ export function sortModelsForProviderInstance( return Arr.sort(models, Order.combineAll(orders)); } +export interface LegacyPartitionItem { + readonly isLegacy?: boolean | undefined; +} + +/** + * Split an already-sorted model list into the main list and the collapsed + * legacy group. Favorited models stay in the main list even when they are + * legacy, so the favorite hoist wins over the legacy split; unfavoriting a + * legacy model returns it to the legacy group. `isFavorite` decides membership. + */ +export function partitionLegacyModels( + models: ReadonlyArray, + isFavorite: (model: T) => boolean, +): { readonly current: T[]; readonly legacy: T[] } { + const current: T[] = []; + const legacy: T[] = []; + for (const model of models) { + if (model.isLegacy === true && !isFavorite(model)) { + legacy.push(model); + } else { + current.push(model); + } + } + return { current, legacy }; +} + export function sortProviderModelItems( items: ReadonlyArray, options?: {