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.
This commit is contained in:
TJ
2026-06-30 11:34:14 -07:00
committed by GitHub
parent 377c1309b7
commit 2be540e469
3 changed files with 192 additions and 25 deletions
@@ -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,
@@ -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<ModelsPageViewProps> = ({
}) => {
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<string>(ALL_PROVIDERS_VALUE);
const providerLabelByModelId = new Map<string, string>();
for (const providerState of providerStates) {
for (const providerModel of providerState.modelConfigs) {
providerLabelByModelId.set(providerModel.id, providerState.label);
const providerKeyByModelId = useMemo(() => {
const map = new Map<string, string>();
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<string, string>();
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 (
<div>
@@ -122,6 +195,38 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
<ErrorAlert error={error} />
</div>
)}
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="flex-1">
<InputGroup>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
type="search"
placeholder="Search models..."
aria-label="Search models"
value={searchQuery}
onChange={(e) => handleSearchChange(e.target.value)}
/>
</InputGroup>
</div>
<Select value={providerFilter} onValueChange={handleProviderChange}>
<SelectTrigger
className="w-full sm:w-60"
aria-label="Filter by provider"
>
<SelectValue placeholder="All providers" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_PROVIDERS_VALUE}>All providers</SelectItem>
{providerStates.map((providerState) => (
<SelectItem key={providerState.key} value={providerState.key}>
{providerState.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Table className="table-fixed" aria-label="Models">
<TableHeader>
<TableRow>
@@ -137,7 +242,7 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
<TableBody size="lg">
{isLoading ? (
<TableLoader />
) : models.length === 0 ? (
) : !hasModels ? (
<TableEmpty
message="No models configured"
description="Configured models will appear here."
@@ -148,6 +253,11 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
/>
}
/>
) : filteredModels.length === 0 ? (
<TableEmpty
message="No models match your filters"
description="Try a different search term or provider."
/>
) : (
pagedItems.map((model) => (
<ModelRow
@@ -160,13 +270,13 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
)}
</TableBody>
</Table>
{models.length > 0 && (
{filteredModels.length > 0 && (
<div className="flex items-center justify-between pt-4">
<div className="flex-1">
<PaginationWidgetBase
currentPage={clampedPage}
pageSize={MODELS_PAGE_SIZE}
totalRecords={models.length}
totalRecords={filteredModels.length}
onPageChange={setPage}
hasPreviousPage={hasPreviousPage}
hasNextPage={hasNextPage}
@@ -174,7 +284,15 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
</div>
<span className="text-xs text-content-secondary">
Showing <strong className="font-medium">{pagedItems.length}</strong>{" "}
of <strong className="font-medium">{models.length}</strong> models
of <strong className="font-medium">{filteredModels.length}</strong>{" "}
models
{hasFilters && (
<>
{" "}
(filtered from{" "}
<strong className="font-medium">{models.length}</strong>)
</>
)}
</span>
</div>
)}
@@ -38,12 +38,19 @@ export const ModelRow: FC<ModelRowProps> = ({
>
<ProviderIcon provider={model.provider} />
</Avatar>
<span
className="truncate text-sm font-medium leading-6 text-content-primary"
title={displayName}
>
{displayName}
</span>
<div className="flex min-w-0 items-center gap-2">
<span
className="truncate text-sm font-medium leading-6 text-content-primary"
title={displayName}
>
{displayName}
</span>
{model.is_default && (
<Badge variant="default" className="shrink-0">
Default
</Badge>
)}
</div>
</div>
</TableCell>
<TableCell className="min-w-0">
@@ -60,12 +67,9 @@ export const ModelRow: FC<ModelRowProps> = ({
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
{model.is_default && <Badge variant="default">Default</Badge>}
<Badge variant="default">
{model.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
<Badge variant="default">
{model.enabled ? "Enabled" : "Disabled"}
</Badge>
</TableCell>
<TableCell className="w-10 text-center">
<div className="flex justify-end items-center gap-8 pr-4">