From 2be540e469677e716d48612a5f5d078a8eb35bce Mon Sep 17 00:00:00 2001 From: TJ Date: Tue, 30 Jun 2026 11:34:14 -0700 Subject: [PATCH] feat(site/src/pages/AISettingsPage/ModelsPage): add search and provider filter to models list (#26683) Adds a search input and provider filter dropdown above the Models table on `/ai/settings/models`, and moves the `Default` badge to sit beside the model name. ## Changes - **Search**: text input matches against model display name, model identifier, and provider label (case-insensitive). - **Provider filter**: select dropdown listing every configured provider, plus an `All providers` default. - Filters apply before pagination and reset to page one when changed. - New empty state when filters return no matches. - Pagination footer now shows the filtered total, with `(filtered from N)` when filters are active. - `Default` badge moved from the Status column to inline next to the model name. - Stories cover the new search, provider filter, and no-match empty state. ## Screenshots Please see the Storybook stories under `pages/AISettingsPage/ModelsPage/ModelsPageView` for `Default`, `SearchByName`, `FilterByProvider`, and `NoMatchingModels`. ## Verification - `pnpm --dir site exec biome check src/pages/AISettingsPage/ModelsPage/` - `pnpm --dir site exec tsc -p . --noEmit` - `pnpm --dir site test:storybook -- --project=chromium src/pages/AISettingsPage/ModelsPage/` (8/8 ModelsPageView stories pass; the unrelated `MCP Tool Completed` failure under `AgentsPage/components/ChatElements` reproduces on `main`) --- > [!NOTE] > Opened by Coder Agents on behalf of @tracyjohnsonux. --- .../ModelsPage/ModelsPageView.stories.tsx | 45 ++++++ .../ModelsPage/ModelsPageView.tsx | 144 ++++++++++++++++-- .../ModelsPage/components/ModelRow.tsx | 28 ++-- 3 files changed, 192 insertions(+), 25 deletions(-) diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx index 7c7a81d410..6e5a32a16b 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx @@ -47,6 +47,12 @@ export const Default: Story = { await expect( canvas.getByRole("button", { name: /add model/i }), ).toBeInTheDocument(); + await expect( + canvas.getByRole("searchbox", { name: /search models/i }), + ).toBeInTheDocument(); + await expect( + canvas.getByRole("combobox", { name: /filter by provider/i }), + ).toBeInTheDocument(); await expect(canvas.getByText("GPT-5")).toBeInTheDocument(); await expect(canvas.getByText("Claude Sonnet 4.5")).toBeInTheDocument(); await expect(canvas.getAllByText("OpenAI").length).toBeGreaterThan(0); @@ -63,6 +69,45 @@ export const Default: Story = { }, }; +export const SearchByName: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const search = canvas.getByRole("searchbox", { name: /search models/i }); + await userEvent.type(search, "claude"); + await expect(canvas.getByText("Claude Sonnet 4.5")).toBeInTheDocument(); + await expect(canvas.queryByText("GPT-5")).not.toBeInTheDocument(); + await expect(canvas.queryByText("GPT-4o mini")).not.toBeInTheDocument(); + }, +}; + +export const FilterByProvider: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const providerFilter = canvas.getByRole("combobox", { + name: /filter by provider/i, + }); + await userEvent.click(providerFilter); + const anthropicOption = await within(document.body).findByRole("option", { + name: "Anthropic", + }); + await userEvent.click(anthropicOption); + await expect(canvas.getByText("Claude Sonnet 4.5")).toBeInTheDocument(); + await expect(canvas.queryByText("GPT-5")).not.toBeInTheDocument(); + await expect(canvas.queryByText("GPT-4o mini")).not.toBeInTheDocument(); + }, +}; + +export const NoMatchingModels: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const search = canvas.getByRole("searchbox", { name: /search models/i }); + await userEvent.type(search, "no-such-model"); + await expect( + canvas.getByText("No models match your filters"), + ).toBeInTheDocument(); + }, +}; + export const Loading: Story = { args: { isLoading: true, diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx index d7decac622..321ae70dd5 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx @@ -1,5 +1,5 @@ -import { ChevronDownIcon, PlusIcon } from "lucide-react"; -import { type FC, useState } from "react"; +import { ChevronDownIcon, PlusIcon, SearchIcon } from "lucide-react"; +import { type FC, useMemo, useState } from "react"; import { useNavigate } from "react-router"; import type { ChatModelConfig } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; @@ -10,7 +10,19 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "#/components/DropdownMenu/DropdownMenu"; +import { + InputGroup, + InputGroupAddon, + InputGroupInput, +} from "#/components/InputGroup/InputGroup"; import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "#/components/Select/Select"; import { SettingsHeader, SettingsHeaderDescription, @@ -34,6 +46,7 @@ import { paginateItems } from "#/utils/paginateItems"; import { ModelRow } from "./components/ModelRow"; const MODELS_PAGE_SIZE = 10; +const ALL_PROVIDERS_VALUE = "all"; const AddModelDropdown: FC<{ providerStates: readonly ProviderState[]; @@ -96,15 +109,75 @@ const ModelsPageView: FC = ({ }) => { const navigate = useNavigate(); const [page, setPage] = useState(1); - const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } = - paginateItems(models, MODELS_PAGE_SIZE, page); + const [searchQuery, setSearchQuery] = useState(""); + const [providerFilter, setProviderFilter] = + useState(ALL_PROVIDERS_VALUE); - const providerLabelByModelId = new Map(); - for (const providerState of providerStates) { - for (const providerModel of providerState.modelConfigs) { - providerLabelByModelId.set(providerModel.id, providerState.label); + const providerKeyByModelId = useMemo(() => { + const map = new Map(); + for (const providerState of providerStates) { + for (const providerModel of providerState.modelConfigs) { + map.set(providerModel.id, providerState.key); + } } - } + return map; + }, [providerStates]); + + const providerLabelByModelId = useMemo(() => { + const map = new Map(); + for (const providerState of providerStates) { + for (const providerModel of providerState.modelConfigs) { + map.set(providerModel.id, providerState.label); + } + } + return map; + }, [providerStates]); + + const filteredModels = useMemo(() => { + const normalizedQuery = searchQuery.trim().toLowerCase(); + return models.filter((model) => { + if ( + providerFilter !== ALL_PROVIDERS_VALUE && + providerKeyByModelId.get(model.id) !== providerFilter + ) { + return false; + } + if (normalizedQuery.length === 0) { + return true; + } + const haystack = [ + model.display_name, + model.model, + providerLabelByModelId.get(model.id) ?? "", + ] + .join(" ") + .toLowerCase(); + return haystack.includes(normalizedQuery); + }); + }, [ + models, + providerFilter, + providerKeyByModelId, + providerLabelByModelId, + searchQuery, + ]); + + const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } = + paginateItems(filteredModels, MODELS_PAGE_SIZE, page); + + const hasModels = models.length > 0; + const hasFilters = + searchQuery.trim().length > 0 || providerFilter !== ALL_PROVIDERS_VALUE; + + const handleSearchChange = (value: string) => { + setSearchQuery(value); + setPage(1); + }; + + const handleProviderChange = (value: string) => { + setProviderFilter(value); + setPage(1); + }; return (
@@ -122,6 +195,38 @@ const ModelsPageView: FC = ({
)} +
+
+ + + + + handleSearchChange(e.target.value)} + /> + +
+ +
@@ -137,7 +242,7 @@ const ModelsPageView: FC = ({ {isLoading ? ( - ) : models.length === 0 ? ( + ) : !hasModels ? ( = ({ /> } /> + ) : filteredModels.length === 0 ? ( + ) : ( pagedItems.map((model) => ( = ({ )}
- {models.length > 0 && ( + {filteredModels.length > 0 && (
= ({
Showing {pagedItems.length}{" "} - of {models.length} models + of {filteredModels.length}{" "} + models + {hasFilters && ( + <> + {" "} + (filtered from{" "} + {models.length}) + + )}
)} diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx index ce3fc9b018..d0212f0057 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelRow.tsx @@ -38,12 +38,19 @@ export const ModelRow: FC = ({ > - - {displayName} - +
+ + {displayName} + + {model.is_default && ( + + Default + + )} +
@@ -60,12 +67,9 @@ export const ModelRow: FC = ({ -
- {model.is_default && Default} - - {model.enabled ? "Enabled" : "Disabled"} - -
+ + {model.enabled ? "Enabled" : "Disabled"} +