mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add searchable agent model picker (#26927)
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user