From b4ba128148befcfacb1249735d4cb2b7d67a3aa8 Mon Sep 17 00:00:00 2001 From: Manuel Grillo Date: Thu, 15 Jan 2026 13:09:59 +0100 Subject: [PATCH 1/3] feat: add OpenCode support in ModelSelector with fetching and display logic --- .../board-view/shared/model-selector.tsx | 155 +++++++++++++++++- 1 file changed, 153 insertions(+), 2 deletions(-) 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..a2992f7b3 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 @@ -2,7 +2,7 @@ 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 { 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'; @@ -31,8 +31,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,6 +46,9 @@ export function ModelSelector({ // Check if Codex CLI is available const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated; + // Check if OpenCode CLI is available + const isOpencodeAvailable = opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; + // Fetch Codex models on mount useEffect(() => { if (isCodexAvailable && codexModels.length === 0 && !codexModelsLoading) { @@ -49,6 +56,13 @@ export function ModelSelector({ } }, [isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels]); + // Fetch OpenCode models on mount + useEffect(() => { + if (isOpencodeAvailable && dynamicOpencodeModels.length === 0 && !opencodeModelsLoading) { + fetchOpencodeModels(); + } + }, [isOpencodeAvailable, dynamicOpencodeModels.length, opencodeModelsLoading, fetchOpencodeModels]); + // Transform codex models from store to ModelOption format const dynamicCodexModels: ModelOption[] = codexModels.map((model) => { // Infer badge based on tier @@ -67,6 +81,24 @@ export function ModelSelector({ }; }); + // Transform opencode models from store to ModelOption format + const transformedOpencodeModels: ModelOption[] = dynamicOpencodeModels.map((model) => { + // Infer badge based on tier + let badge: string | undefined; + if (model.tier === 'premium') badge = 'Premium'; + else if (model.tier === 'basic') badge = 'Free'; + else if (model.tier === 'standard') badge = 'Balanced'; + + return { + id: model.id, + label: model.name, + description: model.description || '', + badge, + provider: 'opencode' as ModelProvider, + hasThinking: false, + }; + }); + // Filter Cursor models based on enabled models from global settings const filteredCursorModels = CURSOR_MODELS.filter((model) => { // Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto") @@ -83,6 +115,11 @@ 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 || 'opencode/big-pickle'; + onModelSelect(defaultModelId); } else if (provider === 'claude' && selectedProvider !== 'claude') { // Switch to Claude's default model onModelSelect('sonnet'); @@ -123,6 +160,20 @@ export function ModelSelector({ Cursor CLI + + + + )} + + {/* Model list */} + {!opencodeModelsLoading && !opencodeModelsError && transformedOpencodeModels.length === 0 && ( +
+ No OpenCode models available. Make sure you're logged in with GitHub Copilot or other providers. +
+ )} + + {!opencodeModelsLoading && transformedOpencodeModels.length > 0 && ( +
+ {transformedOpencodeModels.map((option) => { + const isSelected = selectedModel === option.id; + return ( + + ); + })} +
+ )} + + )} + {/* Codex Models */} {selectedProvider === 'codex' && (
From 5062b1ebdafd5bbe321aec2cc98e780eb849f369 Mon Sep 17 00:00:00 2001 From: Manuel Grillo Date: Thu, 15 Jan 2026 13:25:31 +0100 Subject: [PATCH 2/3] fix: format model-selector.tsx with prettier --- .../board-view/shared/model-selector.tsx | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) 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 a2992f7b3..f643e820b 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 @@ -47,7 +47,8 @@ export function ModelSelector({ const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated; // Check if OpenCode CLI is available - const isOpencodeAvailable = opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; + const isOpencodeAvailable = + opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; // Fetch Codex models on mount useEffect(() => { @@ -61,7 +62,12 @@ export function ModelSelector({ if (isOpencodeAvailable && dynamicOpencodeModels.length === 0 && !opencodeModelsLoading) { fetchOpencodeModels(); } - }, [isOpencodeAvailable, dynamicOpencodeModels.length, opencodeModelsLoading, fetchOpencodeModels]); + }, [ + isOpencodeAvailable, + dynamicOpencodeModels.length, + opencodeModelsLoading, + fetchOpencodeModels, + ]); // Transform codex models from store to ModelOption format const dynamicCodexModels: ModelOption[] = codexModels.map((model) => { @@ -118,7 +124,8 @@ export function ModelSelector({ } 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 || 'opencode/big-pickle'; + const defaultModelId = + defaultModel?.id || dynamicOpencodeModels[0]?.id || 'opencode/big-pickle'; onModelSelect(defaultModelId); } else if (provider === 'claude' && selectedProvider !== 'claude') { // Switch to Claude's default model @@ -348,11 +355,14 @@ export function ModelSelector({ )} {/* Model list */} - {!opencodeModelsLoading && !opencodeModelsError && transformedOpencodeModels.length === 0 && ( -
- No OpenCode models available. Make sure you're logged in with GitHub Copilot or other providers. -
- )} + {!opencodeModelsLoading && + !opencodeModelsError && + transformedOpencodeModels.length === 0 && ( +
+ No OpenCode models available. Make sure you're logged in with GitHub Copilot or + other providers. +
+ )} {!opencodeModelsLoading && transformedOpencodeModels.length > 0 && (
@@ -382,8 +392,8 @@ export function ModelSelector({ isSelected ? 'border-primary-foreground/50 text-primary-foreground' : option.badge === 'Free' - ? 'border-green-500/50 text-green-600 dark:text-green-400' - : 'border-muted-foreground/50 text-muted-foreground' + ? 'border-green-500/50 text-green-600 dark:text-green-400' + : 'border-muted-foreground/50 text-muted-foreground' )} > {option.badge} From 2e25c64b23f3003ac0f6f8049a9b8df07e98c262 Mon Sep 17 00:00:00 2001 From: Manuel Grillo Date: Thu, 15 Jan 2026 13:36:06 +0100 Subject: [PATCH 3/3] refactor(model-selector): apply Gemini review suggestions - Replace magic string 'opencode/big-pickle' with DEFAULT_OPENCODE_MODEL constant - Extract useProviderModels custom hook to reduce duplicate useEffect logic - Extract transformModels helper function with badge mapping objects - Create reusable DynamicModelList component for OpenCode and Codex model lists - Improve maintainability and prepare for easier addition of new providers --- .../board-view/shared/model-selector.tsx | 446 +++++++++--------- 1 file changed, 231 insertions(+), 215 deletions(-) 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 f643e820b..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 { 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}" @@ -50,60 +240,22 @@ export function ModelSelector({ const isOpencodeAvailable = opencodeCliStatus?.installed && opencodeCliStatus?.auth?.authenticated; - // Fetch Codex models on mount - useEffect(() => { - if (isCodexAvailable && codexModels.length === 0 && !codexModelsLoading) { - fetchCodexModels(); - } - }, [isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels]); - - // Fetch OpenCode models on mount - useEffect(() => { - if (isOpencodeAvailable && dynamicOpencodeModels.length === 0 && !opencodeModelsLoading) { - fetchOpencodeModels(); - } - }, [ + // Fetch provider models on mount using custom hook + useProviderModels(isCodexAvailable, codexModels.length, codexModelsLoading, fetchCodexModels); + useProviderModels( isOpencodeAvailable, dynamicOpencodeModels.length, opencodeModelsLoading, - fetchOpencodeModels, - ]); - - // 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'; - - return { - id: model.id, - label: model.label, - description: model.description, - badge, - provider: 'codex' as ModelProvider, - hasThinking: model.hasThinking, - }; - }); - - // Transform opencode models from store to ModelOption format - const transformedOpencodeModels: ModelOption[] = dynamicOpencodeModels.map((model) => { - // Infer badge based on tier - let badge: string | undefined; - if (model.tier === 'premium') badge = 'Premium'; - else if (model.tier === 'basic') badge = 'Free'; - else if (model.tier === 'standard') badge = 'Balanced'; + fetchOpencodeModels + ); - return { - id: model.id, - label: model.name, - description: model.description || '', - badge, - provider: 'opencode' as ModelProvider, - hasThinking: false, - }; - }); + // 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) => { @@ -125,7 +277,7 @@ export function ModelSelector({ // Switch to OpenCode's default model const defaultModel = dynamicOpencodeModels.find((m) => m.default); const defaultModelId = - defaultModel?.id || dynamicOpencodeModels[0]?.id || 'opencode/big-pickle'; + defaultModel?.id || dynamicOpencodeModels[0]?.id || DEFAULT_OPENCODE_MODEL; onModelSelect(defaultModelId); } else if (provider === 'claude' && selectedProvider !== 'claude') { // Switch to Claude's default model @@ -329,82 +481,17 @@ export function ModelSelector({
- {/* Loading state */} - {opencodeModelsLoading && transformedOpencodeModels.length === 0 && ( -
- - Loading models... -
- )} - - {/* Error state */} - {opencodeModelsError && !opencodeModelsLoading && ( -
- -
-
Failed to load OpenCode models
- -
-
- )} - - {/* Model list */} - {!opencodeModelsLoading && - !opencodeModelsError && - transformedOpencodeModels.length === 0 && ( -
- No OpenCode models available. Make sure you're logged in with GitHub Copilot or - other providers. -
- )} - - {!opencodeModelsLoading && transformedOpencodeModels.length > 0 && ( -
- {transformedOpencodeModels.map((option) => { - const isSelected = selectedModel === option.id; - return ( - - ); - })} -
- )} + fetchOpencodeModels(true)} + emptyMessage="No OpenCode models available. Make sure you're logged in with GitHub Copilot or other providers." + badgeColorClass="border-muted-foreground/50 text-muted-foreground" + />
)} @@ -432,90 +519,19 @@ export function ModelSelector({ - {/* Loading state */} - {codexModelsLoading && dynamicCodexModels.length === 0 && ( -
- - Loading models... -
- )} - - {/* Error state */} - {codexModelsError && !codexModelsLoading && ( -
- -
-
Failed to load Codex models
- -
-
- )} - - {/* 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 + /> )}