From e2cfbf45e5302fcf12d227413d75a21bd81e313d Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 1 Jul 2026 17:40:39 +0000 Subject: [PATCH 1/5] feat(site): add searchable agent model picker --- .../ChatElements/ModelSelector.test.tsx | 35 ++- .../components/ChatElements/ModelSelector.tsx | 230 +++++++++++------- 2 files changed, 171 insertions(+), 94 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx index de8683059b031..147b059f6b018 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { ModelSelector, type ModelSelectorOption } from "./ModelSelector"; import { MockModelSelectorOption } from "./modelSelectorFixtures"; @@ -9,6 +10,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", () => { @@ -28,3 +37,27 @@ test("suppresses mouse-focus ring but keeps keyboard-focus ring on model selecto expect(trigger.className).toContain("focus-visible:ring-2"); expect(trigger.className).not.toContain("focus-visible:ring-0"); }); + +test("filters models and selects a search result", async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + , + ); + + await user.click(screen.getByRole("combobox")); + await user.type(screen.getByPlaceholderText("Search..."), "opus"); + + const listbox = screen.getByRole("listbox"); + expect(within(listbox).queryByText("GPT-4o mini")).not.toBeInTheDocument(); + + await user.click( + within(listbox).getByRole("option", { name: /Claude Opus 4.1/ }), + ); + + expect(onValueChange).toHaveBeenCalledWith("claude-opus"); +}); diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index 1e5da57cccd11..7d39f87cf45ee 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"; @@ -45,12 +47,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, @@ -68,11 +81,30 @@ export const ModelSelector: FC = ({ onTriggerTouchStart, enableMobileFullWidthDropdown = false, }) => { + const [internalOpen, setInternalOpen] = useState(false); + const [search, setSearch] = useState(""); + const isOpen = open ?? internalOpen; + const setOpen = (nextOpen: boolean) => { + if (!nextOpen) { + setSearch(""); + } + onOpenChange?.(nextOpen); + if (open === undefined) { + setInternalOpen(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 +115,126 @@ 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} - - )} - - + )} + + ); }; From bbd4f8c31c96e1b9cf13c60722aef186d89b3cf5 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 1 Jul 2026 17:48:14 +0000 Subject: [PATCH 2/5] fix(site): left-align agent model picker --- site/src/pages/AgentsPage/components/AgentChatInput.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 /> )} From 0435afbdaed7c1c667891d46a923f254e4e5769d Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 1 Jul 2026 19:54:55 +0000 Subject: [PATCH 3/5] fix(site): address agent model picker feedback --- .../ChatElements/ModelSelector.stories.tsx | 74 +++++++++++++++++-- .../ChatElements/ModelSelector.test.tsx | 27 +------ .../components/ChatElements/ModelSelector.tsx | 3 - 3 files changed, 68 insertions(+), 36 deletions(-) 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 147b059f6b018..df892aa3ea12f 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.test.tsx @@ -1,5 +1,4 @@ -import { render, screen, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { render, screen } from "@testing-library/react"; import { ModelSelector, type ModelSelectorOption } from "./ModelSelector"; import { MockModelSelectorOption } from "./modelSelectorFixtures"; @@ -37,27 +36,3 @@ test("suppresses mouse-focus ring but keeps keyboard-focus ring on model selecto expect(trigger.className).toContain("focus-visible:ring-2"); expect(trigger.className).not.toContain("focus-visible:ring-0"); }); - -test("filters models and selects a search result", async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - render( - , - ); - - await user.click(screen.getByRole("combobox")); - await user.type(screen.getByPlaceholderText("Search..."), "opus"); - - const listbox = screen.getByRole("listbox"); - expect(within(listbox).queryByText("GPT-4o mini")).not.toBeInTheDocument(); - - await user.click( - within(listbox).getByRole("option", { name: /Claude Opus 4.1/ }), - ); - - expect(onValueChange).toHaveBeenCalledWith("claude-opus"); -}); diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index 7d39f87cf45ee..6a581ca05b38b 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -214,10 +214,7 @@ const ModelOptionItem: FC = ({ }) => { return ( Date: Wed, 1 Jul 2026 20:39:48 +0000 Subject: [PATCH 4/5] fix(site): remove stale model selector comment --- site/src/pages/AgentsPage/components/ContextUsageIndicator.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ( From 0813e0284fc137adc2180bca2aaabfe33445d170 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 1 Jul 2026 21:05:58 +0000 Subject: [PATCH 5/5] fix(site): simplify model selector open state --- .../components/ChatElements/ModelSelector.tsx | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx index 6a581ca05b38b..ef1a749c378cf 100644 --- a/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx +++ b/site/src/pages/AgentsPage/components/ChatElements/ModelSelector.tsx @@ -38,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; } @@ -76,22 +74,16 @@ export const ModelSelector: FC = ({ dropdownSide = "bottom", dropdownAlign = "start", contentClassName, - open, - onOpenChange, onTriggerTouchStart, enableMobileFullWidthDropdown = false, }) => { - const [internalOpen, setInternalOpen] = useState(false); + const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); - const isOpen = open ?? internalOpen; - const setOpen = (nextOpen: boolean) => { + const handleOpenChange = (nextOpen: boolean) => { if (!nextOpen) { setSearch(""); } - onOpenChange?.(nextOpen); - if (open === undefined) { - setInternalOpen(nextOpen); - } + setOpen(nextOpen); }; const selectedModel = options.find((option) => option.id === value); const isDisabled = disabled || options.length === 0; @@ -117,11 +109,11 @@ export const ModelSelector: FC = ({ })(); return ( - + = ({ isSelected={option.id === value} onSelect={() => { onValueChange(option.id); - setOpen(false); + handleOpenChange(false); }} /> ))}