import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router'; import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react'; import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { createLogger } from '@automaker/utils/logger'; import { Sidebar } from '@/components/layout/sidebar'; import { ProjectSwitcher } from '@/components/layout/project-switcher'; import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser, } from '@/contexts/file-browser-context'; import { useAppStore, getStoredTheme, type ThemeMode } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { useAuthStore } from '@/store/auth-store'; import { getElectronAPI, isElectron } from '@/lib/electron'; import { isMac } from '@/lib/utils'; import { initializeProject } from '@/lib/project-init'; import { initApiKey, verifySession, checkSandboxEnvironment, getServerUrlSync, getHttpApiClient, handleServerOffline, } from '@/lib/http-api-client'; import { hydrateStoreFromSettings, parseLocalStorageSettings, signalMigrationComplete, performSettingsMigration, } from '@/hooks/use-settings-migration'; import { queryClient } from '@/lib/query-client'; import { createIDBPersister, PERSIST_MAX_AGE_MS, PERSIST_THROTTLE_MS } from '@/lib/query-persist'; import { Toaster } from 'sonner'; import { ThemeOption, themeOptions } from '@/config/theme-options'; import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog'; import { SandboxRejectionScreen } from '@/components/dialogs/sandbox-rejection-screen'; import { LoadingState } from '@/components/ui/loading-state'; import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader'; import { useIsCompact } from '@/hooks/use-media-query'; import type { Project } from '@/lib/electron'; import type { GlobalSettings } from '@automaker/types'; import { syncUICache, restoreFromUICache } from '@/store/ui-cache-store'; import { setItem } from '@/lib/storage'; const logger = createLogger('RootLayout'); const IS_DEV = import.meta.env.DEV; const SERVER_READY_MAX_ATTEMPTS = 8; const SERVER_READY_BACKOFF_BASE_MS = 250; const SERVER_READY_MAX_DELAY_MS = 1500; const SERVER_READY_TIMEOUT_MS = 2000; const NO_STORE_CACHE_MODE: RequestCache = 'no-store'; const AUTO_OPEN_HISTORY_INDEX = 0; const SINGLE_PROJECT_COUNT = 1; const DEFAULT_LAST_OPENED_TIME_MS = 0; // IndexedDB persister for React Query cache (survives tab discard) const idbPersister = createIDBPersister(); /** Options for PersistQueryClientProvider */ const persistOptions = { persister: idbPersister, maxAge: PERSIST_MAX_AGE_MS, // Throttle IndexedDB writes to prevent excessive I/O on every query state change. // Without this, every query update triggers an IndexedDB write — especially costly on mobile. throttleTime: PERSIST_THROTTLE_MS, // Build hash injected by Vite — same hash used by swCacheBuster for the SW CACHE_NAME. // When the app is rebuilt, this changes and both the IDB query cache and SW cache // are invalidated together, preventing stale data from surviving a deployment. // In dev mode this is a stable hash of the package version so the cache persists // across hot reloads. buster: typeof __APP_BUILD_HASH__ !== 'undefined' ? __APP_BUILD_HASH__ : '', dehydrateOptions: { shouldDehydrateQuery: (query: { state: { status: string } }) => query.state.status === 'success', }, }; const AUTO_OPEN_STATUS = { idle: 'idle', opening: 'opening', done: 'done', } as const; type AutoOpenStatus = (typeof AUTO_OPEN_STATUS)[keyof typeof AUTO_OPEN_STATUS]; // Apply stored theme immediately on page load (before React hydration) // This prevents flash of default theme on login/setup pages function applyStoredTheme(): void { const storedTheme = getStoredTheme(); if (storedTheme) { const root = document.documentElement; // Remove all theme classes (themeOptions doesn't include 'system' which is only in ThemeMode) const themeClasses = themeOptions.map((option) => option.value); root.classList.remove(...themeClasses); // Apply the stored theme if (storedTheme === 'dark') { root.classList.add('dark'); } else if (storedTheme === 'system') { const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; root.classList.add(isDark ? 'dark' : 'light'); } else if (storedTheme !== 'light') { root.classList.add(storedTheme); } else { root.classList.add('light'); } } } // Apply stored theme immediately (runs synchronously before render) applyStoredTheme(); async function waitForServerReady(): Promise { const serverUrl = getServerUrlSync(); for (let attempt = 1; attempt <= SERVER_READY_MAX_ATTEMPTS; attempt++) { try { const response = await fetch(`${serverUrl}/api/health`, { method: 'GET', signal: AbortSignal.timeout(SERVER_READY_TIMEOUT_MS), cache: NO_STORE_CACHE_MODE, }); if (response.ok) { return true; } } catch (error) { logger.warn(`Server readiness check failed (attempt ${attempt})`, error); } const delayMs = Math.min(SERVER_READY_MAX_DELAY_MS, SERVER_READY_BACKOFF_BASE_MS * attempt); await new Promise((resolve) => setTimeout(resolve, delayMs)); } return false; } function getProjectLastOpenedMs(project: Project): number { if (!project.lastOpened) return DEFAULT_LAST_OPENED_TIME_MS; const parsed = Date.parse(project.lastOpened); return Number.isNaN(parsed) ? DEFAULT_LAST_OPENED_TIME_MS : parsed; } function selectAutoOpenProject( currentProject: Project | null, projects: Project[], projectHistory: string[] ): Project | null { if (currentProject) return currentProject; if (projectHistory.length > 0) { const historyProjectId = projectHistory[AUTO_OPEN_HISTORY_INDEX]; const historyProject = projects.find((project) => project.id === historyProjectId); if (historyProject) { return historyProject; } } if (projects.length === SINGLE_PROJECT_COUNT) { return projects[AUTO_OPEN_HISTORY_INDEX] ?? null; } if (projects.length > SINGLE_PROJECT_COUNT) { let latestProject: Project | null = projects[AUTO_OPEN_HISTORY_INDEX] ?? null; let latestTimestamp = latestProject ? getProjectLastOpenedMs(latestProject) : DEFAULT_LAST_OPENED_TIME_MS; for (const project of projects) { const openedAt = getProjectLastOpenedMs(project); if (openedAt > latestTimestamp) { latestTimestamp = openedAt; latestProject = project; } } return latestProject; } return null; } function RootLayoutContent() { const location = useLocation(); // IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent // re-rendering on every store mutation. The bare call subscribes to the ENTIRE store, // which during initialization causes cascading re-renders as multiple effects write // to the store (settings hydration, project settings, auto-open, etc.). With enough // rapid mutations, React hits the maximum update depth limit (error #185). // // Each selector only triggers a re-render when its specific slice of state changes. const projects = useAppStore((s) => s.projects); const currentProject = useAppStore((s) => s.currentProject); const projectHistory = useAppStore((s) => s.projectHistory); const sidebarStyle = useAppStore((s) => s.sidebarStyle); const skipSandboxWarning = useAppStore((s) => s.skipSandboxWarning); // Subscribe to theme and font state to trigger re-renders when they change const theme = useAppStore((s) => s.theme); const fontFamilySans = useAppStore((s) => s.fontFamilySans); const fontFamilyMono = useAppStore((s) => s.fontFamilyMono); // Subscribe to previewTheme so that getEffectiveTheme() re-renders when // hover previews change the document theme. Without this, the selector // for getEffectiveTheme (a stable function ref) won't trigger re-renders. const previewTheme = useAppStore((s) => s.previewTheme); void previewTheme; // Used only for subscription // Actions (stable references from Zustand - never change between renders) const setIpcConnected = useAppStore((s) => s.setIpcConnected); const upsertAndSetCurrentProject = useAppStore((s) => s.upsertAndSetCurrentProject); const getEffectiveTheme = useAppStore((s) => s.getEffectiveTheme); const getEffectiveFontSans = useAppStore((s) => s.getEffectiveFontSans); const getEffectiveFontMono = useAppStore((s) => s.getEffectiveFontMono); const setSkipSandboxWarning = useAppStore((s) => s.setSkipSandboxWarning); const fetchCodexModels = useAppStore((s) => s.fetchCodexModels); const setupComplete = useSetupStore((s) => s.setupComplete); const codexCliStatus = useSetupStore((s) => s.codexCliStatus); const navigate = useNavigate(); const [isMounted, setIsMounted] = useState(false); const [streamerPanelOpen, setStreamerPanelOpen] = useState(false); const authChecked = useAuthStore((s) => s.authChecked); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const settingsLoaded = useAuthStore((s) => s.settingsLoaded); const { openFileBrowser } = useFileBrowser(); // Load project settings when switching projects useProjectSettingsLoader(); const isSetupRoute = location.pathname === '/setup'; const isLoginRoute = location.pathname === '/login'; const isLoggedOutRoute = location.pathname === '/logged-out'; const isDashboardRoute = location.pathname === '/dashboard'; const isRootRoute = location.pathname === '/'; const [autoOpenStatus, setAutoOpenStatus] = useState(AUTO_OPEN_STATUS.idle); const autoOpenCandidate = selectAutoOpenProject(currentProject, projects, projectHistory); const canAutoOpen = authChecked && isAuthenticated && settingsLoaded && setupComplete && !isLoginRoute && !isLoggedOutRoute && !isSetupRoute && !!autoOpenCandidate; // Only block the UI with "Opening project..." when on the root route. // When already on /board or /dashboard, auto-open runs silently in the background — // blocking here would cause a visible flash when switching back to the PWA. const shouldAutoOpen = canAutoOpen && autoOpenStatus !== AUTO_OPEN_STATUS.done && isRootRoute; const shouldBlockForSettings = authChecked && isAuthenticated && !settingsLoaded && !isLoginRoute && !isLoggedOutRoute; // Sandbox environment check state type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; // Always start from pending on a fresh page load so the user sees the prompt // each time the app is launched/refreshed (unless running in a container). const [sandboxStatus, setSandboxStatus] = useState('pending'); // Hidden streamer panel - opens with "\" key const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => { const activeElement = document.activeElement; if (activeElement) { const tagName = activeElement.tagName.toLowerCase(); if (tagName === 'input' || tagName === 'textarea' || tagName === 'select') { return; } if (activeElement.getAttribute('contenteditable') === 'true') { return; } const role = activeElement.getAttribute('role'); if (role === 'textbox' || role === 'searchbox' || role === 'combobox') { return; } // Don't intercept when focused inside a terminal if (activeElement.closest('.xterm') || activeElement.closest('[data-terminal-container]')) { return; } } if (event.ctrlKey || event.altKey || event.metaKey) { return; } if (event.key === '\\') { event.preventDefault(); setStreamerPanelOpen((prev) => !prev); } }, []); useEffect(() => { window.addEventListener('keydown', handleStreamerPanelShortcut); return () => { window.removeEventListener('keydown', handleStreamerPanelShortcut); }; }, [handleStreamerPanelShortcut]); const effectiveTheme = getEffectiveTheme(); // Defer the theme value to keep UI responsive during rapid hover changes const deferredTheme = useDeferredValue(effectiveTheme); // Get effective theme and fonts for the current project // Note: theme/fontFamilySans/fontFamilyMono are destructured above to ensure re-renders when they change void theme; // Used for subscription void fontFamilySans; // Used for subscription void fontFamilyMono; // Used for subscription const effectiveFontSans = getEffectiveFontSans(); const effectiveFontMono = getEffectiveFontMono(); useEffect(() => { setIsMounted(true); }, []); // Sync critical UI state to the persistent UI cache store // This keeps the cache up-to-date so tab discard recovery is instant useEffect(() => { const unsubscribe = useAppStore.subscribe((state) => { syncUICache({ currentProject: state.currentProject, sidebarOpen: state.sidebarOpen, sidebarStyle: state.sidebarStyle, worktreePanelCollapsed: state.worktreePanelCollapsed, collapsedNavSections: state.collapsedNavSections, currentWorktreeByProject: state.currentWorktreeByProject, }); }); return unsubscribe; }, []); // Check sandbox environment only after user is authenticated, setup is complete, and settings are loaded useEffect(() => { // Skip if already decided if (sandboxStatus !== 'pending') { return; } // Don't check sandbox until user is authenticated, has completed setup, and settings are loaded // CRITICAL: settingsLoaded must be true to ensure skipSandboxWarning has been hydrated from server if (!authChecked || !isAuthenticated || !setupComplete || !settingsLoaded) { return; } const checkSandbox = async () => { try { const result = await checkSandboxEnvironment(); if (result.isContainerized) { // Running in a container, no warning needed setSandboxStatus('containerized'); } else if (result.skipSandboxWarning || skipSandboxWarning) { // Skip if env var is set OR if user preference is set setSandboxStatus('confirmed'); } else { // Not containerized, show warning dialog setSandboxStatus('needs-confirmation'); } } catch (error) { logger.error('Failed to check environment:', error); // On error, assume not containerized and show warning if (skipSandboxWarning) { setSandboxStatus('confirmed'); } else { setSandboxStatus('needs-confirmation'); } } }; checkSandbox(); }, [ sandboxStatus, skipSandboxWarning, authChecked, isAuthenticated, setupComplete, settingsLoaded, ]); // Handle sandbox risk confirmation const handleSandboxConfirm = useCallback( (skipInFuture: boolean) => { if (skipInFuture) { setSkipSandboxWarning(true); } setSandboxStatus('confirmed'); }, [setSkipSandboxWarning] ); // Handle sandbox risk denial const handleSandboxDeny = useCallback(async () => { if (isElectron()) { // In Electron mode, quit the application // Use window.electronAPI directly since getElectronAPI() returns the HTTP client try { const electronAPI = window.electronAPI; if (electronAPI?.quit) { await electronAPI.quit(); } else { logger.error('quit() not available on electronAPI'); } } catch (error) { logger.error('Failed to quit app:', error); } } else { // In web mode, show rejection screen setSandboxStatus('denied'); } }, []); // Ref to prevent concurrent auth checks from running const authCheckRunning = useRef(false); // Global listener for 401/403 responses during normal app usage. // This is triggered by the HTTP client whenever an authenticated request returns 401/403. // Works for ALL modes (unified flow) useEffect(() => { const handleLoggedOut = () => { logger.warn('automaker:logged-out event received!'); // Only update auth state — the centralized routing effect will handle // navigation to /logged-out when it detects isAuthenticated is false useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); }; window.addEventListener('automaker:logged-out', handleLoggedOut); return () => { window.removeEventListener('automaker:logged-out', handleLoggedOut); }; }, []); // Global listener for server offline/connection errors. // This is triggered when a connection error is detected (e.g., server stopped). // Redirects to login page which will detect server is offline and show error UI. useEffect(() => { const handleServerOffline = () => { logger.warn('automaker:server-offline event received!'); useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); // Navigate to login - the login page will detect server is offline and show appropriate UI if (location.pathname !== '/login' && location.pathname !== '/logged-out') { navigate({ to: '/login' }); } }; window.addEventListener('automaker:server-offline', handleServerOffline); return () => { window.removeEventListener('automaker:server-offline', handleServerOffline); }; }, [location.pathname, navigate]); // Initialize authentication // - Electron mode: Uses API key from IPC (header-based auth) // - Web mode: Uses HTTP-only session cookie // // Optimizations applied: // 1. Instant hydration from localStorage settings cache (optimistic) // 2. Parallelized server checks: verifySession + fetchSettings fire together // 3. Server settings reconcile in background after optimistic render useEffect(() => { // Prevent concurrent auth checks if (authCheckRunning.current) { return; } const initAuth = async () => { authCheckRunning.current = true; try { // OPTIMIZATION: Restore UI layout from the UI cache store immediately. // This gives instant visual continuity (sidebar state, nav sections, etc.) // before server settings arrive. Will be reconciled by hydrateStoreFromSettings(). restoreFromUICache((state) => useAppStore.setState(state)); // OPTIMIZATION: Immediately hydrate from localStorage settings cache // This gives the user an instant UI while server data loads in the background const cachedSettings = parseLocalStorageSettings(); let optimisticallyHydrated = false; if (cachedSettings && cachedSettings.projects && cachedSettings.projects.length > 0) { logger.info('[FAST_HYDRATE] Optimistically hydrating from localStorage cache'); hydrateStoreFromSettings(cachedSettings as GlobalSettings); optimisticallyHydrated = true; } // OPTIMIZATION: Take the fast path BEFORE any async work when localStorage is warm. // // Previously the fast path check came after `await initApiKey()`. Even though // initApiKey() is a no-op in web mode, the `await` still yields to the microtask // queue — adding one unnecessary event loop tick before authChecked becomes true. // By moving this check before any `await`, we set authChecked synchronously within // the same React render cycle, eliminating a frame of spinner on mobile. // // The background verify (waitForServerReady + verifySession) still runs after the // `await initApiKey()` below, so Electron mode still gets its server URL before // any API calls are made. if (optimisticallyHydrated) { logger.info( '[FAST_HYDRATE] localStorage settings warm — marking auth complete optimistically' ); signalMigrationComplete(); useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true, settingsLoaded: true, }); // OPTIMIZATION: Skip the blocking "Opening project..." auto-open screen // when restoring from cache. On a warm restart (PWA memory eviction, tab // discard, page reload), currentProject is already restored from the UI // cache (restoreFromUICache ran above). The auto-open effect calls // initializeProject() which makes 5+ blocking HTTP calls to verify the // .automaker directory structure — this is needed for first-time opens // but redundant for returning users. Marking auto-open as done lets the // routing effect navigate to /board immediately without the detour. const restoredProject = useAppStore.getState().currentProject; if (restoredProject) { logger.info( '[FAST_HYDRATE] Project already restored from cache — skipping auto-open', restoredProject.name ); setAutoOpenStatus(AUTO_OPEN_STATUS.done); } // Initialize API key then start background verification. // We do this AFTER marking auth complete so the spinner is already gone. // In web mode initApiKey() is a no-op; in Electron it fetches the IPC server URL. await initApiKey(); // Background verify: confirm session is still valid + fetch fresh settings. // The UI is already rendered from cached data — this reconciles stale state. // // IMPORTANT: We skip waitForServerReady() here intentionally. // waitForServerReady() uses cache:'no-store' (bypasses the service worker) // and makes a dedicated /api/health round trip before any real work. // On mobile cellular (100-300ms RTT) that pre-flight adds visible delay. // Instead we fire verifySession + getGlobal directly — both already handle // server-down gracefully via their .catch() wrappers. If the server isn't // up yet the catches return null/failure and we simply keep the cached session. // // IMPORTANT: Distinguish definitive auth failures (401/403 → false) from // transient errors (timeouts, network failures → null/throw). Only a definitive // failure should reset isAuthenticated — transient errors keep the user logged in. void (async () => { try { const api = getHttpApiClient(); const [sessionValid, settingsResult] = await Promise.all([ // verifySession() returns true (valid), false (401/403), or throws (transient). // Map throws → null so we can distinguish "definitively invalid" from "couldn't check". verifySession().catch((err) => { logger.debug('[FAST_HYDRATE] Background verify threw (transient):', err?.message); return null; }), api.settings.getGlobal().catch(() => ({ success: false, settings: null }) as const), ]); if (sessionValid === false) { // Session is definitively expired (server returned 401/403) — log them out logger.warn('[FAST_HYDRATE] Background verify: session invalid, logging out'); useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); return; } // Server responded — mark IPC connected (replaces the separate health check) if (sessionValid === true) { setIpcConnected(true); } if (sessionValid === null) { // Transient error (timeout, network, 5xx) — keep the user logged in. // The next real API call will detect an expired session if needed. logger.info( '[FAST_HYDRATE] Background verify inconclusive — keeping session active' ); } // Update the localStorage cache with fresh server data so the NEXT // cold start uses up-to-date settings. But do NOT call // hydrateStoreFromSettings() here — the store was already hydrated // from localStorage cache moments ago. Re-hydrating from the server // response would create new object references for projects, settings // arrays, etc., which triggers useSettingsSync's store subscriber // to fire an immediate sync-back POST, causing a visible re-render // flash (board → spinner → board) on mobile. // // The localStorage cache and server data are nearly always identical // (the sync hook wrote the cache from the last successful sync). // Any genuine differences (e.g., settings changed on another device) // will be picked up on the next user interaction or the sync hook's // periodic reconciliation. if (settingsResult.success && settingsResult.settings) { try { const { settings: finalSettings } = await performSettingsMigration( settingsResult.settings as unknown as Parameters< typeof performSettingsMigration >[0] ); // Persist fresh server data to localStorage for the next cold start setItem('automaker-settings-cache', JSON.stringify(finalSettings)); logger.info( '[FAST_HYDRATE] Background reconcile: cache updated (store untouched)' ); // Selectively reconcile event hooks and ntfy endpoints from server. // Unlike projects/theme, these aren't rendered on the main view, // so updating them won't cause a visible re-render flash. const serverHooks = (finalSettings as GlobalSettings).eventHooks ?? []; const currentHooks = useAppStore.getState().eventHooks; if (JSON.stringify(serverHooks) !== JSON.stringify(currentHooks)) { logger.info( `[FAST_HYDRATE] Reconciling eventHooks from server (server=${serverHooks.length}, store=${currentHooks.length})` ); useAppStore.setState({ eventHooks: serverHooks }); } // Reconcile ntfy endpoints from server (same rationale as eventHooks) const serverEndpoints = (finalSettings as GlobalSettings).ntfyEndpoints ?? []; const currentEndpoints = useAppStore.getState().ntfyEndpoints; if (JSON.stringify(serverEndpoints) !== JSON.stringify(currentEndpoints)) { logger.info( `[FAST_HYDRATE] Reconciling ntfyEndpoints from server (server=${serverEndpoints.length}, store=${currentEndpoints.length})` ); useAppStore.setState({ ntfyEndpoints: serverEndpoints }); } } catch (e) { logger.debug('[FAST_HYDRATE] Failed to update cache:', e); } } } catch (error) { // Outer catch for unexpected errors — do NOT reset auth state. // If the session is truly expired, the next API call will handle it. logger.warn( '[FAST_HYDRATE] Background verify failed (server may be restarting):', error ); } })(); return; // Auth is done — foreground initAuth exits here } // Initialize API key for Electron mode (needed before any server calls) await initApiKey(); // Cold start path: server not yet confirmed running, wait for it // (Only reached when localStorage has no cached settings) const serverReady = await waitForServerReady(); if (!serverReady) { handleServerOffline(); return; } // OPTIMIZATION: Fire verifySession and fetchSettings in parallel // instead of waiting for session verification before fetching settings const api = getHttpApiClient(); const [sessionValid, settingsResult] = await Promise.all([ // verifySession() returns true (valid), false (401/403), or throws (transient). // Map throws → null (matching background verify behaviour) so transient // failures don't cause unnecessary logouts on cold start. verifySession().catch((error) => { logger.warn('Session verification threw (transient, keeping session):', error?.message); return null; }), api.settings.getGlobal().catch((error) => { logger.warn('Settings fetch failed during parallel init:', error); return { success: false, settings: null } as const; }), ]); if (sessionValid === true || sessionValid === null) { // Settings were fetched in parallel - use them directly if (settingsResult.success && settingsResult.settings) { const { settings: finalSettings, migrated } = await performSettingsMigration( settingsResult.settings as unknown as Parameters[0] ); if (migrated) { logger.info('Settings migration from localStorage completed'); } // Hydrate store with the final settings (reconcile with optimistic data) hydrateStoreFromSettings(finalSettings); // CRITICAL: Wait for React to render the hydrated state before // signaling completion. Zustand updates are synchronous, but React // hasn't necessarily re-rendered yet. This prevents race conditions // where useSettingsSync reads state before the UI has updated. await new Promise((resolve) => setTimeout(resolve, 0)); // Signal that settings hydration is complete FIRST. signalMigrationComplete(); // Now mark auth as checked AND settings as loaded. useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true, settingsLoaded: true, }); return; } // Settings weren't available in parallel response - retry with backoff try { const maxAttempts = 6; const baseDelayMs = 250; let lastError: unknown = settingsResult; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const delayMs = Math.min(1500, baseDelayMs * attempt); logger.warn( `Settings not ready (attempt ${attempt}/${maxAttempts}); retrying in ${delayMs}ms...`, lastError ); await new Promise((resolve) => setTimeout(resolve, delayMs)); try { const retryResult = await api.settings.getGlobal(); if (retryResult.success && retryResult.settings) { const { settings: finalSettings, migrated } = await performSettingsMigration( retryResult.settings as unknown as Parameters< typeof performSettingsMigration >[0] ); if (migrated) { logger.info('Settings migration from localStorage completed'); } hydrateStoreFromSettings(finalSettings); await new Promise((resolve) => setTimeout(resolve, 0)); signalMigrationComplete(); useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true, settingsLoaded: true, }); return; } lastError = retryResult; } catch (error) { lastError = error; } } throw lastError ?? new Error('Failed to load settings'); } catch (error) { logger.error('Failed to fetch settings after valid session:', error); // If optimistically hydrated, allow the user to continue with cached data if (optimisticallyHydrated) { logger.info('[FAST_HYDRATE] Using optimistic cache as fallback (server unavailable)'); signalMigrationComplete(); useAuthStore.getState().setAuthState({ isAuthenticated: true, authChecked: true, settingsLoaded: true, }); return; } // If we can't load settings, we must NOT start syncing defaults to the server. // Only update auth state — the routing effect handles navigation to /logged-out. // Calling navigate() here AND in the routing effect causes duplicate navigations // that can trigger React error #185 (maximum update depth exceeded) on cold start. useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); signalMigrationComplete(); return; } } else { // Session is definitively invalid (server returned 401/403) - treat as not authenticated. // Only update auth state — the routing effect handles navigation to /logged-out. // Calling navigate() here AND in the routing effect causes duplicate navigations // that can trigger React error #185 (maximum update depth exceeded) on cold start. useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); // Signal migration complete so sync hook doesn't hang (nothing to sync when not authenticated) signalMigrationComplete(); } } catch (error) { logger.error('Failed to initialize auth:', error); // On error, treat as not authenticated. // Only update auth state — the routing effect handles navigation to /logged-out. // Calling navigate() here AND in the routing effect causes duplicate navigations // that can trigger React error #185 (maximum update depth exceeded) on cold start. useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); // Signal migration complete so sync hook doesn't hang signalMigrationComplete(); } finally { authCheckRunning.current = false; } }; initAuth(); // eslint-disable-next-line react-hooks/exhaustive-deps -- setIpcConnected is stable, runs once on mount }, []); // Runs once per load; auth state drives routing rules // Note: Settings are now loaded in __root.tsx after successful session verification // This ensures a unified flow across all modes (Electron, web, external server) // Routing rules (ALL modes - unified flow): // - If not authenticated: force /logged-out (even /setup is protected) // - If authenticated but setup incomplete: force /setup // - If authenticated and setup complete: allow access to app useEffect(() => { logger.debug('Routing effect triggered:', { authChecked, isAuthenticated, settingsLoaded, setupComplete, pathname: location.pathname, }); // Wait for auth check to complete before enforcing any redirects if (!authChecked) { logger.debug('Auth not checked yet, skipping routing'); return; } // Unauthenticated -> force /logged-out (but allow /login so user can authenticate) if (!isAuthenticated) { logger.warn('Not authenticated, redirecting to /logged-out. Auth state:', { authChecked, isAuthenticated, settingsLoaded, currentPath: location.pathname, }); if (location.pathname !== '/logged-out' && location.pathname !== '/login') { navigate({ to: '/logged-out' }); } return; } // Wait for settings to be loaded before making setupComplete-based routing decisions // This prevents redirecting to /setup before we know the actual setupComplete value if (!settingsLoaded) return; // Authenticated -> determine whether setup is required if (!setupComplete && location.pathname !== '/setup') { navigate({ to: '/setup' }); return; } // Setup complete but user is still on /setup -> go to dashboard if (setupComplete && location.pathname === '/setup') { navigate({ to: '/dashboard' }); } }, [authChecked, isAuthenticated, settingsLoaded, setupComplete, location.pathname, navigate]); // Fallback: If auth is checked and authenticated but settings not loaded, // it means login-view or another component set auth state before __root.tsx's // auth flow completed. Load settings now to prevent sync with empty state. useEffect(() => { // Only trigger if auth is valid but settings aren't loaded yet // This handles the case where login-view sets authChecked=true before we finish our auth flow if (!authChecked || !isAuthenticated || settingsLoaded) { logger.debug('Fallback skipped:', { authChecked, isAuthenticated, settingsLoaded }); return; } logger.info('Auth valid but settings not loaded - triggering fallback load'); const loadSettings = async () => { const api = getHttpApiClient(); try { logger.debug('Fetching settings in fallback...'); const settingsResult = await api.settings.getGlobal(); logger.debug('Settings fetched:', settingsResult.success ? 'success' : 'failed'); if (settingsResult.success && settingsResult.settings) { const { settings: finalSettings } = await performSettingsMigration( settingsResult.settings as unknown as Parameters[0] ); logger.debug('Settings migrated, hydrating stores...'); hydrateStoreFromSettings(finalSettings); await new Promise((resolve) => setTimeout(resolve, 0)); signalMigrationComplete(); logger.debug('Setting settingsLoaded=true'); useAuthStore.getState().setAuthState({ settingsLoaded: true }); logger.info('Fallback settings load completed successfully'); } } catch (error) { logger.error('Failed to load settings in fallback:', error); } }; loadSettings(); }, [authChecked, isAuthenticated, settingsLoaded]); useEffect(() => { setGlobalFileBrowser(openFileBrowser); }, [openFileBrowser]); // Test IPC connection on mount. // For returning users on the fast-hydrate path, the background IIFE in initAuth // already calls waitForServerReady() which performs a health check. Doing a second // concurrent health check wastes a connection slot on mobile's limited TCP pool. // Instead, set ipcConnected optimistically for returning users (auth already marked // true at module load time) and let the background verify surface any real failures. useEffect(() => { // Returning users: auth store was pre-populated from localStorage at module load. // The background verify IIFE in initAuth handles the real health check. // Optimistically mark connected — if the server is truly down, the next API call // (triggered by the background verify) will surface the error. const { authChecked: alreadyChecked, isAuthenticated: alreadyAuthed } = useAuthStore.getState(); if (!isElectron() && alreadyChecked && alreadyAuthed) { setIpcConnected(true); return; } const testConnection = async () => { try { if (isElectron()) { const api = getElectronAPI(); const result = await api.ping(); setIpcConnected(result === 'pong'); return; } // Web mode: check backend availability without instantiating the full HTTP client const response = await fetch(`${getServerUrlSync()}/api/health`, { method: 'GET', signal: AbortSignal.timeout(2000), }); setIpcConnected(response.ok); } catch (error) { logger.error('IPC connection failed:', error); setIpcConnected(false); } }; testConnection(); }, [setIpcConnected]); // Redirect from welcome page based on project state useEffect(() => { if (isMounted && isRootRoute) { if (!settingsLoaded || shouldAutoOpen) { return; } if (currentProject) { // Project is selected, go to board navigate({ to: '/board' }); } else { // No project selected, go to dashboard navigate({ to: '/dashboard' }); } } }, [isMounted, currentProject, isRootRoute, navigate, shouldAutoOpen, settingsLoaded]); // Auto-open the most recent project on startup useEffect(() => { if (!canAutoOpen) return; if (autoOpenStatus !== AUTO_OPEN_STATUS.idle) return; if (!autoOpenCandidate) return; setAutoOpenStatus(AUTO_OPEN_STATUS.opening); const openProject = async () => { try { const initResult = await initializeProject(autoOpenCandidate.path); if (!initResult.success) { logger.warn('Auto-open project failed:', initResult.error); if (isRootRoute) { navigate({ to: '/dashboard' }); } return; } if (!currentProject || currentProject.id !== autoOpenCandidate.id) { upsertAndSetCurrentProject( autoOpenCandidate.path, autoOpenCandidate.name, autoOpenCandidate.theme as ThemeMode | undefined ); } if (isRootRoute) { navigate({ to: '/board' }); } } catch (error) { logger.error('Auto-open project crashed:', error); if (isRootRoute) { navigate({ to: '/dashboard' }); } } finally { setAutoOpenStatus(AUTO_OPEN_STATUS.done); } }; void openProject(); }, [ canAutoOpen, autoOpenStatus, autoOpenCandidate, currentProject, navigate, upsertAndSetCurrentProject, isRootRoute, ]); // Bootstrap Codex models on app startup (after auth completes) useEffect(() => { // Only fetch if authenticated and Codex CLI is available if (!authChecked || !isAuthenticated) return; const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.hasApiKey; if (!isCodexAvailable) return; // Fetch models in the background fetchCodexModels().catch((error) => { logger.warn('Failed to bootstrap Codex models:', error); }); }, [authChecked, isAuthenticated, codexCliStatus, fetchCodexModels]); // Apply theme class to document - use deferred value to avoid blocking UI useEffect(() => { const root = document.documentElement; // Remove all theme classes dynamically from themeOptions const themeClasses = themeOptions .map((option) => option.value) .filter((theme) => theme !== ('system' as ThemeOption['value'])); root.classList.remove(...themeClasses); if (deferredTheme === 'dark') { root.classList.add('dark'); } else if (deferredTheme === 'system') { const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; root.classList.add(isDark ? 'dark' : 'light'); } else if (deferredTheme && deferredTheme !== 'light') { root.classList.add(deferredTheme); } else { root.classList.add('light'); } }, [deferredTheme]); // Apply font CSS variables for project-specific font overrides useEffect(() => { const root = document.documentElement; if (effectiveFontSans) { root.style.setProperty('--font-sans', effectiveFontSans); } else { root.style.removeProperty('--font-sans'); } if (effectiveFontMono) { root.style.setProperty('--font-mono', effectiveFontMono); } else { root.style.removeProperty('--font-mono'); } }, [effectiveFontSans, effectiveFontMono]); // Show sandbox rejection screen if user denied the risk warning if (sandboxStatus === 'denied') { return ; } // Show sandbox risk dialog if not containerized and user hasn't confirmed // The dialog is rendered as an overlay while the main content is blocked const showSandboxDialog = sandboxStatus === 'needs-confirmation'; // Show login page (full screen, no sidebar) // Note: No sandbox dialog here - it only shows after login and setup complete if (isLoginRoute || isLoggedOutRoute) { return (
); } // Wait for auth check before rendering protected routes (ALL modes - unified flow). // The visual here intentionally matches the inline HTML app shell (index.html) // so the transition from HTML → React is seamless — no layout shift, no flash. if (!authChecked) { return (
{/* Pure CSS spinner — no icon dependencies, so vendor-icons can be deferred/prefetched. Matches the HTML app shell in index.html for a seamless HTML→React transition. */}
); } // Redirect to logged-out if not authenticated (ALL modes - unified flow) // Show loading state while navigation is in progress if (!isAuthenticated) { return (
); } if (shouldBlockForSettings) { return (
); } if (shouldAutoOpen) { return (
); } // Show setup page (full screen, no sidebar) - authenticated only if (isSetupRoute) { return (
); } // Show dashboard page (full screen, no sidebar) - authenticated only if (isDashboardRoute) { return ( <>
); } return ( <>
{/* Full-width titlebar drag region for Electron window dragging */} {isElectron() && (
); } function RootLayout() { // Hide devtools on compact screens (mobile/tablet) to avoid overlap with UI controls const isCompact = useIsCompact(); // Get the user's preference for showing devtools from the app store const showQueryDevtools = useAppStore((state) => state.showQueryDevtools); // Show devtools only if: in dev mode, user setting enabled, and not compact screen const shouldShowDevtools = IS_DEV && showQueryDevtools && !isCompact; return ( {shouldShowDevtools && ( )} ); } export const Route = createRootRoute({ component: RootLayout, });