From f1a5bcd17afbfa78353fbcee3564add238141d20 Mon Sep 17 00:00:00 2001 From: Soham Dasgupta Date: Wed, 14 Jan 2026 19:46:43 +0530 Subject: [PATCH] fix: load OpenRouter models on kanban board without visiting settings Problem: - OpenRouter dynamic models only appeared after visiting settings page - PhaseModelSelector (used in Add/Edit Feature dialogs) only fetched Codex models - dynamicOpencodeModels remained empty until OpencodeSettingsTab mounted Solution: - Add fetchOpencodeModels() action to app-store mirroring fetchCodexModels pattern - Add state tracking: opencodeModelsLoading, opencodeModelsError, timestamps - Call fetchOpencodeModels() in PhaseModelSelector useEffect on mount - Use same caching strategy: 5min success cache, 30sec failure cooldown Files changed: - apps/ui/src/store/app-store.ts - Add OpenCode model loading state properties - Add fetchOpencodeModels action with error handling & caching - apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx - Add opencodeModelsLoading, fetchOpencodeModels to store hook - Add useEffect to fetch OpenCode models on mount Result: - OpenRouter models now appear in Add/Edit Feature dialogs immediately - No need to visit settings page first - Consistent with Codex model loading behavior --- .../model-defaults/phase-model-selector.tsx | 11 +++ apps/ui/src/store/app-store.ts | 70 +++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index 4a559cdb..1651e2b7 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -166,6 +166,8 @@ export function PhaseModelSelector({ codexModelsLoading, fetchCodexModels, dynamicOpencodeModels, + opencodeModelsLoading, + fetchOpencodeModels, } = useAppStore(); // Detect mobile devices to use inline expansion instead of nested popovers @@ -185,6 +187,15 @@ export function PhaseModelSelector({ } }, [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; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 4dd2faa5..2bb1a131 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -601,6 +601,10 @@ export interface AppState { authenticated: boolean; authMethod?: string; }>; // Cached providers + opencodeModelsLoading: boolean; // Whether OpenCode models are being fetched + opencodeModelsError: string | null; // Error message if fetch failed + opencodeModelsLastFetched: number | null; // Timestamp of last successful fetch + opencodeModelsLastFailedAt: number | null; // Timestamp of last failed fetch // Claude Agent SDK Settings autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option @@ -1182,6 +1186,9 @@ export interface AppActions { }> ) => void; + // OpenCode Models actions + fetchOpencodeModels: (forceRefresh?: boolean) => Promise; + // Init Script State actions (keyed by projectPath::branch to support concurrent scripts) setInitScriptState: ( projectPath: string, @@ -1253,6 +1260,10 @@ const initialState: AppState = { dynamicOpencodeModels: [], // Empty until fetched from OpenCode CLI enabledDynamicModelIds: [], // Empty until user enables dynamic models cachedOpencodeProviders: [], // Empty until fetched from OpenCode CLI + opencodeModelsLoading: false, + opencodeModelsError: null, + opencodeModelsLastFetched: null, + opencodeModelsLastFailedAt: null, autoLoadClaudeMd: false, // Default to disabled (user must opt-in) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) mcpServers: [], // No MCP servers configured by default @@ -3251,6 +3262,65 @@ export const useAppStore = create()((set, get) => ({ codexModelsLastFetched: Date.now(), }), + // OpenCode Models actions + fetchOpencodeModels: async (forceRefresh = false) => { + const FAILURE_COOLDOWN_MS = 30 * 1000; // 30 seconds + const SUCCESS_CACHE_MS = 5 * 60 * 1000; // 5 minutes + + const { opencodeModelsLastFetched, opencodeModelsLoading, opencodeModelsLastFailedAt } = get(); + + // Skip if already loading + if (opencodeModelsLoading) return; + + // Skip if recently failed and not forcing refresh + if ( + !forceRefresh && + opencodeModelsLastFailedAt && + Date.now() - opencodeModelsLastFailedAt < FAILURE_COOLDOWN_MS + ) { + return; + } + + // Skip if recently fetched successfully and not forcing refresh + if ( + !forceRefresh && + opencodeModelsLastFetched && + Date.now() - opencodeModelsLastFetched < SUCCESS_CACHE_MS + ) { + return; + } + + set({ opencodeModelsLoading: true, opencodeModelsError: null }); + + try { + const api = getElectronAPI(); + if (!api.setup) { + throw new Error('Setup API not available'); + } + + const result = await api.setup.getOpencodeModels(forceRefresh); + + if (!result.success) { + throw new Error(result.error || 'Failed to fetch OpenCode models'); + } + + set({ + dynamicOpencodeModels: result.models || [], + opencodeModelsLastFetched: Date.now(), + opencodeModelsLoading: false, + opencodeModelsError: null, + opencodeModelsLastFailedAt: null, // Clear failure on success + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + set({ + opencodeModelsError: errorMessage, + opencodeModelsLoading: false, + opencodeModelsLastFailedAt: Date.now(), // Record failure time for cooldown + }); + } + }, + // Pipeline actions setPipelineConfig: (projectPath, config) => { set({