feat(KB-061): add model filter search to model selectors

- Add search input with clear button and results count to ModelSelectorTab
- Extract filterModels utility for shared filtering logic across components
- Apply filtering to SettingsModal model section
- Add CSS styles for filter UI components
- Add comprehensive tests for ModelSelectorTab and SettingsModal filtering
This commit is contained in:
gsxdsm
2026-03-29 20:55:01 -07:00
parent 0e26d1df30
commit c5b09e1cb1
7 changed files with 498 additions and 11 deletions

View File

@@ -1,8 +1,9 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useMemo } from "react";
import { fetchModels, updateTask } from "../api";
import type { ModelInfo } from "../api";
import type { Task, TaskDetail } from "@kb/core";
import type { ToastType } from "../hooks/useToast";
import { filterModels } from "../utils/modelFilter";
interface ModelSelectorTabProps {
task: Task | TaskDetail;
@@ -14,6 +15,10 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
// Filter states for executor and validator
const [executorFilter, setExecutorFilter] = useState("");
const [validatorFilter, setValidatorFilter] = useState("");
// Local state for selections (not saved until user clicks Save)
const [executorProvider, setExecutorProvider] = useState<string | undefined>(task.modelProvider);
const [executorModelId, setExecutorModelId] = useState<string | undefined>(task.modelId);
@@ -50,11 +55,24 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
setHasChanges(executorChanged || validatorChanged);
}, [executorProvider, executorModelId, validatorProvider, validatorModelId, task]);
// Group models by provider
const modelsByProvider = availableModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
(acc[m.provider] ??= []).push(m);
return acc;
}, {});
// Filtered models for executor and validator
const filteredExecutorModels = useMemo(() => filterModels(availableModels, executorFilter), [availableModels, executorFilter]);
const filteredValidatorModels = useMemo(() => filterModels(availableModels, validatorFilter), [availableModels, validatorFilter]);
// Group filtered models by provider
const executorModelsByProvider = useMemo(() => {
return filteredExecutorModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
(acc[m.provider] ??= []).push(m);
return acc;
}, {});
}, [filteredExecutorModels]);
const validatorModelsByProvider = useMemo(() => {
return filteredValidatorModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
(acc[m.provider] ??= []).push(m);
return acc;
}, {});
}, [filteredValidatorModels]);
// Build select values (provider/id combination or empty for default)
const executorValue = executorProvider && executorModelId
@@ -160,6 +178,31 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
</span>
)}
</div>
{/* Filter input for executor */}
<div className="model-selector-filter">
<input
type="text"
className="model-selector-filter-input"
placeholder="Filter models…"
value={executorFilter}
onChange={(e) => setExecutorFilter(e.target.value)}
disabled={isSaving}
/>
{executorFilter && (
<button
type="button"
className="model-selector-filter-clear"
onClick={() => setExecutorFilter("")}
disabled={isSaving}
aria-label="Clear filter"
>
×
</button>
)}
<span className="model-selector-results-count">
{filteredExecutorModels.length} model{filteredExecutorModels.length !== 1 ? "s" : ""}
</span>
</div>
<select
id="executorModel"
value={executorValue}
@@ -167,7 +210,7 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
disabled={isSaving}
>
<option value="">Use default</option>
{Object.entries(modelsByProvider).map(([provider, models]) => (
{Object.entries(executorModelsByProvider).map(([provider, models]) => (
<optgroup key={provider} label={provider}>
{models.map((m) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
@@ -177,6 +220,11 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
</optgroup>
))}
</select>
{filteredExecutorModels.length === 0 && executorFilter && (
<div className="model-selector-no-results">
No models match &apos;{executorFilter}&apos;
</div>
)}
<small>The AI model used to implement this task.</small>
</div>
@@ -192,6 +240,31 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
</span>
)}
</div>
{/* Filter input for validator */}
<div className="model-selector-filter">
<input
type="text"
className="model-selector-filter-input"
placeholder="Filter models…"
value={validatorFilter}
onChange={(e) => setValidatorFilter(e.target.value)}
disabled={isSaving}
/>
{validatorFilter && (
<button
type="button"
className="model-selector-filter-clear"
onClick={() => setValidatorFilter("")}
disabled={isSaving}
aria-label="Clear filter"
>
×
</button>
)}
<span className="model-selector-results-count">
{filteredValidatorModels.length} model{filteredValidatorModels.length !== 1 ? "s" : ""}
</span>
</div>
<select
id="validatorModel"
value={validatorValue}
@@ -199,7 +272,7 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
disabled={isSaving}
>
<option value="">Use default</option>
{Object.entries(modelsByProvider).map(([provider, models]) => (
{Object.entries(validatorModelsByProvider).map(([provider, models]) => (
<optgroup key={provider} label={provider}>
{models.map((m) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
@@ -209,6 +282,11 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
</optgroup>
))}
</select>
{filteredValidatorModels.length === 0 && validatorFilter && (
<div className="model-selector-no-results">
No models match &apos;{validatorFilter}&apos;
</div>
)}
<small>The AI model used to review code and plans for this task.</small>
</div>

View File

@@ -5,6 +5,7 @@ import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutPr
import type { AuthProvider, ModelInfo } from "../api";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
import { filterModels } from "../utils/modelFilter";
/**
* Settings sections configuration.
@@ -77,6 +78,7 @@ export function SettingsModal({
// Model state
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelFilter, setModelFilter] = useState("");
useEffect(() => {
fetchSettings()
@@ -241,8 +243,9 @@ export function SettingsModal({
</>
);
case "model": {
// Group models by provider
const modelsByProvider = availableModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
// Filter and group models by provider
const filteredModels = filterModels(availableModels, modelFilter);
const modelsByProvider = filteredModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
(acc[m.provider] ??= []).push(m);
return acc;
}, {});
@@ -261,6 +264,29 @@ export function SettingsModal({
) : (
<div className="form-group">
<label htmlFor="defaultModel">Default Model</label>
{/* Filter input */}
<div className="model-selector-filter">
<input
type="text"
className="model-selector-filter-input"
placeholder="Filter models…"
value={modelFilter}
onChange={(e) => setModelFilter(e.target.value)}
/>
{modelFilter && (
<button
type="button"
className="model-selector-filter-clear"
onClick={() => setModelFilter("")}
aria-label="Clear filter"
>
×
</button>
)}
<span className="model-selector-results-count">
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
</span>
</div>
<select
id="defaultModel"
value={selectedValue}
@@ -289,7 +315,12 @@ export function SettingsModal({
</optgroup>
))}
</select>
<small>Select the AI model used for agent sessions. "Use default" lets the engine choose automatically.</small>
{filteredModels.length === 0 && modelFilter && (
<div className="model-selector-no-results">
No models match &apos;{modelFilter}&apos;
</div>
)}
<small>Select the AI model used for agent sessions. &quot;Use default&quot; lets the engine choose automatically.</small>
</div>
)}
{(() => {

View File

@@ -278,4 +278,205 @@ describe("ModelSelectorTab", () => {
expect(mockAddToast).toHaveBeenCalledWith("Save failed", "error");
});
});
// Filter functionality tests
it("renders filter input for executor and validator selectors", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Both filter inputs should be present (placeholder text)
const filterInputs = screen.getAllByPlaceholderText("Filter models…");
expect(filterInputs.length).toBe(2);
});
it("filters executor models by provider name", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Type "openai" in the executor filter
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "openai");
// Should show result count
expect(within(executorSection!).getByText("1 model")).toBeInTheDocument();
// The select should only show openai models
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("Use default");
expect(options).toContain("GPT-4o");
expect(options).not.toContain("Claude Sonnet 4.5");
expect(options).not.toContain("Claude Opus 4");
});
it("filters models by model ID", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Type "gpt-4o" in the executor filter
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "gpt-4o");
// Should show only GPT-4o
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("GPT-4o");
expect(options).not.toContain("Claude Sonnet 4.5");
});
it("filters models by display name", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Type "opus" (case insensitive)
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "opus");
// Should show only Claude Opus 4
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("Claude Opus 4");
expect(options).not.toContain("Claude Sonnet 4.5");
expect(options).not.toContain("GPT-4o");
});
it("supports multi-word filter (AND logic)", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Type "anthropic claude" - both terms must match
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "anthropic claude");
// Should show only anthropic claude models
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("Claude Sonnet 4.5");
expect(options).toContain("Claude Opus 4");
expect(options).not.toContain("GPT-4o");
});
it("Use default option remains visible regardless of filter", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Type a filter that matches nothing
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "nonexistent");
// Use default should still be visible
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("Use default");
});
it("clear button clears filter and restores full list", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Type a filter
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "openai");
// Verify filter is applied
expect(within(executorSection!).getByText("1 model")).toBeInTheDocument();
// Click clear button
const clearButton = within(executorSection!).getByLabelText("Clear filter");
await user.click(clearButton);
// Filter should be cleared and all models should be visible
expect(filterInput).toHaveValue("");
expect(within(executorSection!).getByText("3 models")).toBeInTheDocument();
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
const options = Array.from(executorSelect.options).map((o) => o.textContent);
expect(options).toContain("Claude Sonnet 4.5");
expect(options).toContain("Claude Opus 4");
expect(options).toContain("GPT-4o");
});
it("shows empty state message when filter matches nothing", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Type a filter that matches nothing
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "xyz123");
// Should show no results message
expect(within(executorSection!).getByText("No models match 'xyz123'")).toBeInTheDocument();
expect(within(executorSection!).getByText("0 models")).toBeInTheDocument();
});
it("selecting a model from filtered list works correctly", async () => {
const user = userEvent.setup();
mockUpdateTask.mockResolvedValue({ ...FAKE_TASK });
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Filter to show only openai
const executorSection = screen.getByLabelText("Executor Model").closest(".form-group");
const filterInput = within(executorSection!).getByPlaceholderText("Filter models…");
await user.type(filterInput, "openai");
// Select the filtered model
const executorSelect = screen.getByLabelText("Executor Model");
await user.selectOptions(executorSelect, "openai/gpt-4o");
// Save
await user.click(screen.getByText("Save"));
// Verify correct model was saved
await waitFor(() => {
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
modelProvider: "openai",
modelId: "gpt-4o",
validatorModelProvider: undefined,
validatorModelId: undefined,
});
});
});
});

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SettingsModal } from "../SettingsModal";
import type { Settings } from "@kb/core";
@@ -878,4 +879,86 @@ describe("SettingsModal", () => {
expect(checkbox.checked).toBe(true);
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
});
// Model filter tests
it("renders filter input in Model section", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Filter input should be present
expect(screen.getByPlaceholderText("Filter models…")).toBeTruthy();
});
it("filters default model options in Model section", async () => {
const user = userEvent.setup();
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Type a filter - only claude model should match
const filterInput = screen.getByPlaceholderText("Filter models…");
await user.type(filterInput, "claude");
// Should show result count (1 model matches "claude")
expect(screen.getByText("1 model")).toBeTruthy();
// The select should be updated (Use default + 1 filtered model)
const select = screen.getByLabelText("Default Model") as HTMLSelectElement;
const options = Array.from(select.options).map((o) => o.textContent);
expect(options).toContain("Use default");
expect(options).toContain("Claude Sonnet 4.5");
expect(options).not.toContain("GPT-4o");
});
it("clear button resets filter in Model section", async () => {
const user = userEvent.setup();
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Type a filter
const filterInput = screen.getByPlaceholderText("Filter models…");
await user.type(filterInput, "openai");
// Should show filtered count
expect(screen.getByText("1 model")).toBeTruthy();
// Click clear button
const clearButton = screen.getByLabelText("Clear filter");
await user.click(clearButton);
// Filter should be cleared
expect(filterInput).toHaveValue("");
expect(screen.getByText("2 models")).toBeTruthy();
// All models should be visible again
const select = screen.getByLabelText("Default Model") as HTMLSelectElement;
const options = Array.from(select.options).map((o) => o.textContent);
expect(options).toContain("GPT-4o");
expect(options).toContain("Claude Sonnet 4.5");
});
it("shows empty state in Model section when filter matches nothing", async () => {
const user = userEvent.setup();
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Model"));
await waitFor(() => expect(fetchModels).toHaveBeenCalled());
// Type a filter that matches nothing
const filterInput = screen.getByPlaceholderText("Filter models…");
await user.type(filterInput, "nonexistent");
// Should show no results message
expect(screen.getByText("No models match 'nonexistent'")).toBeTruthy();
expect(screen.getByText("0 models")).toBeTruthy();
});
});