import { Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { useIsMobile } from '@/hooks/use-media-query'; import type { ModelAlias, CursorModelId, CodexModelId, OpencodeModelId, GroupedModel, PhaseModelEntry, } from '@automaker/types'; import { stripProviderPrefix, STANDALONE_CURSOR_MODELS, getModelGroup, isGroupSelected, getSelectedVariant, codexModelHasThinking, } from '@automaker/types'; import { CLAUDE_MODELS, CURSOR_MODELS, OPENCODE_MODELS, THINKING_LEVELS, THINKING_LEVEL_LABELS, REASONING_EFFORT_LEVELS, REASONING_EFFORT_LABELS, type ModelOption, } from '@/components/views/board-view/shared/model-constants'; import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react'; import { AnthropicIcon, CursorIcon, OpenAIIcon, getProviderIconForModel, } 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'; const OPENCODE_CLI_GROUP_LABEL = 'OpenCode CLI'; const OPENCODE_PROVIDER_FALLBACK = 'opencode'; const OPENCODE_PROVIDER_WORD_SEPARATOR = '-'; const OPENCODE_MODEL_ID_SEPARATOR = '/'; const OPENCODE_SECTION_GROUP_PADDING = 'pt-2'; const OPENCODE_STATIC_PROVIDER_LABELS: Record = { [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)', }; const OPENCODE_DYNAMIC_PROVIDER_LABELS: Record = { 'github-copilot': 'GitHub Copilot', 'zai-coding-plan': 'Z.AI Coding Plan', google: 'Google AI', openai: 'OpenAI', openrouter: 'OpenRouter', anthropic: 'Anthropic', xai: 'xAI', deepseek: 'DeepSeek', ollama: 'Ollama (Local)', lmstudio: 'LM Studio (Local)', azure: 'Azure OpenAI', [OPENCODE_PROVIDER_FALLBACK]: 'OpenCode (Free)', }; const OPENCODE_DYNAMIC_PROVIDER_ORDER = [ 'github-copilot', 'google', 'openai', 'openrouter', 'anthropic', 'xai', 'deepseek', 'ollama', 'lmstudio', 'azure', 'zai-coding-plan', ]; const OPENCODE_SECTION_ORDER = ['free', 'dynamic'] as const; const OPENCODE_SECTION_LABELS: Record<(typeof OPENCODE_SECTION_ORDER)[number], string> = { free: 'Free Tier', dynamic: 'Connected Providers', }; const OPENCODE_STATIC_PROVIDER_BY_ID = new Map( OPENCODE_MODELS.map((model) => [model.id, model.provider]) ); function formatProviderLabel(providerKey: string): string { return providerKey .split(OPENCODE_PROVIDER_WORD_SEPARATOR) .map((word) => (word ? word[0].toUpperCase() + word.slice(1) : word)) .join(' '); } function getOpencodeSectionKey(providerKey: string): (typeof OPENCODE_SECTION_ORDER)[number] { if (providerKey === OPENCODE_PROVIDER_FALLBACK) { return 'free'; } return 'dynamic'; } function getOpencodeGroupLabel( providerKey: string, sectionKey: (typeof OPENCODE_SECTION_ORDER)[number] ): string { if (sectionKey === 'free') { return OPENCODE_STATIC_PROVIDER_LABELS[providerKey] || 'OpenCode Free Tier'; } return OPENCODE_DYNAMIC_PROVIDER_LABELS[providerKey] || formatProviderLabel(providerKey); } 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] = useState(false); const [expandedGroup, setExpandedGroup] = useState(null); const [expandedClaudeModel, setExpandedClaudeModel] = useState(null); const [expandedCodexModel, setExpandedCodexModel] = useState(null); const commandListRef = useRef(null); const expandedTriggerRef = useRef(null); const expandedClaudeTriggerRef = useRef(null); const expandedCodexTriggerRef = useRef(null); const { enabledCursorModels, favoriteModels, toggleFavoriteModel, codexModels, codexModelsLoading, fetchCodexModels, dynamicOpencodeModels, opencodeModelsLoading, fetchOpencodeModels, disabledProviders, } = useAppStore(); // Detect mobile devices to use inline expansion instead of nested popovers const isMobile = useIsMobile(); // Extract model and thinking/reasoning levels from value const selectedModel = value.model; const selectedThinkingLevel = value.thinkingLevel || 'none'; const selectedReasoningEffort = value.reasoningEffort || 'none'; // Fetch Codex models on mount useEffect(() => { if (codexModels.length === 0 && !codexModelsLoading) { fetchCodexModels().catch(() => { // Silently fail - user will see empty Codex section }); } }, [codexModels.length, codexModelsLoading, fetchCodexModels]); // Fetch OpenCode models on mount useEffect(() => { if (dynamicOpencodeModels.length === 0 && !opencodeModelsLoading) { fetchOpencodeModels().catch(() => { // Silently fail - user will see only static OpenCode models }); } }, [dynamicOpencodeModels.length, opencodeModelsLoading, fetchOpencodeModels]); // Close expanded group when trigger scrolls out of view 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 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 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]); // Transform dynamic Codex models from store to component format const transformedCodexModels = useMemo(() => { return codexModels.map((model) => ({ id: model.id, label: model.label, description: model.description, provider: 'codex' as const, badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Speed' : undefined, })); }, [codexModels]); // Filter Cursor models to only show enabled ones const availableCursorModels = CURSOR_MODELS.filter((model) => { // Compare model.id directly since both model.id and enabledCursorModels use full IDs with prefix return enabledCursorModels.includes(model.id as CursorModelId); }); // Helper to find current selected model details const currentModel = 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) => 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 = transformedCodexModels.find((m) => m.id === selectedModel); if (codexModel) return { ...codexModel, icon: OpenAIIcon }; // Check OpenCode models (static) - use dynamic icon resolution for provider-specific icons const opencodeModel = OPENCODE_MODELS.find((m) => m.id === selectedModel); if (opencodeModel) return { ...opencodeModel, icon: getProviderIconForModel(opencodeModel.id) }; // Check dynamic OpenCode models - use dynamic icon resolution for provider-specific icons const dynamicModel = dynamicOpencodeModels.find((m) => m.id === selectedModel); if (dynamicModel) { return { id: dynamicModel.id, label: dynamicModel.name, description: dynamicModel.description, provider: 'opencode' as const, icon: getProviderIconForModel(dynamicModel.id), }; } return null; }, [ selectedModel, selectedThinkingLevel, availableCursorModels, transformedCodexModels, dynamicOpencodeModels, ]); // Compute grouped vs standalone Cursor models const { groupedModels, standaloneCursorModels } = 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]); // Combine static and dynamic OpenCode models const allOpencodeModels: ModelOption[] = useMemo(() => { // Start with static models const staticModels = [...OPENCODE_MODELS]; // Add dynamic models (convert ModelDefinition to ModelOption) const dynamicModelOptions: ModelOption[] = dynamicOpencodeModels.map((model) => ({ id: model.id, label: model.name, description: model.description, badge: model.tier === 'premium' ? 'Premium' : model.tier === 'basic' ? 'Free' : undefined, provider: 'opencode' as const, })); // Merge, avoiding duplicates (static models take precedence for same ID) // In practice, static and dynamic IDs don't overlap const staticIds = new Set(staticModels.map((m) => m.id)); const uniqueDynamic = dynamicModelOptions.filter((m) => !staticIds.has(m.id)); return [...staticModels, ...uniqueDynamic]; }, [dynamicOpencodeModels]); // Group models (filtering out disabled providers) const { favorites, claude, cursor, codex, opencode } = useMemo(() => { const favs: typeof CLAUDE_MODELS = []; const cModels: typeof CLAUDE_MODELS = []; const curModels: typeof CURSOR_MODELS = []; const codModels: typeof transformedCodexModels = []; const ocModels: ModelOption[] = []; const isClaudeDisabled = disabledProviders.includes('claude'); const isCursorDisabled = disabledProviders.includes('cursor'); const isCodexDisabled = disabledProviders.includes('codex'); const isOpencodeDisabled = disabledProviders.includes('opencode'); // Process Claude Models (skip if provider is disabled) if (!isClaudeDisabled) { CLAUDE_MODELS.forEach((model) => { if (favoriteModels.includes(model.id)) { favs.push(model); } else { cModels.push(model); } }); } // Process Cursor Models (skip if provider is disabled) if (!isCursorDisabled) { availableCursorModels.forEach((model) => { if (favoriteModels.includes(model.id)) { favs.push(model); } else { curModels.push(model); } }); } // Process Codex Models (skip if provider is disabled) if (!isCodexDisabled) { transformedCodexModels.forEach((model) => { if (favoriteModels.includes(model.id)) { favs.push(model); } else { codModels.push(model); } }); } // Process OpenCode Models (skip if provider is disabled) if (!isOpencodeDisabled) { allOpencodeModels.forEach((model) => { if (favoriteModels.includes(model.id)) { favs.push(model); } else { ocModels.push(model); } }); } return { favorites: favs, claude: cModels, cursor: curModels, codex: codModels, opencode: ocModels, }; }, [ favoriteModels, availableCursorModels, transformedCodexModels, allOpencodeModels, disabledProviders, ]); // Group OpenCode models by model type for better organization const opencodeSections = useMemo(() => { type OpencodeSectionKey = (typeof OPENCODE_SECTION_ORDER)[number]; type OpencodeGroup = { key: string; label: string; models: ModelOption[] }; type OpencodeSection = { key: OpencodeSectionKey; label: string; showGroupLabels: boolean; groups: OpencodeGroup[]; }; const sections: Record> = { free: {}, dynamic: {}, }; const dynamicProviderById = new Map( dynamicOpencodeModels.map((model) => [model.id, model.provider]) ); const resolveProviderKey = (modelId: string): string => { const staticProvider = OPENCODE_STATIC_PROVIDER_BY_ID.get(modelId); if (staticProvider) return staticProvider; const dynamicProvider = dynamicProviderById.get(modelId); if (dynamicProvider) return dynamicProvider; return modelId.includes(OPENCODE_MODEL_ID_SEPARATOR) ? modelId.split(OPENCODE_MODEL_ID_SEPARATOR)[0] : OPENCODE_PROVIDER_FALLBACK; }; const addModelToGroup = ( sectionKey: OpencodeSectionKey, providerKey: string, model: ModelOption ) => { if (!sections[sectionKey][providerKey]) { sections[sectionKey][providerKey] = { key: providerKey, label: getOpencodeGroupLabel(providerKey, sectionKey), models: [], }; } sections[sectionKey][providerKey].models.push(model); }; opencode.forEach((model) => { const providerKey = resolveProviderKey(model.id); const sectionKey = getOpencodeSectionKey(providerKey); addModelToGroup(sectionKey, providerKey, model); }); const buildGroupList = (sectionKey: OpencodeSectionKey): OpencodeGroup[] => { const groupMap = sections[sectionKey]; const priorityOrder = sectionKey === 'dynamic' ? OPENCODE_DYNAMIC_PROVIDER_ORDER : []; const priorityMap = new Map(priorityOrder.map((provider, index) => [provider, index])); return Object.keys(groupMap) .sort((a, b) => { const aPriority = priorityMap.get(a); const bPriority = priorityMap.get(b); if (aPriority !== undefined && bPriority !== undefined) { return aPriority - bPriority; } if (aPriority !== undefined) return -1; if (bPriority !== undefined) return 1; return groupMap[a].label.localeCompare(groupMap[b].label); }) .map((key) => groupMap[key]); }; const builtSections = OPENCODE_SECTION_ORDER.map((sectionKey) => { const groups = buildGroupList(sectionKey); if (groups.length === 0) return null; return { key: sectionKey, label: OPENCODE_SECTION_LABELS[sectionKey], showGroupLabels: sectionKey !== 'free', groups, }; }).filter(Boolean) as OpencodeSection[]; return builtSections; }, [opencode, dynamicOpencodeModels]); // Render Codex model item with secondary popover for reasoning effort (only for models that support it) const renderCodexModelItem = (model: (typeof transformedCodexModels)[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 // On mobile, render inline expansion instead of nested popover if (isMobile) { return (
setExpandedCodexModel(isExpanded ? null : (model.id as CodexModelId))} className="group flex items-center justify-between py-2" >
{model.label} {isSelected && currentReasoning !== 'none' ? `Reasoning: ${REASONING_EFFORT_LABELS[currentReasoning]}` : model.description}
{isSelected && !isExpanded && }
{/* Inline reasoning effort options on mobile */} {isExpanded && (
Reasoning Effort
{REASONING_EFFORT_LEVELS.map((effort) => ( ))}
)}
); } // Desktop: Use nested popover 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 OpenCode model item (simple selector, no thinking/reasoning options) const renderOpencodeModelItem = (model: (typeof OPENCODE_MODELS)[0]) => { const isSelected = selectedModel === model.id; const isFavorite = favoriteModels.includes(model.id); // Get the appropriate icon based on the specific model ID const ProviderIcon = getProviderIconForModel(model.id); return ( { onChange({ model: model.id as OpencodeModelId }); setOpen(false); }} className="group flex items-center justify-between py-2" >
{model.label} {model.description}
{model.badge && ( {model.badge} )} {isSelected && }
); }; // 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'; // On mobile, render inline expansion instead of nested popover if (isMobile) { return (
setExpandedClaudeModel(isExpanded ? null : (model.id as ModelAlias))} className="group flex items-center justify-between py-2" >
{model.label} {isSelected && currentThinking !== 'none' ? `Thinking: ${THINKING_LEVEL_LABELS[currentThinking]}` : model.description}
{isSelected && !isExpanded && }
{/* Inline thinking level options on mobile */} {isExpanded && (
Thinking Level
{THINKING_LEVELS.map((level) => ( ))}
)}
); } // Desktop: Use nested popover 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'; // On mobile, render inline expansion instead of nested popover if (isMobile) { return (
setExpandedGroup(isExpanded ? null : group.baseId)} className="group flex items-center justify-between py-2" >
{group.label} {selectedVariant ? `Selected: ${selectedVariant.label}` : group.description}
{groupIsSelected && !isExpanded && ( )}
{/* Inline variant options on mobile */} {isExpanded && (
{variantTypeLabel}
{group.variants.map((variant) => ( ))}
)}
); } // Desktop: Use nested popover 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()} onTouchMove={(e) => e.stopPropagation()} onPointerDownOutside={(e) => { // Only prevent close if clicking inside a nested popover (thinking level panel) const target = e.target as HTMLElement; if (target.closest('[data-slot="popover-content"]')) { 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 as (typeof transformedCodexModels)[0]); } // OpenCode model if (model.provider === 'opencode') { return renderOpencodeModelItem(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))} )} {opencodeSections.length > 0 && ( {opencodeSections.map((section, sectionIndex) => (
{section.label}
{section.groups.map((group) => (
{section.showGroupLabels && (
{group.label}
)} {group.models.map((model) => renderOpencodeModelItem(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}
); }