From e192359da908b2b15493a65b17abcd367162d685 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 5 Jun 2026 15:09:24 +0300 Subject: [PATCH] fix: improve startup readiness performance --- packages/ui/src/App.tsx | 12 +- packages/ui/src/apps/ElectronMiniChatApp.tsx | 4 +- packages/ui/src/apps/MobileApp.tsx | 4 +- .../chat/AgentMentionAutocomplete.tsx | 5 +- .../ui/src/components/chat/ModelControls.tsx | 14 ++ .../ui/src/components/layout/VSCodeLayout.tsx | 4 +- .../src/components/multirun/AgentSelector.tsx | 4 +- .../sections/openchamber/AboutSettings.tsx | 38 ++++ .../components/session/ForkSessionDialog.tsx | 2 +- .../session/ScheduledTaskEditorDialog.tsx | 4 +- .../src/components/session/TodoSendDialog.tsx | 2 +- .../ui/src/lib/i18n/messages/en.settings.ts | 2 + .../ui/src/lib/i18n/messages/es.settings.ts | 2 + .../ui/src/lib/i18n/messages/ko.settings.ts | 2 + .../ui/src/lib/i18n/messages/pl.settings.ts | 2 + .../src/lib/i18n/messages/pt-BR.settings.ts | 2 + .../ui/src/lib/i18n/messages/uk.settings.ts | 2 + .../src/lib/i18n/messages/zh-CN.settings.ts | 2 + .../src/lib/i18n/messages/zh-TW.settings.ts | 2 + packages/ui/src/lib/openCodeStatus.ts | 2 +- packages/ui/src/lib/opencode/client.ts | 35 ++- packages/ui/src/lib/runtime-fetch.ts | 14 +- packages/ui/src/lib/startupTrace.ts | 90 ++++++++ packages/ui/src/stores/useAgentsStore.ts | 4 +- packages/ui/src/stores/useConfigStore.ts | 210 +++++++++++++++--- packages/vscode/src/bridge-system-runtime.ts | 26 +++ packages/vscode/src/extension.ts | 2 +- packages/vscode/src/opencode.ts | 2 +- packages/vscode/webview/main.tsx | 18 ++ packages/web/server/lib/opencode/lifecycle.js | 53 ++--- .../server/lib/opencode/network-runtime.js | 2 +- packages/web/server/lib/opencode/routes.js | 45 ++++ 32 files changed, 502 insertions(+), 110 deletions(-) create mode 100644 packages/ui/src/lib/startupTrace.ts diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index fc096ec5..3ad10d35 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -56,6 +56,7 @@ import { SyncAppEffects } from '@/apps/AppEffects'; import { useAppFontEffects } from '@/apps/useAppFontEffects'; import { resetStreamingState } from '@/sync/streaming'; import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast'; +import { markStartupTrace, startupTraceEnabled } from '@/lib/startupTrace'; // Lazy-loaded heavy views — loaded on demand to reduce initial bundle size. const OnboardingScreen = lazyWithChunkRecovery(() => @@ -201,6 +202,13 @@ const EmbeddedSessionChatContent: React.FC<{ }; function App({ apis }: AppProps) { + React.useEffect(() => { + markStartupTrace('App:mounted'); + if (startupTraceEnabled()) { + console.info('[startup-trace] enabled. Run console.table(window.__OPENCHAMBER_STARTUP_TRACE__) after startup.'); + } + }, []); + const initializeApp = useConfigStore((s) => s.initializeApp); const isInitialized = useConfigStore((s) => s.isInitialized); const isConnected = useConfigStore((s) => s.isConnected); @@ -470,8 +478,8 @@ function App({ apis }: AppProps) { const state = useConfigStore.getState(); if (state.providers.length > 0 && state.agents.length > 0) return; try { - if (state.providers.length === 0) await loadProviders(); - if (useConfigStore.getState().agents.length === 0) await loadAgents(); + if (state.providers.length === 0) await loadProviders({ source: 'startupRecovery' }); + if (useConfigStore.getState().agents.length === 0) await loadAgents({ source: 'startupRecovery' }); } catch { /* retry next interval */ } }; diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index 25232a2a..e7ed2823 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -114,8 +114,8 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => React.useEffect(() => { if (!isConnected) return; - if (providersCount === 0) void loadProviders(); - if (agentsCount === 0) void loadAgents(); + if (providersCount === 0) void loadProviders({ source: 'electronMiniChat:recovery' }); + if (agentsCount === 0) void loadAgents({ source: 'electronMiniChat:recovery' }); }, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]); const sessionBootstrappedRef = React.useRef(false); diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 7a3a8ee9..594703e9 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -373,8 +373,8 @@ export function MobileApp({ apis }: MobileAppProps) { React.useEffect(() => { if (!isConnected) return; - if (providersCount === 0) void loadProviders(); - if (agentsCount === 0) void loadAgents(); + if (providersCount === 0) void loadProviders({ source: 'mobileApp:recovery' }); + if (agentsCount === 0) void loadAgents({ source: 'mobileApp:recovery' }); }, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]); React.useEffect(() => { diff --git a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx index 808b1384..ac2394b8 100644 --- a/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/AgentMentionAutocomplete.tsx @@ -49,14 +49,15 @@ export const AgentMentionAutocomplete = React.forwardRef([]); const ignoreTabClickRef = React.useRef(false); const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents); + const configAgentsCount = useConfigStore((state) => state.agents.length); const agentsWithMetadata = useAgentsStore((state) => state.agents); const loadAgents = useAgentsStore((state) => state.loadAgents); React.useEffect(() => { - if (agentsWithMetadata.length === 0) { + if (agentsWithMetadata.length === 0 && configAgentsCount === 0) { void loadAgents(); } - }, [loadAgents, agentsWithMetadata.length]); + }, [loadAgents, agentsWithMetadata.length, configAgentsCount]); React.useEffect(() => { const visibleAgents = getVisibleAgents(); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 1f929d3c..ea01c976 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -38,6 +38,7 @@ import { formatEffortLabel, getCycledPrimaryAgentName, type MobileControlsPanel import { useI18n } from '@/lib/i18n'; import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness'; import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts'; +import { markStartupTrace } from '@/lib/startupTrace'; type IconComponent = IconName; @@ -312,6 +313,19 @@ export const ModelControls: React.FC = ({ // Use visible agents (excludes hidden internal agents) const agents = getVisibleAgents(); const primaryAgents = React.useMemo(() => agents.filter((agent) => agent.mode === 'primary'), [agents]); + const tracedReadyRef = React.useRef(false); + + React.useEffect(() => { + if (tracedReadyRef.current || !isReady) return; + tracedReadyRef.current = true; + markStartupTrace('ModelControls:ready', { + providers: providers.length, + agents: agents.length, + currentProviderId, + currentModelId, + currentAgentName, + }); + }, [agents.length, currentAgentName, currentModelId, currentProviderId, isReady, providers.length]); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const getDirectoryForSession = useSessionUIStore((s) => s.getDirectoryForSession); diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index d238d2ba..3ab623ef 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -320,10 +320,10 @@ export const VSCodeLayout: React.FC = () => { // Keep trying to fetch core datasets on cold starts. if (configStore.isConnected) { if (configStore.providers.length === 0) { - await configStore.loadProviders(); + await configStore.loadProviders({ source: 'vscodeLayout:bootstrap' }); } if (configStore.agents.length === 0) { - await configStore.loadAgents(); + await configStore.loadAgents({ source: 'vscodeLayout:bootstrap' }); } } diff --git a/packages/ui/src/components/multirun/AgentSelector.tsx b/packages/ui/src/components/multirun/AgentSelector.tsx index a538a268..8de702ac 100644 --- a/packages/ui/src/components/multirun/AgentSelector.tsx +++ b/packages/ui/src/components/multirun/AgentSelector.tsx @@ -50,8 +50,8 @@ export const AgentSelector: React.FC = ({ // Load agents on mount React.useEffect(() => { - loadAgents(); - }, [loadAgents]); + if (agents.length === 0) void loadAgents(); + }, [agents.length, loadAgents]); // Ensure we always have a valid selection (defaults to current default agent, then first selectable agent). React.useEffect(() => { diff --git a/packages/ui/src/components/sections/openchamber/AboutSettings.tsx b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx index e7c8fcaa..4c98af4b 100644 --- a/packages/ui/src/components/sections/openchamber/AboutSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/AboutSettings.tsx @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; +import { runtimeFetch } from '@/lib/runtime-fetch'; const GITHUB_URL = 'https://github.com/btriapitsyn/openchamber'; @@ -17,6 +18,7 @@ export const AboutSettings: React.FC = () => { const { t } = useI18n(); const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false); const [showChecking, setShowChecking] = React.useState(false); + const [openCodeVersion, setOpenCodeVersion] = React.useState(null); const updateStore = useUpdateStore(useShallow((s) => ({ info: s.info, checking: s.checking, @@ -34,6 +36,33 @@ export const AboutSettings: React.FC = () => { const currentVersion = updateStore.info?.currentVersion || 'unknown'; + React.useEffect(() => { + let cancelled = false; + + const loadOpenCodeVersion = async () => { + try { + const response = await runtimeFetch('/api/opencode/version', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) return; + const data = await response.json().catch(() => null) as { version?: unknown } | null; + const version = typeof data?.version === 'string' && data.version.trim().length > 0 + ? data.version.trim() + : null; + if (!cancelled) setOpenCodeVersion(version); + } catch { + if (!cancelled) setOpenCodeVersion(null); + } + }; + + void loadOpenCodeVersion(); + + return () => { + cancelled = true; + }; + }, []); + // Track if we initiated a check to show toast on completion const didInitiateCheck = React.useRef(false); @@ -91,6 +120,11 @@ export const AboutSettings: React.FC = () => { )} +
+ {t('settings.openchamber.about.field.openCodeVersion')} + {openCodeVersion || t('settings.openchamber.about.state.unknown')} +
+ {updateStore.error && (

{updateStore.error}

)} @@ -159,6 +193,10 @@ export const AboutSettings: React.FC = () => { {t('settings.openchamber.about.field.version')} {currentVersion} +
+ {t('settings.openchamber.about.field.openCodeVersion')} + {openCodeVersion || t('settings.openchamber.about.state.unknown')} +
{updateStore.checking && ( diff --git a/packages/ui/src/components/session/ForkSessionDialog.tsx b/packages/ui/src/components/session/ForkSessionDialog.tsx index 4ac32d07..922f92ab 100644 --- a/packages/ui/src/components/session/ForkSessionDialog.tsx +++ b/packages/ui/src/components/session/ForkSessionDialog.tsx @@ -53,7 +53,7 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) { React.useEffect(() => { if (!open) return; - void loadProviders({ directory: projectDirectory }); + void loadProviders({ directory: projectDirectory, source: 'forkSessionDialog' }); void loadConfigAgents({ directory: projectDirectory }); void loadAgentsStoreAgents(); }, [open, loadProviders, loadConfigAgents, loadAgentsStoreAgents, projectDirectory]); diff --git a/packages/ui/src/components/session/ScheduledTaskEditorDialog.tsx b/packages/ui/src/components/session/ScheduledTaskEditorDialog.tsx index 32e739b6..a481922f 100644 --- a/packages/ui/src/components/session/ScheduledTaskEditorDialog.tsx +++ b/packages/ui/src/components/session/ScheduledTaskEditorDialog.tsx @@ -662,8 +662,8 @@ export function ScheduledTaskEditorDialog(props: { if (!open) { return; } - void loadProviders(); - void loadAgents(); + void loadProviders({ source: 'scheduledTaskEditor' }); + void loadAgents({ source: 'scheduledTaskEditor' }); }, [open, loadProviders, loadAgents]); React.useEffect(() => { diff --git a/packages/ui/src/components/session/TodoSendDialog.tsx b/packages/ui/src/components/session/TodoSendDialog.tsx index 5ec7c55d..054d835c 100644 --- a/packages/ui/src/components/session/TodoSendDialog.tsx +++ b/packages/ui/src/components/session/TodoSendDialog.tsx @@ -66,7 +66,7 @@ export function TodoSendDialog(props: TodoSendDialogProps) { React.useEffect(() => { if (!open) return; - void loadProviders({ directory: projectDirectory }); + void loadProviders({ directory: projectDirectory, source: 'todoSendDialog' }); void loadConfigAgents({ directory: projectDirectory }); void loadAgentsStoreAgents(); }, [open, loadProviders, loadConfigAgents, loadAgentsStoreAgents, projectDirectory]); diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 161f85ee..b855ab08 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -360,8 +360,10 @@ export const settingsDict = { 'settings.projects.actions.toast.saved': 'Project actions saved', 'settings.openchamber.about.title': 'About OpenChamber', 'settings.openchamber.about.field.version': 'Version', + 'settings.openchamber.about.field.openCodeVersion': 'OpenCode version', 'settings.openchamber.about.state.checking': 'Checking...', 'settings.openchamber.about.state.upToDate': 'Up to date', + 'settings.openchamber.about.state.unknown': 'unknown', 'settings.openchamber.about.actions.checkUpdates': 'Check updates', 'settings.openchamber.about.actions.update': 'Update', 'settings.openchamber.about.actions.updateToVersion': 'Update to {version}', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 9ef24ebf..cf97c42d 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -327,8 +327,10 @@ export const settingsDict = { "settings.projects.actions.toast.saved": "Acciones del proyecto guardadas", "settings.openchamber.about.title": "Acerca de OpenChamber", "settings.openchamber.about.field.version": "Versión", + "settings.openchamber.about.field.openCodeVersion": "Versión de OpenCode", "settings.openchamber.about.state.checking": "Comprobando...", "settings.openchamber.about.state.upToDate": "Actualizado", + "settings.openchamber.about.state.unknown": "desconocido", "settings.openchamber.about.actions.checkUpdates": "Comprobar actualizaciones", "settings.openchamber.about.actions.update": "Actualizar", "settings.openchamber.about.actions.updateToVersion": "Actualizar a {version}", diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 2590bb92..61fc1ea4 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -327,8 +327,10 @@ export const settingsDict = { 'settings.projects.actions.toast.saved': '프로젝트 작업이 저장되었습니다', 'settings.openchamber.about.title': 'OpenChamber 정보', 'settings.openchamber.about.field.version': '버전', + 'settings.openchamber.about.field.openCodeVersion': 'OpenCode 버전', 'settings.openchamber.about.state.checking': '확인 중...', 'settings.openchamber.about.state.upToDate': '최신 상태', + 'settings.openchamber.about.state.unknown': '알 수 없음', 'settings.openchamber.about.actions.checkUpdates': '업데이트 확인', 'settings.openchamber.about.actions.update': '업데이트', 'settings.openchamber.about.actions.updateToVersion': '{version}으로 업데이트', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index ac559b94..58f3f1ab 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -640,8 +640,10 @@ export const settingsDict = { 'settings.openchamber.about.actions.update': 'Aktualizuj', 'settings.openchamber.about.actions.updateToVersion': 'Aktualizuj do wersji {version}', 'settings.openchamber.about.field.version': 'Wersja', + 'settings.openchamber.about.field.openCodeVersion': 'Wersja OpenCode', 'settings.openchamber.about.state.checking': 'Sprawdzanie...', 'settings.openchamber.about.state.upToDate': 'Aktualna wersja', + 'settings.openchamber.about.state.unknown': 'nieznane', 'settings.openchamber.about.title': 'O OpenChamber', 'settings.openchamber.about.toast.latestVersion': 'Używasz najnowszej wersji', 'settings.openchamber.defaults.field.defaultAgent': 'Domyślny Agent', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 58afd50c..4b68286b 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -327,8 +327,10 @@ export const settingsDict = { "settings.projects.actions.toast.saved": "Ações do projeto salvas", "settings.openchamber.about.title": "Sobre o OpenChamber", "settings.openchamber.about.field.version": "Versão", + "settings.openchamber.about.field.openCodeVersion": "Versão do OpenCode", "settings.openchamber.about.state.checking": "Verificando...", "settings.openchamber.about.state.upToDate": "Atualizado", + "settings.openchamber.about.state.unknown": "desconhecido", "settings.openchamber.about.actions.checkUpdates": "Verificar atualizações", "settings.openchamber.about.actions.update": "Atualizar", "settings.openchamber.about.actions.updateToVersion": "Atualizar a {version}", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index ae7c0d9a..1d3b957a 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -327,8 +327,10 @@ export const settingsDict = { "settings.projects.actions.toast.saved": "Дії проєкту збережено", "settings.openchamber.about.title": "Про OpenChamber", "settings.openchamber.about.field.version": "Версія", + "settings.openchamber.about.field.openCodeVersion": "Версія OpenCode", "settings.openchamber.about.state.checking": "Перевірка...", "settings.openchamber.about.state.upToDate": "В актуальному стані", + "settings.openchamber.about.state.unknown": "невідомо", "settings.openchamber.about.actions.checkUpdates": "Перевірити оновлення", "settings.openchamber.about.actions.update": "Оновити", "settings.openchamber.about.actions.updateToVersion": "Оновити до {version}", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 44a29ed0..4410a6c7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -327,8 +327,10 @@ export const settingsDict = { 'settings.projects.actions.toast.saved': '项目操作已保存', 'settings.openchamber.about.title': '关于 OpenChamber', 'settings.openchamber.about.field.version': '版本', + 'settings.openchamber.about.field.openCodeVersion': 'OpenCode 版本', 'settings.openchamber.about.state.checking': '检查中...', 'settings.openchamber.about.state.upToDate': '已是最新', + 'settings.openchamber.about.state.unknown': '未知', 'settings.openchamber.about.actions.checkUpdates': '检查更新', 'settings.openchamber.about.actions.update': '更新', 'settings.openchamber.about.actions.updateToVersion': '更新到 {version}', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index dd5a2757..387a49a6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -324,8 +324,10 @@ 'settings.projects.actions.toast.saved': '專案操作已儲存', 'settings.openchamber.about.title': '關於 OpenChamber', 'settings.openchamber.about.field.version': '版本', + 'settings.openchamber.about.field.openCodeVersion': 'OpenCode 版本', 'settings.openchamber.about.state.checking': '檢查中...', 'settings.openchamber.about.state.upToDate': '已是最新', + 'settings.openchamber.about.state.unknown': '未知', 'settings.openchamber.about.actions.checkUpdates': '檢查更新', 'settings.openchamber.about.actions.update': '更新', 'settings.openchamber.about.actions.updateToVersion': '更新到 {version}', diff --git a/packages/ui/src/lib/openCodeStatus.ts b/packages/ui/src/lib/openCodeStatus.ts index 692ba74d..68652db2 100644 --- a/packages/ui/src/lib/openCodeStatus.ts +++ b/packages/ui/src/lib/openCodeStatus.ts @@ -235,7 +235,7 @@ export const buildOpenCodeStatusReport = async (): Promise => { }; const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [ - { label: 'health', path: '/global/health', includeDirectory: false }, + { label: 'health', path: '/api/health', includeDirectory: false }, { label: 'config', path: '/config', includeDirectory: true }, { label: 'providers', path: '/config/providers', includeDirectory: true }, { label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 }, diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 81b7c499..9d89213f 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -18,6 +18,7 @@ import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap"; import { getRuntimeUrlResolver } from "@/lib/runtime-url"; import { runtimeFetch } from "@/lib/runtime-fetch"; import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry"; +import { markStartupTrace } from "@/lib/startupTrace"; import { assertProviderCircuitClosed, recordProviderSuccess, @@ -346,7 +347,14 @@ class OpencodeService { // Set the current working directory for all API calls setDirectory(directory: string | undefined) { - this.currentDirectory = this.normalizeCandidatePath(directory) ?? directory; + const normalized = this.normalizeCandidatePath(directory) ?? directory; + if (this.currentDirectory !== normalized) { + markStartupTrace('opencodeClient:setDirectory', { + previous: this.currentDirectory ?? null, + next: normalized ?? null, + }); + } + this.currentDirectory = normalized; } getDirectory(): string | undefined { @@ -1414,33 +1422,24 @@ class OpencodeService { } } - // Health Check - using /health endpoint for detailed status + // Lightweight readiness check. Full diagnostics still live at /health. async checkHealth(): Promise { try { - // Health endpoint is at root, not under /api - let healthUrl: string; const normalizedBase = this.baseUrl.endsWith('/') ? this.baseUrl.replace(/\/+$/, '') : this.baseUrl; - if (normalizedBase === '/api') { - healthUrl = '/health'; - } else if (normalizedBase.endsWith('/api')) { - // Desktop: http://127.0.0.1:PORT/api -> http://127.0.0.1:PORT/health - healthUrl = `${normalizedBase.slice(0, -4)}/health`; - } else { - healthUrl = `${normalizedBase}/health`; - } + const healthUrl = normalizedBase === '/api' || normalizedBase.endsWith('/api') + ? '/api/opencode/health' + : `${normalizedBase}/opencode/health`; + markStartupTrace('opencodeClient.checkHealth:url', { baseUrl: this.baseUrl, healthUrl }); const response = await runtimeFetch(healthUrl); + markStartupTrace('opencodeClient.checkHealth:response', { status: response.status }); if (!response.ok) { return false; } const healthData = await response.json(); + markStartupTrace('opencodeClient.checkHealth:result', { healthy: healthData?.healthy }); - // Check if the upstream API is ready (not just OpenChamber server) - if (healthData.isOpenCodeReady === false) { - return false; - } - - return true; + return healthData?.healthy === true; } catch { return false; } diff --git a/packages/ui/src/lib/runtime-fetch.ts b/packages/ui/src/lib/runtime-fetch.ts index 4fdac546..3f364b83 100644 --- a/packages/ui/src/lib/runtime-fetch.ts +++ b/packages/ui/src/lib/runtime-fetch.ts @@ -126,14 +126,12 @@ export const runtimeFetch = async (input: string | URL | Request, init: RuntimeF const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined; const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput)); - if (resolvedInput instanceof Request) { - return fetch(new Request(resolvedInput, { ...requestInit, headers })); - } - - return fetch(resolvedInput, { - ...requestInit, - headers, - }); + return resolvedInput instanceof Request + ? fetch(new Request(resolvedInput, { ...requestInit, headers })) + : fetch(resolvedInput, { + ...requestInit, + headers, + }); }; let runtimeFetchBridgeInstalled = false; diff --git a/packages/ui/src/lib/startupTrace.ts b/packages/ui/src/lib/startupTrace.ts new file mode 100644 index 00000000..4034a223 --- /dev/null +++ b/packages/ui/src/lib/startupTrace.ts @@ -0,0 +1,90 @@ +export type StartupTraceEvent = { + t: number; + name: string; + data?: Record; +}; + +declare global { + interface Window { + __OPENCHAMBER_STARTUP_TRACE__?: StartupTraceEvent[]; + __OPENCHAMBER_STARTUP_TRACE_START__?: number; + } +} + +const MAX_STARTUP_TRACE_EVENTS = 500; + +const enabled = () => { + if (typeof window === 'undefined') return false; + try { + const params = new URLSearchParams(window.location.search); + return params.get('startupTrace') === '1' || window.localStorage?.getItem('OPENCHAMBER_STARTUP_TRACE') === '1'; + } catch { + return false; + } +}; + +export const startupTraceEnabled = () => enabled(); + +export const markStartupTrace = (name: string, data?: Record) => { + if (!enabled()) return; + const now = typeof performance !== 'undefined' ? performance.now() : Date.now(); + window.__OPENCHAMBER_STARTUP_TRACE_START__ ??= now; + window.__OPENCHAMBER_STARTUP_TRACE__ ??= []; + window.__OPENCHAMBER_STARTUP_TRACE__.push({ + t: Math.round(now - window.__OPENCHAMBER_STARTUP_TRACE_START__), + name, + ...(data ? { data } : {}), + }); + if (window.__OPENCHAMBER_STARTUP_TRACE__.length > MAX_STARTUP_TRACE_EVENTS) { + window.__OPENCHAMBER_STARTUP_TRACE__.splice( + 0, + window.__OPENCHAMBER_STARTUP_TRACE__.length - MAX_STARTUP_TRACE_EVENTS, + ); + } +}; + +export const getStartupTraceSummary = () => { + const trace = typeof window !== 'undefined' ? window.__OPENCHAMBER_STARTUP_TRACE__ ?? [] : []; + const readyIndex = trace.findIndex((event) => event.name === 'ModelControls:ready'); + const endIndex = readyIndex >= 0 ? Math.min(trace.length, readyIndex + 8) : trace.length; + return trace.slice(0, endIndex).filter((event) => ( + event.name.includes('checkConnection') + || event.name.includes('checkHealth') + || event.name.includes('initializeApp') + || event.name.includes('initApp') + || event.name.includes('loadProviders') + || event.name.includes('loadAgents') + || event.name.includes('config.defaults') + || event.name.includes('modelsMetadata') + || event.name.includes('ModelControls') + || event.name.includes('activateDirectory') + || event.name.includes('opencodeClient:setDirectory') + )); +}; + +if (typeof window !== 'undefined') { + (window as typeof window & { __OPENCHAMBER_STARTUP_TRACE_SUMMARY__?: typeof getStartupTraceSummary }) + .__OPENCHAMBER_STARTUP_TRACE_SUMMARY__ = getStartupTraceSummary; +} + +export const measureStartupTrace = async ( + name: string, + fn: () => Promise, + data?: Record, +): Promise => { + const started = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace(`${name}:start`, data); + try { + const result = await fn(); + const ended = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace(`${name}:end`, { durationMs: Math.round(ended - started) }); + return result; + } catch (error) { + const ended = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace(`${name}:error`, { + durationMs: Math.round(ended - started), + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } +}; diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index d5d7658c..2e07aebd 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -620,10 +620,10 @@ async function performConfigRefresh(options: { const sdkRefreshTasks: Promise[] = []; for (const directory of directoriesToRefresh) { if (refreshProviders) { - sdkRefreshTasks.push(configStore.loadProviders({ directory }).then(() => undefined)); + sdkRefreshTasks.push(configStore.loadProviders({ directory, source: 'agentsStore:refreshConfig' }).then(() => undefined)); } if (refreshSdkAgents) { - sdkRefreshTasks.push(configStore.loadAgents({ directory }).then(() => undefined)); + sdkRefreshTasks.push(configStore.loadAgents({ directory, source: 'agentsStore:refreshConfig' }).then(() => undefined)); } } diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 5dc69c10..94f01f94 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -15,6 +15,7 @@ import { useDirectoryStore } from "@/stores/useDirectoryStore"; import { streamDebugEnabled } from "@/stores/utils/streamDebug"; import { parseModelIdentifier } from "@/lib/modelIdentifier"; import { runtimeFetch } from "@/lib/runtime-fetch"; +import { markStartupTrace, measureStartupTrace } from "@/lib/startupTrace"; const MODELS_DEV_API_URL = "https://models.dev/api.json"; const MODELS_DEV_PROXY_URL = "/api/openchamber/models-metadata"; @@ -61,6 +62,18 @@ interface OpenChamberDefaults { } const fetchOpenChamberDefaults = async (): Promise => { + markStartupTrace('config.defaults:start'); + const started = typeof performance !== 'undefined' ? performance.now() : Date.now(); + const finish = (source: string, result: OpenChamberDefaults) => { + const ended = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace('config.defaults:end', { + source, + durationMs: Math.round(ended - started), + hasDefaultModel: Boolean(result.defaultModel), + hasDefaultAgent: Boolean(result.defaultAgent), + }); + return result; + }; try { // 1. Runtime settings API (VSCode) const runtimeSettings = getRegisteredRuntimeAPIs()?.settings; @@ -86,7 +99,7 @@ const fetchOpenChamberDefaults = async (): Promise => { const sttSilenceThresholdDb = normalizeSttSilenceThresholdDb(data?.sttSilenceThresholdDb); const sttSilenceHoldMs = normalizeSttSilenceHoldMs(data?.sttSilenceHoldMs); - return { + return finish('runtime-settings', { defaultModel: defaultModel.length > 0 ? defaultModel : undefined, defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined, defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, @@ -101,7 +114,7 @@ const fetchOpenChamberDefaults = async (): Promise => { sttLanguage, sttSilenceThresholdDb, sttSilenceHoldMs, - }; + }); } } catch { // Fall through to fetch @@ -114,7 +127,7 @@ const fetchOpenChamberDefaults = async (): Promise => { headers: { Accept: 'application/json' }, }); if (!response.ok) { - return {}; + return finish('settings-route-not-ok', {}); } const data = await response.json(); const defaultModel = typeof data?.defaultModel === 'string' ? data.defaultModel.trim() : ''; @@ -134,7 +147,7 @@ const fetchOpenChamberDefaults = async (): Promise => { const sttSilenceThresholdDb = normalizeSttSilenceThresholdDb(data?.sttSilenceThresholdDb); const sttSilenceHoldMs = normalizeSttSilenceHoldMs(data?.sttSilenceHoldMs); - return { + return finish('settings-route', { defaultModel: defaultModel.length > 0 ? defaultModel : undefined, defaultVariant: defaultVariant.length > 0 ? defaultVariant : undefined, defaultAgent: defaultAgent.length > 0 ? defaultAgent : undefined, @@ -149,9 +162,10 @@ const fetchOpenChamberDefaults = async (): Promise => { sttLanguage, sttSilenceThresholdDb, sttSilenceHoldMs, - }; - } catch { - return {}; + }); + } catch (error) { + markStartupTrace('config.defaults:error', { error: error instanceof Error ? error.message : String(error) }); + return finish('error', {}); } }; @@ -458,9 +472,11 @@ const ensureModelsMetadataFetch = ( return; } - modelsMetadataInFlight = fetchModelsDevMetadata() + markStartupTrace('modelsMetadata:queued'); + modelsMetadataInFlight = measureStartupTrace('modelsMetadata', fetchModelsDevMetadata) .then((metadata) => { if (metadata.size > 0) { + markStartupTrace('modelsMetadata:set', { entries: metadata.size }); setModelsMetadata(metadata); } return metadata; @@ -602,8 +618,8 @@ interface ConfigStore { activateDirectory: (directory: string | null | undefined) => Promise; - loadProviders: (options?: { directory?: string | null }) => Promise; - loadAgents: (options?: { directory?: string | null }) => Promise; + loadProviders: (options?: { directory?: string | null; source?: string }) => Promise; + loadAgents: (options?: { directory?: string | null; source?: string }) => Promise; invalidateModelMetadataCache: () => void; setProvider: (providerId: string) => void; setModel: (modelId: string) => void; @@ -906,10 +922,14 @@ export const useConfigStore = create()( })(), activateDirectory: async (directory) => { const directoryKey = toDirectoryKey(directory); + let snapshotHadProviders = false; + let snapshotHadAgents = false; set((state) => { const snapshot = state.directoryScoped[directoryKey]; if (snapshot) { + snapshotHadProviders = snapshot.providers.length > 0; + snapshotHadAgents = snapshot.agents.length > 0; return { activeDirectoryKey: directoryKey, providers: snapshot.providers, @@ -941,18 +961,36 @@ export const useConfigStore = create()( return; } - await get().loadProviders({ directory: fromDirectoryKey(directoryKey) }); - await get().loadAgents({ directory: fromDirectoryKey(directoryKey) }); + if (snapshotHadProviders) { + markStartupTrace('activateDirectory:skipProviders', { directoryKey }); + } else { + await get().loadProviders({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory' }); + } + + if (snapshotHadAgents) { + markStartupTrace('activateDirectory:skipAgents', { directoryKey }); + } else { + await get().loadAgents({ directory: fromDirectoryKey(directoryKey), source: 'activateDirectory' }); + } }, loadProviders: async (options) => { - const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey)); + const requestedDirectory = options?.directory ?? fromDirectoryKey(get().activeDirectoryKey); + const effectiveDirectory = requestedDirectory ?? opencodeClient.getDirectory() ?? null; + const directoryKey = toDirectoryKey(requestedDirectory); + const source = options?.source ?? 'unknown'; + markStartupTrace('loadProviders:called', { directoryKey, source, requestedDirectory, effectiveDirectory }); // Dedup: if a load is already in-flight for this directory, reuse it const existing = _inFlightProviders.get(directoryKey); - if (existing) return existing; + if (existing) { + markStartupTrace('loadProviders:deduped', { directoryKey, source, requestedDirectory, effectiveDirectory }); + return existing; + } const promise = (async () => { + const loaderStarted = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace('loadProviders:start', { directoryKey, source, requestedDirectory, effectiveDirectory }); const existingSnapshot = get().directoryScoped[directoryKey]; const previousProviders = existingSnapshot?.providers ?? (get().activeDirectoryKey === directoryKey ? get().providers : []); const previousDefaults = existingSnapshot?.defaultProviders ?? (get().activeDirectoryKey === directoryKey ? get().defaultProviders : {}); @@ -964,9 +1002,13 @@ export const useConfigStore = create()( () => get().modelsMetadata, (metadata) => set({ modelsMetadata: metadata }), ); - const apiResult = await opencodeClient.withDirectory( - fromDirectoryKey(directoryKey), - () => opencodeClient.getProviders() + const apiResult = await measureStartupTrace( + 'loadProviders:api', + () => opencodeClient.withDirectory( + fromDirectoryKey(directoryKey), + () => opencodeClient.getProviders() + ), + { directoryKey, source, requestedDirectory, effectiveDirectory, attempt: attempt + 1 }, ); const providers = Array.isArray(apiResult?.providers) ? apiResult.providers : []; const defaults = apiResult?.default || {}; @@ -1036,15 +1078,40 @@ export const useConfigStore = create()( return nextState; }); + const loaderEnded = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace('loadProviders:end', { + directoryKey, + source, + requestedDirectory, + effectiveDirectory, + durationMs: Math.round(loaderEnded - loaderStarted), + providers: processedProviders.length, + models: processedProviders.reduce((count, provider) => count + provider.models.length, 0), + }); return; } catch (error) { lastError = error; + markStartupTrace('loadProviders:attemptError', { + directoryKey, + source, + requestedDirectory, + effectiveDirectory, + attempt: attempt + 1, + error: error instanceof Error ? error.message : String(error), + }); const waitMs = 200 * (attempt + 1); await new Promise((resolve) => setTimeout(resolve, waitMs)); } } console.error("Failed to load providers:", lastError); + markStartupTrace('loadProviders:error', { + directoryKey, + source, + requestedDirectory, + effectiveDirectory, + error: lastError instanceof Error ? lastError.message : String(lastError), + }); set((state) => { const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { @@ -1314,13 +1381,22 @@ export const useConfigStore = create()( }, loadAgents: async (options) => { - const directoryKey = toDirectoryKey(options?.directory ?? fromDirectoryKey(get().activeDirectoryKey)); + const requestedDirectory = options?.directory ?? fromDirectoryKey(get().activeDirectoryKey); + const effectiveDirectory = requestedDirectory ?? opencodeClient.getDirectory() ?? null; + const directoryKey = toDirectoryKey(requestedDirectory); + const source = options?.source ?? 'unknown'; + markStartupTrace('loadAgents:called', { directoryKey, source, requestedDirectory, effectiveDirectory }); // Dedup: if a load is already in-flight for this directory, reuse it const existing = _inFlightAgents.get(directoryKey); - if (existing) return existing; + if (existing) { + markStartupTrace('loadAgents:deduped', { directoryKey, source, requestedDirectory, effectiveDirectory }); + return existing; + } const promise = (async (): Promise => { + const loaderStarted = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace('loadAgents:start', { directoryKey, source, requestedDirectory, effectiveDirectory }); const existingSnapshot = get().directoryScoped[directoryKey]; const previousAgents = existingSnapshot?.agents ?? (get().activeDirectoryKey === directoryKey ? get().agents : []); let lastError: unknown = null; @@ -1329,12 +1405,22 @@ export const useConfigStore = create()( try { // Fetch agents and OpenChamber settings in parallel const [agents, openChamberDefaults] = await Promise.all([ - opencodeClient.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.listAgents()), + measureStartupTrace( + 'loadAgents:api', + () => opencodeClient.withDirectory(fromDirectoryKey(directoryKey), () => opencodeClient.listAgents()), + { directoryKey, source, requestedDirectory, effectiveDirectory, attempt: attempt + 1 }, + ), fetchOpenChamberDefaults(), ]); const safeAgents = Array.isArray(agents) ? agents : []; + const providerLoad = _inFlightProviders.get(directoryKey); + if (providerLoad) { + markStartupTrace('loadAgents:awaitProviders', { directoryKey, source }); + await providerLoad; + } + const providers = get().activeDirectoryKey === directoryKey ? get().providers : (get().directoryScoped[directoryKey]?.providers ?? []); @@ -1452,6 +1538,15 @@ export const useConfigStore = create()( return nextState; }); + const loaderEnded = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace('loadAgents:end', { + directoryKey, + source, + requestedDirectory, + effectiveDirectory, + durationMs: Math.round(loaderEnded - loaderStarted), + agents: safeAgents.length, + }); return true; } @@ -1592,15 +1687,39 @@ export const useConfigStore = create()( }); } + const loaderEnded = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace('loadAgents:end', { + directoryKey, + source, + requestedDirectory, + effectiveDirectory, + durationMs: Math.round(loaderEnded - loaderStarted), + agents: safeAgents.length, + }); return true; } catch (error) { lastError = error; + markStartupTrace('loadAgents:attemptError', { + directoryKey, + source, + requestedDirectory, + effectiveDirectory, + attempt: attempt + 1, + error: error instanceof Error ? error.message : String(error), + }); const waitMs = 200 * (attempt + 1); await new Promise((resolve) => setTimeout(resolve, waitMs)); } } console.error("Failed to load agents:", lastError); + markStartupTrace('loadAgents:error', { + directoryKey, + source, + requestedDirectory, + effectiveDirectory, + error: lastError instanceof Error ? lastError.message : String(lastError), + }); set((state) => { const providers = state.activeDirectoryKey === directoryKey @@ -2064,13 +2183,31 @@ export const useConfigStore = create()( }, checkConnection: async () => { + markStartupTrace('checkConnection:start'); const maxAttempts = 5; let attempt = 0; let lastError: unknown = null; while (attempt < maxAttempts) { try { - const isHealthy = await opencodeClient.checkHealth(); + markStartupTrace('checkConnection:attempt', { attempt: attempt + 1 }); + const isHealthy = await measureStartupTrace( + 'checkConnection:health', + () => opencodeClient.checkHealth(), + { attempt: attempt + 1 }, + ); + if (!isHealthy && attempt < maxAttempts - 1) { + const hasEverConnected = get().hasEverConnected; + set({ + isConnected: false, + connectionPhase: hasEverConnected ? "reconnecting" : "connecting", + lastDisconnectReason: 'health_check_unhealthy', + }); + attempt += 1; + await sleep(400 * attempt); + continue; + } + const hasEverConnected = get().hasEverConnected; set(isHealthy ? { isConnected: true, hasEverConnected: true, connectionPhase: "connected" } @@ -2079,6 +2216,7 @@ export const useConfigStore = create()( connectionPhase: hasEverConnected ? "reconnecting" : "connecting", lastDisconnectReason: 'health_check_unhealthy', }); + markStartupTrace('checkConnection:end', { healthy: isHealthy, attempts: attempt + 1 }); return isHealthy; } catch (error) { lastError = error; @@ -2096,15 +2234,19 @@ export const useConfigStore = create()( connectionPhase: get().hasEverConnected ? "reconnecting" : "connecting", lastDisconnectReason: 'health_check_failed', }); + markStartupTrace('checkConnection:end', { healthy: false, attempts: maxAttempts }); return false; }, initializeApp: async () => { if (_initializeAppInFlight) { + markStartupTrace('initializeApp:deduped'); return _initializeAppInFlight; } const run = (async () => { + const initStarted = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace('initializeApp:start'); try { const debug = streamDebugEnabled(); if (debug) console.log("Starting app initialization..."); @@ -2123,15 +2265,21 @@ export const useConfigStore = create()( } if (debug) console.log("Initializing app..."); - await opencodeClient.initApp(); + markStartupTrace('initApp:skipped', { reason: 'checkConnection already verified health' }); - if (debug) console.log("Loading providers..."); - await get().loadProviders(); - - if (debug) console.log("Loading agents..."); - await get().loadAgents(); + if (debug) console.log("Loading providers and agents..."); + await Promise.all([ + get().loadProviders({ source: 'initializeApp' }), + get().loadAgents({ source: 'initializeApp' }), + ]); set({ isInitialized: true, isConnected: true, hasEverConnected: true, connectionPhase: "connected" }); + const initEnded = typeof performance !== 'undefined' ? performance.now() : Date.now(); + markStartupTrace('initializeApp:end', { + durationMs: Math.round(initEnded - initStarted), + providers: get().providers.length, + agents: get().agents.length, + }); if (debug) console.log("App initialized successfully"); } catch (error) { console.error("Failed to initialize app:", error); @@ -2141,6 +2289,7 @@ export const useConfigStore = create()( connectionPhase: get().hasEverConnected ? "reconnecting" : "connecting", lastDisconnectReason: 'init_error', }); + markStartupTrace('initializeApp:error', { error: error instanceof Error ? error.message : String(error) }); } })().finally(() => { _initializeAppInFlight = null; @@ -2235,16 +2384,16 @@ let unsubscribeConfigStoreChanges: (() => void) | null = null; if (!unsubscribeConfigStoreChanges) { unsubscribeConfigStoreChanges = subscribeToConfigChanges(async (event) => { - const tasks: Promise[] = []; + const tasks: Promise[] = []; if (scopeMatches(event, "agents")) { const { loadAgents } = useConfigStore.getState(); - tasks.push(loadAgents().then(() => {})); + tasks.push(loadAgents({ source: 'configChange:agents' }).then(() => {})); } if (scopeMatches(event, "providers")) { const { loadProviders } = useConfigStore.getState(); - tasks.push(loadProviders()); + tasks.push(loadProviders({ source: 'configChange:providers' })); } if (tasks.length > 0) { @@ -2263,6 +2412,7 @@ if (typeof window !== "undefined" && !unsubscribeConfigStoreDirectoryChanges) { return; } + markStartupTrace('directoryStore:changed', { previous: prevKey, next: nextKey }); void useConfigStore.getState().activateDirectory(state.currentDirectory); }); } diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index 635fe497..0faaac76 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -241,6 +241,32 @@ export async function handleSystemBridgeMessage( } } + case 'api:opencode/version': { + try { + const apiUrl = ctx?.manager?.getApiUrl(); + if (!apiUrl) { + return { id, type, success: true, data: { version: null, error: 'OpenCode manager unavailable' } }; + } + const base = `${apiUrl.replace(/\/+$/, '')}/`; + const response = await fetch(new URL('global/health', base).toString(), { + method: 'GET', + headers: { Accept: 'application/json', ...ctx?.manager?.getOpenCodeAuthHeaders() }, + }); + const health = await response.json().catch(() => null) as { version?: unknown; error?: unknown } | null; + if (!response.ok) { + const message = typeof health?.error === 'string' ? health.error : response.statusText || 'Failed to read OpenCode version'; + return { id, type, success: true, data: { version: null, error: message } }; + } + const version = typeof health?.version === 'string' && health.version.trim().length > 0 + ? health.version.trim().replace(/^v/, '') + : null; + return { id, type, success: true, data: { version } }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: true, data: { version: null, error: errorMessage } }; + } + } + case 'api:session-activity:get': { return { id, type, success: true, data: getSessionActivitySnapshot() }; } diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index d56e92a2..805425a8 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -547,7 +547,7 @@ export async function activate(context: vscode.ExtensionContext) { }; const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [ - { label: 'health', path: '/global/health', includeDirectory: false }, + { label: 'health', path: '/api/health', includeDirectory: false }, { label: 'config', path: '/config', includeDirectory: true }, { label: 'providers', path: '/config/providers', includeDirectory: true }, // Can be slower on large configs; keep the probe from producing false negatives. diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index 12a54ad5..4d716d10 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -568,7 +568,7 @@ async function waitForReady( const timeout = setTimeout(() => controller.abort(), 3000); // OpenCode readiness check. - const url = new URL(`${baseUrl}/global/health`); + const url = new URL(`${baseUrl}/api/health`); const res = await fetch(url.toString(), { method: 'GET', headers: { Accept: 'application/json', ...authHeaders }, diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index c3b907de..1756fe9c 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -968,6 +968,24 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R } } + if (pathname === '/api/opencode/version' && method === 'GET') { + try { + const data = await sendBridgeMessage('api:opencode/version'); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ version: null, error: message }), { status: 502, headers: { 'Content-Type': 'application/json' } }); + } + } + + if (pathname === '/api/opencode/health' && method === 'GET') { + const connectionStatus = window.__OPENCHAMBER_CONNECTION__?.status; + return new Response(JSON.stringify({ healthy: connectionStatus === 'connected' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (pathname === '/api/zen/models' && method === 'GET') { try { const data = await sendBridgeMessage('api:zen:models'); diff --git a/packages/web/server/lib/opencode/lifecycle.js b/packages/web/server/lib/opencode/lifecycle.js index 3e028f47..69706ba9 100644 --- a/packages/web/server/lib/opencode/lifecycle.js +++ b/packages/web/server/lib/opencode/lifecycle.js @@ -392,7 +392,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { } try { - const response = await fetch(buildOpenCodeUrl('/global/health', ''), { + const response = await fetch(buildOpenCodeUrl('/api/health', ''), { method: 'GET', headers: { Accept: 'application/json', @@ -417,7 +417,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 3000); const base = origin ?? `http://127.0.0.1:${port}`; - const response = await fetch(`${base}/global/health`, { + const response = await fetch(`${base}/api/health`, { method: 'GET', headers: { Accept: 'application/json', @@ -659,51 +659,40 @@ export const createOpenCodeLifecycleRuntime = (deps) => { let lastError = null; while (Date.now() < deadline) { + let timeout = null; try { - const [configResult, agentResult] = await Promise.all([ - fetch(buildOpenCodeUrl('/config', ''), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, - }).catch((error) => error), - fetch(buildOpenCodeUrl('/agent', ''), { - method: 'GET', - headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, - }).catch((error) => error), - ]); + const controller = new AbortController(); + timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS); + const response = await fetch(buildOpenCodeUrl('/api/health', ''), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + signal: controller.signal, + }); + clearTimeout(timeout); + timeout = null; - if (configResult instanceof Error) { - lastError = configResult; + if (!response.ok) { + lastError = new Error(`OpenCode health endpoint responded with status ${response.status}`); await new Promise((resolve) => setTimeout(resolve, intervalMs)); continue; } - if (!configResult.ok) { - lastError = new Error(`OpenCode config endpoint responded with status ${configResult.status}`); + const body = await response.json().catch(() => null); + if (body?.healthy !== true) { + lastError = new Error('OpenCode health endpoint returned unhealthy response'); await new Promise((resolve) => setTimeout(resolve, intervalMs)); continue; } - await configResult.json().catch(() => null); - - if (agentResult instanceof Error) { - lastError = agentResult; - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - continue; - } - - if (!agentResult.ok) { - lastError = new Error(`Agent endpoint responded with status ${agentResult.status}`); - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - continue; - } - - await agentResult.json().catch(() => []); - state.isOpenCodeReady = true; state.lastOpenCodeError = null; return; } catch (error) { lastError = error; + } finally { + if (timeout) { + clearTimeout(timeout); + } } await new Promise((resolve) => setTimeout(resolve, intervalMs)); diff --git a/packages/web/server/lib/opencode/network-runtime.js b/packages/web/server/lib/opencode/network-runtime.js index a9094e46..896ff431 100644 --- a/packages/web/server/lib/opencode/network-runtime.js +++ b/packages/web/server/lib/opencode/network-runtime.js @@ -33,7 +33,7 @@ export const createOpenCodeNetworkRuntime = (deps) => { try { const controller = new AbortController(); timeout = setTimeout(() => controller.abort(), 3000); - const response = await fetch(`${url.replace(/\/+$/, '')}/global/health`, { + const response = await fetch(`${url.replace(/\/+$/, '')}/api/health`, { method: 'GET', headers: { Accept: 'application/json', diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index 151977a4..ebcc6eff 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -212,6 +212,51 @@ export const registerOpenCodeRoutes = (app, dependencies) => { } }); + app.get('/api/opencode/health', async (_req, res) => { + try { + const healthResponse = await fetch(buildOpenCodeUrl('/api/health', ''), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }); + const health = await healthResponse.json().catch(() => null); + if (!healthResponse.ok) { + return res.status(healthResponse.status).json({ + healthy: false, + error: health?.error || healthResponse.statusText || 'OpenCode health check failed', + }); + } + return res.json({ healthy: health?.healthy === true }); + } catch (error) { + return res.status(503).json({ + healthy: false, + error: error instanceof Error ? error.message : 'OpenCode health check failed', + }); + } + }); + + app.get('/api/opencode/version', async (_req, res) => { + try { + const healthResponse = await fetch(buildOpenCodeUrl('/global/health', ''), { + method: 'GET', + headers: { Accept: 'application/json', ...getOpenCodeAuthHeaders() }, + }); + const health = await healthResponse.json().catch(() => null); + if (!healthResponse.ok) { + return res.status(healthResponse.status).json({ + version: null, + error: health?.error || healthResponse.statusText || 'Failed to read OpenCode version', + }); + } + const version = typeof health?.version === 'string' ? health.version.replace(/^v/, '') : null; + return res.json({ version }); + } catch (error) { + return res.status(500).json({ + version: null, + error: error instanceof Error ? error.message : 'Failed to read OpenCode version', + }); + } + }); + app.put('/api/config/settings', async (req, res) => { console.log('[API:PUT /api/config/settings] Received request'); try {