import { useCallback, useEffect, useRef, useMemo, useState } from 'react'; import { Check, Brain } from 'lucide-react'; import { ProviderIcon } from '@renderer/components/ProviderIcons'; import type { ModelDefinition, ModelProvider } from '@shared/domain/models'; import { providerMeta } from '@shared/domain/models'; import type { ReasoningEffort } from '@shared/domain/workflow'; interface ModelSelectorProps { models: ReadonlyArray; selectedModelId?: string; selectedReasoning?: ReasoningEffort; onSelect: (model: ModelDefinition) => void; onReasoningChange: (effort: ReasoningEffort | undefined) => void; onClose: () => void; } const reasoningLevels: { value: ReasoningEffort; label: string; description: string }[] = [ { value: 'low', label: 'Low', description: 'Fast, concise' }, { value: 'medium', label: 'Med', description: 'Balanced' }, { value: 'high', label: 'High', description: 'Thorough' }, { value: 'xhigh', label: 'Max', description: 'Exhaustive' }, ]; interface ProviderGroup { provider: ModelProvider | 'other'; label: string; models: ModelDefinition[]; } export function ModelSelector({ models, selectedModelId, selectedReasoning, onSelect, onReasoningChange, onClose, }: ModelSelectorProps) { const containerRef = useRef(null); const flatModels = useMemo(() => [...models], [models]); const initialIndex = flatModels.findIndex((m) => m.id === selectedModelId); const [focusedIndex, setFocusedIndex] = useState(initialIndex >= 0 ? initialIndex : 0); const optionRefs = useRef>(new Map()); // Scroll the focused option into view whenever it changes useEffect(() => { optionRefs.current.get(focusedIndex)?.scrollIntoView({ block: 'nearest' }); }, [focusedIndex]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowDown': { e.preventDefault(); setFocusedIndex((prev) => (prev + 1) % flatModels.length); break; } case 'ArrowUp': { e.preventDefault(); setFocusedIndex((prev) => (prev - 1 + flatModels.length) % flatModels.length); break; } case 'Home': { e.preventDefault(); setFocusedIndex(0); break; } case 'End': { e.preventDefault(); setFocusedIndex(flatModels.length - 1); break; } case 'Enter': case ' ': { e.preventDefault(); const model = flatModels[focusedIndex]; if (model) onSelect(model); break; } case 'Escape': { e.stopPropagation(); onClose(); break; } } }, [flatModels, focusedIndex, onSelect, onClose], ); useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { onClose(); } }; document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; }, [onClose]); // Focus the container on mount so keyboard events are captured immediately useEffect(() => { containerRef.current?.focus(); }, []); const selectedModel = models.find((m) => m.id === selectedModelId); const supportedEfforts = selectedModel?.supportedReasoningEfforts; // Group models by provider const groups = useMemo((): ProviderGroup[] => { const providerOrder = providerMeta.map((p) => p.id); const providerLabels = new Map(providerMeta.map((p) => [p.id, p.label])); const grouped = new Map(); for (const model of models) { const key = model.provider ?? 'other'; const list = grouped.get(key) ?? []; list.push(model); grouped.set(key, list); } const result: ProviderGroup[] = []; for (const providerId of providerOrder) { const providerModels = grouped.get(providerId); if (providerModels) { result.push({ provider: providerId, label: providerLabels.get(providerId) ?? providerId, models: providerModels, }); } } // Any models without a known provider const other = grouped.get('other'); if (other) { result.push({ provider: 'other', label: 'Other', models: other }); } return result; }, [models]); // Build a flat index for each model so we can map group-based rendering // back to the flat focusedIndex. let flatIndex = -1; return (
{/* Model list — grouped by provider */}
{groups.map((group, gi) => (
{gi > 0 &&
} {/* Provider header */}
{group.provider !== 'other' && ( )} {group.label}
{/* Models in this provider group */} {group.models.map((model) => { flatIndex++; const modelIndex = flatIndex; const isSelected = model.id === selectedModelId; const isFocused = modelIndex === focusedIndex; const tierLabel = model.tier === 'premium' ? 'PRO' : model.tier === 'fast' ? 'FAST' : undefined; const tierColor = model.tier === 'premium' ? 'text-amber-400 bg-amber-400/10' : model.tier === 'fast' ? 'text-emerald-400 bg-emerald-400/10' : ''; return ( ); })}
))}
{/* Reasoning effort — only shown when selected model supports it */} {supportedEfforts && supportedEfforts.length > 0 && (
Reasoning Effort
{reasoningLevels .filter((lvl) => supportedEfforts.includes(lvl.value)) .map((lvl) => { const isActive = selectedReasoning === lvl.value; return ( ); })}
)}
); }