diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts index 113eae5a..75558354 100644 --- a/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts +++ b/src/features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter.ts @@ -18,8 +18,8 @@ import type { ServiceContext } from '@main/services'; const CODEX_THREAD_LIMIT = 20; const CODEX_INITIALIZE_TIMEOUT_MS = 6_000; -const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000; -const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000; +const CODEX_LIVE_FETCH_TIMEOUT_MS = 18_000; +const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 6_000; const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500; const CODEX_TOTAL_FETCH_TIMEOUT_MS = CODEX_INITIALIZE_TIMEOUT_MS + @@ -61,6 +61,10 @@ function getFullFailureReason(result: CodexRecentThreadsResult): string | null { return result.live.error; } + if (result.archived.skipped) { + return result.live.error; + } + return `live: ${result.live.error}; archived: ${result.archived.error}`; } @@ -227,6 +231,14 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor return; } + if (result[segment].skipped) { + this.deps.logger.info('codex recent-projects thread list skipped', { + segment, + reason: error, + }); + return; + } + if (segment === 'archived' && !result.live.error) { this.deps.logger.info('codex recent-projects archived thread list degraded', { error, diff --git a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts index cc1d6975..6aeeeac7 100644 --- a/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts +++ b/src/features/recent-projects/main/infrastructure/codex/CodexAppServerClient.ts @@ -46,6 +46,7 @@ export interface CodexThreadSummary { export interface CodexThreadSegmentResult { threads: CodexThreadSummary[]; error?: string; + skipped?: boolean; } export interface CodexRecentThreadsResult { @@ -142,6 +143,17 @@ export class CodexAppServerClient { limit: options.limit, timeoutMs: liveRequestTimeoutMs, }); + if (live.error) { + return { + live, + archived: { + threads: [], + error: `Skipped archived thread/list after live thread/list failed: ${live.error}`, + skipped: true, + }, + }; + } + const archived = await this.#requestThreadListSegment(session, { archived: true, limit: options.limit, diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index 8b9cfbd2..e70dedd3 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -10,18 +10,19 @@ import type { RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadViewInput, - RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelsResponse, + RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementProviderResponse, + RuntimeProviderManagementRuntimeId, RuntimeProviderManagementSetDefaultModelInput, RuntimeProviderManagementTestModelInput, - RuntimeProviderManagementRuntimeId, RuntimeProviderManagementViewResponse, } from '@features/runtime-provider-management/contracts'; import type { ChildProcessWithoutNullStreams } from 'child_process'; const COMMAND_TIMEOUT_MS = 45_000; const PROBE_COMMAND_TIMEOUT_MS = 90_000; +const COMMAND_ERROR_DETAIL_LIMIT = 1_600; type RuntimeProviderManagementErrorResponse = | RuntimeProviderManagementViewResponse @@ -54,9 +55,54 @@ function extractJsonObject(raw: string): T { return JSON.parse(raw.slice(start, end + 1)) as T; } +function tryExtractJsonObject(raw: string | null): T | null { + if (!raw) { + return null; + } + try { + return extractJsonObject(raw); + } catch { + return null; + } +} + +function readErrorTextProperty(error: unknown, propertyName: 'stderr' | 'stdout'): string | null { + if (!error || typeof error !== 'object' || !(propertyName in error)) { + return null; + } + const value = (error as Record)[propertyName]; + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + return null; +} + +function extractJsonObjectFromError(error: unknown): T | null { + return ( + tryExtractJsonObject(readErrorTextProperty(error, 'stdout')) ?? + tryExtractJsonObject(readErrorTextProperty(error, 'stderr')) + ); +} + +function truncateCommandErrorDetail(message: string): string { + if (message.length <= COMMAND_ERROR_DETAIL_LIMIT) { + return message; + } + return `${message.slice(0, COMMAND_ERROR_DETAIL_LIMIT).trimEnd()}...`; +} + function normalizeCommandFailure(error: unknown): string { + const stderr = readErrorTextProperty(error, 'stderr'); + if (stderr) { + return truncateCommandErrorDetail(stderr); + } + const stdout = readErrorTextProperty(error, 'stdout'); + if (stdout) { + return truncateCommandErrorDetail(stdout); + } if (error instanceof Error && error.message.trim()) { - return error.message; + return truncateCommandErrorDetail(error.message); } return 'Runtime provider management command failed'; } @@ -156,6 +202,10 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv ); return extractJsonObject(stdout); } catch (error) { + const response = extractJsonObjectFromError(error); + if (response) { + return response; + } return errorResponse( input.runtimeId, normalizeCommandFailure(error) @@ -208,6 +258,10 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv ); } } catch (error) { + const response = extractJsonObjectFromError(error); + if (response) { + return response; + } return errorResponse( input.runtimeId, normalizeCommandFailure(error) @@ -287,6 +341,10 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv }); return extractJsonObject(stdout); } catch (error) { + const response = extractJsonObjectFromError(error); + if (response) { + return response; + } return errorResponse( input.runtimeId, normalizeCommandFailure(error) @@ -325,6 +383,11 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv ); return extractJsonObject(stdout); } catch (error) { + const response = + extractJsonObjectFromError(error); + if (response) { + return response; + } return errorResponse( input.runtimeId, normalizeCommandFailure(error), @@ -366,6 +429,10 @@ export class AgentTeamsRuntimeProviderManagementCliClient implements RuntimeProv ); return extractJsonObject(stdout); } catch (error) { + const response = extractJsonObjectFromError(error); + if (response) { + return response; + } return errorResponse( input.runtimeId, normalizeCommandFailure(error), diff --git a/src/features/runtime-provider-management/renderer/adapters/createTeamDefaultModelWriter.ts b/src/features/runtime-provider-management/renderer/adapters/createTeamDefaultModelWriter.ts index 067c537b..9006a8fa 100644 --- a/src/features/runtime-provider-management/renderer/adapters/createTeamDefaultModelWriter.ts +++ b/src/features/runtime-provider-management/renderer/adapters/createTeamDefaultModelWriter.ts @@ -1,8 +1,14 @@ import { + getStoredCreateTeamModel, setStoredCreateTeamModel, setStoredCreateTeamProvider, } from '@renderer/services/createTeamPreferences'; +export function getOpenCodeModelForNewTeams(): string | null { + const modelId = getStoredCreateTeamModel('opencode').trim(); + return modelId.length > 0 ? modelId : null; +} + export function saveOpenCodeModelForNewTeams(modelId: string): void { setStoredCreateTeamProvider('opencode'); setStoredCreateTeamModel('opencode', modelId); diff --git a/src/features/runtime-provider-management/renderer/assets/provider-icons/opencode-favicon.png b/src/features/runtime-provider-management/renderer/assets/provider-icons/opencode-favicon.png new file mode 100644 index 00000000..15266d28 Binary files /dev/null and b/src/features/runtime-provider-management/renderer/assets/provider-icons/opencode-favicon.png differ diff --git a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts index 5095f688..0b0cd27a 100644 --- a/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts +++ b/src/features/runtime-provider-management/renderer/hooks/useRuntimeProviderManagement.ts @@ -3,7 +3,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { api } from '@renderer/api'; import { selectInitialProviderId } from '../../core/domain'; -import { saveOpenCodeModelForNewTeams } from '../adapters/createTeamDefaultModelWriter'; +import { + getOpenCodeModelForNewTeams, + saveOpenCodeModelForNewTeams, +} from '../adapters/createTeamDefaultModelWriter'; import type { RuntimeProviderConnectionDto, @@ -25,6 +28,7 @@ export interface RuntimeProviderManagementState { view: RuntimeProviderManagementViewDto | null; providers: readonly RuntimeProviderConnectionDto[]; selectedProviderId: string | null; + providerQuery: string; activeFormProviderId: string | null; apiKeyValue: string; modelPickerProviderId: string | null; @@ -46,6 +50,7 @@ export interface RuntimeProviderManagementState { export interface RuntimeProviderManagementActions { refresh: () => Promise; selectProvider: (providerId: string) => void; + setProviderQuery: (value: string) => void; startConnect: (providerId: string) => void; cancelConnect: () => void; setApiKeyValue: (value: string) => void; @@ -111,11 +116,35 @@ function withUiTimeout(promise: Promise, message: string, timeoutMs = 70_0 }); } +function buildFailedModelTestResult( + providerId: string, + modelId: string, + message: string +): RuntimeProviderModelTestResultDto { + return { + providerId, + modelId, + ok: false, + availability: 'unknown', + message, + diagnostics: [], + }; +} + +function resolveSavedModelForNewTeams(models: readonly RuntimeProviderModelDto[]): string | null { + const savedModelId = getOpenCodeModelForNewTeams(); + if (!savedModelId) { + return null; + } + return models.some((model) => model.modelId === savedModelId) ? savedModelId : null; +} + export function useRuntimeProviderManagement( options: UseRuntimeProviderManagementOptions ): [RuntimeProviderManagementState, RuntimeProviderManagementActions] { const [view, setView] = useState(null); const [selectedProviderId, setSelectedProviderId] = useState(null); + const [providerQuery, setProviderQuery] = useState(''); const [activeFormProviderId, setActiveFormProviderId] = useState(null); const [apiKeyValue, setApiKeyValue] = useState(''); const [modelPickerProviderId, setModelPickerProviderId] = useState(null); @@ -170,6 +199,7 @@ export function useRuntimeProviderManagement( useEffect(() => { if (!options.enabled) { + setProviderQuery(''); setApiKeyValue(''); setActiveFormProviderId(null); const reset = resetModelState(); @@ -216,9 +246,7 @@ export function useRuntimeProviderManagement( if (current && nextModels.some((model) => model.modelId === current)) { return current; } - return ( - nextModels.find((model) => model.default)?.modelId ?? nextModels[0]?.modelId ?? null - ); + return resolveSavedModelForNewTeams(nextModels); }); }) .catch((modelsLoadError) => { @@ -413,7 +441,7 @@ export function useRuntimeProviderManagement( const useModelForNewTeams = useCallback((modelId: string): void => { saveOpenCodeModelForNewTeams(modelId); setSelectedModelId(modelId); - setSuccessMessage('Model saved for new teams'); + setSuccessMessage(null); setError(null); }, []); @@ -433,7 +461,10 @@ export function useRuntimeProviderManagement( 100_000 ); if (response.error) { - setError(response.error.message); + setModelResults((current) => ({ + ...current, + [modelId]: buildFailedModelTestResult(providerId, modelId, response.error!.message), + })); return; } if (response.result) { @@ -441,10 +472,16 @@ export function useRuntimeProviderManagement( ...current, [modelId]: response.result!, })); - setSuccessMessage(response.result.ok ? 'Model probe passed' : response.result.message); } } catch (testError) { - setError(testError instanceof Error ? testError.message : 'Failed to test model'); + setModelResults((current) => ({ + ...current, + [modelId]: buildFailedModelTestResult( + providerId, + modelId, + testError instanceof Error ? testError.message : 'Failed to test model' + ), + })); } finally { setTestingModelId(null); } @@ -504,6 +541,7 @@ export function useRuntimeProviderManagement( view, providers: view?.providers ?? [], selectedProviderId, + providerQuery, activeFormProviderId, apiKeyValue, modelPickerProviderId, @@ -533,6 +571,7 @@ export function useRuntimeProviderManagement( models, modelsError, modelsLoading, + providerQuery, savingDefaultModelId, savingProviderId, selectedModelId, @@ -547,6 +586,7 @@ export function useRuntimeProviderManagement( () => ({ refresh, selectProvider, + setProviderQuery, startConnect, cancelConnect, setApiKeyValue, diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index 2dae445f..8d09799c 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -1,13 +1,20 @@ +import { useEffect, useMemo, useState } from 'react'; + import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; +import { Checkbox } from '@renderer/components/ui/checkbox'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; +import { + compareOpenCodeTeamModelRecommendations, + getOpenCodeTeamModelRecommendation, + isOpenCodeTeamModelRecommended, +} from '@renderer/utils/openCodeModelRecommendations'; import { AlertTriangle, CheckCircle2, KeyRound, Loader2, - Play, RefreshCcw, Search, Star, @@ -21,6 +28,8 @@ import { getProviderModelsLabel, } from '../../core/domain'; +import { ProviderBrandIcon } from './providerBrandIcons'; + import type { RuntimeProviderManagementActions, RuntimeProviderManagementState, @@ -30,7 +39,7 @@ import type { RuntimeProviderModelDto, RuntimeProviderModelTestResultDto, } from '@features/runtime-provider-management/contracts'; -import type { JSX } from 'react'; +import type { CSSProperties, JSX, KeyboardEvent } from 'react'; interface RuntimeProviderManagementPanelViewProps { readonly state: RuntimeProviderManagementState; @@ -43,8 +52,6 @@ interface ProviderActionsProps { readonly busy: boolean; readonly disabled: boolean; readonly onStartConnect: () => void; - readonly onUse: () => void; - readonly onSetDefault: () => void; readonly onForget: () => void; } @@ -62,7 +69,7 @@ interface ProviderRowProps { function stateClassName(provider: RuntimeProviderConnectionDto): string { switch (provider.state) { case 'connected': - return 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200'; + return 'border-emerald-400/35 bg-emerald-400/10'; case 'available': return 'border-sky-400/25 bg-sky-400/10 text-sky-200'; case 'error': @@ -74,6 +81,18 @@ function stateClassName(provider: RuntimeProviderConnectionDto): string { } } +function stateStyle(provider: RuntimeProviderConnectionDto): CSSProperties | undefined { + if (provider.state !== 'connected') { + return undefined; + } + + return { + color: '#86efac', + borderColor: 'rgba(74, 222, 128, 0.38)', + backgroundColor: 'rgba(74, 222, 128, 0.11)', + }; +} + function RuntimeSummary({ state, onRefresh, @@ -82,9 +101,11 @@ function RuntimeSummary({ onRefresh: () => void; }): JSX.Element { const runtime = state.view?.runtime; + const loadingWithoutRuntime = state.loading && !runtime; return (
- - {runtime ? formatRuntimeState(runtime) : state.loading ? 'Loading' : 'Unavailable'} + + {runtime + ? formatRuntimeState(runtime) + : state.loading + ? 'Checking runtime' + : 'Unavailable'} {runtime?.version ? ( v{runtime.version} ) : null} {state.view?.defaultModel ? ( - Default: {state.view.defaultModel} + OpenCode default: {state.view.defaultModel} ) : null}
+ {state.loading ? ( +
+ + + Loading managed OpenCode runtime, connected providers, and model defaults... + +
+ ) : null} {state.view?.diagnostics.length ? (
)} - Refresh + {state.loading ? 'Checking...' : 'Refresh'}
); } +function RuntimeProviderLoadingPlaceholder(): JSX.Element { + return ( +
+
+
+
+
+
+ Loading OpenCode providers +
+
+
+
+