feat(KB-081): add provider icons to model selector with custom dropdown

- Create ProviderIcon component with provider-to-icon mappings (OpenAI, Anthropic, Google, etc.)

- Build CustomModelDropdown with icon display, filtering, and keyboard navigation

- Integrate custom dropdown into ModelSelectorTab replacing native select element

- Add CSS styles for provider icons in dropdown items and model badges

- Add comprehensive tests for ProviderIcon, CustomModelDropdown, and ModelSelectorTab
This commit is contained in:
gsxdsm
2026-03-29 22:51:23 -07:00
parent bdb4eb782d
commit c676a26854
8 changed files with 892 additions and 332 deletions

View File

@@ -0,0 +1,343 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import type { ModelInfo } from "../api";
import { filterModels } from "../utils/modelFilter";
import { ProviderIcon } from "./ProviderIcon";
export interface CustomModelDropdownProps {
models: ModelInfo[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
id?: string;
label: string;
}
/**
* CustomModelDropdown - A dropdown component combining selection with icon-enhanced provider groups.
*
* Interaction pattern:
* - Closed: Shows trigger button with current selection and provider icon
* - Open: Dropdown with search input at top, scrollable list of models grouped by provider with icons
* - Filtering: Real-time filtering using filterModels() utility
* - Keyboard: Arrow keys navigate, Enter selects, Escape closes, Tab moves focus
*/
export function CustomModelDropdown({
models,
value,
onChange,
placeholder = "Select a model…",
disabled = false,
id,
label,
}: CustomModelDropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [localFilter, setLocalFilter] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Filter models based on local filter text
const filteredModels = useMemo(() => filterModels(models, localFilter), [models, localFilter]);
// Group filtered models by provider
const modelsByProvider = useMemo(() => {
return filteredModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
(acc[m.provider] ??= []).push(m);
return acc;
}, {});
}, [filteredModels]);
// Get current provider from value
const currentProvider = useMemo(() => {
if (!value) return null;
const slashIdx = value.indexOf("/");
return slashIdx === -1 ? null : value.slice(0, slashIdx);
}, [value]);
// Build list of all selectable options (for keyboard navigation)
const optionsList = useMemo(() => {
const options: Array<{ type: "default" | "provider" | "model"; value: string; label: string; provider?: string }> = [
{ type: "default", value: "", label: "Use default" },
];
Object.entries(modelsByProvider).forEach(([provider, providerModels]) => {
options.push({ type: "provider", value: `__group_${provider}`, label: provider, provider });
providerModels.forEach((m) => {
options.push({
type: "model",
value: `${m.provider}/${m.id}`,
label: m.name,
provider: m.provider,
});
});
});
return options;
}, [modelsByProvider]);
// Get current selection display text
const selectedDisplayText = useMemo(() => {
if (!value) return "Use default";
const slashIdx = value.indexOf("/");
if (slashIdx === -1) return value;
const provider = value.slice(0, slashIdx);
const modelId = value.slice(slashIdx + 1);
const model = models.find((m) => m.provider === provider && m.id === modelId);
return model?.name || value;
}, [value, models]);
// Find index of current value in options list
const currentValueIndex = useMemo(() => {
return optionsList.findIndex((opt) => opt.value === value);
}, [optionsList, value]);
// Reset highlighted index when opening
useEffect(() => {
if (isOpen) {
// Start with current value highlighted, or first selectable option
const selectableIndex = optionsList.findIndex(
(opt, idx) => idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider"
);
setHighlightedIndex(selectableIndex >= 0 ? selectableIndex : 0);
}
}, [isOpen, optionsList, currentValueIndex]);
// Focus search input when opening
useEffect(() => {
if (isOpen) {
setTimeout(() => searchInputRef.current?.focus(), 0);
}
}, [isOpen]);
// Click outside to close
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setLocalFilter("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
// Keyboard navigation
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
// Find next selectable option (skip provider headers)
let nextIndex = highlightedIndex;
for (let i = 1; i <= optionsList.length; i++) {
const idx = (highlightedIndex + i) % optionsList.length;
if (optionsList[idx]?.type !== "provider") {
nextIndex = idx;
break;
}
}
setHighlightedIndex(nextIndex);
}
break;
case "ArrowUp":
e.preventDefault();
if (isOpen) {
// Find previous selectable option (skip provider headers)
let prevIndex = highlightedIndex;
for (let i = 1; i <= optionsList.length; i++) {
const idx = (highlightedIndex - i + optionsList.length) % optionsList.length;
if (optionsList[idx]?.type !== "provider") {
prevIndex = idx;
break;
}
}
setHighlightedIndex(prevIndex);
}
break;
case "Enter":
e.preventDefault();
if (isOpen) {
const option = optionsList[highlightedIndex];
if (option && option.type !== "provider") {
onChange(option.value);
setIsOpen(false);
setLocalFilter("");
}
} else {
setIsOpen(true);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setLocalFilter("");
break;
case "Tab":
// Close dropdown on tab (focus moves to next field)
if (isOpen) {
setIsOpen(false);
setLocalFilter("");
}
break;
}
},
[isOpen, highlightedIndex, optionsList, onChange]
);
const handleSelect = useCallback(
(optionValue: string) => {
onChange(optionValue);
setIsOpen(false);
setLocalFilter("");
},
[onChange]
);
const handleClearFilter = useCallback(() => {
setLocalFilter("");
searchInputRef.current?.focus();
}, []);
const handleTriggerClick = useCallback(() => {
if (!disabled) {
setIsOpen((prev) => !prev);
}
}, [disabled]);
// Scroll highlighted option into view
useEffect(() => {
if (isOpen && listRef.current) {
const highlightedEl = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`);
if (highlightedEl && typeof highlightedEl.scrollIntoView === "function") {
highlightedEl.scrollIntoView({ block: "nearest" });
}
}
}, [highlightedIndex, isOpen]);
const hasFilter = localFilter.length > 0;
return (
<div ref={containerRef} className="model-combobox" onKeyDown={handleKeyDown}>
{/* Trigger Button */}
<button
type="button"
id={id}
className="model-combobox-trigger"
onClick={handleTriggerClick}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={label}
>
{currentProvider && (
<span className="model-combobox-trigger-icon">
<ProviderIcon provider={currentProvider} size="sm" />
</span>
)}
<span className="model-combobox-trigger-text">{selectedDisplayText}</span>
<span className="model-combobox-trigger-arrow"></span>
</button>
{/* Dropdown Panel */}
{isOpen && (
<div className="model-combobox-dropdown" role="listbox">
{/* Search Input */}
<div className="model-combobox-search-wrapper">
<input
ref={searchInputRef}
type="text"
className="model-combobox-search"
placeholder="Filter models…"
value={localFilter}
onChange={(e) => setLocalFilter(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{hasFilter && (
<button
type="button"
className="model-combobox-clear"
onClick={handleClearFilter}
aria-label="Clear filter"
>
×
</button>
)}
</div>
{/* Results Count */}
<div className="model-combobox-results-count">
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
</div>
{/* Options List */}
<div ref={listRef} className="model-combobox-list">
{/* Use default option */}
<div
data-index={0}
className={`model-combobox-option ${highlightedIndex === 0 ? "model-combobox-option--highlighted" : ""} ${value === "" ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect("")}
onMouseEnter={() => setHighlightedIndex(0)}
role="option"
aria-selected={value === ""}
>
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
</div>
{/* Provider groups */}
{Object.entries(modelsByProvider).map(([provider, providerModels]) => {
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
return (
<div key={provider} className="model-combobox-group">
<div className="model-combobox-optgroup" data-index={groupStartIndex}>
<ProviderIcon provider={provider} size="sm" />
<span className="model-combobox-optgroup-text">{provider}</span>
</div>
{providerModels.map((m) => {
const optionValue = `${m.provider}/${m.id}`;
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
const isHighlighted = highlightedIndex === optionIndex;
const isSelected = value === optionValue;
return (
<div
key={optionValue}
data-index={optionIndex}
className={`model-combobox-option ${isHighlighted ? "model-combobox-option--highlighted" : ""} ${isSelected ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect(optionValue)}
onMouseEnter={() => setHighlightedIndex(optionIndex)}
role="option"
aria-selected={isSelected}
>
<span className="model-combobox-option-text">{m.name}</span>
<span className="model-combobox-option-id">{m.id}</span>
</div>
);
})}
</div>
);
})}
{/* No results message */}
{filteredModels.length === 0 && hasFilter && (
<div className="model-combobox-no-results">No models match &apos;{localFilter}&apos;</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,343 +1,16 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useState, useEffect, useCallback } 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";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { ProviderIcon } from "./ProviderIcon";
interface ModelSelectorTabProps {
task: Task | TaskDetail;
addToast: (message: string, type?: ToastType) => void;
}
interface ModelComboboxProps {
value: string; // provider/id combo like "anthropic/claude-sonnet-4-5" or "" for default
onChange: (value: string) => void;
models: ModelInfo[];
disabled?: boolean;
placeholder?: string;
label: string;
id: string;
}
/**
* ModelCombobox - A combobox component combining dropdown and filter input.
*
* Interaction pattern:
* - Closed: Shows trigger button with current selection
* - Open: Dropdown with search input at top, scrollable list of models grouped by provider
* - Filtering: Real-time filtering using filterModels() utility
* - Keyboard: Arrow keys navigate, Enter selects, Escape closes, Tab moves focus
*/
function ModelCombobox({
value,
onChange,
models,
disabled = false,
placeholder = "Select a model…",
label,
id,
}: ModelComboboxProps) {
const [isOpen, setIsOpen] = useState(false);
const [localFilter, setLocalFilter] = useState("");
const [highlightedIndex, setHighlightedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Filter models based on local filter text
const filteredModels = useMemo(() =>
filterModels(models, localFilter),
[models, localFilter]
);
// Group filtered models by provider
const modelsByProvider = useMemo(() => {
return filteredModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
(acc[m.provider] ??= []).push(m);
return acc;
}, {});
}, [filteredModels]);
// Build list of all selectable options (for keyboard navigation)
const optionsList = useMemo(() => {
const options: Array<{ type: "default" | "provider" | "model"; value: string; label: string; provider?: string }> = [
{ type: "default", value: "", label: "Use default" },
];
Object.entries(modelsByProvider).forEach(([provider, providerModels]) => {
options.push({ type: "provider", value: `__group_${provider}`, label: provider, provider });
providerModels.forEach((m) => {
options.push({
type: "model",
value: `${m.provider}/${m.id}`,
label: m.name,
provider: m.provider
});
});
});
return options;
}, [modelsByProvider]);
// Get current selection display text
const selectedDisplayText = useMemo(() => {
if (!value) return "Use default";
const slashIdx = value.indexOf("/");
if (slashIdx === -1) return value;
const provider = value.slice(0, slashIdx);
const modelId = value.slice(slashIdx + 1);
const model = models.find((m) => m.provider === provider && m.id === modelId);
return model?.name || value;
}, [value, models]);
// Find index of current value in options list
const currentValueIndex = useMemo(() => {
return optionsList.findIndex((opt) => opt.value === value);
}, [optionsList, value]);
// Reset highlighted index when opening
useEffect(() => {
if (isOpen) {
// Start with current value highlighted, or first selectable option
const selectableIndex = optionsList.findIndex((opt, idx) =>
idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider"
);
setHighlightedIndex(selectableIndex >= 0 ? selectableIndex : 0);
}
}, [isOpen, optionsList, currentValueIndex]);
// Focus search input when opening
useEffect(() => {
if (isOpen) {
setTimeout(() => searchInputRef.current?.focus(), 0);
}
}, [isOpen]);
// Click outside to close
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setLocalFilter("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
// Keyboard navigation
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
// Find next selectable option (skip provider headers)
let nextIndex = highlightedIndex;
for (let i = 1; i <= optionsList.length; i++) {
const idx = (highlightedIndex + i) % optionsList.length;
if (optionsList[idx]?.type !== "provider") {
nextIndex = idx;
break;
}
}
setHighlightedIndex(nextIndex);
}
break;
case "ArrowUp":
e.preventDefault();
if (isOpen) {
// Find previous selectable option (skip provider headers)
let prevIndex = highlightedIndex;
for (let i = 1; i <= optionsList.length; i++) {
const idx = (highlightedIndex - i + optionsList.length) % optionsList.length;
if (optionsList[idx]?.type !== "provider") {
prevIndex = idx;
break;
}
}
setHighlightedIndex(prevIndex);
}
break;
case "Enter":
e.preventDefault();
if (isOpen) {
const option = optionsList[highlightedIndex];
if (option && option.type !== "provider") {
onChange(option.value);
setIsOpen(false);
setLocalFilter("");
}
} else {
setIsOpen(true);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setLocalFilter("");
break;
case "Tab":
// Close dropdown on tab (focus moves to next field)
if (isOpen) {
setIsOpen(false);
setLocalFilter("");
}
break;
}
}, [isOpen, highlightedIndex, optionsList, onChange]);
const handleSelect = useCallback((optionValue: string) => {
onChange(optionValue);
setIsOpen(false);
setLocalFilter("");
}, [onChange]);
const handleClearFilter = useCallback(() => {
setLocalFilter("");
searchInputRef.current?.focus();
}, []);
const handleTriggerClick = useCallback(() => {
if (!disabled) {
setIsOpen((prev) => !prev);
}
}, [disabled]);
// Scroll highlighted option into view
useEffect(() => {
if (isOpen && listRef.current) {
const highlightedEl = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`);
if (highlightedEl && typeof highlightedEl.scrollIntoView === "function") {
highlightedEl.scrollIntoView({ block: "nearest" });
}
}
}, [highlightedIndex, isOpen]);
const hasFilter = localFilter.length > 0;
return (
<div ref={containerRef} className="model-combobox" onKeyDown={handleKeyDown}>
{/* Trigger Button */}
<button
type="button"
id={id}
className="model-combobox-trigger"
onClick={handleTriggerClick}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-label={label}
>
<span className="model-combobox-trigger-text">{selectedDisplayText}</span>
<span className="model-combobox-trigger-arrow"></span>
</button>
{/* Dropdown Panel */}
{isOpen && (
<div className="model-combobox-dropdown" role="listbox">
{/* Search Input */}
<div className="model-combobox-search-wrapper">
<input
ref={searchInputRef}
type="text"
className="model-combobox-search"
placeholder="Filter models…"
value={localFilter}
onChange={(e) => setLocalFilter(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{hasFilter && (
<button
type="button"
className="model-combobox-clear"
onClick={handleClearFilter}
aria-label="Clear filter"
>
×
</button>
)}
</div>
{/* Results Count */}
<div className="model-combobox-results-count">
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
</div>
{/* Options List */}
<div ref={listRef} className="model-combobox-list">
{/* Use default option */}
<div
data-index={0}
className={`model-combobox-option ${highlightedIndex === 0 ? "model-combobox-option--highlighted" : ""} ${value === "" ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect("")}
onMouseEnter={() => setHighlightedIndex(0)}
role="option"
aria-selected={value === ""}
>
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
</div>
{/* Provider groups */}
{Object.entries(modelsByProvider).map(([provider, providerModels]) => {
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
return (
<div key={provider} className="model-combobox-group">
<div
className="model-combobox-optgroup"
data-index={groupStartIndex}
>
{provider}
</div>
{providerModels.map((m) => {
const optionValue = `${m.provider}/${m.id}`;
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
const isHighlighted = highlightedIndex === optionIndex;
const isSelected = value === optionValue;
return (
<div
key={optionValue}
data-index={optionIndex}
className={`model-combobox-option ${isHighlighted ? "model-combobox-option--highlighted" : ""} ${isSelected ? "model-combobox-option--selected" : ""}`}
onClick={() => handleSelect(optionValue)}
onMouseEnter={() => setHighlightedIndex(optionIndex)}
role="option"
aria-selected={isSelected}
>
<span className="model-combobox-option-text">{m.name}</span>
<span className="model-combobox-option-id">{m.id}</span>
</div>
);
})}
</div>
);
})}
{/* No results message */}
{filteredModels.length === 0 && hasFilter && (
<div className="model-combobox-no-results">
No models match &apos;{localFilter}&apos;
</div>
)}
</div>
</div>
)}
</div>
);
}
export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
@@ -479,11 +152,12 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
<span className="model-badge model-badge-default">Using default</span>
) : (
<span className="model-badge model-badge-custom">
{task.modelProvider && <ProviderIcon provider={task.modelProvider} size="sm" />}
{task.modelProvider}/{task.modelId}
</span>
)}
</div>
<ModelCombobox
<CustomModelDropdown
id="executorModel"
label="Executor Model"
value={executorValue}
@@ -503,11 +177,12 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
<span className="model-badge model-badge-default">Using default</span>
) : (
<span className="model-badge model-badge-custom">
{task.validatorModelProvider && <ProviderIcon provider={task.validatorModelProvider} size="sm" />}
{task.validatorModelProvider}/{task.validatorModelId}
</span>
)}
</div>
<ModelCombobox
<CustomModelDropdown
id="validatorModel"
label="Validator Model"
value={validatorValue}

View File

@@ -0,0 +1,34 @@
import { Brain, Sparkles, Search, Terminal, Cpu } from "lucide-react";
export interface ProviderIconProps {
provider: string;
size?: "sm" | "md" | "lg";
}
const sizeMap = {
sm: 16,
md: 20,
lg: 24,
};
const providerConfig: Record<string, { icon: typeof Brain; color: string }> = {
anthropic: { icon: Brain, color: "#d4a27f" }, // warm tan
openai: { icon: Sparkles, color: "#10a37f" }, // green
google: { icon: Search, color: "#4285f4" }, // blue
gemini: { icon: Search, color: "#4285f4" }, // blue (same as google)
ollama: { icon: Terminal, color: "#fff" }, // white
};
export function ProviderIcon({ provider, size = "sm" }: ProviderIconProps) {
const normalizedProvider = provider.toLowerCase();
const config = providerConfig[normalizedProvider];
const Icon = config?.icon ?? Cpu;
const color = config?.color ?? "var(--text-muted)";
const iconSize = sizeMap[size];
return (
<span className="provider-icon" style={{ color }} data-provider={normalizedProvider}>
<Icon size={iconSize} />
</span>
);
}

View File

@@ -0,0 +1,310 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CustomModelDropdown } from "../CustomModelDropdown";
import type { ModelInfo } from "../../api";
// Mock ProviderIcon to avoid rendering actual icons
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
}));
const MOCK_MODELS: ModelInfo[] = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
{ provider: "anthropic", id: "claude-opus-4", name: "Claude Opus 4", reasoning: true, contextWindow: 200000 },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
{ provider: "ollama", id: "llama3", name: "Llama 3", reasoning: false, contextWindow: 4096 },
];
const defaultProps = {
models: MOCK_MODELS,
value: "",
onChange: vi.fn(),
label: "Test Model",
id: "test-model",
};
describe("CustomModelDropdown", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders trigger button with placeholder text", () => {
render(<CustomModelDropdown {...defaultProps} />);
expect(screen.getByLabelText("Test Model")).toBeInTheDocument();
expect(screen.getByText("Use default")).toBeInTheDocument();
});
it("renders trigger button with selected model name", () => {
render(<CustomModelDropdown {...defaultProps} value="anthropic/claude-sonnet-4-5" />);
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
});
it("shows provider icon in trigger when model is selected", () => {
render(<CustomModelDropdown {...defaultProps} value="anthropic/claude-sonnet-4-5" />);
expect(screen.getByTestId("provider-icon-anthropic")).toBeInTheDocument();
});
it("does not show provider icon in trigger when using default", () => {
render(<CustomModelDropdown {...defaultProps} value="" />);
expect(screen.queryByTestId(/provider-icon-/)).not.toBeInTheDocument();
});
it("opens dropdown when trigger is clicked", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
expect(screen.getByText("4 models")).toBeInTheDocument();
});
it("groups models by provider in dropdown", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
// Provider groups should be visible with icons
expect(screen.getByTestId("provider-icon-anthropic")).toBeInTheDocument();
expect(screen.getByTestId("provider-icon-openai")).toBeInTheDocument();
expect(screen.getByTestId("provider-icon-ollama")).toBeInTheDocument();
});
it("displays provider names in group headers", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByText("anthropic")).toBeInTheDocument();
expect(screen.getByText("openai")).toBeInTheDocument();
expect(screen.getByText("ollama")).toBeInTheDocument();
});
it("displays model names in dropdown", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
expect(screen.getByText("Llama 3")).toBeInTheDocument();
});
it("displays model IDs next to names", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByText("claude-sonnet-4-5")).toBeInTheDocument();
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
});
it("calls onChange when a model is selected", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<CustomModelDropdown {...defaultProps} onChange={onChange} />);
await user.click(screen.getByLabelText("Test Model"));
await user.click(screen.getByText("GPT-4o"));
expect(onChange).toHaveBeenCalledWith("openai/gpt-4o");
});
it("calls onChange with empty string when 'Use default' is selected", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<CustomModelDropdown {...defaultProps} value="anthropic/claude-sonnet-4-5" onChange={onChange} />);
await user.click(screen.getByLabelText("Test Model"));
// Find and click the "Use default" option (it's always first)
const defaultOptions = screen.getAllByText("Use default");
// Click the one in the dropdown list (not the trigger)
const dropdownDefault = defaultOptions.find((el) => el.classList.contains("model-combobox-option-text--default"));
if (dropdownDefault) {
await user.click(dropdownDefault);
}
expect(onChange).toHaveBeenCalledWith("");
});
it("filters models when typing in search input", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "openai");
expect(screen.getByText("1 model")).toBeInTheDocument();
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
});
it("filters models by model name", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "opus");
expect(screen.getByText("1 model")).toBeInTheDocument();
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
});
it("clear button clears filter and restores full list", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "openai");
expect(screen.getByText("1 model")).toBeInTheDocument();
await user.click(screen.getByLabelText("Clear filter"));
expect(searchInput).toHaveValue("");
expect(screen.getByText("4 models")).toBeInTheDocument();
});
it("shows empty state when filter matches nothing", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
const searchInput = screen.getByPlaceholderText("Filter models…");
await user.type(searchInput, "xyz123");
expect(screen.getByText("0 models")).toBeInTheDocument();
expect(screen.getByText(/No models match/)).toBeInTheDocument();
});
it("closes dropdown when clicking outside", async () => {
const user = userEvent.setup();
render(
<div>
<CustomModelDropdown {...defaultProps} />
<div data-testid="outside">Outside</div>
</div>
);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
await user.click(screen.getByTestId("outside"));
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
it("closes dropdown on Escape key", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
await user.keyboard("{Escape}");
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
it("opens dropdown with arrow down key", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
screen.getByLabelText("Test Model").focus();
await user.keyboard("{ArrowDown}");
await waitFor(() => {
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
});
});
it("navigates with arrow keys and selects with Enter", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<CustomModelDropdown {...defaultProps} onChange={onChange} />);
screen.getByLabelText("Test Model").focus();
await user.keyboard("{ArrowDown}");
await waitFor(() => {
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
});
// Navigate down and press Enter
await user.keyboard("{ArrowDown}");
await user.keyboard("{Enter}");
await waitFor(() => {
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
expect(onChange).toHaveBeenCalled();
});
it("is disabled when disabled prop is true", () => {
render(<CustomModelDropdown {...defaultProps} disabled />);
expect(screen.getByLabelText("Test Model")).toBeDisabled();
});
it("does not open when disabled", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} disabled />);
await user.click(screen.getByLabelText("Test Model"));
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
});
it("has correct ARIA attributes", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
const trigger = screen.getByLabelText("Test Model");
// Native button element - role is implicit
expect(trigger.tagName.toLowerCase()).toBe("button");
expect(trigger).toHaveAttribute("aria-haspopup", "listbox");
expect(trigger).toHaveAttribute("aria-expanded", "false");
await user.click(trigger);
expect(trigger).toHaveAttribute("aria-expanded", "true");
});
it("marks selected option with aria-selected", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} value="openai/gpt-4o" />);
await user.click(screen.getByLabelText("Test Model"));
// Find the selected option by looking for the selected class and aria-selected
const options = screen.getAllByRole("option");
const selectedOption = options.find((opt) => opt.getAttribute("aria-selected") === "true");
expect(selectedOption).toHaveTextContent("GPT-4o");
});
it("shows 'Use default' option at the top", async () => {
const user = userEvent.setup();
render(<CustomModelDropdown {...defaultProps} />);
await user.click(screen.getByLabelText("Test Model"));
// The "Use default" option should be visible
const defaultOptions = screen.getAllByText("Use default");
expect(defaultOptions.length).toBeGreaterThan(0);
});
it("shows fallback value text when model is not found", () => {
render(<CustomModelDropdown {...defaultProps} value="unknown/unknown-model" />);
expect(screen.getByText("unknown/unknown-model")).toBeInTheDocument();
});
});

View File

@@ -15,6 +15,11 @@ vi.mock("../../api", async () => {
};
});
// Mock ProviderIcon to avoid rendering actual icons
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
}));
const mockFetchModels = api.fetchModels as ReturnType<typeof vi.fn>;
const mockUpdateTask = api.updateTask as ReturnType<typeof vi.fn>;
@@ -94,6 +99,40 @@ describe("ModelSelectorTab", () => {
expect(screen.getByText("openai/gpt-4o")).toBeInTheDocument();
});
it("displays provider icon next to current selection in badge", async () => {
const taskWithModels = {
...FAKE_TASK,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
};
render(<ModelSelectorTab task={taskWithModels} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Verify provider icons are rendered in the component (both badges and dropdown trigger)
const anthropicIcons = screen.getAllByTestId("provider-icon-anthropic");
const openaiIcons = screen.getAllByTestId("provider-icon-openai");
expect(anthropicIcons.length).toBeGreaterThanOrEqual(1);
expect(openaiIcons.length).toBeGreaterThanOrEqual(1);
});
it("does not display provider icon in badge when using default", async () => {
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// No provider icons should be rendered when using default
expect(screen.queryByTestId(/provider-icon-/)).not.toBeInTheDocument();
});
it("opens combobox when trigger is clicked", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
@@ -131,6 +170,23 @@ describe("ModelSelectorTab", () => {
expect(screen.getByText("openai")).toBeInTheDocument();
});
it("displays provider icons in dropdown group headers", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
});
// Open the combobox
const executorTrigger = screen.getByLabelText("Executor Model");
await user.click(executorTrigger);
// Check provider icons are displayed in dropdown headers
expect(screen.getByTestId("provider-icon-anthropic")).toBeInTheDocument();
expect(screen.getByTestId("provider-icon-openai")).toBeInTheDocument();
});
it("enables Save button when selections change", async () => {
const user = userEvent.setup();
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);

View File

@@ -0,0 +1,116 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { ProviderIcon } from "../ProviderIcon";
// Mock lucide-react to avoid rendering actual SVGs in tests
vi.mock("lucide-react", () => ({
Brain: ({ size }: { size?: number }) => <span data-testid="brain-icon" data-size={size} />,
Sparkles: ({ size }: { size?: number }) => <span data-testid="sparkles-icon" data-size={size} />,
Search: ({ size }: { size?: number }) => <span data-testid="search-icon" data-size={size} />,
Terminal: ({ size }: { size?: number }) => <span data-testid="terminal-icon" data-size={size} />,
Cpu: ({ size }: { size?: number }) => <span data-testid="cpu-icon" data-size={size} />,
}));
describe("ProviderIcon", () => {
it("renders Brain icon for anthropic provider", () => {
render(<ProviderIcon provider="anthropic" />);
expect(screen.getByTestId("brain-icon")).toBeInTheDocument();
});
it("renders Sparkles icon for openai provider", () => {
render(<ProviderIcon provider="openai" />);
expect(screen.getByTestId("sparkles-icon")).toBeInTheDocument();
});
it("renders Search icon for google provider", () => {
render(<ProviderIcon provider="google" />);
expect(screen.getByTestId("search-icon")).toBeInTheDocument();
});
it("renders Search icon for gemini provider", () => {
render(<ProviderIcon provider="gemini" />);
expect(screen.getByTestId("search-icon")).toBeInTheDocument();
});
it("renders Terminal icon for ollama provider", () => {
render(<ProviderIcon provider="ollama" />);
expect(screen.getByTestId("terminal-icon")).toBeInTheDocument();
});
it("renders Cpu icon as fallback for unknown providers", () => {
render(<ProviderIcon provider="unknown" />);
expect(screen.getByTestId("cpu-icon")).toBeInTheDocument();
});
it("renders Cpu icon as fallback for empty provider", () => {
render(<ProviderIcon provider="" />);
expect(screen.getByTestId("cpu-icon")).toBeInTheDocument();
});
it("normalizes provider name to lowercase", () => {
render(<ProviderIcon provider="Anthropic" />);
expect(screen.getByTestId("brain-icon")).toBeInTheDocument();
});
it("applies provider-specific color for anthropic", () => {
render(<ProviderIcon provider="anthropic" />);
const icon = screen.getByTestId("brain-icon").parentElement;
expect(icon).toHaveStyle({ color: "#d4a27f" });
});
it("applies provider-specific color for openai", () => {
render(<ProviderIcon provider="openai" />);
const icon = screen.getByTestId("sparkles-icon").parentElement;
expect(icon).toHaveStyle({ color: "#10a37f" });
});
it("applies provider-specific color for google", () => {
render(<ProviderIcon provider="google" />);
const icon = screen.getByTestId("search-icon").parentElement;
expect(icon).toHaveStyle({ color: "#4285f4" });
});
it("applies provider-specific color for ollama", () => {
render(<ProviderIcon provider="ollama" />);
const icon = screen.getByTestId("terminal-icon").parentElement;
expect(icon).toHaveStyle({ color: "#fff" });
});
it("applies default color for unknown providers", () => {
render(<ProviderIcon provider="unknown" />);
const icon = screen.getByTestId("cpu-icon").parentElement;
expect(icon).toHaveStyle({ color: "var(--text-muted)" });
});
it("sets data-provider attribute with normalized provider name", () => {
render(<ProviderIcon provider="Anthropic" />);
const icon = screen.getByTestId("brain-icon").parentElement;
expect(icon).toHaveAttribute("data-provider", "anthropic");
});
it("uses sm size (16px) by default", () => {
render(<ProviderIcon provider="anthropic" />);
expect(screen.getByTestId("brain-icon")).toHaveAttribute("data-size", "16");
});
it("uses sm size when explicitly specified", () => {
render(<ProviderIcon provider="anthropic" size="sm" />);
expect(screen.getByTestId("brain-icon")).toHaveAttribute("data-size", "16");
});
it("uses md size (20px) when specified", () => {
render(<ProviderIcon provider="anthropic" size="md" />);
expect(screen.getByTestId("brain-icon")).toHaveAttribute("data-size", "20");
});
it("uses lg size (24px) when specified", () => {
render(<ProviderIcon provider="anthropic" size="lg" />);
expect(screen.getByTestId("brain-icon")).toHaveAttribute("data-size", "24");
});
it("renders with className provider-icon", () => {
render(<ProviderIcon provider="anthropic" />);
const icon = screen.getByTestId("brain-icon").parentElement;
expect(icon).toHaveClass("provider-icon");
});
});

View File

@@ -3563,10 +3563,18 @@ body {
}
.model-badge-custom {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--todo);
color: var(--bg);
}
.model-badge-custom .provider-icon {
display: inline-flex;
flex-shrink: 0;
}
.model-selector-actions {
display: flex;
gap: 8px;
@@ -3701,6 +3709,12 @@ body {
text-align: left;
}
.model-combobox-trigger-icon {
display: inline-flex;
margin-right: 8px;
flex-shrink: 0;
}
.model-combobox-trigger-arrow {
font-size: 10px;
color: var(--text-muted);
@@ -3843,6 +3857,9 @@ body {
}
.model-combobox-optgroup {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 10px;
font-weight: 600;
@@ -3853,6 +3870,10 @@ body {
cursor: default;
}
.model-combobox-optgroup-text {
flex: 1;
}
.model-combobox-no-results {
padding: 16px 12px;
text-align: center;