Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/__tests__/model-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ describe("ModelRegistry", () => {
contextWindowOptions: ["200k", "1m"],
supportedReasoningLevels: ["low", "medium", "high", "xhigh", "max"],
defaultReasoningLevel: "high",
lifecycle: "legacy",
});
});

Expand Down
118 changes: 94 additions & 24 deletions apps/web/src/components/chat/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
findModelById,
isModelAvailable,
registerProviderModels,
toModelDefinition,
type ModelProvider,
} from "@/lib/model-registry";
import { getTransport } from "@/transport";
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -507,6 +562,7 @@ export function ModelSelector({
const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState<ModelPickerTab>(FAVORITES_TAB);
const [query, setQuery] = useState("");
const [legacyFold, setLegacyFold] = useState<LegacyFoldState>("auto");

const favorites = useModelFavoritesStore((store) => store.entries);
const toggleFavorite = useModelFavoritesStore((store) => store.toggleFavorite);
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
};

Expand Down Expand Up @@ -616,7 +686,7 @@ export function ModelSelector({
numberedShortcuts
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
onTabChange={showTab}
query={query}
onQueryChange={setQuery}
searchPlaceholder="Search models"
Expand All @@ -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}
Expand Down
92 changes: 92 additions & 0 deletions apps/web/src/components/chat/__tests__/ModelSelector.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ModelSelector selectedModelId={selectedModelId} selectedProviderId="claude"
onSelect={onSelect} locked={false} />);
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(<ModelSelector selectedModelId="claude-opus-5-5" selectedProviderId="claude"
onSelect={vi.fn()} locked={false} />);
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"]);
});
});
});
22 changes: 19 additions & 3 deletions apps/web/src/components/ui/picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -112,7 +114,7 @@ export interface PickerProps<T> {
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;
}
Expand Down Expand Up @@ -403,7 +405,9 @@ function useShortcutRows<T>(rows: readonly ListRow<T>[], 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]);
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -596,6 +601,7 @@ function PickerOptionContent({ row, digit, selected, selectedCheck, onRunAction
) : null}
</span>
{row.tag ? <span className="max-w-[60%] min-w-0 text-caption text-fade text-muted">{row.tag}</span> : null}
<PickerDisclosureChevron expanded={row.expanded} />
{digit !== undefined ? (
<span aria-hidden className="shrink-0 text-muted">
<Kbd variant="keycap">{formatKeybinding(`mod+${digit}`, isMac)}</Kbd>
Expand All @@ -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 (
<span aria-hidden className="flex size-[1.4rem] shrink-0 items-center justify-center text-muted">
<Chevron className="size-[1.4rem]" />
</span>
);
}

function PickerRowActionButton({ action, onRun }: { readonly action: PickerRowAction; readonly onRun: () => void }) {
return (
<button
Expand Down
Loading
Loading