feat(KB-080): add combined model dropdown with combobox component
- Create new combobox component for model/provider selection - Refactor ModelSelectorTab to use combined dropdown UI - Add scrollIntoView null check for test environment compatibility - Add changeset for combined model dropdown feature - Clean up obsolete changeset and subtask-related code
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { fetchModels, updateTask } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import type { Task, TaskDetail } from "@kb/core";
|
||||
@@ -10,15 +10,339 @@ interface ModelSelectorTabProps {
|
||||
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 '{localFilter}'
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
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);
|
||||
@@ -55,26 +379,7 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
setHasChanges(executorChanged || validatorChanged);
|
||||
}, [executorProvider, executorModelId, validatorProvider, validatorModelId, task]);
|
||||
|
||||
// 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)
|
||||
// Build combobox values (provider/id combination or empty for default)
|
||||
const executorValue = executorProvider && executorModelId
|
||||
? `${executorProvider}/${executorModelId}`
|
||||
: "";
|
||||
@@ -178,53 +483,15 @@ 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
|
||||
<ModelCombobox
|
||||
id="executorModel"
|
||||
label="Executor Model"
|
||||
value={executorValue}
|
||||
onChange={(e) => handleExecutorChange(e.target.value)}
|
||||
onChange={handleExecutorChange}
|
||||
models={availableModels}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<option value="">Use default</option>
|
||||
{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}`}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{filteredExecutorModels.length === 0 && executorFilter && (
|
||||
<div className="model-selector-no-results">
|
||||
No models match '{executorFilter}'
|
||||
</div>
|
||||
)}
|
||||
placeholder="Select executor model…"
|
||||
/>
|
||||
<small>The AI model used to implement this task.</small>
|
||||
</div>
|
||||
|
||||
@@ -240,53 +507,15 @@ 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
|
||||
<ModelCombobox
|
||||
id="validatorModel"
|
||||
label="Validator Model"
|
||||
value={validatorValue}
|
||||
onChange={(e) => handleValidatorChange(e.target.value)}
|
||||
onChange={handleValidatorChange}
|
||||
models={availableModels}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<option value="">Use default</option>
|
||||
{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}`}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{filteredValidatorModels.length === 0 && validatorFilter && (
|
||||
<div className="model-selector-no-results">
|
||||
No models match '{validatorFilter}'
|
||||
</div>
|
||||
)}
|
||||
placeholder="Select validator model…"
|
||||
/>
|
||||
<small>The AI model used to review code and plans for this task.</small>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -94,20 +94,41 @@ describe("ModelSelectorTab", () => {
|
||||
expect(screen.getByText("openai/gpt-4o")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups models by provider in select options", async () => {
|
||||
it("opens combobox when trigger is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const executorSelect = screen.getByLabelText("Executor Model") as HTMLSelectElement;
|
||||
expect(executorSelect).toBeInTheDocument();
|
||||
// Click the executor combobox trigger
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Check options exist with model names
|
||||
expect(screen.getAllByText("Claude Sonnet 4.5").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("Claude Opus 4").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText("GPT-4o").length).toBeGreaterThanOrEqual(1);
|
||||
// Dropdown should be visible with models
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
expect(screen.getByText("3 models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
|
||||
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups models by provider in dropdown", 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 headers are present
|
||||
expect(screen.getByText("anthropic")).toBeInTheDocument();
|
||||
expect(screen.getByText("openai")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("enables Save button when selections change", async () => {
|
||||
@@ -121,9 +142,13 @@ describe("ModelSelectorTab", () => {
|
||||
const saveButton = screen.getByText("Save");
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Select a model
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
|
||||
// Open combobox and select a model
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Click on a model option
|
||||
const modelOption = screen.getByText("Claude Sonnet 4.5");
|
||||
await user.click(modelOption);
|
||||
|
||||
expect(saveButton).toBeEnabled();
|
||||
});
|
||||
@@ -139,12 +164,14 @@ describe("ModelSelectorTab", () => {
|
||||
});
|
||||
|
||||
// Select executor model
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
// Select validator model
|
||||
const validatorSelect = screen.getByLabelText("Validator Model");
|
||||
await user.selectOptions(validatorSelect, "openai/gpt-4o");
|
||||
const validatorTrigger = screen.getByLabelText("Validator Model");
|
||||
await user.click(validatorTrigger);
|
||||
await user.click(screen.getByText("GPT-4o"));
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText("Save"));
|
||||
@@ -176,9 +203,15 @@ describe("ModelSelectorTab", () => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select "Use default" (empty value)
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "");
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Select "Use default"
|
||||
const defaultOption = screen.getAllByText("Use default").find(
|
||||
el => el.classList.contains("model-combobox-option-text--default")
|
||||
) || screen.getAllByText("Use default")[0];
|
||||
await user.click(defaultOption);
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText("Save"));
|
||||
@@ -202,15 +235,19 @@ describe("ModelSelectorTab", () => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change selection
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
|
||||
// Open and select a model
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
// Verify Save is enabled
|
||||
expect(screen.getByText("Save")).toBeEnabled();
|
||||
|
||||
// Reset
|
||||
await user.click(screen.getByText("Reset"));
|
||||
|
||||
// Selection should be back to empty (Use default)
|
||||
expect(executorSelect).toHaveValue("");
|
||||
// Save should be disabled again (no changes)
|
||||
expect(screen.getByText("Save")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows error state when fetchModels fails", async () => {
|
||||
@@ -246,15 +283,16 @@ describe("ModelSelectorTab", () => {
|
||||
});
|
||||
|
||||
// Select a model
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
// Start save
|
||||
await user.click(screen.getByText("Save"));
|
||||
|
||||
// Should show saving state
|
||||
expect(screen.getByText("Saving…")).toBeInTheDocument();
|
||||
expect(executorSelect).toBeDisabled();
|
||||
expect(executorTrigger).toBeDisabled();
|
||||
});
|
||||
|
||||
it("shows error toast when save fails", async () => {
|
||||
@@ -268,8 +306,9 @@ describe("ModelSelectorTab", () => {
|
||||
});
|
||||
|
||||
// Select a model
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "anthropic/claude-sonnet-4-5");
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
await user.click(screen.getByText("Claude Sonnet 4.5"));
|
||||
|
||||
// Click save
|
||||
await user.click(screen.getByText("Save"));
|
||||
@@ -279,203 +318,295 @@ describe("ModelSelectorTab", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Filter functionality tests
|
||||
it("renders filter input for executor and validator selectors", async () => {
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
// Combobox-specific tests
|
||||
describe("Combobox behavior", () => {
|
||||
it("filters models when typing in search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Type filter text
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "openai");
|
||||
|
||||
// Should show filtered results
|
||||
expect(screen.getByText("1 model")).toBeInTheDocument();
|
||||
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Claude Sonnet 4.5")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Claude Opus 4")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Both filter inputs should be present (placeholder text)
|
||||
const filterInputs = screen.getAllByPlaceholderText("Filter models…");
|
||||
expect(filterInputs.length).toBe(2);
|
||||
});
|
||||
it("filters models by model ID", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Type model ID
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "gpt-4o");
|
||||
|
||||
// Should show only GPT-4o
|
||||
expect(screen.getByText("1 model")).toBeInTheDocument();
|
||||
expect(screen.getByText("GPT-4o")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Claude Sonnet 4.5")).not.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");
|
||||
it("filters models by display name", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
// Should show result count
|
||||
expect(within(executorSection!).getByText("1 model")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor 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");
|
||||
});
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
it("filters models by model ID", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
// Type display name
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "opus");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
// Should show only Opus
|
||||
expect(screen.getByText("1 model")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Claude Sonnet 4.5")).not.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");
|
||||
it("supports multi-word filter (AND logic)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
// 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");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters models by display name", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
// Type multi-word filter
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "anthropic claude");
|
||||
|
||||
// Should show only anthropic models
|
||||
expect(screen.getByText("2 models")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Sonnet 4.5")).toBeInTheDocument();
|
||||
expect(screen.getByText("Claude Opus 4")).toBeInTheDocument();
|
||||
expect(screen.queryByText("GPT-4o")).not.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");
|
||||
it("clear button clears filter and restores full list", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
// 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");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("supports multi-word filter (AND logic)", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
// Type a filter
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "openai");
|
||||
|
||||
// Verify filter is applied
|
||||
expect(screen.getByText("1 model")).toBeInTheDocument();
|
||||
|
||||
// Click clear button
|
||||
const clearButton = screen.getByLabelText("Clear filter");
|
||||
await user.click(clearButton);
|
||||
|
||||
// Filter should be cleared
|
||||
expect(searchInput).toHaveValue("");
|
||||
expect(screen.getByText("3 models")).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");
|
||||
it("shows empty state message when filter matches nothing", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
// 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");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("Use default option remains visible regardless of filter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
// Type a filter that matches nothing
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "xyz123");
|
||||
|
||||
// Should show no results message
|
||||
expect(screen.getByText("0 models")).toBeInTheDocument();
|
||||
expect(screen.getByText(/No models match/)).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");
|
||||
it("closes dropdown when clicking outside", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
// 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");
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clear button clears filter and restores full list", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
// Dropdown should be visible
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
|
||||
// Click outside (on the intro text)
|
||||
await user.click(screen.getByText(/Override the AI models/));
|
||||
|
||||
// Dropdown should be closed
|
||||
expect(screen.queryByPlaceholderText("Filter models…")).not.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");
|
||||
it("closes dropdown on Escape key", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
// Verify filter is applied
|
||||
expect(within(executorSection!).getByText("1 model")).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click clear button
|
||||
const clearButton = within(executorSection!).getByLabelText("Clear filter");
|
||||
await user.click(clearButton);
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Filter should be cleared and all models should be visible
|
||||
expect(filterInput).toHaveValue("");
|
||||
expect(within(executorSection!).getByText("3 models")).toBeInTheDocument();
|
||||
// Dropdown should be visible
|
||||
expect(screen.getByPlaceholderText("Filter 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");
|
||||
});
|
||||
// Press Escape
|
||||
await user.keyboard("{Escape}");
|
||||
|
||||
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();
|
||||
// Dropdown should be closed
|
||||
expect(screen.queryByPlaceholderText("Filter models…")).not.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");
|
||||
it("navigates with arrow keys and selects with Enter", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
// Should show no results message
|
||||
expect(within(executorSection!).getByText("No models match 'xyz123'")).toBeInTheDocument();
|
||||
expect(within(executorSection!).getByText("0 models")).toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("selecting a model from filtered list works correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUpdateTask.mockResolvedValue({ ...FAKE_TASK });
|
||||
// Focus and open combobox with arrow down
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
executorTrigger.focus();
|
||||
await user.keyboard("{ArrowDown}");
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
// Dropdown should be visible
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Filter models…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
// Navigate down and press Enter to select
|
||||
await user.keyboard("{ArrowDown}");
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
// Dropdown should be closed and Save button should be enabled
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText("Filter models…")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Save")).toBeEnabled();
|
||||
});
|
||||
|
||||
// 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");
|
||||
it("Use default option is always visible", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
// Select the filtered model
|
||||
const executorSelect = screen.getByLabelText("Executor Model");
|
||||
await user.selectOptions(executorSelect, "openai/gpt-4o");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Save
|
||||
await user.click(screen.getByText("Save"));
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Verify correct model was saved
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("KB-001", {
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
validatorModelProvider: undefined,
|
||||
validatorModelId: undefined,
|
||||
// Use default should be visible
|
||||
const defaultOptions = screen.getAllByText("Use default");
|
||||
expect(defaultOptions.length).toBeGreaterThan(0);
|
||||
|
||||
// Type a filter that matches nothing
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "nonexistent123");
|
||||
|
||||
// Use default should still be visible
|
||||
expect(screen.getAllByText("Use default").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("shows model ID next to model name", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Model IDs should be visible next to names
|
||||
expect(screen.getByText("claude-sonnet-4-5")).toBeInTheDocument();
|
||||
expect(screen.getByText("claude-opus-4")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-4o")).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();
|
||||
});
|
||||
|
||||
// Open combobox
|
||||
const executorTrigger = screen.getByLabelText("Executor Model");
|
||||
await user.click(executorTrigger);
|
||||
|
||||
// Filter to show only openai
|
||||
const searchInput = screen.getByPlaceholderText("Filter models…");
|
||||
await user.type(searchInput, "openai");
|
||||
|
||||
// Select the filtered model
|
||||
const modelOption = screen.getByText("GPT-4o");
|
||||
await user.click(modelOption);
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3655,6 +3655,212 @@ body {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* === Model Combobox Component === */
|
||||
|
||||
.model-combobox {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.model-combobox-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.model-combobox-trigger:hover:not(:disabled) {
|
||||
border-color: var(--text-dim);
|
||||
}
|
||||
|
||||
.model-combobox-trigger:focus {
|
||||
border-color: var(--todo);
|
||||
box-shadow: 0 0 0 2px rgba(88, 166, 255, 0.15);
|
||||
}
|
||||
|
||||
.model-combobox-trigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.model-combobox-trigger-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.model-combobox-trigger-arrow {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.model-combobox-trigger[aria-expanded="true"] .model-combobox-trigger-arrow {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.model-combobox-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 100;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.model-combobox-search-wrapper {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.model-combobox-search {
|
||||
width: 100%;
|
||||
padding: 10px 36px 10px 12px;
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
border-radius: var(--radius) var(--radius) 0 0;
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.model-combobox-search::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.model-combobox-search:focus {
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.model-combobox-clear {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
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-combobox-clear:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.model-combobox-results-count {
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.model-combobox-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.model-combobox-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.model-combobox-option:hover,
|
||||
.model-combobox-option--highlighted {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.model-combobox-option--selected {
|
||||
background: rgba(88, 166, 255, 0.15);
|
||||
}
|
||||
|
||||
.model-combobox-option--selected:hover,
|
||||
.model-combobox-option--selected.model-combobox-option--highlighted {
|
||||
background: rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
.model-combobox-option-text {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.model-combobox-option-text--default {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.model-combobox-option-id {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
font-family: "SF Mono", Monaco, Consolas, monospace;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.model-combobox-group {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.model-combobox-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.model-combobox-optgroup {
|
||||
padding: 6px 12px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.model-combobox-no-results {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
THEME SYSTEM
|
||||
============================================================ */
|
||||
|
||||
Reference in New Issue
Block a user