mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +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(
|
await expect(
|
||||||
canvas.getByRole("button", { name: /add model/i }),
|
canvas.getByRole("button", { name: /add model/i }),
|
||||||
).toBeInTheDocument();
|
).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("GPT-5")).toBeInTheDocument();
|
||||||
await expect(canvas.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
await expect(canvas.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
||||||
await expect(canvas.getAllByText("OpenAI").length).toBeGreaterThan(0);
|
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 = {
|
export const Loading: Story = {
|
||||||
args: {
|
args: {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ChevronDownIcon, PlusIcon } from "lucide-react";
|
import { ChevronDownIcon, PlusIcon, SearchIcon } from "lucide-react";
|
||||||
import { type FC, useState } from "react";
|
import { type FC, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import type { ChatModelConfig } from "#/api/typesGenerated";
|
import type { ChatModelConfig } from "#/api/typesGenerated";
|
||||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||||
@@ -10,7 +10,19 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "#/components/DropdownMenu/DropdownMenu";
|
} from "#/components/DropdownMenu/DropdownMenu";
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from "#/components/InputGroup/InputGroup";
|
||||||
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
|
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "#/components/Select/Select";
|
||||||
import {
|
import {
|
||||||
SettingsHeader,
|
SettingsHeader,
|
||||||
SettingsHeaderDescription,
|
SettingsHeaderDescription,
|
||||||
@@ -34,6 +46,7 @@ import { paginateItems } from "#/utils/paginateItems";
|
|||||||
import { ModelRow } from "./components/ModelRow";
|
import { ModelRow } from "./components/ModelRow";
|
||||||
|
|
||||||
const MODELS_PAGE_SIZE = 10;
|
const MODELS_PAGE_SIZE = 10;
|
||||||
|
const ALL_PROVIDERS_VALUE = "all";
|
||||||
|
|
||||||
const AddModelDropdown: FC<{
|
const AddModelDropdown: FC<{
|
||||||
providerStates: readonly ProviderState[];
|
providerStates: readonly ProviderState[];
|
||||||
@@ -96,15 +109,75 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const { pagedItems, clampedPage, hasPreviousPage, hasNextPage } =
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
paginateItems(models, MODELS_PAGE_SIZE, page);
|
const [providerFilter, setProviderFilter] =
|
||||||
|
useState<string>(ALL_PROVIDERS_VALUE);
|
||||||
|
|
||||||
const providerLabelByModelId = new Map<string, string>();
|
const providerKeyByModelId = useMemo(() => {
|
||||||
for (const providerState of providerStates) {
|
const map = new Map<string, string>();
|
||||||
for (const providerModel of providerState.modelConfigs) {
|
for (const providerState of providerStates) {
|
||||||
providerLabelByModelId.set(providerModel.id, providerState.label);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -122,6 +195,38 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
|
|||||||
<ErrorAlert error={error} />
|
<ErrorAlert error={error} />
|
||||||
</div>
|
</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">
|
<Table className="table-fixed" aria-label="Models">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -137,7 +242,7 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
|
|||||||
<TableBody size="lg">
|
<TableBody size="lg">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableLoader />
|
<TableLoader />
|
||||||
) : models.length === 0 ? (
|
) : !hasModels ? (
|
||||||
<TableEmpty
|
<TableEmpty
|
||||||
message="No models configured"
|
message="No models configured"
|
||||||
description="Configured models will appear here."
|
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) => (
|
pagedItems.map((model) => (
|
||||||
<ModelRow
|
<ModelRow
|
||||||
@@ -160,13 +270,13 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
{models.length > 0 && (
|
{filteredModels.length > 0 && (
|
||||||
<div className="flex items-center justify-between pt-4">
|
<div className="flex items-center justify-between pt-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<PaginationWidgetBase
|
<PaginationWidgetBase
|
||||||
currentPage={clampedPage}
|
currentPage={clampedPage}
|
||||||
pageSize={MODELS_PAGE_SIZE}
|
pageSize={MODELS_PAGE_SIZE}
|
||||||
totalRecords={models.length}
|
totalRecords={filteredModels.length}
|
||||||
onPageChange={setPage}
|
onPageChange={setPage}
|
||||||
hasPreviousPage={hasPreviousPage}
|
hasPreviousPage={hasPreviousPage}
|
||||||
hasNextPage={hasNextPage}
|
hasNextPage={hasNextPage}
|
||||||
@@ -174,7 +284,15 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-content-secondary">
|
<span className="text-xs text-content-secondary">
|
||||||
Showing <strong className="font-medium">{pagedItems.length}</strong>{" "}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,12 +38,19 @@ export const ModelRow: FC<ModelRowProps> = ({
|
|||||||
>
|
>
|
||||||
<ProviderIcon provider={model.provider} />
|
<ProviderIcon provider={model.provider} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
className="truncate text-sm font-medium leading-6 text-content-primary"
|
<span
|
||||||
title={displayName}
|
className="truncate text-sm font-medium leading-6 text-content-primary"
|
||||||
>
|
title={displayName}
|
||||||
{displayName}
|
>
|
||||||
</span>
|
{displayName}
|
||||||
|
</span>
|
||||||
|
{model.is_default && (
|
||||||
|
<Badge variant="default" className="shrink-0">
|
||||||
|
Default
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="min-w-0">
|
<TableCell className="min-w-0">
|
||||||
@@ -60,12 +67,9 @@ export const ModelRow: FC<ModelRowProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<Badge variant="default">
|
||||||
{model.is_default && <Badge variant="default">Default</Badge>}
|
{model.enabled ? "Enabled" : "Disabled"}
|
||||||
<Badge variant="default">
|
</Badge>
|
||||||
{model.enabled ? "Enabled" : "Disabled"}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-10 text-center">
|
<TableCell className="w-10 text-center">
|
||||||
<div className="flex justify-end items-center gap-8 pr-4">
|
<div className="flex justify-end items-center gap-8 pr-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user