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:
5
.changeset/add-model-filter-search.md
Normal file
5
.changeset/add-model-filter-search.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@dustinbyrne/kb": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add type-to-filter search to model selection dropdowns. Users can now quickly find AI models by typing to filter by provider, model ID, or name.
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
import { fetchModels, updateTask } from "../api";
|
import { fetchModels, updateTask } from "../api";
|
||||||
import type { ModelInfo } from "../api";
|
import type { ModelInfo } from "../api";
|
||||||
import type { Task, TaskDetail } from "@kb/core";
|
import type { Task, TaskDetail } from "@kb/core";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
|
import { filterModels } from "../utils/modelFilter";
|
||||||
|
|
||||||
interface ModelSelectorTabProps {
|
interface ModelSelectorTabProps {
|
||||||
task: Task | TaskDetail;
|
task: Task | TaskDetail;
|
||||||
@@ -14,6 +15,10 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
|||||||
const [modelsLoading, setModelsLoading] = useState(false);
|
const [modelsLoading, setModelsLoading] = useState(false);
|
||||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
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)
|
// Local state for selections (not saved until user clicks Save)
|
||||||
const [executorProvider, setExecutorProvider] = useState<string | undefined>(task.modelProvider);
|
const [executorProvider, setExecutorProvider] = useState<string | undefined>(task.modelProvider);
|
||||||
const [executorModelId, setExecutorModelId] = useState<string | undefined>(task.modelId);
|
const [executorModelId, setExecutorModelId] = useState<string | undefined>(task.modelId);
|
||||||
@@ -50,11 +55,24 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
|||||||
setHasChanges(executorChanged || validatorChanged);
|
setHasChanges(executorChanged || validatorChanged);
|
||||||
}, [executorProvider, executorModelId, validatorProvider, validatorModelId, task]);
|
}, [executorProvider, executorModelId, validatorProvider, validatorModelId, task]);
|
||||||
|
|
||||||
// Group models by provider
|
// Filtered models for executor and validator
|
||||||
const modelsByProvider = availableModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
|
const filteredExecutorModels = useMemo(() => filterModels(availableModels, executorFilter), [availableModels, executorFilter]);
|
||||||
(acc[m.provider] ??= []).push(m);
|
const filteredValidatorModels = useMemo(() => filterModels(availableModels, validatorFilter), [availableModels, validatorFilter]);
|
||||||
return acc;
|
|
||||||
}, {});
|
// 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)
|
// Build select values (provider/id combination or empty for default)
|
||||||
const executorValue = executorProvider && executorModelId
|
const executorValue = executorProvider && executorModelId
|
||||||
@@ -160,6 +178,31 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<select
|
||||||
id="executorModel"
|
id="executorModel"
|
||||||
value={executorValue}
|
value={executorValue}
|
||||||
@@ -167,7 +210,7 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
|||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
<option value="">Use default</option>
|
<option value="">Use default</option>
|
||||||
{Object.entries(modelsByProvider).map(([provider, models]) => (
|
{Object.entries(executorModelsByProvider).map(([provider, models]) => (
|
||||||
<optgroup key={provider} label={provider}>
|
<optgroup key={provider} label={provider}>
|
||||||
{models.map((m) => (
|
{models.map((m) => (
|
||||||
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
|
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
|
||||||
@@ -177,6 +220,11 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{filteredExecutorModels.length === 0 && executorFilter && (
|
||||||
|
<div className="model-selector-no-results">
|
||||||
|
No models match '{executorFilter}'
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<small>The AI model used to implement this task.</small>
|
<small>The AI model used to implement this task.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -192,6 +240,31 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<select
|
||||||
id="validatorModel"
|
id="validatorModel"
|
||||||
value={validatorValue}
|
value={validatorValue}
|
||||||
@@ -199,7 +272,7 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
|||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
<option value="">Use default</option>
|
<option value="">Use default</option>
|
||||||
{Object.entries(modelsByProvider).map(([provider, models]) => (
|
{Object.entries(validatorModelsByProvider).map(([provider, models]) => (
|
||||||
<optgroup key={provider} label={provider}>
|
<optgroup key={provider} label={provider}>
|
||||||
{models.map((m) => (
|
{models.map((m) => (
|
||||||
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
|
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
|
||||||
@@ -209,6 +282,11 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
{filteredValidatorModels.length === 0 && validatorFilter && (
|
||||||
|
<div className="model-selector-no-results">
|
||||||
|
No models match '{validatorFilter}'
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<small>The AI model used to review code and plans for this task.</small>
|
<small>The AI model used to review code and plans for this task.</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fetchSettings, updateSettings, fetchAuthStatus, loginProvider, logoutPr
|
|||||||
import type { AuthProvider, ModelInfo } from "../api";
|
import type { AuthProvider, ModelInfo } from "../api";
|
||||||
import type { ToastType } from "../hooks/useToast";
|
import type { ToastType } from "../hooks/useToast";
|
||||||
import { ThemeSelector } from "./ThemeSelector";
|
import { ThemeSelector } from "./ThemeSelector";
|
||||||
|
import { filterModels } from "../utils/modelFilter";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings sections configuration.
|
* Settings sections configuration.
|
||||||
@@ -77,6 +78,7 @@ export function SettingsModal({
|
|||||||
// Model state
|
// Model state
|
||||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||||
const [modelsLoading, setModelsLoading] = useState(false);
|
const [modelsLoading, setModelsLoading] = useState(false);
|
||||||
|
const [modelFilter, setModelFilter] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSettings()
|
fetchSettings()
|
||||||
@@ -241,8 +243,9 @@ export function SettingsModal({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
case "model": {
|
case "model": {
|
||||||
// Group models by provider
|
// Filter and group models by provider
|
||||||
const modelsByProvider = availableModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
|
const filteredModels = filterModels(availableModels, modelFilter);
|
||||||
|
const modelsByProvider = filteredModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
|
||||||
(acc[m.provider] ??= []).push(m);
|
(acc[m.provider] ??= []).push(m);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -261,6 +264,29 @@ export function SettingsModal({
|
|||||||
) : (
|
) : (
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="defaultModel">Default Model</label>
|
<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
|
<select
|
||||||
id="defaultModel"
|
id="defaultModel"
|
||||||
value={selectedValue}
|
value={selectedValue}
|
||||||
@@ -289,7 +315,12 @@ export function SettingsModal({
|
|||||||
</optgroup>
|
</optgroup>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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 '{modelFilter}'
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<small>Select the AI model used for agent sessions. "Use default" lets the engine choose automatically.</small>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|||||||
@@ -278,4 +278,205 @@ describe("ModelSelectorTab", () => {
|
|||||||
expect(mockAddToast).toHaveBeenCalledWith("Save failed", "error");
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { SettingsModal } from "../SettingsModal";
|
import { SettingsModal } from "../SettingsModal";
|
||||||
import type { Settings } from "@kb/core";
|
import type { Settings } from "@kb/core";
|
||||||
|
|
||||||
@@ -878,4 +879,86 @@ describe("SettingsModal", () => {
|
|||||||
expect(checkbox.checked).toBe(true);
|
expect(checkbox.checked).toBe(true);
|
||||||
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3269,6 +3269,80 @@ body {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Model selector filter styles */
|
||||||
|
.model-selector-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector-filter-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 12px;
|
||||||
|
padding-right: 32px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector-filter-input:focus {
|
||||||
|
border-color: var(--todo);
|
||||||
|
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector-filter-input::placeholder {
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector-filter-clear {
|
||||||
|
position: absolute;
|
||||||
|
right: 64px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector-filter-clear:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector-filter-clear:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector-results-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector-no-results {
|
||||||
|
padding: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
background: var(--surface);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
THEME SYSTEM
|
THEME SYSTEM
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
15
packages/dashboard/app/utils/modelFilter.ts
Normal file
15
packages/dashboard/app/utils/modelFilter.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ModelInfo } from "../api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter models by search terms matching provider, id, or name.
|
||||||
|
* Supports multi-word filters (space-separated AND logic).
|
||||||
|
* Case-insensitive substring matching.
|
||||||
|
*/
|
||||||
|
export function filterModels(models: ModelInfo[], filter: string): ModelInfo[] {
|
||||||
|
const terms = filter.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (terms.length === 0) return models;
|
||||||
|
return models.filter((m) => {
|
||||||
|
const haystack = `${m.provider} ${m.id} ${m.name}`.toLowerCase();
|
||||||
|
return terms.every((term) => haystack.includes(term));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user