diff --git a/apps/web/src/__tests__/model-registry.test.ts b/apps/web/src/__tests__/model-registry.test.ts index ae8be38f0..e26587bbd 100644 --- a/apps/web/src/__tests__/model-registry.test.ts +++ b/apps/web/src/__tests__/model-registry.test.ts @@ -112,6 +112,7 @@ describe("ModelRegistry", () => { contextWindowOptions: ["200k", "1m"], supportedReasoningLevels: ["low", "medium", "high", "xhigh", "max"], defaultReasoningLevel: "high", + lifecycle: "legacy", }); }); diff --git a/apps/web/src/components/chat/ModelSelector.tsx b/apps/web/src/components/chat/ModelSelector.tsx index b6cce16dc..2ea44ba3d 100644 --- a/apps/web/src/components/chat/ModelSelector.tsx +++ b/apps/web/src/components/chat/ModelSelector.tsx @@ -8,6 +8,7 @@ import { findModelById, isModelAvailable, registerProviderModels, + toModelDefinition, type ModelProvider, } from "@/lib/model-registry"; import { getTransport } from "@/transport"; @@ -94,6 +95,7 @@ interface ProviderModelCatalog { /** One model the picker can list, from a provider catalog or the favourites. */ interface ModelOption { + readonly kind: "model"; readonly modelId: string; readonly providerId: string; readonly label: string; @@ -105,6 +107,25 @@ interface ModelOption { readonly showProvider: boolean; } +/** The row at the end of a provider tab that shows or hides its legacy models. */ +interface LegacyFoldOption { + readonly kind: "legacy-fold"; + readonly providerId: string; + readonly count: number; + readonly expanded: boolean; +} + +type ModelPickerOption = ModelOption | LegacyFoldOption; + +/** Divides the legacy fold and the models behind it from the current models above. */ +const LEGACY_GROUP = "legacy"; + +/** + * The fold follows the selected model until the user toggles it: `auto` opens it only when the + * selected model is legacy, so a thread's own model is never hidden behind it. + */ +type LegacyFoldState = "auto" | "open" | "closed"; + function findDisplayProvider( selectedProviderId: string | undefined, normalizedModelId: string, @@ -272,16 +293,7 @@ function useProviderModelCatalog(): ProviderModelCatalog { setLoadingProviders((currentProviders) => new Set(currentProviders).add(providerId)); try { const providerModels = await getTransport().listProviderModels(providerId); - const models: ModelProvider["models"] = providerModels.map((model) => ({ - id: model.id, - label: model.name, - providerId, - group: model.group, - contextWindow: model.contextWindow, - supportedReasoningLevels: model.supportedReasoningEfforts, - defaultReasoningLevel: model.defaultReasoningEffort, - multiplier: model.multiplier, - })); + const models = providerModels.map((model) => toModelDefinition(providerId, model)); const updatedModels = new Map(dynamicModelsRef.current).set(providerId, models); dynamicModelsRef.current = updatedModels; setDynamicModels(updatedModels); @@ -368,25 +380,57 @@ function formatEndDate(availableUntil: string): string { }); } -function providerModelOptions( +function isLegacyModel(model: ModelProvider["models"][number]): boolean { + return model.lifecycle === "legacy"; +} + +/** Whether the legacy fold shows its models, given the user's toggle and the selected model. */ +function isLegacyFoldExpanded( + foldState: LegacyFoldState, + models: ModelProvider["models"], + selectedModelId: string | undefined, +): boolean { + if (foldState !== "auto") return foldState === "open"; + return models.some((model) => isLegacyModel(model) && model.id === selectedModelId); +} + +function catalogModelOptions( providerId: string, models: ModelProvider["models"], - searchQuery: string, + groupOverride?: string, ): ModelOption[] { - const filteredModels = filterModelsBySearchQuery(models, searchQuery); - const groups = groupModels(filteredModels) ?? [{ label: undefined, models: filteredModels }]; + const groups = groupModels(models) ?? [{ label: undefined, models }]; return groups.flatMap((group) => group.models.map((model) => ({ + kind: "model" as const, modelId: model.id, providerId, label: model.label, - group: group.label, + group: groupOverride ?? group.label, endedOn: accessEndedOn(model), showProvider: false, })), ); } +function providerModelOptions( + providerId: string, + models: ModelProvider["models"], + searchQuery: string, + legacyExpanded: boolean, +): ModelPickerOption[] { + // A search reaches every model, so a legacy match is listed with the rest instead of behind the fold. + if (tokenizeSearch(searchQuery).length > 0) { + return catalogModelOptions(providerId, filterModelsBySearchQuery(models, searchQuery)); + } + const currentOptions = catalogModelOptions(providerId, models.filter((model) => !isLegacyModel(model))); + const legacyModels = models.filter(isLegacyModel); + if (legacyModels.length === 0) return currentOptions; + const fold: LegacyFoldOption = { kind: "legacy-fold", providerId, count: legacyModels.length, expanded: legacyExpanded }; + const legacyOptions = legacyExpanded ? catalogModelOptions(providerId, legacyModels, LEGACY_GROUP) : []; + return [...currentOptions, fold, ...legacyOptions]; +} + function accessEndedOn(model: ModelProvider["models"][number]): string | undefined { return model.availableUntil && !isModelAvailable(model) ? formatEndDate(model.availableUntil) : undefined; } @@ -400,6 +444,7 @@ function favoriteModelOptions( const provider = MODEL_PROVIDERS.find((entry) => entry.id === favorite.providerId); const model = provider && getModels(provider).find((entry) => entry.id === favorite.modelId); return { + kind: "model" as const, modelId: favorite.modelId, providerId: favorite.providerId, label: favorite.label, @@ -445,6 +490,16 @@ function favoriteAction( }; } +function legacyFoldRow(option: LegacyFoldOption): PickerRow { + return { + key: `${option.providerId}:legacy-fold`, + name: "Legacy models", + tag: String(option.count), + expanded: option.expanded, + group: LEGACY_GROUP, + }; +} + function LockedModelLabel({ iconProviderId, shortLabel, @@ -507,6 +562,7 @@ export function ModelSelector({ const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState(FAVORITES_TAB); const [query, setQuery] = useState(""); + const [legacyFold, setLegacyFold] = useState("auto"); const favorites = useModelFavoritesStore((store) => store.entries); const toggleFavorite = useModelFavoritesStore((store) => store.toggleFavorite); @@ -538,16 +594,21 @@ export function ModelSelector({ [favorites], ); - const items = useMemo(() => { + const selectedModelInTab = presentation.selectedProviderId === activeTab ? presentation.normalizedModelId : undefined; + const items = useMemo((): ModelPickerOption[] => { if (activeTab === FAVORITES_TAB) { return favoriteModelOptions(filterFavoritesBySearchQuery(favoritesVisible, query), getModels); } const provider = MODEL_PROVIDERS.find((entry) => entry.id === activeTab); - return provider ? providerModelOptions(provider.id, getModels(provider), query) : []; - }, [activeTab, favoritesVisible, getModels, query]); + if (!provider) return []; + const models = getModels(provider); + const legacyExpanded = isLegacyFoldExpanded(legacyFold, models, selectedModelInTab); + return providerModelOptions(provider.id, models, query, legacyExpanded); + }, [activeTab, favoritesVisible, getModels, query, legacyFold, selectedModelInTab]); const renderItem = useCallback( - (option: ModelOption): PickerRow => { + (option: ModelPickerOption): PickerRow => { + if (option.kind === "legacy-fold") return legacyFoldRow(option); const key = optionKey(option.providerId, option.modelId); const starred = starredKeys.has(key); return { @@ -569,11 +630,20 @@ export function ModelSelector({ [starredKeys, toggleFavorite], ); - useResetPickerOnOpen(open, providerLocked, displayProvider, defaultProviderId, setActiveTab, setQuery); + const showTab = useCallback((tab: ModelPickerTab) => { + setActiveTab(tab); + setLegacyFold("auto"); + }, []); + + useResetPickerOnOpen(open, providerLocked, displayProvider, defaultProviderId, showTab, setQuery); useFetchProviderModelsWhenOpen(open, locked, activeTab, favoritesVisible, fetchProviderModels); - const handleSelectModel = (modelId: string, providerId: string) => { - onSelect(modelId, providerId); + const handlePick = (option: ModelPickerOption) => { + if (option.kind === "legacy-fold") { + setLegacyFold(option.expanded ? "closed" : "open"); + return; + } + onSelect(option.modelId, option.providerId); setOpen(false); }; @@ -616,7 +686,7 @@ export function ModelSelector({ numberedShortcuts tabs={tabs} activeTab={activeTab} - onTabChange={setActiveTab} + onTabChange={showTab} query={query} onQueryChange={setQuery} searchPlaceholder="Search models" @@ -629,7 +699,7 @@ export function ModelSelector({ : undefined } renderItem={renderItem} - onSelect={(option) => handleSelectModel(option.modelId, option.providerId)} + onSelect={handlePick} footer={settings} hairlines={false} selectedCheck={false} diff --git a/apps/web/src/components/chat/__tests__/ModelSelector.test.tsx b/apps/web/src/components/chat/__tests__/ModelSelector.test.tsx index 989a7c4a2..c7073bbc4 100644 --- a/apps/web/src/components/chat/__tests__/ModelSelector.test.tsx +++ b/apps/web/src/components/chat/__tests__/ModelSelector.test.tsx @@ -351,4 +351,96 @@ describe("ModelSelector", () => { expect(goTo1).not.toHaveBeenCalled(); expect(onSelect).toHaveBeenCalledWith("gpt-9-zeta", "codex"); }); + + describe("legacy models", () => { + const LIVE_CLAUDE = [ + { id: "claude-opus-5-5", name: "Claude Opus 5.5" }, + { id: "claude-opus-5", name: "Claude Opus 5", lifecycle: "legacy" }, + { id: "claude-sonnet-5-5", name: "Claude Sonnet 5.5" }, + { id: "claude-sonnet-5", name: "Claude Sonnet 5", lifecycle: "legacy" }, + ]; + + async function openClaudeTab(selectedModelId: string, onSelect = vi.fn()) { + listProviderModels.mockResolvedValue(LIVE_CLAUDE); + render(); + await openPicker(); + await waitFor(() => expect(optionNames()[0]).toBe("Claude Opus 5.5")); + } + + it("folds legacy models behind a counted row that reveals and hides them in place", async () => { + const onSelect = vi.fn(); + await openClaudeTab("claude-opus-5-5", onSelect); + + expect(optionNames()).toEqual(["Claude Opus 5.5", "Claude Sonnet 5.5", "Legacy models2"]); + const fold = screen.getByRole("option", { name: /Legacy models/ }); + expect(fold).toHaveAttribute("aria-expanded", "false"); + + await userEvent.click(fold); + expect(optionNames()).toEqual([ + "Claude Opus 5.5", "Claude Sonnet 5.5", "Legacy models2", "Claude Opus 5", "Claude Sonnet 5", + ]); + expect(screen.getByRole("option", { name: /Legacy models/ })).toHaveAttribute("aria-expanded", "true"); + expect(onSelect).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("option", { name: /Legacy models/ })); + expect(optionNames()).toEqual(["Claude Opus 5.5", "Claude Sonnet 5.5", "Legacy models2"]); + }); + + it("opens the fold with Enter from the search field", async () => { + await openClaudeTab("claude-opus-5-5"); + await waitFor(() => expect(screen.getByRole("combobox")).toHaveFocus()); + + await userEvent.keyboard("{End}{Enter}"); + + expect(screen.getByRole("option", { name: /Claude Opus 5$/ })).toBeInTheDocument(); + }); + + it("gives the numbered shortcuts to models, not to the fold row", async () => { + const onSelect = vi.fn(); + await openClaudeTab("claude-opus-5-5", onSelect); + await userEvent.click(screen.getByRole("option", { name: /Legacy models/ })); + + await userEvent.keyboard("{Control>}3{/Control}"); + + expect(onSelect).toHaveBeenCalledWith("claude-opus-5", "claude"); + }); + + it("opens the fold on the selected legacy model so it is never hidden", async () => { + await openClaudeTab("claude-sonnet-5"); + + const selected = screen.getByRole("option", { selected: true }); + expect(selected).toHaveTextContent("Claude Sonnet 5"); + expect(screen.getByRole("option", { name: /Legacy models/ })).toHaveAttribute("aria-expanded", "true"); + }); + + it("lists matching legacy models unfolded while searching", async () => { + await openClaudeTab("claude-opus-5-5"); + + await userEvent.type(screen.getByRole("combobox"), "sonnet"); + + expect(optionNames()).toEqual(["Claude Sonnet 5.5", "Claude Sonnet 5"]); + }); + + it("shows no fold for a catalog without legacy models", async () => { + listProviderModels.mockResolvedValue(LIVE_CLAUDE.filter((model) => model.lifecycle === undefined)); + render(); + await openPicker(); + + await waitFor(() => expect(optionNames()).toEqual(["Claude Opus 5.5", "Claude Sonnet 5.5"])); + }); + + it("keeps a starred legacy model in Favourites", async () => { + useModelFavoritesStore.setState({ + entries: [{ providerId: "claude", modelId: "claude-opus-5", label: "Claude Opus 5" }], + }); + await openClaudeTab("claude-opus-5-5"); + + await userEvent.click(screen.getByRole("radio", { name: "Favourites" })); + + expect(optionNames()).toEqual(["Claude Opus 5Claude"]); + }); + }); }); diff --git a/apps/web/src/components/ui/picker.tsx b/apps/web/src/components/ui/picker.tsx index fb1dcc387..dabe265c3 100644 --- a/apps/web/src/components/ui/picker.tsx +++ b/apps/web/src/components/ui/picker.tsx @@ -11,7 +11,7 @@ import { type KeyboardEvent, type ReactNode, } from "react"; -import { CheckIcon, SearchIcon } from "lucide-react"; +import { CheckIcon, ChevronDownIcon, ChevronRightIcon, SearchIcon } from "lucide-react"; import { formatKeybinding } from "@/lib/keybinding-manager"; import { isMac } from "@/lib/platform"; @@ -46,6 +46,8 @@ export interface PickerRow { readonly description?: ReactNode; /** A secondary action on the row, such as starring it. Running it never picks the row. */ readonly action?: PickerRowAction; + /** Makes the row a disclosure whose pick shows or hides the rows after it, drawn with a chevron. */ + readonly expanded?: boolean; /** Rows sharing a group sit together; a divider marks where the group changes. */ readonly group?: string; /** Text heading drawn before the first row of this row's group, in place of the divider. */ @@ -112,7 +114,7 @@ export interface PickerProps { readonly hairlines?: boolean; /** Set false to mark the selected row by its fill alone, without the trailing check. Defaults to true. */ readonly selectedCheck?: boolean; - /** Ctrl/Cmd+1..9 pick the Nth selectable row, and each such row shows its keycap. Defaults to false. */ + /** Ctrl/Cmd+1..9 pick the Nth selectable row, skipping disclosure rows; each such row shows its keycap. Defaults to false. */ readonly numberedShortcuts?: boolean; readonly onSelect: (item: T) => void; } @@ -403,7 +405,9 @@ function useShortcutRows(rows: readonly ListRow[], enabled: boolean): read if (!enabled) return []; const indexes: number[] = []; for (let index = 0; index < rows.length && indexes.length < 9; index += 1) { - if (!rows[index]?.row.disabled) indexes.push(index); + // A disclosure row shows or hides rows rather than picking one, so it takes no pick shortcut. + const row = rows[index]?.row; + if (row && !row.disabled && row.expanded === undefined) indexes.push(index); } return indexes; }, [rows, enabled]); @@ -559,6 +563,7 @@ function PickerOptionItem({ id, row, digit, active, selected, selectedCheck, onH role="option" aria-selected={selected} aria-disabled={row.disabled || undefined} + aria-expanded={row.expanded} aria-describedby={descriptions.describedBy} aria-keyshortcuts={descriptions.keyShortcuts} data-active={active || undefined} @@ -596,6 +601,7 @@ function PickerOptionContent({ row, digit, selected, selectedCheck, onRunAction ) : null} {row.tag ? {row.tag} : null} + {digit !== undefined ? ( {formatKeybinding(`mod+${digit}`, isMac)} @@ -611,6 +617,16 @@ function PickerOptionContent({ row, digit, selected, selectedCheck, onRunAction ); } +function PickerDisclosureChevron({ expanded }: { readonly expanded: boolean | undefined }) { + if (expanded === undefined) return null; + const Chevron = expanded ? ChevronDownIcon : ChevronRightIcon; + return ( + + + + ); +} + function PickerRowActionButton({ action, onRun }: { readonly action: PickerRowAction; readonly onRun: () => void }) { return (