Files
openchamber/packages/ui/src/App.tsx
T

946 lines
33 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 { useTraySync } from '@/hooks/useTraySync';
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, restartDesktopApp, invokeDesktop } 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 { markSessionViewed } from '@/sync/notification-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
import { resumeAutoReviewRun } from '@/lib/reviewFlow';
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 { 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 { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedChat';
import { SyncAppEffects } from '@/apps/AppEffects';
import { resetAppForRuntimeEndpointChange } from '@/apps/runtimeEndpointReset';
import { useAppFontEffects } from '@/apps/useAppFontEffects';
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(() =>
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' || !isEmbeddedSessionChat()) {
return null;
}
const params = new URLSearchParams(window.location.search);
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}`;
// Skip if this session was already bootstrapped and a session is still
// active — allows in-place navigation (e.g. "Open subtask") to change
// currentSessionId without this effect forcing it back. Only re-bootstrap
// when currentSessionId was cleared (store init, draft, delete/archive,
// runtime-switch remount).
if (bootstrapKeyRef.current === bootstrapKey && currentSessionId) {
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) {
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);
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 [runtimeEndpointEpoch, setRuntimeEndpointEpoch] = 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(() => {
return subscribeRuntimeEndpointChanged((detail) => {
resetAppForRuntimeEndpointChange(detail);
setRuntimeEndpointEpoch((epoch) => epoch + 1);
setInitRetryExhausted(false);
setInitRetryEpoch((epoch) => epoch + 1);
});
}, []);
const autoReviewResumeSignature = useAutoReviewStore((state) => {
const runtimeKey = getRuntimeKey();
return Object.values(state.runsByOriginalSessionID)
.filter((run) => run.status === 'running' && run.runtimeKey === runtimeKey)
.map((run) => `${run.originalSessionID}:${run.phase}:${run.lastForwardedMessageID ?? ''}:${run.expectedAssistantParentID ?? ''}`)
.sort()
.join('|');
});
React.useEffect(() => {
if (embeddedSessionChat) {
return;
}
const runtimeKey = getRuntimeKey();
const runs = Object.values(useAutoReviewStore.getState().runsByOriginalSessionID)
.filter((run) => run.status === 'running' && run.runtimeKey === runtimeKey);
for (const run of runs) {
resumeAutoReviewRun(run.originalSessionID);
}
}, [autoReviewResumeSignature, embeddedSessionChat, runtimeEndpointEpoch]);
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 runtimeFetch('/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({ source: 'startupRecovery' });
if (useConfigStore.getState().agents.length === 0) await loadAgents({ source: 'startupRecovery' });
} 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);
}, []);
// Open a draft Mini Chat window from the native File menu / tray. Uses a
// dedicated single-fire event (not the menu-action channel) because draft
// mini-chat windows are NOT deduplicated — a double dispatch would open two.
React.useEffect(() => {
if (typeof window === 'undefined') return;
const onOpenMiniChat = () => {
const currentDir = useDirectoryStore.getState().currentDirectory;
const { activeProjectId, projects } = useProjectsStore.getState();
const activeProject = projects.find((p) => p.id === activeProjectId) ?? null;
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: currentDir || activeProject?.path || '',
projectId: activeProject?.id ?? null,
});
};
window.addEventListener('openchamber:open-mini-chat', onOpenMiniChat);
return () => window.removeEventListener('openchamber:open-mini-chat', onOpenMiniChat);
}, []);
// When the window regains focus, mark the currently-selected session as seen.
// Turn-completes that arrive while the app is backgrounded are intentionally
// left unseen (see isViewedInCurrentSession); coming back to the window is the
// signal that the user has now looked at it, so the marker clears.
React.useEffect(() => {
if (typeof window === 'undefined') return;
const onFocus = () => {
const sessionId = useSessionUIStore.getState().currentSessionId;
if (sessionId) markSessionViewed(sessionId);
};
window.addEventListener('focus', onFocus);
return () => window.removeEventListener('focus', onFocus);
}, []);
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;
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);
useTraySync();
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({
isDesktopShell: isDesktopShell(),
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 key={runtimeEndpointEpoch} 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 is a lightweight shell; its
// heavy children are only activated when actually needed.
const isBootShell = !isInitialized && !isDesktopRuntime;
return (
<ErrorBoundary>
<SyncProvider key={runtimeEndpointEpoch} sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
<RuntimeAPIProvider apis={apis}>
<FireworksProvider>
<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>
</FireworksProvider>
</RuntimeAPIProvider>
</SyncProvider>
</ErrorBoundary>
);
}
export default App;