diff --git a/site/src/pages/AgentsPage/components/AgentChatInput.tsx b/site/src/pages/AgentsPage/components/AgentChatInput.tsx index cec54bf7274d3..2e560fa49395a 100644 --- a/site/src/pages/AgentsPage/components/AgentChatInput.tsx +++ b/site/src/pages/AgentsPage/components/AgentChatInput.tsx @@ -1409,7 +1409,7 @@ export const AgentChatInput: FC = ({ placeholder={modelSelectorPlaceholder} className="md:shrink" dropdownSide="top" - dropdownAlign="center" + dropdownAlign="start" enableMobileFullWidthDropdown /> )} diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx index a2114afda742b..dea8feb462ffe 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.stories.tsx @@ -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", + ); + }, +}; diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx index de8683059b031..df892aa3ea12f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx @@ -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", () => { diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index 1e5da57cccd11..ef1a749c378cf 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -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 = ({ options, value, @@ -63,16 +74,29 @@ export const ModelSelector: FC = ({ 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(); 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 = ({ return Array.from(grouped.entries()); })(); - const isDisabled = disabled || options.length === 0; return ( - + + ))} + + + + ); }; interface ModelOptionItemProps { option: ModelSelectorOption; - providerLabel: string; isSelected: boolean; + onSelect: () => void; } const ModelOptionItem: FC = ({ 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 ( - - - - - {label} - - {subtext} - - - - - - - {label} via {providerLabel} + + + {option.displayName} + + {option.contextLimit != null && option.contextLimit > 0 && ( + + ({formatContextLimit(option.contextLimit)}) - {contextInfo && ( - - {contextInfo} - - )} - - + )} + + ); }; diff --git a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx index d69c671ba09f8..0111490ef4d8f 100644 --- a/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx +++ b/site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx @@ -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 (