* feat: implement desktop boot outcome architecture
- Add structured DesktopBootOutcome with target/status fields
- Implement boot outcome computation and validation
- Add desktop hosts configuration management (Tauri + TypeScript)
- Add desktop hosts probing with timeout and retry logic
- Support local/remote host classification and health checks
This provides the foundational infrastructure for desktop onboarding
flow to determine whether to show local setup, remote connection,
or recovery screens based on OpenCode availability and remote host
reachability.
* feat: add desktop onboarding UI components
Add comprehensive onboarding flow for desktop app:
- ChooserScreen: First-launch local/remote selection
- LocalSetupScreen: CLI installation guidance and manual detection
- RecoveryScreen: Recovery mode with routing to local/remote
- RemoteConnectionForm: Remote host connection with validation
- DesktopConnectionRecovery: Recovery variants and routing logic
- ConnectionSettingsPage: Manage remote connections
Components handle:
- Local vs remote choice persistence
- Recovery scenarios (unreachable, wrong-service, missing)
- Manual CLI detection (replaced auto-polling)
- Back navigation and state preservation
* feat: integrate desktop onboarding with app shell
- Update App.tsx to handle onboarding routing and recovery
- Add onboarding mode switching (first-launch/local-setup/recovery)
- Integrate desktop hosts in SettingsView
- Update DesktopHostSwitcher with recovery routing
- Add desktop shell utilities for onboarding detection
- Update web manifest for desktop app metadata
Completes the desktop onboarding feature integration,
allowing users to choose local or remote OpenCode on
first launch and recover from connection failures.
* fix: hide back button in remote connection form for first-launch chooser
In first-launch chooser mode, the back button is redundant since users
can simply click the "Local Install" tab. The back button is still shown
in recovery mode where there's no tab interface.
Changes:
- Add showBackButton prop to RemoteConnectionForm (default: true)
- Set showBackButton={false} in ChooserScreen remote tab
- Keep showBackButton={true} in RecoveryScreen for navigation
* refactor: remove Connection Settings page and simplify recovery UI
Remove the Connection Settings page as it was redundant:
- Local server is single-instance (no need to "choose")
- Remote servers are one-time setup (first-launch chooser)
- SSH Instances remain for multi-instance management
Changes:
- Remove ConnectionSettingsPage component and directory
- Remove 'connection' from Settings metadata
- Remove "Open Settings" button from recovery screens
- Remove desktopBootBypassToSettings state and logic
- Update recovery config to use 'local' icon instead of 'settings'
- Update tests to reflect removed showOpenSettings field
This simplifies the UX by focusing on:
- First-launch chooser for initial local/remote decision
- Remote Instances (SSH) for managing multiple remote machines
- No persistent "server management" needed for typical desktop usage
* fix: remove unused enableCliPolling prop and clean up TypeScript errors
Remove the obsolete enableCliPolling prop that was used for auto-
polling CLI detection. We replaced this with manual "Check and Continue"
button in a previous commit, so this prop is no longer needed.
Changes:
- Remove enableCliPolling from OnboardingScreen props and usage
- Remove enableCliPolling from App.tsx calls
- Remove unused 'connection' case from getSettingsNavIcon()
- Remove unused RiGlobalLine import
This resolves all TypeScript compilation errors reported by Copilot.
* fix: remove unused onChooseLocal prop and CLI_MISSING_ERROR_REGEX
These were left over from the refactoring:
- onChooseLocal in RecoveryScreen was defined but never used
- CLI_MISSING_ERROR_REGEX in App.tsx was leftover from removed enableCliPolling code
* fix: remove unused variables and fix React Hook dependency warnings
Remove unused memoized components and variables that were causing
lint errors in packages/ui:
- MainLayout.tsx: Remove unused MemoHeader, MemoChatView, MemoPlanView,
MemoGitView, MemoDiffView, MemoTerminalView, MemoFilesView,
MemoRightSidebarTabs, DesktopLeftSidebar, and DesktopRightPanel
- useGitHubPrStatusStore.ts: Remove unused prVisualPriority function
- useChatScrollManager.ts: Add missing markProgrammaticScroll dependency
to React.useEffect hook
These fixes resolve the CI lint failures in PR 850.
* chore: remove local claude settings from repo
* refactor(desktop): drop vibrancy code from onboarding PR
---------
Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
722 lines
24 KiB
TypeScript
722 lines
24 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 { 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 { 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 { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
|
|
import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionRecovery';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
|
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 { 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';
|
|
|
|
const AboutDialogWrapper: React.FC = () => {
|
|
const isAboutDialogOpen = useUIStore((s) => s.isAboutDialogOpen);
|
|
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
|
|
return (
|
|
<AboutDialog
|
|
open={isAboutDialogOpen}
|
|
onOpenChange={setAboutDialogOpen}
|
|
/>
|
|
);
|
|
};
|
|
|
|
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 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();
|
|
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 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;
|
|
|
|
React.useEffect(() => {
|
|
setStreamPerfEnabled(showMemoryDebug);
|
|
return () => {
|
|
setStreamPerfEnabled(false);
|
|
};
|
|
}, [showMemoryDebug]);
|
|
|
|
React.useEffect(() => {
|
|
setIsVSCodeRuntime(apis.runtime.isVSCode);
|
|
}, [apis.runtime.isVSCode]);
|
|
|
|
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;
|
|
|
|
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;
|
|
}
|
|
await initializeApp();
|
|
};
|
|
|
|
init();
|
|
}, [initializeApp, isVSCodeRuntime]);
|
|
|
|
// 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;
|
|
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();
|
|
}, []);
|
|
|
|
// 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">
|
|
<OnboardingScreen
|
|
mode="first-launch"
|
|
onCliAvailable={handleDesktopBootDismiss}
|
|
onChooseRemote={() => {
|
|
// Switch to remote tab - handled internally by OnboardingScreen
|
|
}}
|
|
/>
|
|
</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">
|
|
<OnboardingScreen
|
|
mode="recovery"
|
|
recoveryVariant={recoveryVariant}
|
|
recoveryHostUrl={hostUrl}
|
|
recoveryHostLabel={undefined}
|
|
onCliAvailable={handleDesktopBootDismiss}
|
|
/>
|
|
</div>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
if (embeddedSessionChat) {
|
|
return (
|
|
<ErrorBoundary>
|
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
|
<RuntimeAPIProvider apis={apis}>
|
|
<TooltipProvider delayDuration={700} 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>
|
|
);
|
|
}
|
|
|
|
// 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={700} 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={700} skipDelayDuration={150}>
|
|
<div className="h-full text-foreground bg-background">
|
|
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
|
<VSCodeLayout />
|
|
<Toaster />
|
|
</div>
|
|
</TooltipProvider>
|
|
</FireworksProvider>
|
|
</RuntimeAPIProvider>
|
|
</SyncProvider>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<ErrorBoundary>
|
|
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || ''}>
|
|
<RuntimeAPIProvider apis={apis}>
|
|
<FireworksProvider>
|
|
<VoiceProvider>
|
|
<TooltipProvider delayDuration={700} skipDelayDuration={150}>
|
|
<div className={isDesktopRuntime ? 'h-full text-foreground bg-transparent' : 'h-full text-foreground bg-background'}>
|
|
<SyncAppEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
|
<MainLayout />
|
|
<Toaster />
|
|
<ConfigUpdateOverlay />
|
|
<AboutDialogWrapper />
|
|
{showMemoryDebug && (
|
|
<MemoryDebugPanel onClose={() => setShowMemoryDebug(false)} />
|
|
)}
|
|
</div>
|
|
</TooltipProvider>
|
|
</VoiceProvider>
|
|
</FireworksProvider>
|
|
</RuntimeAPIProvider>
|
|
</SyncProvider>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|
|
export default App;
|