Files
openchamber/packages/ui/src/App.tsx
T
Bohdan Triapitsyn 87d13cf61b fix: enable web runtime notifications without push (#1322)
Web notifications now listen to the notification stream directly
Native notifications no longer depend on PWA push being enabled
Window focus is respected when deciding whether to send background push notifications
2026-05-19 13:16:47 +03:00

886 lines
30 KiB
TypeScript

import React from 'react';
import { MainLayout } from '@/components/layout/MainLayout';
import { ChatView } from '@/components/views/ChatView';
import { FireworksProvider } from '@/contexts/FireworksContext';
import { Toaster } from '@/components/ui/sonner';
import { Button } from '@/components/ui/button';
import { MemoryDebugPanel } from '@/components/ui/MemoryDebugPanel';
import { setStreamPerfEnabled } from '@/stores/utils/streamDebug';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
// useEventStream removed — replaced by SyncProvider + SyncBridge
import { useMenuActions } from '@/hooks/useMenuActions';
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
import { useRouter } from '@/hooks/useRouter';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { useConfigStore } from '@/stores/useConfigStore';
import { hasModifier } from '@/lib/utils';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, restartDesktopApp } from '@/lib/desktop';
import {
getInjectedBootOutcome,
getBootInjectionStatus,
resolveDesktopBootView,
canDismissInitialLoading,
shouldRestartDesktopBootFlow,
type BootInjectionStatus,
type DesktopBootView,
} from '@/lib/desktopBoot';
import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionRecovery';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { SyncProvider } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { AboutDialog } from '@/components/ui/AboutDialog';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { VoiceProvider } from '@/components/voice';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
import type { RuntimeAPIs } from '@/lib/api/types';
import { TooltipProvider } from '@/components/ui/tooltip';
import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage';
import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import { useI18n } from '@/lib/i18n';
import { applyMobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import { SyncAppEffects } from '@/apps/AppEffects';
import { useAppFontEffects } from '@/apps/useAppFontEffects';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
// Lazy-loaded heavy views — loaded on demand to reduce initial bundle size.
const OnboardingScreen = lazyWithChunkRecovery(() =>
import('@/components/onboarding/OnboardingScreen').then((m) => ({ default: m.OnboardingScreen })),
);
const AboutDialogWrapper: React.FC = () => {
const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
return (
<AboutDialog
open={isAboutDialogOpen}
onOpenChange={setAboutDialogOpen}
/>
);
};
const StartupInitializationRecovery: React.FC<{
onRetry: () => void;
isRetrying: boolean;
}> = ({ onRetry, isRetrying }) => {
const { t } = useI18n();
return (
<div className="flex h-full items-center justify-center bg-background px-6 text-foreground">
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex flex-col gap-2">
<h1 className="typography-title text-foreground">{t('startup.initRecovery.title')}</h1>
<p className="typography-body text-muted-foreground">{t('startup.initRecovery.description')}</p>
</div>
<Button type="button" onClick={onRetry} disabled={isRetrying}>
{isRetrying ? t('startup.initRecovery.retrying') : t('startup.initRecovery.retry')}
</Button>
</div>
</div>
);
};
type AppProps = {
apis: RuntimeAPIs;
};
type EmbeddedSessionChatConfig = {
sessionId: string;
directory: string | null;
readOnly: boolean;
};
type EmbeddedVisibilityPayload = {
visible?: unknown;
};
const normalizeEmbeddedDirectory = (value: string | null | undefined): string => {
if (!value) return '';
return value.replace(/\\/g, '/').replace(/\/+$/g, '');
};
const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
if (typeof window === 'undefined') {
return null;
}
const params = new URLSearchParams(window.location.search);
if (params.get('ocPanel') !== 'session-chat') {
return null;
}
const sessionIdRaw = params.get('sessionId');
const sessionId = typeof sessionIdRaw === 'string' ? sessionIdRaw.trim() : '';
if (!sessionId) {
return null;
}
const directoryRaw = params.get('directory');
const directory = typeof directoryRaw === 'string' && directoryRaw.trim().length > 0
? directoryRaw.trim()
: null;
return {
sessionId,
directory,
readOnly: params.get('readOnly') === '1' || params.get('readOnly') === 'true',
};
};
const isMcpOAuthCallbackPath = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
return window.location.pathname === MCP_OAUTH_CALLBACK_PATH;
};
const EmbeddedSessionChatContent: React.FC<{
embeddedSessionChat: EmbeddedSessionChatConfig;
isVSCodeRuntime: boolean;
embeddedBackgroundWorkEnabled: boolean;
}> = ({ embeddedSessionChat, isVSCodeRuntime, embeddedBackgroundWorkEnabled }) => {
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const sync = useSync();
const bootstrapKeyRef = React.useRef<string | null>(null);
const expectedDirectory = normalizeEmbeddedDirectory(embeddedSessionChat.directory);
const activeDirectory = normalizeEmbeddedDirectory(currentDirectory);
React.useEffect(() => {
if (isVSCodeRuntime) return;
if (expectedDirectory && activeDirectory !== expectedDirectory) return;
const bootstrapKey = `${expectedDirectory}\n${embeddedSessionChat.sessionId}`;
if (bootstrapKeyRef.current === bootstrapKey && currentSessionId === embeddedSessionChat.sessionId) {
return;
}
bootstrapKeyRef.current = bootstrapKey;
setCurrentSession(embeddedSessionChat.sessionId, embeddedSessionChat.directory);
void sync.ensureSessionRenderable(embeddedSessionChat.sessionId, true);
}, [
activeDirectory,
currentSessionId,
embeddedSessionChat.directory,
embeddedSessionChat.sessionId,
expectedDirectory,
isVSCodeRuntime,
setCurrentSession,
sync,
]);
if (expectedDirectory && activeDirectory !== expectedDirectory) {
return null;
}
return (
<>
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<OpenCodeUpdateToast />
<ChatView readOnly={embeddedSessionChat.readOnly} />
<Toaster />
</>
);
};
function App({ apis }: AppProps) {
const initializeApp = useConfigStore((s) => s.initializeApp);
const isInitialized = useConfigStore((s) => s.isInitialized);
const isConnected = useConfigStore((s) => s.isConnected);
const providersCount = useConfigStore((state) => state.providers.length);
const agentsCount = useConfigStore((state) => state.agents.length);
const loadProviders = useConfigStore((state) => state.loadProviders);
const loadAgents = useConfigStore((state) => state.loadAgents);
const error = useSessionUIStore((s) => s.error);
const clearError = useSessionUIStore((s) => s.clearError);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const setDirectory = useDirectoryStore((state) => state.setDirectory);
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
const [initRetryExhausted, setInitRetryExhausted] = React.useState(false);
const [initRetryEpoch, setInitRetryEpoch] = React.useState(0);
const [manualInitRetrying, setManualInitRetrying] = React.useState(false);
const wideChatLayoutEnabled = useUIStore((state) => state.wideChatLayoutEnabled);
const mobileKeyboardMode = useUIStore((state) => state.mobileKeyboardMode);
const isDesktopRuntime = React.useMemo(() => isDesktopShell(), []);
const setPlanModeEnabled = useFeatureFlagsStore((state) => state.setPlanModeEnabled);
const [bootInjectionStatus, setBootInjectionStatus] = React.useState<BootInjectionStatus>(() => {
return getBootInjectionStatus();
});
const [bootView, setBootView] = React.useState<DesktopBootView | null>(() => {
const outcome = getInjectedBootOutcome();
return outcome !== null
? resolveDesktopBootView({ isDesktopShell: true, bootOutcome: outcome })
: null;
});
const appReadyDispatchedRef = React.useRef(false);
const embeddedSessionChat = React.useMemo<EmbeddedSessionChatConfig | null>(() => readEmbeddedSessionChatConfig(), []);
const embeddedBackgroundWorkEnabled = !embeddedSessionChat || isEmbeddedVisible;
const isMcpOAuthCallback = React.useMemo(() => isMcpOAuthCallbackPath(), []);
React.useEffect(() => {
setStreamPerfEnabled(showMemoryDebug);
return () => {
setStreamPerfEnabled(false);
};
}, [showMemoryDebug]);
React.useEffect(() => {
applyMobileKeyboardMode(mobileKeyboardMode);
}, [mobileKeyboardMode]);
React.useEffect(() => {
setIsVSCodeRuntime(apis.runtime.isVSCode);
}, [apis.runtime.isVSCode]);
React.useEffect(() => {
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
return () => {
document.documentElement.classList.remove('wide-chat-layout');
};
}, [wideChatLayoutEnabled]);
React.useEffect(() => {
registerRuntimeAPIs(apis);
return () => registerRuntimeAPIs(null);
}, [apis]);
React.useEffect(() => {
if (embeddedSessionChat) {
return;
}
void refreshGitHubAuthStatus(apis.github, { force: true });
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
useAppFontEffects();
const bootOutcomeKnown = bootInjectionStatus === 'valid';
const bootViewIsMain = bootView?.screen === 'main';
// Splash dismissal: use the authoritative loading gate from desktopBoot.
// Desktop shells strictly require a valid boot outcome before dismissing.
// Non-main outcomes (chooser/recovery) can dismiss without waiting for init.
React.useEffect(() => {
if (!canDismissInitialLoading({
isDesktopShell: isDesktopRuntime,
isInitialized,
bootOutcomeKnown,
bootViewIsMain,
})) {
return;
}
const timer = setTimeout(() => {
const loadingElement = document.getElementById('initial-loading');
if (loadingElement) {
loadingElement.classList.add('fade-out');
setTimeout(() => {
loadingElement.remove();
}, 300);
}
}, 150);
return () => clearTimeout(timer);
}, [isDesktopRuntime, isInitialized, bootOutcomeKnown, bootViewIsMain]);
// Deterministic malformed handling: update splash text so the user
// sees a specific error instead of a generic spinner, but do NOT
// dismiss the splash (that only happens on a valid outcome).
React.useEffect(() => {
if (!isDesktopRuntime || bootInjectionStatus !== 'malformed') {
return;
}
const loadingElement = document.getElementById('initial-loading');
if (loadingElement) {
loadingElement.textContent = 'Desktop startup failed — please restart the app.';
}
}, [isDesktopRuntime, bootInjectionStatus]);
// Non-desktop fallback: remove splash after 5 seconds even if init stalls.
React.useEffect(() => {
if (isDesktopRuntime) {
return;
}
const fallbackTimer = setTimeout(() => {
const loadingElement = document.getElementById('initial-loading');
if (loadingElement && !isInitialized) {
loadingElement.classList.add('fade-out');
setTimeout(() => {
loadingElement.remove();
}, 300);
}
}, 5000);
return () => clearTimeout(fallbackTimer);
}, [isDesktopRuntime, isInitialized]);
React.useEffect(() => {
let cancelled = false;
const run = async () => {
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
if (!res || !res.ok || cancelled) return;
const data = (await res.json().catch(() => null)) as null | {
planModeExperimentalEnabled?: unknown;
};
if (!data || cancelled) return;
const raw = data.planModeExperimentalEnabled;
const enabled = raw === true || raw === 1 || raw === '1' || raw === 'true';
setPlanModeEnabled(enabled);
};
void run();
return () => {
cancelled = true;
};
}, [setPlanModeEnabled]);
React.useEffect(() => {
// VS Code runtime bootstraps config + sessions after the managed OpenCode instance reports "connected".
// Doing the default initialization here can race with startup and lead to one-shot failures.
if (isVSCodeRuntime) {
return;
}
void initializeApp();
}, [initializeApp, isVSCodeRuntime]);
React.useEffect(() => {
if (isVSCodeRuntime || isInitialized) return;
let active = true;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
let retryCount = 0;
const MAX_RETRIES = 10;
const BASE_DELAY_MS = 1000;
const retryInitialization = async () => {
if (!active) return;
if (retryCount >= MAX_RETRIES) {
setInitRetryExhausted(true);
return;
}
const state = useConfigStore.getState();
if (state.isInitialized) {
setInitRetryExhausted(false);
return;
}
retryCount += 1;
await state.initializeApp();
const next = useConfigStore.getState();
if (!active) return;
if (next.isInitialized) {
setInitRetryExhausted(false);
return;
}
if (retryCount >= MAX_RETRIES) {
setInitRetryExhausted(true);
return;
}
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, retryCount - 1), 16000);
retryTimer = setTimeout(retryInitialization, delay);
};
retryTimer = setTimeout(retryInitialization, BASE_DELAY_MS);
return () => {
active = false;
if (retryTimer) clearTimeout(retryTimer);
};
}, [initRetryEpoch, isInitialized, isVSCodeRuntime]);
React.useEffect(() => {
if (isInitialized) {
setInitRetryExhausted(false);
}
}, [isInitialized]);
React.useEffect(() => {
if (!initRetryExhausted) return;
const loadingElement = document.getElementById('initial-loading');
if (loadingElement) {
loadingElement.classList.add('fade-out');
setTimeout(() => {
loadingElement.remove();
}, 300);
}
}, [initRetryExhausted]);
// 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.
React.useEffect(() => {
if (isVSCodeRuntime || !isConnected) return;
if (providersCount > 0 && agentsCount > 0) return;
let active = true;
let retries = 0;
const MAX_RETRIES = 15;
const attempt = async () => {
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();
} catch { /* retry next interval */ }
};
void attempt();
const id = setInterval(() => {
if (!active) return;
if (++retries >= MAX_RETRIES) { clearInterval(id); return; }
void attempt();
}, 2000);
return () => { active = false; clearInterval(id); };
}, [isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount, agentsCount]);
React.useEffect(() => {
if (isSwitchingDirectory) {
return;
}
// VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
if (isVSCodeRuntime) {
return;
}
if (!isConnected) {
return;
}
opencodeClient.setDirectory(currentDirectory);
// Session loading is handled by the sync system's bootstrap — no manual loadSessions needed.
}, [currentDirectory, isSwitchingDirectory, isConnected, isVSCodeRuntime]);
React.useEffect(() => {
if (!embeddedSessionChat || typeof window === 'undefined') {
return;
}
const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {
const nextVisible = payload?.visible === true;
setIsEmbeddedVisible(nextVisible);
};
const handleMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) {
return;
}
const data = event.data as { type?: unknown; payload?: EmbeddedVisibilityPayload };
if (data?.type !== 'openchamber:embedded-visibility') {
return;
}
applyVisibility(data.payload);
};
const scopedWindow = window as unknown as {
__openchamberSetEmbeddedVisibility?: (payload?: EmbeddedVisibilityPayload) => void;
};
scopedWindow.__openchamberSetEmbeddedVisibility = applyVisibility;
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
if (scopedWindow.__openchamberSetEmbeddedVisibility === applyVisibility) {
delete scopedWindow.__openchamberSetEmbeddedVisibility;
}
};
}, [embeddedSessionChat]);
React.useEffect(() => {
if (!embeddedSessionChat?.directory || isVSCodeRuntime) {
return;
}
if (currentDirectory === embeddedSessionChat.directory) {
return;
}
setDirectory(embeddedSessionChat.directory, { showOverlay: false });
}, [currentDirectory, embeddedSessionChat, isVSCodeRuntime, setDirectory]);
React.useEffect(() => {
if (!embeddedSessionChat || typeof window === 'undefined') {
return;
}
const handleStorage = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage) {
return;
}
if (event.key !== 'ui-store') {
return;
}
void useUIStore.persist.rehydrate();
};
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener('storage', handleStorage);
};
}, [embeddedSessionChat]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ sessionId?: string; directory?: string }>).detail;
const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : '';
if (!sessionId) return;
const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0
? detail.directory.trim()
: null;
useUIStore.getState().setActiveMainTab('chat');
void useSessionUIStore.getState().setCurrentSession(sessionId, directory);
};
window.addEventListener('openchamber:open-session', handler as EventListener);
return () => window.removeEventListener('openchamber:open-session', handler as EventListener);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ directory?: string; projectId?: string }>).detail;
const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0
? detail.directory.trim()
: null;
const projectId = typeof detail?.projectId === 'string' && detail.projectId.trim().length > 0
? detail.projectId.trim()
: null;
useUIStore.getState().setActiveMainTab('chat');
useUIStore.getState().setSessionSwitcherOpen(false);
useSessionUIStore.getState().openNewSessionDraft({
selectedProjectId: projectId,
directoryOverride: directory,
preserveDirectoryOverride: Boolean(directory),
});
};
window.addEventListener('openchamber:open-draft-session', handler as EventListener);
return () => window.removeEventListener('openchamber:open-draft-session', handler as EventListener);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ projectPath?: string }>).detail;
const projectPath = typeof detail?.projectPath === 'string' ? detail.projectPath.trim() : '';
if (!projectPath) return;
const projectsStore = useProjectsStore.getState();
const existing = projectsStore.projects.find((project) => project.path === projectPath);
if (existing) {
projectsStore.setActiveProject(existing.id);
} else {
projectsStore.addProject(projectPath);
}
};
window.addEventListener('openchamber:open-project', handler as EventListener);
return () => window.removeEventListener('openchamber:open-project', handler as EventListener);
}, []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
if (!isInitialized || isSwitchingDirectory) return;
if (appReadyDispatchedRef.current) return;
appReadyDispatchedRef.current = true;
(window as unknown as { __openchamberAppReady?: boolean }).__openchamberAppReady = true;
window.dispatchEvent(new Event('openchamber:app-ready'));
}, [isInitialized, isSwitchingDirectory]);
// useEventStream replaced by SyncProvider + SyncBridge
// Session attention now handled by notification-store via SSE events (session.idle/session.error)
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
useWebNotificationStream({ enabled: embeddedBackgroundWorkEnabled });
usePwaInstallPrompt();
useWindowTitle();
useRouter();
const handleToggleMemoryDebug = React.useCallback(() => {
setShowMemoryDebug(prev => !prev);
}, []);
useMenuActions(handleToggleMemoryDebug);
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
React.useEffect(() => {
if (embeddedSessionChat) {
return;
}
const handleKeyDown = (e: KeyboardEvent) => {
const isDebugShortcut = hasModifier(e)
&& e.shiftKey
&& !e.altKey
&& (e.code === 'KeyD' || e.key.toLowerCase() === 'd');
if (isDebugShortcut) {
e.preventDefault();
setShowMemoryDebug(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyDown, true);
return () => window.removeEventListener('keydown', handleKeyDown, true);
}, [embeddedSessionChat]);
React.useEffect(() => {
if (embeddedSessionChat) {
return;
}
if (error) {
setTimeout(() => clearError(), 5000);
}
}, [clearError, embeddedSessionChat, error]);
// Poll for the injected boot outcome until it becomes available (desktop only).
// The Rust backend sets window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ once the
// sidecar reaches a stable state. We poll with exponential backoff to handle
// potential race conditions during startup and config writes.
React.useEffect(() => {
if (!isDesktopRuntime || bootInjectionStatus !== 'not-injected') {
return;
}
let cancelled = false;
let attempts = 0;
const BASE_INTERVAL = 200;
const MAX_INTERVAL = 2000;
const MAX_ATTEMPTS = 50; // 10 seconds total (200ms * 50 with exponential backoff cap)
const pollWithBackoff = () => {
if (cancelled) return;
attempts++;
const status = getBootInjectionStatus();
if (status !== 'not-injected') {
cancelled = true;
setBootInjectionStatus(status);
if (status === 'valid') {
const outcome = getInjectedBootOutcome();
if (outcome) {
setBootView(resolveDesktopBootView({ isDesktopShell: true, bootOutcome: outcome }));
}
}
// If status is 'malformed', we keep the splash visible with error text
// handled by the separate useEffect below
return;
}
// Exponential backoff with cap
const nextInterval = Math.min(BASE_INTERVAL * Math.pow(1.1, attempts), MAX_INTERVAL);
if (attempts >= MAX_ATTEMPTS) {
// Max attempts reached - keep polling but show error
const loadingElement = document.getElementById('initial-loading');
if (loadingElement && !loadingElement.textContent?.includes('taking longer')) {
loadingElement.textContent = 'Desktop startup is taking longer than expected...';
}
}
window.setTimeout(pollWithBackoff, nextInterval);
};
// Start polling
window.setTimeout(pollWithBackoff, BASE_INTERVAL);
return () => {
cancelled = true;
};
}, [isDesktopRuntime, bootInjectionStatus]);
const handleDesktopBootDismiss = React.useCallback(async () => {
if (shouldRestartDesktopBootFlow({
isTauriShell: isTauriShell(),
isDesktopLocalOriginActive: isDesktopLocalOriginActive(),
})) {
await restartDesktopApp();
return;
}
window.location.reload();
}, []);
const handleManualInitRetry = React.useCallback(async () => {
if (manualInitRetrying) return;
setInitRetryExhausted(false);
setManualInitRetrying(true);
try {
await useConfigStore.getState().initializeApp();
} finally {
setManualInitRetrying(false);
}
if (!useConfigStore.getState().isInitialized) {
setInitRetryEpoch((value) => value + 1);
}
}, [manualInitRetrying]);
// Map boot outcome kind to recovery variant
const mapBootViewToRecoveryVariant = (view: DesktopBootView): RecoveryVariant | undefined => {
if (view.screen === 'recovery') {
return view.variant;
}
return undefined;
};
// Desktop boot view routing.
// When the boot outcome resolves to a non-main screen (chooser, recovery),
// render OnboardingScreen with appropriate mode/variant.
if (isDesktopRuntime && bootView && bootView.screen !== 'main') {
// First-launch chooser
if (bootView.screen === 'chooser') {
return (
<ErrorBoundary>
<div className="h-full text-foreground bg-transparent">
<React.Suspense fallback={<div className="h-full" />}>
<OnboardingScreen
mode="first-launch"
onCliAvailable={handleDesktopBootDismiss}
onChooseRemote={() => {
// Switch to remote tab - handled internally by OnboardingScreen
}}
/>
</React.Suspense>
</div>
</ErrorBoundary>
);
}
// Recovery screens
const recoveryVariant = mapBootViewToRecoveryVariant(bootView);
const hostUrl = bootView.screen === 'recovery' && 'url' in bootView ? bootView.url : undefined;
return (
<ErrorBoundary>
<div className="h-full text-foreground bg-transparent">
<React.Suspense fallback={<div className="h-full" />}>
<OnboardingScreen
mode="recovery"
recoveryVariant={recoveryVariant}
recoveryHostUrl={hostUrl}
recoveryHostLabel={undefined}
onCliAvailable={handleDesktopBootDismiss}
/>
</React.Suspense>
</div>
</ErrorBoundary>
);
}
if (embeddedSessionChat) {
return (
<ErrorBoundary>
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<RuntimeAPIProvider apis={apis}>
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<EmbeddedSessionChatContent
embeddedSessionChat={embeddedSessionChat}
isVSCodeRuntime={isVSCodeRuntime}
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
/>
</div>
</TooltipProvider>
</RuntimeAPIProvider>
</SyncProvider>
</ErrorBoundary>
);
}
if (isMcpOAuthCallback) {
return (
<ErrorBoundary>
<McpOAuthCallbackPage />
</ErrorBoundary>
);
}
if (initRetryExhausted && !isInitialized && !isVSCodeRuntime && !embeddedSessionChat) {
return (
<ErrorBoundary>
<StartupInitializationRecovery
onRetry={() => { void handleManualInitRetry(); }}
isRetrying={manualInitRetrying}
/>
</ErrorBoundary>
);
}
// Always mount the full provider tree to avoid remounts when isInitialized
// flips from false → true. FireworksProvider and VoiceProvider are lightweight
// shells; their heavy children are only activated when actually needed.
const isBootShell = !isInitialized && !isDesktopRuntime;
return (
<ErrorBoundary>
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<RuntimeAPIProvider apis={apis}>
<FireworksProvider>
<VoiceProvider>
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<OpenCodeUpdateToast />
<MainLayout />
<Toaster />
{!isBootShell && (
<>
<ConfigUpdateOverlay />
<AboutDialogWrapper />
{showMemoryDebug && (
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
)}
</>
)}
</div>
</TooltipProvider>
</VoiceProvider>
</FireworksProvider>
</RuntimeAPIProvider>
</SyncProvider>
</ErrorBoundary>
);
}
export default App;