Files
openchamber/packages/ui/src/App.tsx
T
Bohdan Triapitsyn a0caec0984 feat(desktop): support remote-only startup
Allow Desktop to skip its in-process OpenChamber server with OPENCHAMBER_SKIP_LOCAL_SERVER=1 while continuing to load the packaged UI shell.

Carry local runtime availability through the boot contract so unavailable or unconfigured remotes enter a remote-only chooser instead of offering broken local recovery actions. The chooser can select saved instances, add a server by URL, or redeem an OpenChamber pairing link over direct or E2EE relay transports.

Keep additional windows, Mini Chat, background startup, and unreachable-host recovery functional without a local origin. Render boot and recovery surfaces with the active theme background rather than exposing the native vibrancy backing.

Document the environment variable and cover serverless boot routing plus malformed pairing imports with focused tests.
2026-07-21 21:11:13 +03:00

948 lines
34 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-background">
<React.Suspense fallback={<div className="h-full" />}>
<OnboardingScreen
mode="first-launch"
localAvailable={bootView.localAvailable !== false}
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-background">
<React.Suspense fallback={<div className="h-full" />}>
<OnboardingScreen
mode="recovery"
recoveryVariant={recoveryVariant}
recoveryHostUrl={hostUrl}
recoveryHostLabel={undefined}
localAvailable={bootView.localAvailable !== false}
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;