946 lines
33 KiB
TypeScript
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;
|