diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 23ebf108..388d0b88 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -55,9 +55,10 @@ import { TooltipProvider } from '@/components/ui/tooltip'; import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog'; import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage'; import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth'; +import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; // Lazy-loaded heavy views — loaded on demand to reduce initial bundle size. -const OnboardingScreen = React.lazy(() => +const OnboardingScreen = lazyWithChunkRecovery(() => import('@/components/onboarding/OnboardingScreen').then((m) => ({ default: m.OnboardingScreen })), ); @@ -206,6 +207,7 @@ function App({ apis }: AppProps) { : null; }); const appReadyDispatchedRef = React.useRef(false); + const initializationInFlightRef = React.useRef(false); const embeddedSessionChat = React.useMemo(() => readEmbeddedSessionChatConfig(), []); const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible; const isMcpOAuthCallback = React.useMemo(() => isMcpOAuthCallbackPath(), []); @@ -345,12 +347,55 @@ function App({ apis }: AppProps) { if (isVSCodeRuntime) { return; } - await initializeApp(); + if (initializationInFlightRef.current) { + return; + } + initializationInFlightRef.current = true; + try { + await initializeApp(); + } finally { + initializationInFlightRef.current = false; + } }; init(); }, [initializeApp, isVSCodeRuntime]); + React.useEffect(() => { + if (isVSCodeRuntime || isInitialized) return; + + let active = true; + let retryTimer: ReturnType | undefined; + + const retryInitialization = async () => { + if (!active) return; + const state = useConfigStore.getState(); + if (state.isInitialized) return; + if (initializationInFlightRef.current) { + retryTimer = setTimeout(retryInitialization, 1000); + return; + } + + initializationInFlightRef.current = true; + try { + await state.initializeApp(); + } finally { + initializationInFlightRef.current = false; + } + + const next = useConfigStore.getState(); + if (!active || next.isInitialized) return; + retryTimer = setTimeout(retryInitialization, 1000); + }; + + retryTimer = setTimeout(retryInitialization, 1000); + + return () => { + active = false; + if (retryTimer) clearTimeout(retryTimer); + }; + }, [isInitialized, isVSCodeRuntime]); + // Startup recovery: poll until providers AND agents are loaded. // loadProviders/loadAgents resolve normally even on failure (errors swallowed), // so a reactive effect can't detect failure — we need an interval. diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index f8a5fd7e..8a9e4ab0 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -26,13 +26,14 @@ import { filterVisibleParts } from './message/partUtils'; import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts'; import { flattenAssistantTextParts } from '@/lib/messages/messageText'; import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError'; +import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import type { TurnGroupingContext } from './lib/turns/types'; import { copyTextToClipboard } from '@/lib/clipboard'; import { FadeInOnReveal } from './message/FadeInOnReveal'; import { streamPerfCount } from '@/stores/utils/streamDebug'; import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare'; -const ToolOutputDialog = React.lazy(() => import('./message/ToolOutputDialog')); +const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog')); const EXPANDED_TOOLS_CACHE_MAX = 4000; const expandedToolsStateCache = new Map>(); diff --git a/packages/ui/src/components/chat/MarkdownRenderer.tsx b/packages/ui/src/components/chat/MarkdownRenderer.tsx index 94ead794..51afc5e4 100644 --- a/packages/ui/src/components/chat/MarkdownRenderer.tsx +++ b/packages/ui/src/components/chat/MarkdownRenderer.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; // Thin lazy wrapper around the heavy MarkdownRenderer implementation. // The full implementation (marked, react-markdown, beautiful-mermaid, @@ -8,11 +9,11 @@ import React from 'react'; export type { MarkdownVariant } from './MarkdownRendererImpl'; -const MarkdownRendererLazy = React.lazy(() => +const MarkdownRendererLazy = lazyWithChunkRecovery(() => import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer })) ); -const SimpleMarkdownRendererLazy = React.lazy(() => +const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() => import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer })) ); diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 135e3161..e47c1e45 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -22,18 +22,19 @@ import { useDeviceInfo } from '@/lib/device'; import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { cn } from '@/lib/utils'; import { isDesktopShell } from '@/lib/desktop'; +import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import { ChatView } from '@/components/views'; // Heavy views loaded on-demand to reduce initial bundle parse time. -const PlanView = React.lazy(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView }))); -const GitView = React.lazy(() => import('@/components/views/GitView').then(m => ({ default: m.GitView }))); -const DiffView = React.lazy(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView }))); -const TerminalView = React.lazy(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView }))); -const FilesView = React.lazy(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView }))); -const SettingsView = React.lazy(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); -const SettingsWindow = React.lazy(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); -const MultiRunWindow = React.lazy(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow }))); +const PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView }))); +const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView }))); +const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView }))); +const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView }))); +const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView }))); +const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); +const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); +const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow }))); // Mobile drawer width as screen percentage const MOBILE_DRAWER_WIDTH_PERCENT = 85; diff --git a/packages/ui/src/components/layout/VSCodeLayout.tsx b/packages/ui/src/components/layout/VSCodeLayout.tsx index 46fa223c..418c6ac0 100644 --- a/packages/ui/src/components/layout/VSCodeLayout.tsx +++ b/packages/ui/src/components/layout/VSCodeLayout.tsx @@ -24,10 +24,11 @@ import { PaceIndicator } from '@/components/sections/usage/PaceIndicator'; import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { updateDesktopSettings } from '@/lib/persistence'; +import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery'; import type { UsageWindow } from '@/types'; import { RiAddLine, RiArrowLeftLine, RiRefreshLine, RiRobot2Line, RiSettings3Line, RiTimerLine } from '@remixicon/react'; -const SettingsView = React.lazy(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); +const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); const formatTime = (timestamp: number | null) => { if (!timestamp) return '-'; diff --git a/packages/ui/src/lib/chunkLoadRecovery.ts b/packages/ui/src/lib/chunkLoadRecovery.ts new file mode 100644 index 00000000..87666eae --- /dev/null +++ b/packages/ui/src/lib/chunkLoadRecovery.ts @@ -0,0 +1,93 @@ +import { lazy } from 'react'; + +declare const __APP_VERSION__: string | undefined; + +const RELOAD_STORAGE_KEY = 'openchamber:chunk-import-reload'; +const RETRY_DELAY_MS = 250; +const RELOAD_GUARD_MS = 30_000; + +const DYNAMIC_IMPORT_ERROR_PATTERNS = [ + /Importing a module script failed/i, + /Failed to fetch dynamically imported module/i, + /error loading dynamically imported module/i, + /Loading chunk \S+ failed/i, + /ChunkLoadError/i, +]; + +function readErrorText(error: unknown): string { + if (error instanceof Error) { + return `${error.name}\n${error.message}\n${error.stack ?? ''}`; + } + + if (typeof error === 'string') { + return error; + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function isDynamicImportError(error: unknown): boolean { + const text = readErrorText(error); + return DYNAMIC_IMPORT_ERROR_PATTERNS.some((pattern) => pattern.test(text)); +} + +function reloadMarkerSignature(error: unknown): string { + const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'; + return `${appVersion || 'unknown'}:${readErrorText(error).slice(0, 500)}`; +} + +function scheduleReloadOnce(error: unknown): void { + if (typeof window === 'undefined') return; + + const now = Date.now(); + const signature = reloadMarkerSignature(error); + + try { + const rawMarker = window.sessionStorage.getItem(RELOAD_STORAGE_KEY); + const marker = rawMarker ? JSON.parse(rawMarker) as { signature?: unknown; timestamp?: unknown } : null; + const markerTimestamp = typeof marker?.timestamp === 'number' ? marker.timestamp : 0; + if (marker?.signature === signature && now - markerTimestamp < RELOAD_GUARD_MS) { + return; + } + window.sessionStorage.setItem(RELOAD_STORAGE_KEY, JSON.stringify({ signature, timestamp: now })); + } catch { + return; + } + + window.setTimeout(() => { + window.location.reload(); + }, 0); +} + +export async function importWithChunkRecovery( + load: () => Promise, + options: { retries?: number } = {}, +): Promise { + const retries = options.retries ?? 1; + let lastError: unknown; + + for (let attempt = 0; attempt <= retries; attempt += 1) { + try { + return await load(); + } catch (error) { + lastError = error; + if (!isDynamicImportError(error) || attempt >= retries) { + break; + } + + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1))); + } + } + + if (isDynamicImportError(lastError)) { + scheduleReloadOnce(lastError); + } + + throw lastError; +} + +export const lazyWithChunkRecovery: typeof lazy = (load) => lazy(() => importWithChunkRecovery(load)); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index e139b01b..4cbd5cbb 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -426,6 +426,14 @@ const ensureModelsMetadataFetch = ( }; const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const CONNECTION_PROBE_TIMEOUT_MS = 800; + +const probeOpenCodeHealth = async (timeoutMs = CONNECTION_PROBE_TIMEOUT_MS): Promise => { + return Promise.race([ + opencodeClient.checkHealth().catch(() => false), + sleep(Math.max(1, timeoutMs)).then(() => false), + ]); +}; const DIRECTORY_KEY_GLOBAL = "__global__"; @@ -560,6 +568,7 @@ interface ConfigStore { getResolvedGitGenerationModel: () => { providerId: string; modelId: string } | null; saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void; getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null; + probeConnection: (options?: { timeoutMs?: number }) => Promise; checkConnection: () => Promise; initializeApp: () => Promise; getCurrentProvider: () => ProviderWithModelList | undefined; @@ -1899,6 +1908,26 @@ export const useConfigStore = create()( } }, + probeConnection: async (options?: { timeoutMs?: number }) => { + const isHealthy = await probeOpenCodeHealth(options?.timeoutMs); + if (isHealthy) { + set({ isConnected: true, hasEverConnected: true, connectionPhase: "connected" }); + return true; + } + + const state = get(); + if (state.isConnected) { + return true; + } + + set({ + isConnected: false, + connectionPhase: state.hasEverConnected ? "reconnecting" : "connecting", + lastDisconnectReason: 'health_probe_unhealthy', + }); + return false; + }, + checkConnection: async () => { const maxAttempts = 5; let attempt = 0; diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 4217b81a..9d78638c 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -67,14 +67,20 @@ function connectionLostError(): Error { // Wait briefly for the pipeline to re-establish connection before failing a // send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network // blip) otherwise surface as a hard "Connection lost" toast even though the -// pipeline recovers within a second. Poll isConnected at 100ms intervals. +// pipeline recovers within a second. While waiting, run bounded health probes +// inside the same grace window so stale disconnected state can recover quickly. const CONNECTION_GRACE_MS = 2000 export async function waitForConnectionOrThrow(): Promise { - if (useConfigStore.getState().isConnected) return const deadline = Date.now() + CONNECTION_GRACE_MS while (Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 100)) if (useConfigStore.getState().isConnected) return + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) break + if (await useConfigStore.getState().probeConnection({ timeoutMs: Math.min(500, remainingMs) })) return + const sleepMs = Math.min(100, deadline - Date.now()) + if (sleepMs > 0) { + await new Promise((resolve) => setTimeout(resolve, sleepMs)) + } } throw connectionLostError() }