mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user