mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-22 11:43:07 +00:00
* Changes from fix/event-hook-endpoint * fix: Allow empty eventHooks/ntfyEndpoints to reconcile from server Remove the `length > 0` guards in fast-hydrate reconciliation that prevented intentional empty-array clears from syncing across clients. Server-side wipe protection (`__allowEmpty*` escape hatches) already ensures empty arrays in the server are intentional. Addresses PR #831 review feedback from CodeRabbit and Gemini. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1221 lines
49 KiB
TypeScript
1221 lines
49 KiB
TypeScript
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<boolean> {
|
|
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<AutoOpenStatus>(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<SandboxStatus>('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<typeof performSettingsMigration>[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<typeof performSettingsMigration>[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 <SandboxRejectionScreen />;
|
|
}
|
|
|
|
// 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 (
|
|
<main className="h-full overflow-hidden" data-testid="app-container">
|
|
<Outlet />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<main
|
|
className="flex h-full flex-col items-center justify-center gap-6"
|
|
data-testid="app-container"
|
|
>
|
|
<svg
|
|
className="h-14 w-14 opacity-90"
|
|
viewBox="0 0 256 256"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
aria-hidden="true"
|
|
>
|
|
<rect className="fill-foreground/[0.08]" x="16" y="16" width="224" height="224" rx="56" />
|
|
<g
|
|
className="stroke-foreground/70"
|
|
fill="none"
|
|
strokeWidth="20"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<path d="M92 92 L52 128 L92 164" />
|
|
<path d="M144 72 L116 184" />
|
|
<path d="M164 92 L204 128 L164 164" />
|
|
</g>
|
|
</svg>
|
|
{/* 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. */}
|
|
<div
|
|
role="status"
|
|
aria-label="Loading"
|
|
className="h-4 w-4 animate-spin rounded-full border-2 border-foreground/10 border-t-foreground/50"
|
|
/>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
// Redirect to logged-out if not authenticated (ALL modes - unified flow)
|
|
// Show loading state while navigation is in progress
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<main className="flex h-full items-center justify-center" data-testid="app-container">
|
|
<LoadingState message="Redirecting..." />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (shouldBlockForSettings) {
|
|
return (
|
|
<main className="flex h-full items-center justify-center" data-testid="app-container">
|
|
<LoadingState message="Loading settings..." />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (shouldAutoOpen) {
|
|
return (
|
|
<main className="flex h-full items-center justify-center" data-testid="app-container">
|
|
<LoadingState message="Opening project..." />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
// Show setup page (full screen, no sidebar) - authenticated only
|
|
if (isSetupRoute) {
|
|
return (
|
|
<main className="h-full overflow-hidden" data-testid="app-container">
|
|
<Outlet />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
// Show dashboard page (full screen, no sidebar) - authenticated only
|
|
if (isDashboardRoute) {
|
|
return (
|
|
<>
|
|
<main className="h-full overflow-hidden" data-testid="app-container">
|
|
<Outlet />
|
|
<Toaster richColors position="bottom-right" />
|
|
</main>
|
|
<SandboxRiskDialog
|
|
open={showSandboxDialog}
|
|
onConfirm={handleSandboxConfirm}
|
|
onDeny={handleSandboxDeny}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<main className="flex h-full overflow-hidden" data-testid="app-container">
|
|
{/* Full-width titlebar drag region for Electron window dragging */}
|
|
{isElectron() && (
|
|
<div
|
|
className={`fixed top-0 left-0 right-0 h-6 titlebar-drag-region z-40 pointer-events-none ${isMac ? 'pl-20' : ''}`}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
{/* Discord-style layout: narrow project switcher + expandable sidebar */}
|
|
{sidebarStyle === 'discord' && <ProjectSwitcher />}
|
|
<Sidebar />
|
|
<div
|
|
className="flex-1 flex flex-col overflow-hidden transition-all duration-300"
|
|
style={{ marginRight: streamerPanelOpen ? '250px' : '0' }}
|
|
>
|
|
<Outlet />
|
|
</div>
|
|
|
|
{/* Hidden streamer panel - opens with "\" key, pushes content */}
|
|
<div
|
|
className={`fixed top-0 right-0 h-full w-[250px] bg-background border-l border-border transition-transform duration-300 ${
|
|
streamerPanelOpen ? 'translate-x-0' : 'translate-x-full'
|
|
}`}
|
|
/>
|
|
<Toaster richColors position="bottom-right" />
|
|
</main>
|
|
<SandboxRiskDialog
|
|
open={showSandboxDialog}
|
|
onConfirm={handleSandboxConfirm}
|
|
onDeny={handleSandboxDeny}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<PersistQueryClientProvider client={queryClient} persistOptions={persistOptions}>
|
|
<FileBrowserProvider>
|
|
<RootLayoutContent />
|
|
</FileBrowserProvider>
|
|
{shouldShowDevtools && (
|
|
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-right" />
|
|
)}
|
|
</PersistQueryClientProvider>
|
|
);
|
|
}
|
|
|
|
export const Route = createRootRoute({
|
|
component: RootLayout,
|
|
});
|