import * as React from 'react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import type { ModelAlias, CursorModelId, CodexModelId, GroupedModel, PhaseModelEntry, ThinkingLevel, ReasoningEffort, } from '@automaker/types'; import { stripProviderPrefix, STANDALONE_CURSOR_MODELS, getModelGroup, isGroupSelected, getSelectedVariant, isCursorModel, codexModelHasThinking, } from '@automaker/types'; import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, THINKING_LEVELS, THINKING_LEVEL_LABELS, REASONING_EFFORT_LEVELS, REASONING_EFFORT_LABELS, } from '@/components/views/board-view/shared/model-constants'; import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react'; import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; import { Button } from '@/components/ui/button'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from '@/components/ui/command'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; interface PhaseModelSelectorProps { /** Label shown in full mode */ label?: string; /** Description shown in full mode */ description?: string; /** Current model selection */ value: PhaseModelEntry; /** Callback when model is selected */ onChange: (entry: PhaseModelEntry) => void; /** Compact mode - just shows the button trigger without label/description wrapper */ compact?: boolean; /** Custom trigger class name */ triggerClassName?: string; /** Popover alignment */ align?: 'start' | 'end'; /** Disabled state */ disabled?: boolean; } export function PhaseModelSelector({ label, description, value, onChange, compact = false, triggerClassName, align = 'end', disabled = false, }: PhaseModelSelectorProps) { const [open, setOpen] = React.useState(false); const [expandedGroup, setExpandedGroup] = React.useState(null); const [expandedClaudeModel, setExpandedClaudeModel] = React.useState(null); const [expandedCodexModel, setExpandedCodexModel] = React.useState(null); const commandListRef = React.useRef(null); const expandedTriggerRef = React.useRef(null); const expandedClaudeTriggerRef = React.useRef(null); const expandedCodexTriggerRef = React.useRef(null); const { enabledCursorModels, favoriteModels, toggleFavoriteModel } = useAppStore(); // Extract model and thinking/reasoning levels from value const selectedModel = value.model; const selectedThinkingLevel = value.thinkingLevel || 'none'; const selectedReasoningEffort = value.reasoningEffort || 'none'; // Close expanded group when trigger scrolls out of view React.useEffect(() => { const triggerElement = expandedTriggerRef.current; const listElement = commandListRef.current; if (!triggerElement || !listElement || !expandedGroup) return; const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (!entry.isIntersecting) { setExpandedGroup(null); } }, { root: listElement, threshold: 0.1, // Close when less than 10% visible } ); observer.observe(triggerElement); return () => observer.disconnect(); }, [expandedGroup]); // Close expanded Claude model popover when trigger scrolls out of view React.useEffect(() => { const triggerElement = expandedClaudeTriggerRef.current; const listElement = commandListRef.current; if (!triggerElement || !listElement || !expandedClaudeModel) return; const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (!entry.isIntersecting) { setExpandedClaudeModel(null); } }, { root: listElement, threshold: 0.1, } ); observer.observe(triggerElement); return () => observer.disconnect(); }, [expandedClaudeModel]); // Close expanded Codex model popover when trigger scrolls out of view React.useEffect(() => { const triggerElement = expandedCodexTriggerRef.current; const listElement = commandListRef.current; if (!triggerElement || !listElement || !expandedCodexModel) return; const observer = new IntersectionObserver( (entries) => { const entry = entries[0]; if (!entry.isIntersecting) { setExpandedCodexModel(null); } }, { root: listElement, threshold: 0.1, } ); observer.observe(triggerElement); return () => observer.disconnect(); }, [expandedCodexModel]); // Filter Cursor models to only show enabled ones const availableCursorModels = CURSOR_MODELS.filter((model) => { const cursorId = stripProviderPrefix(model.id) as CursorModelId; return enabledCursorModels.includes(cursorId); }); // Helper to find current selected model details const currentModel = React.useMemo(() => { const claudeModel = CLAUDE_MODELS.find((m) => m.id === selectedModel); if (claudeModel) { // Add thinking level to label if not 'none' const thinkingLabel = selectedThinkingLevel !== 'none' ? ` (${THINKING_LEVEL_LABELS[selectedThinkingLevel]} Thinking)` : ''; return { ...claudeModel, label: `${claudeModel.label}${thinkingLabel}`, icon: AnthropicIcon, }; } const cursorModel = availableCursorModels.find( (m) => stripProviderPrefix(m.id) === selectedModel ); if (cursorModel) return { ...cursorModel, icon: CursorIcon }; // Check if selectedModel is part of a grouped model const group = getModelGroup(selectedModel as CursorModelId); if (group) { const variant = getSelectedVariant(group, selectedModel as CursorModelId); return { id: selectedModel, label: `${group.label} (${variant?.label || 'Unknown'})`, description: group.description, provider: 'cursor' as const, icon: CursorIcon, }; } // Check Codex models const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel); if (codexModel) return { ...codexModel, icon: OpenAIIcon }; return null; }, [selectedModel, selectedThinkingLevel, availableCursorModels]); // Compute grouped vs standalone Cursor models const { groupedModels, standaloneCursorModels } = React.useMemo(() => { const grouped: GroupedModel[] = []; const standalone: typeof CURSOR_MODELS = []; const seenGroups = new Set(); availableCursorModels.forEach((model) => { const cursorId = stripProviderPrefix(model.id) as CursorModelId; // Check if this model is standalone if (STANDALONE_CURSOR_MODELS.includes(cursorId)) { standalone.push(model); return; } // Check if this model belongs to a group const group = getModelGroup(cursorId); if (group && !seenGroups.has(group.baseId)) { // Filter variants to only include enabled models const enabledVariants = group.variants.filter((v) => enabledCursorModels.includes(v.id)); if (enabledVariants.length > 0) { grouped.push({ ...group, variants: enabledVariants, }); seenGroups.add(group.baseId); } } }); return { groupedModels: grouped, standaloneCursorModels: standalone }; }, [availableCursorModels, enabledCursorModels]); // Group models const { favorites, claude, cursor, codex } = React.useMemo(() => { const favs: typeof CLAUDE_MODELS = []; const cModels: typeof CLAUDE_MODELS = []; const curModels: typeof CURSOR_MODELS = []; const codModels: typeof CODEX_MODELS = []; // Process Claude Models CLAUDE_MODELS.forEach((model) => { if (favoriteModels.includes(model.id)) { favs.push(model); } else { cModels.push(model); } }); // Process Cursor Models availableCursorModels.forEach((model) => { if (favoriteModels.includes(model.id)) { favs.push(model); } else { curModels.push(model); } }); // Process Codex Models CODEX_MODELS.forEach((model) => { if (favoriteModels.includes(model.id)) { favs.push(model); } else { codModels.push(model); } }); return { favorites: favs, claude: cModels, cursor: curModels, codex: codModels }; }, [favoriteModels, availableCursorModels]); // Render Codex model item with secondary popover for reasoning effort (only for models that support it) const renderCodexModelItem = (model: (typeof CODEX_MODELS)[0]) => { const isSelected = selectedModel === model.id; const isFavorite = favoriteModels.includes(model.id); const hasReasoning = codexModelHasThinking(model.id as CodexModelId); const isExpanded = expandedCodexModel === model.id; const currentReasoning = isSelected ? selectedReasoningEffort : 'none'; // If model doesn't support reasoning, render as simple selector (like Cursor models) if (!hasReasoning) { return ( { onChange({ model: model.id as CodexModelId }); setOpen(false); }} className="group flex items-center justify-between py-2" >
{model.label} {model.description}
{isSelected && }
); } // Model supports reasoning - show popover with reasoning effort options return ( setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} className="p-0 data-[selected=true]:bg-transparent" > { if (!isOpen) { setExpandedCodexModel(null); } }} >
{model.label} {isSelected && currentReasoning !== 'none' ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` : model.description}
{isSelected && }
e.preventDefault()} >
Reasoning Effort
{REASONING_EFFORT_LEVELS.map((effort) => ( ))}
); }; // Render Cursor model item (no thinking level needed) const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { const modelValue = stripProviderPrefix(model.id); const isSelected = selectedModel === modelValue; const isFavorite = favoriteModels.includes(model.id); return ( { onChange({ model: modelValue as CursorModelId }); setOpen(false); }} className="group flex items-center justify-between py-2" >
{model.label} {model.description}
{isSelected && }
); }; // Render Claude model item with secondary popover for thinking level const renderClaudeModelItem = (model: (typeof CLAUDE_MODELS)[0]) => { const isSelected = selectedModel === model.id; const isFavorite = favoriteModels.includes(model.id); const isExpanded = expandedClaudeModel === model.id; const currentThinking = isSelected ? selectedThinkingLevel : 'none'; return ( setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} className="p-0 data-[selected=true]:bg-transparent" > { if (!isOpen) { setExpandedClaudeModel(null); } }} >
{model.label} {isSelected && currentThinking !== 'none' ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` : model.description}
{isSelected && }
e.preventDefault()} >
Thinking Level
{THINKING_LEVELS.map((level) => ( ))}
); }; // Render a grouped model with secondary popover for variant selection const renderGroupedModelItem = (group: GroupedModel) => { const groupIsSelected = isGroupSelected(group, selectedModel as CursorModelId); const selectedVariant = getSelectedVariant(group, selectedModel as CursorModelId); const isExpanded = expandedGroup === group.baseId; const variantTypeLabel = group.variantType === 'compute' ? 'Compute Level' : group.variantType === 'thinking' ? 'Reasoning Mode' : 'Capacity Options'; return ( setExpandedGroup(isExpanded ? null : group.baseId)} className="p-0 data-[selected=true]:bg-transparent" > { if (!isOpen) { setExpandedGroup(null); } }} >
{group.label} {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
{groupIsSelected && }
e.preventDefault()} >
{variantTypeLabel}
{group.variants.map((variant) => ( ))}
); }; // Compact trigger button (for agent view etc.) const compactTrigger = ( ); // Full trigger button (for settings view) const fullTrigger = ( ); // The popover content (shared between both modes) const popoverContent = ( e.stopPropagation()} onPointerDownOutside={(e) => e.preventDefault()} > No model found. {favorites.length > 0 && ( <> {(() => { const renderedGroups = new Set(); return favorites.map((model) => { // Check if this favorite is part of a grouped model if (model.provider === 'cursor') { const cursorId = stripProviderPrefix(model.id) as CursorModelId; const group = getModelGroup(cursorId); if (group) { // Skip if we already rendered this group if (renderedGroups.has(group.baseId)) { return null; } renderedGroups.add(group.baseId); // Find the group in groupedModels (which has filtered variants) const filteredGroup = groupedModels.find((g) => g.baseId === group.baseId); if (filteredGroup) { return renderGroupedModelItem(filteredGroup); } } // Standalone Cursor model return renderCursorModelItem(model); } // Codex model if (model.provider === 'codex') { return renderCodexModelItem(model); } // Claude model return renderClaudeModelItem(model); }); })()} )} {claude.length > 0 && ( {claude.map((model) => renderClaudeModelItem(model))} )} {(groupedModels.length > 0 || standaloneCursorModels.length > 0) && ( {/* Grouped models with secondary popover */} {groupedModels.map((group) => renderGroupedModelItem(group))} {/* Standalone models */} {standaloneCursorModels.map((model) => renderCursorModelItem(model))} )} {codex.length > 0 && ( {codex.map((model) => renderCodexModelItem(model))} )} ); // Compact mode - just the popover with compact trigger if (compact) { return ( {compactTrigger} {popoverContent} ); } // Full mode - with label and description wrapper return (
{/* Label and Description */}

{label}

{description}

{/* Model Selection Popover */} {fullTrigger} {popoverContent}
); }