diff --git a/apps/ui/src/components/views/board-view/shared/model-selector.tsx b/apps/ui/src/components/views/board-view/shared/model-selector.tsx index 323190c83..c1560a2a0 100644 --- a/apps/ui/src/components/views/board-view/shared/model-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/model-selector.tsx @@ -1,17 +1,207 @@ // @ts-nocheck import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; -import { Brain, AlertTriangle } from 'lucide-react'; -import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { Brain, AlertTriangle, RefreshCw } from 'lucide-react'; +import { AnthropicIcon, CursorIcon, OpenAIIcon, OpenCodeIcon } from '@/components/ui/provider-icon'; import { cn } from '@/lib/utils'; import type { ModelAlias } from '@/store/app-store'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; -import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types'; +import { + getModelProvider, + PROVIDER_PREFIXES, + stripProviderPrefix, + DEFAULT_OPENCODE_MODEL, +} from '@automaker/types'; import type { ModelProvider } from '@automaker/types'; import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants'; -import { useEffect } from 'react'; -import { RefreshCw } from 'lucide-react'; +import { useEffect, useCallback } from 'react'; + +// Badge mappings for model tiers +const CODEX_BADGE_MAP: Record = { + premium: 'Premium', + basic: 'Speed', + standard: 'Balanced', +}; + +const OPENCODE_BADGE_MAP: Record = { + premium: 'Premium', + basic: 'Free', + standard: 'Balanced', +}; + +/** + * Custom hook to fetch provider models when available + */ +function useProviderModels( + isAvailable: boolean, + modelsLength: number, + isLoading: boolean, + fetcher: () => void +) { + useEffect(() => { + if (isAvailable && modelsLength === 0 && !isLoading) { + fetcher(); + } + }, [isAvailable, modelsLength, isLoading, fetcher]); +} + +/** + * Transform raw provider models to ModelOption format + */ +function transformModels< + T extends { + id: string; + tier?: string; + hasThinking?: boolean; + name?: string; + label?: string; + description?: string; + }, +>( + models: T[], + provider: ModelProvider, + badgeMap: Record, + defaultHasThinking = false +): ModelOption[] { + return models.map((model) => ({ + id: model.id, + label: model.name || model.label || model.id, + description: model.description || '', + badge: model.tier ? badgeMap[model.tier] : undefined, + provider, + hasThinking: model.hasThinking ?? defaultHasThinking, + })); +} + +interface DynamicModelListProps { + models: ModelOption[]; + selectedModel: string; + onModelSelect: (model: string) => void; + testIdPrefix: string; + isLoading: boolean; + error: string | null; + onRetry: () => void; + emptyMessage: string; + badgeColorClass?: string; + thinkingBadgeColorClass?: string; + showThinkingBadge?: boolean; +} + +/** + * Reusable component for displaying a list of dynamic models + */ +function DynamicModelList({ + models, + selectedModel, + onModelSelect, + testIdPrefix, + isLoading, + error, + onRetry, + emptyMessage, + badgeColorClass = 'border-muted-foreground/50 text-muted-foreground', + thinkingBadgeColorClass = 'border-amber-500/50 text-amber-600 dark:text-amber-400', + showThinkingBadge = false, +}: DynamicModelListProps) { + // Loading state + if (isLoading && models.length === 0) { + return ( +
+ + Loading models... +
+ ); + } + + // Error state + if (error && !isLoading) { + return ( +
+ +
+
Failed to load models
+ +
+
+ ); + } + + // Empty state + if (!isLoading && !error && models.length === 0) { + return ( +
+ {emptyMessage} +
+ ); + } + + // Model list + if (!isLoading && models.length > 0) { + return ( +
+ {models.map((option) => { + const isSelected = selectedModel === option.id; + return ( + + ); + })} +
+ ); + } + + return null; +} interface ModelSelectorProps { selectedModel: string; // Can be ModelAlias or "cursor-{id}" @@ -31,8 +221,12 @@ export function ModelSelector({ codexModelsLoading, codexModelsError, fetchCodexModels, + dynamicOpencodeModels, + opencodeModelsLoading, + opencodeModelsError, + fetchOpencodeModels, } = useAppStore(); - const { cursorCliStatus, codexCliStatus } = useSetupStore(); + const { cursorCliStatus, codexCliStatus, opencodeCliStatus } = useSetupStore(); const selectedProvider = getModelProvider(selectedModel); @@ -42,30 +236,26 @@ export function ModelSelector({ // Check if Codex CLI is available const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated; - // Fetch Codex models on mount - useEffect(() => { - if (isCodexAvailable && codexModels.length === 0 && !codexModelsLoading) { - fetchCodexModels(); - } - }, [isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels]); + // Check if OpenCode CLI is available + const isOpencodeAvailable = + opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; - // Transform codex models from store to ModelOption format - const dynamicCodexModels: ModelOption[] = codexModels.map((model) => { - // Infer badge based on tier - let badge: string | undefined; - if (model.tier === 'premium') badge = 'Premium'; - else if (model.tier === 'basic') badge = 'Speed'; - else if (model.tier === 'standard') badge = 'Balanced'; + // Fetch provider models on mount using custom hook + useProviderModels(isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels); + useProviderModels( + isOpencodeAvailable, + dynamicOpencodeModels.length, + opencodeModelsLoading, + fetchOpencodeModels + ); - return { - id: model.id, - label: model.label, - description: model.description, - badge, - provider: 'codex' as ModelProvider, - hasThinking: model.hasThinking, - }; - }); + // Transform models using helper function + const transformedCodexModels = transformModels(codexModels, 'codex', CODEX_BADGE_MAP); + const transformedOpencodeModels = transformModels( + dynamicOpencodeModels, + 'opencode', + OPENCODE_BADGE_MAP + ); // Filter Cursor models based on enabled models from global settings const filteredCursorModels = CURSOR_MODELS.filter((model) => { @@ -83,6 +273,12 @@ export function ModelSelector({ const defaultModel = codexModels.find((m) => m.isDefault); const defaultModelId = defaultModel?.id || codexModels[0]?.id || 'codex-gpt-5.2-codex'; onModelSelect(defaultModelId); + } else if (provider === 'opencode' && selectedProvider !== 'opencode') { + // Switch to OpenCode's default model + const defaultModel = dynamicOpencodeModels.find((m) => m.default); + const defaultModelId = + defaultModel?.id || dynamicOpencodeModels[0]?.id || DEFAULT_OPENCODE_MODEL; + onModelSelect(defaultModelId); } else if (provider === 'claude' && selectedProvider !== 'claude') { // Switch to Claude's default model onModelSelect('sonnet'); @@ -123,6 +319,20 @@ export function ModelSelector({ Cursor CLI + - - - )} - - {/* Model list */} - {!codexModelsLoading && !codexModelsError && dynamicCodexModels.length === 0 && ( -
- No Codex models available -
- )} - - {!codexModelsLoading && dynamicCodexModels.length > 0 && ( -
- {dynamicCodexModels.map((option) => { - const isSelected = selectedModel === option.id; - return ( - - ); - })} -
- )} + fetchCodexModels(true)} + emptyMessage="No Codex models available" + badgeColorClass="border-muted-foreground/50 text-muted-foreground" + thinkingBadgeColorClass="border-emerald-500/50 text-emerald-600 dark:text-emerald-400" + showThinkingBadge + /> )}