Use base-ui's transition-status pattern (data-starting-style / data-ending-style) for Dialog, DropdownMenu, Select, Tooltip and Popover so open/close animate consistently at 150ms ease-out without flicker. Wrap dialog popups in a centered flex container so the scale animation no longer fights with translate-based positioning, and switch PendingChangesBar to a real Popover so it actually animates closed. Convert ScheduledTaskEditorDialog from raw Radix to the shared Dialog wrapper, give ScheduledTasksDialog a stable min-height to prevent layout shift mid-animation, and reset NewWorktreeDialog form state on open instead of close so fields don't empty during the close transition. Drop tooltip delay from 700ms to 300ms globally and remove all per-component overrides except the model/variant/agent selectors in the chat input (600ms).
944 lines
32 KiB
TypeScript
944 lines
32 KiB
TypeScript
import React from 'react';
|
|
import { MainLayout } from '@/components/layout/MainLayout';
|
|
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
|
import { AgentManagerView } from '@/components/views/agent-manager';
|
|
import { ChatView } from '@/components/views';
|
|
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 { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
|
import { useMenuActions } from '@/hooks/useMenuActions';
|
|
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
|
|
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
|
import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
|
|
import { useRouter } from '@/hooks/useRouter';
|
|
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
|
import { usePwaManifestSync } from '@/hooks/usePwaManifestSync';
|
|
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
|
import { useWindowControlsOverlayLayout } from '@/hooks/useWindowControlsOverlayLayout';
|
|
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { hasModifier } from '@/lib/utils';
|
|
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell, restartDesktopApp } from '@/lib/desktop';
|
|
import {
|
|
getInjectedBootOutcome,
|
|
getBootInjectionStatus,
|
|
resolveDesktopBootView,
|
|
canDismissInitialLoading,
|
|
shouldRestartDesktopBootFlow,
|
|
type BootInjectionStatus,
|
|
type DesktopBootView,
|
|
} from '@/lib/desktopBoot';
|
|
import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionRecovery';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
import { opencodeClient } from '@/lib/opencode/client';
|
|
import { SyncProvider, useSessions } from '@/sync/sync-context';
|
|
import { useSync } from '@/sync/use-sync';
|
|
import { setOptimisticRefs } from '@/sync/session-actions';
|
|
import { useFontPreferences } from '@/hooks/useFontPreferences';
|
|
import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTION_MAP } from '@/lib/fontOptions';
|
|
import { loadMonoFont, loadUiFont } from '@/lib/fontLoader';
|
|
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
|
|
import { AboutDialog } from '@/components/ui/AboutDialog';
|
|
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
|
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
|
import { VoiceProvider } from '@/components/voice';
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
|
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
|
import type { RuntimeAPIs } from '@/lib/api/types';
|
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
|
import { McpOAuthCallbackPage } from '@/components/sections/mcp/McpOAuthCallbackPage';
|
|
import { MCP_OAUTH_CALLBACK_PATH } from '@/components/sections/mcp/mcpOAuth';
|
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
// 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;
|
|
};
|
|
|
|
type EmbeddedVisibilityPayload = {
|
|
visible?: unknown;
|
|
};
|
|
|
|
const readEmbeddedSessionChatConfig = (): EmbeddedSessionChatConfig | null => {
|
|
if (typeof window === 'undefined') {
|
|
return null;
|
|
}
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get('ocPanel') !== 'session-chat') {
|
|
return null;
|
|
}
|
|
|
|
const sessionIdRaw = params.get('sessionId');
|
|
const sessionId = typeof sessionIdRaw === 'string' ? sessionIdRaw.trim() : '';
|
|
if (!sessionId) {
|
|
return null;
|
|
}
|
|
|
|
const directoryRaw = params.get('directory');
|
|
const directory = typeof directoryRaw === 'string' && directoryRaw.trim().length > 0
|
|
? directoryRaw.trim()
|
|
: null;
|
|
|
|
return {
|
|
sessionId,
|
|
directory,
|
|
};
|
|
};
|
|
|
|
const isMcpOAuthCallbackPath = (): boolean => {
|
|
if (typeof window === 'undefined') {
|
|
return false;
|
|
}
|
|
|
|
return window.location.pathname === MCP_OAUTH_CALLBACK_PATH;
|
|
};
|
|
|
|
const EmbeddedSessionSelectionGate: React.FC<{
|
|
embeddedSessionChat: EmbeddedSessionChatConfig | null;
|
|
isVSCodeRuntime: boolean;
|
|
}> = ({ embeddedSessionChat, isVSCodeRuntime }) => {
|
|
const sessions = useSessions();
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
|
|
|
React.useEffect(() => {
|
|
if (!embeddedSessionChat || isVSCodeRuntime) {
|
|
return;
|
|
}
|
|
|
|
if (currentSessionId === embeddedSessionChat.sessionId) {
|
|
return;
|
|
}
|
|
|
|
if (!sessions.some((session) => session.id === embeddedSessionChat.sessionId)) {
|
|
return;
|
|
}
|
|
|
|
void setCurrentSession(embeddedSessionChat.sessionId);
|
|
}, [currentSessionId, embeddedSessionChat, isVSCodeRuntime, sessions, setCurrentSession]);
|
|
|
|
return null;
|
|
};
|
|
|
|
const SyncOptimisticBridge: React.FC = () => {
|
|
const sync = useSync();
|
|
const addRef = React.useRef(sync.optimistic.add);
|
|
const removeRef = React.useRef(sync.optimistic.remove);
|
|
addRef.current = sync.optimistic.add;
|
|
removeRef.current = sync.optimistic.remove;
|
|
|
|
React.useEffect(() => {
|
|
setOptimisticRefs(
|
|
(input) => addRef.current(input),
|
|
(input) => removeRef.current(input),
|
|
);
|
|
}, []);
|
|
|
|
return null;
|
|
};
|
|
|
|
function SyncAppEffects({ embeddedBackgroundWorkEnabled }: {
|
|
embeddedBackgroundWorkEnabled: boolean;
|
|
}) {
|
|
usePwaManifestSync();
|
|
useWindowControlsOverlayLayout();
|
|
useSessionAutoCleanup(embeddedBackgroundWorkEnabled);
|
|
useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled);
|
|
useKeyboardShortcuts();
|
|
|
|
return <SyncOptimisticBridge />;
|
|
}
|
|
|
|
function App({ apis }: AppProps) {
|
|
const initializeApp = useConfigStore((s) => s.initializeApp);
|
|
const isInitialized = useConfigStore((s) => s.isInitialized);
|
|
const isConnected = useConfigStore((s) => s.isConnected);
|
|
const providersCount = useConfigStore((state) => state.providers.length);
|
|
const agentsCount = useConfigStore((state) => state.agents.length);
|
|
const loadProviders = useConfigStore((state) => state.loadProviders);
|
|
const loadAgents = useConfigStore((state) => state.loadAgents);
|
|
const error = useSessionUIStore((s) => s.error);
|
|
const clearError = useSessionUIStore((s) => s.clearError);
|
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
|
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
|
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
|
|
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
|
|
const { uiFont, monoFont } = useFontPreferences();
|
|
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
|
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
|
|
const [isEmbeddedVisible, setIsEmbeddedVisible] = React.useState(true);
|
|
const [initRetryExhausted, setInitRetryExhausted] = React.useState(false);
|
|
const [initRetryEpoch, setInitRetryEpoch] = React.useState(0);
|
|
const [manualInitRetrying, setManualInitRetrying] = React.useState(false);
|
|
const wideChatLayoutEnabled = useUIStore((state) => state.wideChatLayoutEnabled);
|
|
const 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 initializationInFlightRef = 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(() => {
|
|
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
|
}, [apis.runtime.isVSCode]);
|
|
|
|
React.useEffect(() => {
|
|
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
|
|
return () => {
|
|
document.documentElement.classList.remove('wide-chat-layout');
|
|
};
|
|
}, [wideChatLayoutEnabled]);
|
|
|
|
React.useEffect(() => {
|
|
registerRuntimeAPIs(apis);
|
|
return () => registerRuntimeAPIs(null);
|
|
}, [apis]);
|
|
|
|
React.useEffect(() => {
|
|
if (embeddedSessionChat) {
|
|
return;
|
|
}
|
|
|
|
void refreshGitHubAuthStatus(apis.github, { force: true });
|
|
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
const root = document.documentElement;
|
|
const uiStack = UI_FONT_OPTION_MAP[uiFont]?.stack ?? UI_FONT_OPTION_MAP[DEFAULT_UI_FONT].stack;
|
|
const monoStack = CODE_FONT_OPTION_MAP[monoFont]?.stack ?? CODE_FONT_OPTION_MAP[DEFAULT_MONO_FONT].stack;
|
|
void loadUiFont(uiFont);
|
|
void loadMonoFont(monoFont);
|
|
|
|
root.style.setProperty('--font-sans', uiStack);
|
|
root.style.setProperty('--font-heading', uiStack);
|
|
root.style.setProperty('--font-family-sans', uiStack);
|
|
root.style.setProperty('--font-mono', monoStack);
|
|
root.style.setProperty('--font-family-mono', monoStack);
|
|
root.style.setProperty('--ui-regular-font-weight', '400');
|
|
|
|
if (document.body) {
|
|
document.body.style.fontFamily = uiStack;
|
|
}
|
|
}, [uiFont, monoFont]);
|
|
|
|
const bootOutcomeKnown = bootInjectionStatus === 'valid';
|
|
const bootViewIsMain = bootView?.screen === 'main';
|
|
|
|
// Splash dismissal: use the authoritative loading gate from desktopBoot.
|
|
// Desktop shells strictly require a valid boot outcome before dismissing.
|
|
// Non-main outcomes (chooser/recovery) can dismiss without waiting for init.
|
|
React.useEffect(() => {
|
|
if (!canDismissInitialLoading({
|
|
isDesktopShell: isDesktopRuntime,
|
|
isInitialized,
|
|
bootOutcomeKnown,
|
|
bootViewIsMain,
|
|
})) {
|
|
return;
|
|
}
|
|
|
|
const timer = setTimeout(() => {
|
|
const loadingElement = document.getElementById('initial-loading');
|
|
if (loadingElement) {
|
|
loadingElement.classList.add('fade-out');
|
|
setTimeout(() => {
|
|
loadingElement.remove();
|
|
}, 300);
|
|
}
|
|
}, 150);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [isDesktopRuntime, isInitialized, bootOutcomeKnown, bootViewIsMain]);
|
|
|
|
// Deterministic malformed handling: update splash text so the user
|
|
// sees a specific error instead of a generic spinner, but do NOT
|
|
// dismiss the splash (that only happens on a valid outcome).
|
|
React.useEffect(() => {
|
|
if (!isDesktopRuntime || bootInjectionStatus !== 'malformed') {
|
|
return;
|
|
}
|
|
|
|
const loadingElement = document.getElementById('initial-loading');
|
|
if (loadingElement) {
|
|
loadingElement.textContent = 'Desktop startup failed — please restart the app.';
|
|
}
|
|
}, [isDesktopRuntime, bootInjectionStatus]);
|
|
|
|
// Non-desktop fallback: remove splash after 5 seconds even if init stalls.
|
|
React.useEffect(() => {
|
|
if (isDesktopRuntime) {
|
|
return;
|
|
}
|
|
|
|
const fallbackTimer = setTimeout(() => {
|
|
const loadingElement = document.getElementById('initial-loading');
|
|
if (loadingElement && !isInitialized) {
|
|
loadingElement.classList.add('fade-out');
|
|
setTimeout(() => {
|
|
loadingElement.remove();
|
|
}, 300);
|
|
}
|
|
}, 5000);
|
|
|
|
return () => clearTimeout(fallbackTimer);
|
|
}, [isDesktopRuntime, isInitialized]);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const run = async () => {
|
|
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
|
|
if (!res || !res.ok || cancelled) return;
|
|
const data = (await res.json().catch(() => null)) as null | {
|
|
planModeExperimentalEnabled?: unknown;
|
|
};
|
|
if (!data || cancelled) return;
|
|
const raw = data.planModeExperimentalEnabled;
|
|
const enabled = raw === true || raw === 1 || raw === '1' || raw === 'true';
|
|
setPlanModeEnabled(enabled);
|
|
};
|
|
|
|
void run();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [setPlanModeEnabled]);
|
|
|
|
React.useEffect(() => {
|
|
const init = async () => {
|
|
// 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;
|
|
}
|
|
if (initializationInFlightRef.current) {
|
|
return;
|
|
}
|
|
initializationInFlightRef.current = true;
|
|
try {
|
|
await initializeApp();
|
|
} finally {
|
|
initializationInFlightRef.current = false;
|
|
}
|
|
};
|
|
|
|
init();
|
|
}, [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;
|
|
}
|
|
if (initializationInFlightRef.current) {
|
|
retryTimer = setTimeout(retryInitialization, BASE_DELAY_MS);
|
|
return;
|
|
}
|
|
|
|
retryCount += 1;
|
|
initializationInFlightRef.current = true;
|
|
try {
|
|
await state.initializeApp();
|
|
} finally {
|
|
initializationInFlightRef.current = false;
|
|
}
|
|
|
|
const next = useConfigStore.getState();
|
|
if (!active) return;
|
|
if (next.isInitialized) {
|
|
setInitRetryExhausted(false);
|
|
return;
|
|
}
|
|
if (retryCount >= MAX_RETRIES) {
|
|
setInitRetryExhausted(true);
|
|
return;
|
|
}
|
|
const delay = Math.min(BASE_DELAY_MS * Math.pow(2, retryCount - 1), 16000);
|
|
retryTimer = setTimeout(retryInitialization, delay);
|
|
};
|
|
|
|
retryTimer = setTimeout(retryInitialization, BASE_DELAY_MS);
|
|
|
|
return () => {
|
|
active = false;
|
|
if (retryTimer) clearTimeout(retryTimer);
|
|
};
|
|
}, [initRetryEpoch, isInitialized, isVSCodeRuntime]);
|
|
|
|
React.useEffect(() => {
|
|
if (isInitialized) {
|
|
setInitRetryExhausted(false);
|
|
}
|
|
}, [isInitialized]);
|
|
|
|
React.useEffect(() => {
|
|
if (!initRetryExhausted) return;
|
|
|
|
const loadingElement = document.getElementById('initial-loading');
|
|
if (loadingElement) {
|
|
loadingElement.classList.add('fade-out');
|
|
setTimeout(() => {
|
|
loadingElement.remove();
|
|
}, 300);
|
|
}
|
|
}, [initRetryExhausted]);
|
|
|
|
// Startup recovery: poll until providers AND agents are loaded.
|
|
// loadProviders/loadAgents resolve normally even on failure (errors swallowed),
|
|
// so a reactive effect can't detect failure — we need an interval.
|
|
React.useEffect(() => {
|
|
if (isVSCodeRuntime || !isConnected) return;
|
|
if (providersCount > 0 && agentsCount > 0) return;
|
|
|
|
let active = true;
|
|
let retries = 0;
|
|
const MAX_RETRIES = 15;
|
|
const attempt = async () => {
|
|
const state = useConfigStore.getState();
|
|
if (state.providers.length > 0 && state.agents.length > 0) return;
|
|
try {
|
|
if (state.providers.length === 0) await loadProviders();
|
|
if (useConfigStore.getState().agents.length === 0) await loadAgents();
|
|
} catch { /* retry next interval */ }
|
|
};
|
|
|
|
void attempt();
|
|
const id = setInterval(() => {
|
|
if (!active) return;
|
|
if (++retries >= MAX_RETRIES) { clearInterval(id); return; }
|
|
void attempt();
|
|
}, 2000);
|
|
return () => { active = false; clearInterval(id); };
|
|
}, [isConnected, isVSCodeRuntime, loadAgents, loadProviders, providersCount, agentsCount]);
|
|
|
|
React.useEffect(() => {
|
|
if (isSwitchingDirectory) {
|
|
return;
|
|
}
|
|
|
|
// VS Code runtime loads sessions via VSCodeLayout bootstrap to avoid startup races.
|
|
if (isVSCodeRuntime) {
|
|
return;
|
|
}
|
|
|
|
if (!isConnected) {
|
|
return;
|
|
}
|
|
opencodeClient.setDirectory(currentDirectory);
|
|
|
|
// Session loading is handled by the sync system's bootstrap — no manual loadSessions needed.
|
|
}, [currentDirectory, isSwitchingDirectory, isConnected, isVSCodeRuntime]);
|
|
|
|
React.useEffect(() => {
|
|
if (!embeddedSessionChat || typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const applyVisibility = (payload?: EmbeddedVisibilityPayload) => {
|
|
const nextVisible = payload?.visible === true;
|
|
setIsEmbeddedVisible(nextVisible);
|
|
};
|
|
|
|
const handleMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) {
|
|
return;
|
|
}
|
|
|
|
const data = event.data as { type?: unknown; payload?: EmbeddedVisibilityPayload };
|
|
if (data?.type !== 'openchamber:embedded-visibility') {
|
|
return;
|
|
}
|
|
|
|
applyVisibility(data.payload);
|
|
};
|
|
|
|
const scopedWindow = window as unknown as {
|
|
__openchamberSetEmbeddedVisibility?: (payload?: EmbeddedVisibilityPayload) => void;
|
|
};
|
|
|
|
scopedWindow.__openchamberSetEmbeddedVisibility = applyVisibility;
|
|
window.addEventListener('message', handleMessage);
|
|
|
|
return () => {
|
|
window.removeEventListener('message', handleMessage);
|
|
if (scopedWindow.__openchamberSetEmbeddedVisibility === applyVisibility) {
|
|
delete scopedWindow.__openchamberSetEmbeddedVisibility;
|
|
}
|
|
};
|
|
}, [embeddedSessionChat]);
|
|
|
|
React.useEffect(() => {
|
|
if (!embeddedSessionChat?.directory || isVSCodeRuntime) {
|
|
return;
|
|
}
|
|
|
|
if (currentDirectory === embeddedSessionChat.directory) {
|
|
return;
|
|
}
|
|
|
|
setDirectory(embeddedSessionChat.directory, { showOverlay: false });
|
|
}, [currentDirectory, embeddedSessionChat, isVSCodeRuntime, setDirectory]);
|
|
|
|
React.useEffect(() => {
|
|
if (!embeddedSessionChat || typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const handleStorage = (event: StorageEvent) => {
|
|
if (event.storageArea !== window.localStorage) {
|
|
return;
|
|
}
|
|
|
|
if (event.key !== 'ui-store') {
|
|
return;
|
|
}
|
|
|
|
void useUIStore.persist.rehydrate();
|
|
};
|
|
|
|
window.addEventListener('storage', handleStorage);
|
|
return () => {
|
|
window.removeEventListener('storage', handleStorage);
|
|
};
|
|
}, [embeddedSessionChat]);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const handler = (event: Event) => {
|
|
const detail = (event as CustomEvent<{ sessionId?: string }>).detail;
|
|
const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : '';
|
|
if (!sessionId) return;
|
|
void useSessionUIStore.getState().setCurrentSession(sessionId);
|
|
};
|
|
|
|
window.addEventListener('openchamber:open-session', handler as EventListener);
|
|
return () => window.removeEventListener('openchamber:open-session', handler as EventListener);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const handler = (event: Event) => {
|
|
const detail = (event as CustomEvent<{ projectPath?: string }>).detail;
|
|
const projectPath = typeof detail?.projectPath === 'string' ? detail.projectPath.trim() : '';
|
|
if (!projectPath) return;
|
|
const projectsStore = useProjectsStore.getState();
|
|
const existing = projectsStore.projects.find((project) => project.path === projectPath);
|
|
if (existing) {
|
|
projectsStore.setActiveProject(existing.id);
|
|
} else {
|
|
projectsStore.addProject(projectPath);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('openchamber:open-project', handler as EventListener);
|
|
return () => window.removeEventListener('openchamber:open-project', handler as EventListener);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
if (!isInitialized || isSwitchingDirectory) return;
|
|
if (appReadyDispatchedRef.current) return;
|
|
appReadyDispatchedRef.current = true;
|
|
(window as unknown as { __openchamberAppReady?: boolean }).__openchamberAppReady = true;
|
|
window.dispatchEvent(new Event('openchamber:app-ready'));
|
|
}, [isInitialized, isSwitchingDirectory]);
|
|
|
|
// useEventStream replaced by SyncProvider + SyncBridge
|
|
|
|
// Session attention now handled by notification-store via SSE events (session.idle/session.error)
|
|
|
|
usePushVisibilityBeacon({ enabled: embeddedBackgroundWorkEnabled });
|
|
usePwaInstallPrompt();
|
|
|
|
useWindowTitle();
|
|
|
|
useRouter();
|
|
|
|
const handleToggleMemoryDebug = React.useCallback(() => {
|
|
setShowMemoryDebug(prev => !prev);
|
|
}, []);
|
|
|
|
useMenuActions(handleToggleMemoryDebug);
|
|
|
|
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
|
|
|
|
React.useEffect(() => {
|
|
if (embeddedSessionChat) {
|
|
return;
|
|
}
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
const isDebugShortcut = hasModifier(e)
|
|
&& e.shiftKey
|
|
&& !e.altKey
|
|
&& (e.code === 'KeyD' || e.key.toLowerCase() === 'd');
|
|
|
|
if (isDebugShortcut) {
|
|
e.preventDefault();
|
|
setShowMemoryDebug(prev => !prev);
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleKeyDown, true);
|
|
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
|
}, [embeddedSessionChat]);
|
|
|
|
React.useEffect(() => {
|
|
if (embeddedSessionChat) {
|
|
return;
|
|
}
|
|
|
|
if (error) {
|
|
|
|
setTimeout(() => clearError(), 5000);
|
|
}
|
|
}, [clearError, embeddedSessionChat, error]);
|
|
|
|
// Poll for the injected boot outcome until it becomes available (desktop only).
|
|
// The Rust backend sets window.__OPENCHAMBER_DESKTOP_BOOT_OUTCOME__ once the
|
|
// sidecar reaches a stable state. We poll with exponential backoff to handle
|
|
// potential race conditions during startup and config writes.
|
|
React.useEffect(() => {
|
|
if (!isDesktopRuntime || bootInjectionStatus !== 'not-injected') {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
let attempts = 0;
|
|
const BASE_INTERVAL = 200;
|
|
const MAX_INTERVAL = 2000;
|
|
const MAX_ATTEMPTS = 50; // 10 seconds total (200ms * 50 with exponential backoff cap)
|
|
|
|
const pollWithBackoff = () => {
|
|
if (cancelled) return;
|
|
|
|
attempts++;
|
|
const status = getBootInjectionStatus();
|
|
|
|
if (status !== 'not-injected') {
|
|
cancelled = true;
|
|
setBootInjectionStatus(status);
|
|
|
|
if (status === 'valid') {
|
|
const outcome = getInjectedBootOutcome();
|
|
if (outcome) {
|
|
setBootView(resolveDesktopBootView({ isDesktopShell: true, bootOutcome: outcome }));
|
|
}
|
|
}
|
|
// If status is 'malformed', we keep the splash visible with error text
|
|
// handled by the separate useEffect below
|
|
return;
|
|
}
|
|
|
|
// Exponential backoff with cap
|
|
const nextInterval = Math.min(BASE_INTERVAL * Math.pow(1.1, attempts), MAX_INTERVAL);
|
|
|
|
if (attempts >= MAX_ATTEMPTS) {
|
|
// Max attempts reached - keep polling but show error
|
|
const loadingElement = document.getElementById('initial-loading');
|
|
if (loadingElement && !loadingElement.textContent?.includes('taking longer')) {
|
|
loadingElement.textContent = 'Desktop startup is taking longer than expected...';
|
|
}
|
|
}
|
|
|
|
window.setTimeout(pollWithBackoff, nextInterval);
|
|
};
|
|
|
|
// Start polling
|
|
window.setTimeout(pollWithBackoff, BASE_INTERVAL);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [isDesktopRuntime, bootInjectionStatus]);
|
|
|
|
const handleDesktopBootDismiss = React.useCallback(async () => {
|
|
if (shouldRestartDesktopBootFlow({
|
|
isTauriShell: isTauriShell(),
|
|
isDesktopLocalOriginActive: isDesktopLocalOriginActive(),
|
|
})) {
|
|
await restartDesktopApp();
|
|
return;
|
|
}
|
|
|
|
window.location.reload();
|
|
}, []);
|
|
|
|
const handleManualInitRetry = React.useCallback(async () => {
|
|
if (manualInitRetrying || initializationInFlightRef.current) return;
|
|
|
|
setInitRetryExhausted(false);
|
|
setManualInitRetrying(true);
|
|
initializationInFlightRef.current = true;
|
|
try {
|
|
await useConfigStore.getState().initializeApp();
|
|
} finally {
|
|
initializationInFlightRef.current = false;
|
|
setManualInitRetrying(false);
|
|
}
|
|
|
|
if (!useConfigStore.getState().isInitialized) {
|
|
setInitRetryEpoch((value) => value + 1);
|
|
}
|
|
}, [manualInitRetrying]);
|
|
|
|
// Map boot outcome kind to recovery variant
|
|
const mapBootViewToRecoveryVariant = (view: DesktopBootView): RecoveryVariant | undefined => {
|
|
if (view.screen === 'recovery') {
|
|
return view.variant;
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
// Desktop boot view routing.
|
|
// When the boot outcome resolves to a non-main screen (chooser, recovery),
|
|
// render OnboardingScreen with appropriate mode/variant.
|
|
if (isDesktopRuntime && bootView && bootView.screen !== 'main') {
|
|
// First-launch chooser
|
|
if (bootView.screen === 'chooser') {
|
|
return (
|
|
<ErrorBoundary>
|
|
<div className="h-full text-foreground bg-transparent">
|
|
<React.Suspense fallback={<div className="h-full" />}>
|
|
<OnboardingScreen
|
|
mode="first-launch"
|
|
onCliAvailable={handleDesktopBootDismiss}
|
|
onChooseRemote={() => {
|
|
// Switch to remote tab - handled internally by OnboardingScreen
|
|
}}
|
|
/>
|
|
</React.Suspense>
|
|
</div>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
// Recovery screens
|
|
const recoveryVariant = mapBootViewToRecoveryVariant(bootView);
|
|
const hostUrl = bootView.screen === 'recovery' && 'url' in bootView ? bootView.url : undefined;
|
|
|
|
return (
|
|
<ErrorBoundary>
|
|
<div className="h-full text-foreground bg-transparent">
|
|
<React.Suspense fallback={<div className="h-full" />}>
|
|
<OnboardingScreen
|
|
mode="recovery"
|
|
recoveryVariant={recoveryVariant}
|
|
recoveryHostUrl={hostUrl}
|
|
recoveryHostLabel={undefined}
|
|
onCliAvailable={handleDesktopBootDismiss}
|
|
/>
|
|
</React.Suspense>
|
|
</div>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
if (embeddedSessionChat) {
|
|
return (
|
|
<ErrorBoundary>
|
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
|
<RuntimeAPIProvider apis={apis}>
|
|
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
|
<div className="h-full text-foreground bg-background">
|
|
<EmbeddedSessionSelectionGate embeddedSessionChat={embeddedSessionChat} isVSCodeRuntime={isVSCodeRuntime} />
|
|
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
|
<ChatView />
|
|
<Toaster />
|
|
</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>
|
|
);
|
|
}
|
|
|
|
// VS Code runtime - simplified layout without git/terminal views
|
|
if (isVSCodeRuntime) {
|
|
// Check if this is the Agent Manager panel
|
|
const panelType = typeof window !== 'undefined'
|
|
? (window as { __OPENCHAMBER_PANEL_TYPE__?: 'chat' | 'agentManager' }).__OPENCHAMBER_PANEL_TYPE__
|
|
: 'chat';
|
|
|
|
if (panelType === 'agentManager') {
|
|
return (
|
|
<ErrorBoundary>
|
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
|
<RuntimeAPIProvider apis={apis}>
|
|
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
|
<div className="h-full text-foreground bg-background">
|
|
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
|
<AgentManagerView />
|
|
<Toaster />
|
|
</div>
|
|
</TooltipProvider>
|
|
</RuntimeAPIProvider>
|
|
</SyncProvider>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ErrorBoundary>
|
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
|
<RuntimeAPIProvider apis={apis}>
|
|
<FireworksProvider>
|
|
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
|
<div className="h-full text-foreground bg-background">
|
|
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
|
<VSCodeLayout />
|
|
<Toaster />
|
|
</div>
|
|
</TooltipProvider>
|
|
</FireworksProvider>
|
|
</RuntimeAPIProvider>
|
|
</SyncProvider>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
// Always mount the full provider tree to avoid remounts when isInitialized
|
|
// flips from false → true. FireworksProvider and VoiceProvider are lightweight
|
|
// shells; their heavy children are only activated when actually needed.
|
|
const isBootShell = !isInitialized && !isDesktopRuntime;
|
|
|
|
return (
|
|
<ErrorBoundary>
|
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
|
<RuntimeAPIProvider apis={apis}>
|
|
<FireworksProvider>
|
|
<VoiceProvider>
|
|
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
|
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
|
|
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
|
<MainLayout />
|
|
<Toaster />
|
|
{!isBootShell && (
|
|
<>
|
|
<ConfigUpdateOverlay />
|
|
<AboutDialogWrapper />
|
|
{showMemoryDebug && (
|
|
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</TooltipProvider>
|
|
</VoiceProvider>
|
|
</FireworksProvider>
|
|
</RuntimeAPIProvider>
|
|
</SyncProvider>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
export default App;
|