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:
gsxdsm
2026-03-29 22:26:10 -07:00
parent f99ae366f7
commit bcbb22a6ca
4 changed files with 859 additions and 288 deletions

View File

@@ -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 &apos;{localFilter}&apos;
</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 &apos;{executorFilter}&apos;
</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 &apos;{validatorFilter}&apos;
</div>
)}
placeholder="Select validator model…"
/>
<small>The AI model used to review code and plans for this task.</small>
</div>