Fix startup connection and chunk recovery
This commit is contained in:
+47
-2
@@ -55,9 +55,10 @@ import { TooltipProvider } from '@/components/ui/tooltip';
|
|||||||
import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog';
|
import { QuickOpenDialog } from '@/components/ui/QuickOpenDialog';
|
||||||
import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage';
|
import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage';
|
||||||
import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
|
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.
|
// 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 })),
|
import('@/components/onboarding/OnboardingScreen').then((m) => ({ default: m.OnboardingScreen })),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -206,6 +207,7 @@ function App({ apis }: AppProps) {
|
|||||||
: null;
|
: null;
|
||||||
});
|
});
|
||||||
const appReadyDispatchedRef = React.useRef(false);
|
const appReadyDispatchedRef = React.useRef(false);
|
||||||
|
const initializationInFlightRef = React.useRef(false);
|
||||||
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
|
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
|
||||||
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
|
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
|
||||||
const isMcpOAuthCallback = React.useMemo(() => isMcpOAuthCallbackPath(), []);
|
const isMcpOAuthCallback = React.useMemo(() => isMcpOAuthCallbackPath(), []);
|
||||||
@@ -345,12 +347,55 @@ function App({ apis }: AppProps) {
|
|||||||
if (isVSCodeRuntime) {
|
if (isVSCodeRuntime) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await initializeApp();
|
if (initializationInFlightRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initializationInFlightRef.current = true;
|
||||||
|
try {
|
||||||
|
await initializeApp();
|
||||||
|
} finally {
|
||||||
|
initializationInFlightRef.current = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
init();
|
init();
|
||||||
}, [initializeApp, isVSCodeRuntime]);
|
}, [initializeApp, isVSCodeRuntime]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isVSCodeRuntime || isInitialized) return;
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | 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.
|
// Startup recovery: poll until providers AND agents are loaded.
|
||||||
// loadProviders/loadAgents resolve normally even on failure (errors swallowed),
|
// loadProviders/loadAgents resolve normally even on failure (errors swallowed),
|
||||||
// so a reactive effect can't detect failure — we need an interval.
|
// so a reactive effect can't detect failure — we need an interval.
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ import { filterVisibleParts } from './message/partUtils';
|
|||||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||||
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
import type { TurnGroupingContext } from './lib/turns/types';
|
import type { TurnGroupingContext } from './lib/turns/types';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||||
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare';
|
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 EXPANDED_TOOLS_CACHE_MAX = 4000;
|
||||||
const expandedToolsStateCache = new Map<string, Set<string>>();
|
const expandedToolsStateCache = new Map<string, Set<string>>();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
|
|
||||||
// Thin lazy wrapper around the heavy MarkdownRenderer implementation.
|
// Thin lazy wrapper around the heavy MarkdownRenderer implementation.
|
||||||
// The full implementation (marked, react-markdown, beautiful-mermaid,
|
// The full implementation (marked, react-markdown, beautiful-mermaid,
|
||||||
@@ -8,11 +9,11 @@ import React from 'react';
|
|||||||
export type { MarkdownVariant } from './MarkdownRendererImpl';
|
export type { MarkdownVariant } from './MarkdownRendererImpl';
|
||||||
|
|
||||||
|
|
||||||
const MarkdownRendererLazy = React.lazy(() =>
|
const MarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
||||||
import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer }))
|
import('./MarkdownRendererImpl').then((m) => ({ default: m.MarkdownRenderer }))
|
||||||
);
|
);
|
||||||
|
|
||||||
const SimpleMarkdownRendererLazy = React.lazy(() =>
|
const SimpleMarkdownRendererLazy = lazyWithChunkRecovery(() =>
|
||||||
import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer }))
|
import('./MarkdownRendererImpl').then((m) => ({ default: m.SimpleMarkdownRenderer }))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -22,18 +22,19 @@ import { useDeviceInfo } from '@/lib/device';
|
|||||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isDesktopShell } from '@/lib/desktop';
|
import { isDesktopShell } from '@/lib/desktop';
|
||||||
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
|
|
||||||
import { ChatView } from '@/components/views';
|
import { ChatView } from '@/components/views';
|
||||||
|
|
||||||
// Heavy views loaded on-demand to reduce initial bundle parse time.
|
// 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 PlanView = lazyWithChunkRecovery(() => import('@/components/views/PlanView').then(m => ({ default: m.PlanView })));
|
||||||
const GitView = React.lazy(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
|
const GitView = lazyWithChunkRecovery(() => import('@/components/views/GitView').then(m => ({ default: m.GitView })));
|
||||||
const DiffView = React.lazy(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
|
const DiffView = lazyWithChunkRecovery(() => import('@/components/views/DiffView').then(m => ({ default: m.DiffView })));
|
||||||
const TerminalView = React.lazy(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
|
const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView })));
|
||||||
const FilesView = React.lazy(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
|
const FilesView = lazyWithChunkRecovery(() => import('@/components/views/FilesView').then(m => ({ default: m.FilesView })));
|
||||||
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 SettingsWindow = React.lazy(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
||||||
const MultiRunWindow = React.lazy(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
|
const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
|
||||||
|
|
||||||
// Mobile drawer width as screen percentage
|
// Mobile drawer width as screen percentage
|
||||||
const MOBILE_DRAWER_WIDTH_PERCENT = 85;
|
const MOBILE_DRAWER_WIDTH_PERCENT = 85;
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ import { PaceIndicator } from '@/components/sections/usage/PaceIndicator';
|
|||||||
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS, calculatePace, calculateExpectedUsagePercent } from '@/lib/quota';
|
||||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
import type { UsageWindow } from '@/types';
|
import type { UsageWindow } from '@/types';
|
||||||
import { RiAddLine, RiArrowLeftLine, RiRefreshLine, RiRobot2Line, RiSettings3Line, RiTimerLine } from '@remixicon/react';
|
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) => {
|
const formatTime = (timestamp: number | null) => {
|
||||||
if (!timestamp) return '-';
|
if (!timestamp) return '-';
|
||||||
|
|||||||
@@ -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<T>(
|
||||||
|
load: () => Promise<T>,
|
||||||
|
options: { retries?: number } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
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));
|
||||||
@@ -426,6 +426,14 @@ const ensureModelsMetadataFetch = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
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<boolean> => {
|
||||||
|
return Promise.race([
|
||||||
|
opencodeClient.checkHealth().catch(() => false),
|
||||||
|
sleep(Math.max(1, timeoutMs)).then(() => false),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
const DIRECTORY_KEY_GLOBAL = "__global__";
|
const DIRECTORY_KEY_GLOBAL = "__global__";
|
||||||
|
|
||||||
@@ -560,6 +568,7 @@ interface ConfigStore {
|
|||||||
getResolvedGitGenerationModel: () => { providerId: string; modelId: string } | null;
|
getResolvedGitGenerationModel: () => { providerId: string; modelId: string } | null;
|
||||||
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
|
saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void;
|
||||||
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
|
getAgentModelSelection: (agentName: string) => { providerId: string; modelId: string } | null;
|
||||||
|
probeConnection: (options?: { timeoutMs?: number }) => Promise<boolean>;
|
||||||
checkConnection: () => Promise<boolean>;
|
checkConnection: () => Promise<boolean>;
|
||||||
initializeApp: () => Promise<void>;
|
initializeApp: () => Promise<void>;
|
||||||
getCurrentProvider: () => ProviderWithModelList | undefined;
|
getCurrentProvider: () => ProviderWithModelList | undefined;
|
||||||
@@ -1899,6 +1908,26 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
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 () => {
|
checkConnection: async () => {
|
||||||
const maxAttempts = 5;
|
const maxAttempts = 5;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
|
|||||||
@@ -67,14 +67,20 @@ function connectionLostError(): Error {
|
|||||||
// Wait briefly for the pipeline to re-establish connection before failing a
|
// Wait briefly for the pipeline to re-establish connection before failing a
|
||||||
// send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network
|
// send. Transient reconnects (heartbeat race, WS→SSE fallback, brief network
|
||||||
// blip) otherwise surface as a hard "Connection lost" toast even though the
|
// 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
|
const CONNECTION_GRACE_MS = 2000
|
||||||
export async function waitForConnectionOrThrow(): Promise<void> {
|
export async function waitForConnectionOrThrow(): Promise<void> {
|
||||||
if (useConfigStore.getState().isConnected) return
|
|
||||||
const deadline = Date.now() + CONNECTION_GRACE_MS
|
const deadline = Date.now() + CONNECTION_GRACE_MS
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
|
||||||
if (useConfigStore.getState().isConnected) return
|
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()
|
throw connectionLostError()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user