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
This commit is contained in:
Soham Dasgupta
2026-01-14 19:46:43 +05:30
parent e7bfb19203
commit f1a5bcd17a
2 changed files with 81 additions and 0 deletions

View File

@@ -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;

View File

@@ -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<void>;
// 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<AppState & AppActions>()((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({