feat(site): add searchable agent model picker (#26927)

This commit is contained in:
Danielle Maywood
2026-07-01 21:15:35 +00:00
committed by GitHub
parent 5573190530
commit 0c006a40f3
5 changed files with 207 additions and 106 deletions
@@ -1417,7 +1417,7 @@ export const AgentChatInput: FC<AgentChatInputProps> = ({
placeholder={modelSelectorPlaceholder}
className="md:shrink"
dropdownSide="top"
dropdownAlign="center"
dropdownAlign="start"
enableMobileFullWidthDropdown
/>
)}
@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import { ModelSelector, type ModelSelectorOption } from "./ModelSelector";
import { MockModelSelectorOption } from "./modelSelectorFixtures";
@@ -42,7 +42,7 @@ const anthropicModels: ModelSelectorOption[] = [
provider: "anthropic",
model: "claude-3-5-haiku-20241022",
displayName: "Claude 3.5 Haiku",
contextLimit: 200_000,
contextLimit: 1_000_000,
},
];
@@ -140,7 +140,7 @@ export const NoOptions: Story = {
};
// ---------------------------------------------------------------------------
// Play function – selection interaction
// Play function, selection interaction
// ---------------------------------------------------------------------------
export const SelectsModel: Story = {
@@ -152,15 +152,75 @@ export const SelectsModel: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// Open the popover by clicking the trigger.
const trigger = canvas.getByRole("combobox");
await userEvent.click(trigger);
// The dropdown should appear with model options.
const listbox = await within(document.body).findByRole("listbox");
const option = within(listbox).getByText("GPT-4o Mini");
await userEvent.click(option);
await userEvent.click(within(listbox).getByText("GPT-4o Mini"));
expect(args.onValueChange).toHaveBeenCalledWith("openai/gpt-4o-mini");
},
};
export const FiltersModels: Story = {
args: {
options: allModels,
value: "openai/gpt-4o",
onValueChange: fn(),
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const body = within(document.body);
const trigger = canvas.getByRole("combobox", { name: "GPT-4o" });
const openListbox = async () => {
await userEvent.click(trigger);
return body.findByRole("listbox");
};
const searchFor = async (
listbox: HTMLElement,
query: string,
expected: RegExp,
) => {
const input = body.getByPlaceholderText("Search...");
await userEvent.clear(input);
await userEvent.type(input, query);
await waitFor(() => {
expect(
within(listbox).getByRole("option", { name: expected }),
).toBeInTheDocument();
expect(
within(listbox).queryByRole("option", { name: /GPT-4o Mini/ }),
).not.toBeInTheDocument();
});
};
let listbox = await openListbox();
await searchFor(listbox, "anthropic", /Claude Sonnet 4/);
expect(
within(listbox).getByRole("option", { name: /Claude 3.5 Haiku/ }),
).toBeInTheDocument();
await searchFor(listbox, "claude-3-5-haiku-20241022", /Claude 3.5 Haiku/);
await searchFor(listbox, "1M", /Claude 3.5 Haiku/);
await userEvent.click(trigger);
await waitFor(() =>
expect(body.queryByRole("listbox")).not.toBeInTheDocument(),
);
listbox = await openListbox();
expect(
within(listbox).getByRole("option", { name: /GPT-4o Mini/ }),
).toBeInTheDocument();
await userEvent.click(
within(listbox).getByRole("option", { name: /Claude 3.5 Haiku/ }),
);
expect(args.onValueChange).toHaveBeenCalledWith(
"anthropic/claude-haiku-3.5",
);
},
};
@@ -9,6 +9,14 @@ const mockModelOptions: readonly ModelSelectorOption[] = [
model: "gpt-4o-mini",
displayName: "GPT-4o mini",
},
{
...MockModelSelectorOption,
id: "claude-opus",
provider: "anthropic",
model: "claude-opus-4-1",
displayName: "Claude Opus 4.1",
contextLimit: 1_000_000,
},
];
test("suppresses mouse-focus ring but keeps keyboard-focus ring on model selector trigger", () => {
@@ -1,18 +1,20 @@
import type { FC } from "react";
import { CheckIcon } from "lucide-react";
import { type FC, useState } from "react";
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Button } from "#/components/Button/Button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "#/components/Command/Command";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
Popover,
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { formatProviderLabel as defaultFormatProviderLabel } from "#/utils/aiProviders";
import { cn } from "#/utils/cn";
@@ -36,8 +38,6 @@ interface ModelSelectorProps {
dropdownSide?: "top" | "bottom" | "left" | "right";
dropdownAlign?: "start" | "center" | "end";
contentClassName?: string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onTriggerTouchStart?: () => void;
enableMobileFullWidthDropdown?: boolean;
}
@@ -45,12 +45,23 @@ interface ModelSelectorProps {
const formatContextLimit = (tokens: number): string => {
if (tokens >= 1_000_000) {
const m = tokens / 1_000_000;
return `${Number.isInteger(m) ? m : m.toFixed(1)}M context window`;
return `${Number.isInteger(m) ? m : m.toFixed(1)}M`;
}
const k = Math.round(tokens / 1_000);
return `${k}K context window`;
return `${k}K`;
};
const getSearchText = (option: ModelSelectorOption, providerLabel: string) =>
[
providerLabel,
option.provider,
option.displayName,
option.model,
option.contextLimit ? formatContextLimit(option.contextLimit) : "",
]
.join(" ")
.toLowerCase();
export const ModelSelector: FC<ModelSelectorProps> = ({
options,
value,
@@ -63,16 +74,29 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
dropdownSide = "bottom",
dropdownAlign = "start",
contentClassName,
open,
onOpenChange,
onTriggerTouchStart,
enableMobileFullWidthDropdown = false,
}) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen) {
setSearch("");
}
setOpen(nextOpen);
};
const selectedModel = options.find((option) => option.id === value);
const isDisabled = disabled || options.length === 0;
const query = search.trim().toLowerCase();
const optionsByProvider = (() => {
const grouped = new Map<string, ModelSelectorOption[]>();
for (const option of options) {
const providerLabel = formatProviderLabel(option.provider);
if (query && !getSearchText(option, providerLabel).includes(query)) {
continue;
}
const providerOptions = grouped.get(option.provider);
if (providerOptions) {
providerOptions.push(option);
@@ -83,114 +107,123 @@ export const ModelSelector: FC<ModelSelectorProps> = ({
return Array.from(grouped.entries());
})();
const isDisabled = disabled || options.length === 0;
return (
<Select
value={value}
onValueChange={onValueChange}
disabled={isDisabled}
open={open}
onOpenChange={onOpenChange}
>
<SelectTrigger
aria-label={selectedModel ? selectedModel.displayName : placeholder}
className={cn(
"h-8 min-w-0 shrink md:shrink-0 md:w-auto gap-0.5 md:gap-1.5 border-0 bg-transparent px-1 text-xs shadow-none transition-colors hover:bg-transparent hover:text-content-primary focus:ring-0 [&>span]:truncate [&>svg]:shrink-0 [&>svg]:transition-colors [&>svg]:hover:text-content-primary",
className,
)}
onTouchStart={onTriggerTouchStart}
>
<SelectValue placeholder={placeholder}>
{selectedModel ? selectedModel.displayName : placeholder}
</SelectValue>
</SelectTrigger>
<SelectContent
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild disabled={isDisabled}>
<Button
aria-label={selectedModel ? selectedModel.displayName : placeholder}
aria-expanded={open}
aria-haspopup="listbox"
disabled={isDisabled}
role="combobox"
type="button"
variant="subtle"
className={cn(
"h-8 min-w-0 shrink justify-start gap-0.5 border-0 bg-transparent px-1 text-xs font-medium shadow-none transition-colors hover:bg-transparent hover:text-content-primary focus:ring-0 focus-visible:ring-2 focus-visible:ring-content-link md:w-auto md:shrink-0 md:gap-1.5 [&>svg]:shrink-0 [&>svg]:transition-colors [&>svg]:hover:text-content-primary",
className,
)}
onTouchStart={onTriggerTouchStart}
>
<span className="truncate">
{selectedModel ? selectedModel.displayName : placeholder}
</span>
<ChevronDownIcon open={open} className="size-icon-sm" />
</Button>
</PopoverTrigger>
<PopoverContent
side={dropdownSide}
align={dropdownAlign}
className={cn(
enableMobileFullWidthDropdown &&
"mobile-full-width-dropdown mobile-full-width-dropdown-bottom",
"border-border-default [&_[role=option]]:text-xs",
"w-72 overflow-hidden border-border-default p-0",
contentClassName,
)}
>
<TooltipProvider delayDuration={300}>
{optionsByProvider.map(([provider, providerOptions]) => {
const providerLabel = formatProviderLabel(provider);
return (
<SelectGroup key={provider}>
<Command
shouldFilter={false}
className="[&_[cmdk-input-wrapper]]:border-0 [&_[cmdk-input-wrapper]]:border-border-default [&_[cmdk-input-wrapper]]:border-b [&_[cmdk-input-wrapper]]:border-solid [&_[cmdk-input-wrapper]]:px-3 [&_[cmdk-input-wrapper]]:py-2 [&_[cmdk-input-wrapper]>svg]:size-3.5"
>
<CommandInput
value={search}
onValueChange={setSearch}
placeholder="Search..."
aria-label="Search models"
className="h-auto py-0 text-xs font-normal leading-[18px] text-content-primary placeholder:text-content-disabled"
/>
<CommandList
role="listbox"
className={cn(
"max-h-80 border-t-0",
enableMobileFullWidthDropdown &&
"mobile-full-width-dropdown-scroll-area",
)}
>
<CommandEmpty className="py-3 text-xs font-normal leading-[18px] text-content-secondary">
{emptyMessage}
</CommandEmpty>
{optionsByProvider.map(([provider, providerOptions], index) => (
<CommandGroup
key={provider}
heading={formatProviderLabel(provider)}
className={cn(
"p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:leading-[18px] [&_[cmdk-group-heading]]:text-content-secondary",
index > 0 &&
"border-0 border-t border-solid border-border-default",
)}
>
{providerOptions.map((option) => (
<ModelOptionItem
key={option.id}
option={option}
providerLabel={providerLabel}
isSelected={option.id === value}
onSelect={() => {
onValueChange(option.id);
handleOpenChange(false);
}}
/>
))}
</SelectGroup>
);
})}
{options.length === 0 && (
<SelectItem value="__empty__" disabled>
{emptyMessage}
</SelectItem>
)}
</TooltipProvider>
</SelectContent>
</Select>
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
interface ModelOptionItemProps {
option: ModelSelectorOption;
providerLabel: string;
isSelected: boolean;
onSelect: () => void;
}
const ModelOptionItem: FC<ModelOptionItemProps> = ({
option,
providerLabel,
isSelected,
onSelect,
}) => {
const label = option.displayName;
const contextInfo =
option.contextLimit != null && option.contextLimit > 0
? formatContextLimit(option.contextLimit)
: null;
const subtext = contextInfo
? `via ${providerLabel}, ${contextInfo}`
: `via ${providerLabel}`;
return (
<Tooltip>
<TooltipTrigger asChild>
<SelectItem
value={option.id}
className={cn(isSelected && "bg-surface-secondary")}
>
<span className="flex flex-col">
<span>{label}</span>
<span className="text-content-secondary text-[11px] leading-tight md:hidden">
{subtext}
</span>
</span>
</SelectItem>
</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={4}
className="hidden px-2.5 py-1.5 md:block"
>
<span className="block font-semibold text-content-primary leading-tight">
{label} via {providerLabel}
<CommandItem
value={option.id}
onSelect={onSelect}
className={cn(
"gap-2 px-2 py-1 font-medium text-content-secondary data-[selected=true]:bg-surface-tertiary",
isSelected && "bg-surface-secondary",
)}
>
<span className="min-w-0 truncate text-left text-xs font-medium leading-[18px] text-content-secondary">
{option.displayName}
</span>
{option.contextLimit != null && option.contextLimit > 0 && (
<span className="shrink-0 truncate text-left text-xs font-medium leading-[18px] text-content-secondary">
({formatContextLimit(option.contextLimit)})
</span>
{contextInfo && (
<span className="block text-content-secondary leading-tight">
{contextInfo}
</span>
)}
</TooltipContent>
</Tooltip>
)}
<CheckIcon
className={cn("ml-auto size-4 shrink-0", !isSelected && "opacity-0")}
/>
</CommandItem>
);
};
@@ -586,7 +586,7 @@ export const ContextUsageIndicator: FC<{
// On mobile, a tap toggles the popover. On desktop, hover opens
// it like a dropdown menu and skill descriptions appear as
// nested tooltips to the right (same pattern as ModelSelector).
// nested tooltips to the right.
if (isMobileViewport()) {
return (
<Popover>