diff --git a/apps/ui/src/components/views/login-view.tsx b/apps/ui/src/components/views/login-view.tsx index 445bd937..faca109c 100644 --- a/apps/ui/src/components/views/login-view.tsx +++ b/apps/ui/src/components/views/login-view.tsx @@ -13,7 +13,15 @@ import { useReducer, useEffect, useRef } from 'react'; import { useNavigate } from '@tanstack/react-router'; -import { login, getHttpApiClient, getServerUrlSync } from '@/lib/http-api-client'; +import { + login, + getHttpApiClient, + getServerUrlSync, + getApiKey, + getSessionToken, + initApiKey, + waitForApiKeyInit, +} from '@/lib/http-api-client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { KeyRound, AlertCircle, Loader2, RefreshCw, ServerCrash } from 'lucide-react'; @@ -92,6 +100,7 @@ function reducer(state: State, action: Action): State { const MAX_RETRIES = 5; const BACKOFF_BASE_MS = 400; +const NO_STORE_CACHE_MODE: RequestCache = 'no-store'; // ============================================================================= // Imperative Flow Logic (runs once on mount) @@ -102,7 +111,9 @@ const BACKOFF_BASE_MS = 400; * Unlike the httpClient methods, this does NOT call handleUnauthorized() * which would navigate us away to /logged-out. * - * Relies on HTTP-only session cookie being sent via credentials: 'include'. + * Supports both: + * - Electron mode: Uses X-API-Key header (API key from IPC) + * - Web mode: Uses HTTP-only session cookie * * Returns: { authenticated: true } or { authenticated: false } * Throws: on network errors (for retry logic) @@ -110,9 +121,31 @@ const BACKOFF_BASE_MS = 400; async function checkAuthStatusSafe(): Promise<{ authenticated: boolean }> { const serverUrl = getServerUrlSync(); + // Wait for API key to be initialized before checking auth + // This ensures we have a valid API key to send in the header + await waitForApiKeyInit(); + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Electron mode: use API key header + const apiKey = getApiKey(); + if (apiKey) { + headers['X-API-Key'] = apiKey; + } + + // Add session token header if available (web mode) + const sessionToken = getSessionToken(); + if (sessionToken) { + headers['X-Session-Token'] = sessionToken; + } + const response = await fetch(`${serverUrl}/api/auth/status`, { - credentials: 'include', // Send HTTP-only session cookie + headers, + credentials: 'include', signal: AbortSignal.timeout(5000), + cache: NO_STORE_CACHE_MODE, }); // Any response means server is reachable @@ -246,6 +279,14 @@ export function LoginView() { const [state, dispatch] = useReducer(reducer, initialState); const retryControllerRef = useRef(null); + // Initialize API key before checking session + // This ensures getApiKey() returns a valid value in checkAuthStatusSafe() + useEffect(() => { + initApiKey().catch((error) => { + console.warn('Failed to initialize API key:', error); + }); + }, []); + // Run initial server/session check on mount. // IMPORTANT: Do not "run once" via a ref guard here. // In React StrictMode (dev), effects mount -> cleanup -> mount. diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 8e0f6b96..24a91f46 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -44,6 +44,7 @@ import { getElectronAPI } from '@/lib/electron'; import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client'; const logger = createLogger('Terminal'); +const NO_STORE_CACHE_MODE: RequestCache = 'no-store'; // Font size constraints const MIN_FONT_SIZE = 8; @@ -504,6 +505,7 @@ export function TerminalPanel({ const response = await fetch(`${serverUrl}/api/auth/token`, { headers, credentials: 'include', + cache: NO_STORE_CACHE_MODE, }); if (!response.ok) { diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts index 4788bfb1..1f63923b 100644 --- a/apps/ui/src/hooks/use-settings-sync.ts +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -89,6 +89,7 @@ export function useSettingsSync(): SettingsSyncState { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const authChecked = useAuthStore((s) => s.authChecked); + const settingsLoaded = useAuthStore((s) => s.settingsLoaded); const syncTimeoutRef = useRef | null>(null); const lastSyncedRef = useRef(''); @@ -117,9 +118,17 @@ export function useSettingsSync(): SettingsSyncState { // Debounced sync function const syncToServer = useCallback(async () => { try { - // Never sync when not authenticated (prevents overwriting server settings during logout/login transitions) + // Never sync when not authenticated or settings not loaded + // The settingsLoaded flag ensures we don't sync default empty state before hydration const auth = useAuthStore.getState(); - if (!auth.authChecked || !auth.isAuthenticated) { + logger.debug('syncToServer check:', { + authChecked: auth.authChecked, + isAuthenticated: auth.isAuthenticated, + settingsLoaded: auth.settingsLoaded, + projectsCount: useAppStore.getState().projects?.length ?? 0, + }); + if (!auth.authChecked || !auth.isAuthenticated || !auth.settingsLoaded) { + logger.debug('Sync skipped: not authenticated or settings not loaded'); return; } @@ -127,6 +136,8 @@ export function useSettingsSync(): SettingsSyncState { const api = getHttpApiClient(); const appState = useAppStore.getState(); + logger.debug('Syncing to server:', { projectsCount: appState.projects?.length ?? 0 }); + // Build updates object from current state const updates: Record = {}; for (const field of SETTINGS_FIELDS_TO_SYNC) { @@ -147,10 +158,13 @@ export function useSettingsSync(): SettingsSyncState { // Create a hash of the updates to avoid redundant syncs const updateHash = JSON.stringify(updates); if (updateHash === lastSyncedRef.current) { + logger.debug('Sync skipped: no changes'); setState((s) => ({ ...s, syncing: false })); return; } + logger.info('Sending settings update:', { projects: updates.projects }); + const result = await api.settings.updateGlobal(updates); if (result.success) { lastSyncedRef.current = updateHash; @@ -184,11 +198,20 @@ export function useSettingsSync(): SettingsSyncState { void syncToServer(); }, [syncToServer]); - // Initialize sync - WAIT for migration to complete first + // Initialize sync - WAIT for settings to be loaded and migration to complete useEffect(() => { - // Don't initialize syncing until we know auth status and are authenticated. - // Prevents accidental overwrites when the app boots before settings are hydrated. - if (!authChecked || !isAuthenticated) return; + // Don't initialize syncing until: + // 1. Auth has been checked + // 2. User is authenticated + // 3. Settings have been loaded from server (settingsLoaded flag) + // This prevents syncing empty/default state before hydration completes. + logger.debug('useSettingsSync initialization check:', { + authChecked, + isAuthenticated, + settingsLoaded, + stateLoaded: state.loaded, + }); + if (!authChecked || !isAuthenticated || !settingsLoaded) return; if (isInitializedRef.current) return; isInitializedRef.current = true; @@ -198,14 +221,26 @@ export function useSettingsSync(): SettingsSyncState { await waitForApiKeyInit(); // CRITICAL: Wait for migration/hydration to complete before we start syncing - // This prevents overwriting server data with empty/default state + // This is a backup to the settingsLoaded flag for extra safety logger.info('Waiting for migration to complete before starting sync...'); await waitForMigrationComplete(); + + // Wait for React to finish rendering after store hydration. + // Zustand's subscribe() fires during setState(), which happens BEFORE React's + // render completes. Use a small delay to ensure all pending state updates + // have propagated through the React tree before we read state. + await new Promise((resolve) => setTimeout(resolve, 50)); + logger.info('Migration complete, initializing sync'); + // Read state - at this point React has processed the store update + const appState = useAppStore.getState(); + const setupState = useSetupStore.getState(); + + logger.info('Initial state read:', { projectsCount: appState.projects?.length ?? 0 }); + // Store the initial state hash to avoid immediate re-sync // (migration has already hydrated the store from server/localStorage) - const appState = useAppStore.getState(); const updates: Record = {}; for (const field of SETTINGS_FIELDS_TO_SYNC) { if (field === 'currentProjectId') { @@ -214,7 +249,6 @@ export function useSettingsSync(): SettingsSyncState { updates[field] = appState[field as keyof typeof appState]; } } - const setupState = useSetupStore.getState(); for (const field of SETUP_FIELDS_TO_SYNC) { updates[field] = setupState[field as keyof typeof setupState]; } @@ -233,16 +267,33 @@ export function useSettingsSync(): SettingsSyncState { } initializeSync(); - }, [authChecked, isAuthenticated]); + }, [authChecked, isAuthenticated, settingsLoaded]); // Subscribe to store changes and sync to server useEffect(() => { - if (!state.loaded || !authChecked || !isAuthenticated) return; + if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return; // Subscribe to app store changes const unsubscribeApp = useAppStore.subscribe((newState, prevState) => { + const auth = useAuthStore.getState(); + logger.debug('Store subscription fired:', { + prevProjects: prevState.projects?.length ?? 0, + newProjects: newState.projects?.length ?? 0, + authChecked: auth.authChecked, + isAuthenticated: auth.isAuthenticated, + settingsLoaded: auth.settingsLoaded, + loaded: state.loaded, + }); + + // Don't sync if settings not loaded yet + if (!auth.settingsLoaded) { + logger.debug('Store changed but settings not loaded, skipping sync'); + return; + } + // If the current project changed, sync immediately so we can restore on next launch if (newState.currentProject?.id !== prevState.currentProject?.id) { + logger.debug('Current project changed, syncing immediately'); syncNow(); return; } @@ -266,6 +317,7 @@ export function useSettingsSync(): SettingsSyncState { } if (changed) { + logger.debug('Store changed, scheduling sync'); scheduleSyncToServer(); } }); @@ -294,11 +346,11 @@ export function useSettingsSync(): SettingsSyncState { clearTimeout(syncTimeoutRef.current); } }; - }, [state.loaded, authChecked, isAuthenticated, scheduleSyncToServer, syncNow]); + }, [state.loaded, authChecked, isAuthenticated, settingsLoaded, scheduleSyncToServer, syncNow]); // Best-effort flush on tab close / backgrounding useEffect(() => { - if (!state.loaded || !authChecked || !isAuthenticated) return; + if (!state.loaded || !authChecked || !isAuthenticated || !settingsLoaded) return; const handleBeforeUnload = () => { // Fire-and-forget; may not complete in all browsers, but helps in Electron/webview @@ -318,7 +370,7 @@ export function useSettingsSync(): SettingsSyncState { window.removeEventListener('beforeunload', handleBeforeUnload); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [state.loaded, authChecked, isAuthenticated, syncNow]); + }, [state.loaded, authChecked, isAuthenticated, settingsLoaded, syncNow]); return state; } diff --git a/apps/ui/src/lib/api-fetch.ts b/apps/ui/src/lib/api-fetch.ts index f3df93bf..b544c993 100644 --- a/apps/ui/src/lib/api-fetch.ts +++ b/apps/ui/src/lib/api-fetch.ts @@ -13,6 +13,7 @@ import { getApiKey, getSessionToken, getServerUrlSync } from './http-api-client' // Server URL - uses shared cached URL from http-api-client const getServerUrl = (): string => getServerUrlSync(); +const DEFAULT_CACHE_MODE: RequestCache = 'no-store'; export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; @@ -80,7 +81,7 @@ export async function apiFetch( method: HttpMethod = 'GET', options: ApiFetchOptions = {} ): Promise { - const { headers: additionalHeaders, body, skipAuth, ...restOptions } = options; + const { headers: additionalHeaders, body, skipAuth, cache, ...restOptions } = options; const headers = skipAuth ? { 'Content-Type': 'application/json', ...additionalHeaders } @@ -90,6 +91,7 @@ export async function apiFetch( method, headers, credentials: 'include', + cache: cache ?? DEFAULT_CACHE_MODE, ...restOptions, }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 7d442836..8cd6e466 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -39,6 +39,7 @@ import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/typ import { getGlobalFileBrowser } from '@/contexts/file-browser-context'; const logger = createLogger('HttpClient'); +const NO_STORE_CACHE_MODE: RequestCache = 'no-store'; // Cached server URL (set during initialization in Electron mode) let cachedServerUrl: string | null = null; @@ -69,6 +70,7 @@ const handleUnauthorized = (): void => { headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: '{}', + cache: NO_STORE_CACHE_MODE, }).catch(() => {}); notifyLoggedOut(); }; @@ -296,6 +298,7 @@ export const checkAuthStatus = async (): Promise<{ const response = await fetch(`${getServerUrl()}/api/auth/status`, { credentials: 'include', headers: getApiKey() ? { 'X-API-Key': getApiKey()! } : undefined, + cache: NO_STORE_CACHE_MODE, }); const data = await response.json(); return { @@ -322,6 +325,7 @@ export const login = async ( headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ apiKey }), + cache: NO_STORE_CACHE_MODE, }); const data = await response.json(); @@ -361,6 +365,7 @@ export const fetchSessionToken = async (): Promise => { try { const response = await fetch(`${getServerUrl()}/api/auth/status`, { credentials: 'include', // Send the session cookie + cache: NO_STORE_CACHE_MODE, }); if (!response.ok) { @@ -391,6 +396,7 @@ export const logout = async (): Promise<{ success: boolean }> => { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', + cache: NO_STORE_CACHE_MODE, }); // Clear the cached session token @@ -439,6 +445,7 @@ export const verifySession = async (): Promise => { const response = await fetch(`${getServerUrl()}/api/settings/status`, { headers, credentials: 'include', + cache: NO_STORE_CACHE_MODE, // Avoid hanging indefinitely during backend reloads or network issues signal: AbortSignal.timeout(2500), }); @@ -475,6 +482,7 @@ export const checkSandboxEnvironment = async (): Promise<{ try { const response = await fetch(`${getServerUrl()}/api/health/environment`, { method: 'GET', + cache: NO_STORE_CACHE_MODE, signal: AbortSignal.timeout(5000), }); @@ -556,6 +564,7 @@ export class HttpApiClient implements ElectronAPI { const response = await fetch(`${this.serverUrl}/api/auth/token`, { headers, credentials: 'include', + cache: NO_STORE_CACHE_MODE, }); if (response.status === 401 || response.status === 403) { @@ -587,6 +596,17 @@ export class HttpApiClient implements ElectronAPI { this.isConnecting = true; + // Wait for API key initialization to complete before attempting connection + // This prevents race conditions during app startup + waitForApiKeyInit() + .then(() => this.doConnectWebSocketInternal()) + .catch((error) => { + logger.error('Failed to initialize for WebSocket connection:', error); + this.isConnecting = false; + }); + } + + private doConnectWebSocketInternal(): void { // Electron mode typically authenticates with the injected API key. // However, in external-server/cookie-auth flows, the API key may be unavailable. // In that case, fall back to the same wsToken/cookie authentication used in web mode @@ -771,6 +791,7 @@ export class HttpApiClient implements ElectronAPI { const response = await fetch(`${this.serverUrl}${endpoint}`, { headers: this.getHeaders(), credentials: 'include', // Include cookies for session auth + cache: NO_STORE_CACHE_MODE, }); if (response.status === 401 || response.status === 403) { diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index 6ca6535c..ac98044d 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -12,12 +12,14 @@ 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, @@ -30,8 +32,17 @@ 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 type { Project } from '@/lib/electron'; const logger = createLogger('RootLayout'); +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; // Apply stored theme immediately on page load (before React hydration) // This prevents flash of default theme on login/setup pages @@ -60,11 +71,84 @@ function applyStoredTheme(): void { // 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(); const { setIpcConnected, + projects, currentProject, + projectHistory, + upsertAndSetCurrentProject, getEffectiveTheme, skipSandboxWarning, setSkipSandboxWarning, @@ -85,6 +169,8 @@ function RootLayoutContent() { const isLoginRoute = location.pathname === '/login'; const isLoggedOutRoute = location.pathname === '/logged-out'; const isDashboardRoute = location.pathname === '/dashboard'; + const isBoardRoute = location.pathname === '/board'; + const isRootRoute = location.pathname === '/'; // Sandbox environment check state type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; @@ -212,15 +298,18 @@ function RootLayoutContent() { // Ref to prevent concurrent auth checks from running const authCheckRunning = useRef(false); + const autoOpenAttemptedRef = 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!'); useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true }); if (location.pathname !== '/logged-out') { + logger.warn('Navigating to /logged-out due to logged-out event'); navigate({ to: '/logged-out' }); } }; @@ -236,6 +325,7 @@ function RootLayoutContent() { // 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 @@ -266,6 +356,12 @@ function RootLayoutContent() { // Initialize API key for Electron mode await initApiKey(); + const serverReady = await waitForServerReady(); + if (!serverReady) { + handleServerOffline(); + return; + } + // 1. Verify session (Single Request, ALL modes) let isValid = false; try { @@ -302,13 +398,28 @@ function RootLayoutContent() { // Hydrate store with the final settings (merged if migration occurred) hydrateStoreFromSettings(finalSettings); - // Signal that settings hydration is complete so useSettingsSync can start + // 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. + // This ensures useSettingsSync's waitForMigrationComplete() will resolve + // immediately when it starts after auth state change, preventing it from + // syncing default empty state to the server. signalMigrationComplete(); - // Mark auth as checked only after settings hydration succeeded. - useAuthStore - .getState() - .setAuthState({ isAuthenticated: true, authChecked: true }); + // Now mark auth as checked AND settings as loaded. + // The settingsLoaded flag ensures useSettingsSync won't start syncing + // until settings have been properly hydrated, even if authChecked was + // set earlier by login-view. + useAuthStore.getState().setAuthState({ + isAuthenticated: true, + authChecked: true, + settingsLoaded: true, + }); + return; } @@ -368,22 +479,46 @@ function RootLayoutContent() { // Note: Settings are now loaded in __root.tsx after successful session verification // This ensures a unified flow across all modes (Electron, web, external server) + // Get settingsLoaded from auth store for routing decisions + const settingsLoaded = useAuthStore((s) => s.settingsLoaded); + // 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) return; + 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' }); @@ -394,7 +529,46 @@ function RootLayoutContent() { if (setupComplete && location.pathname === '/setup') { navigate({ to: '/dashboard' }); } - }, [authChecked, isAuthenticated, setupComplete, location.pathname, navigate]); + }, [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); @@ -428,7 +602,7 @@ function RootLayoutContent() { // Redirect from welcome page based on project state useEffect(() => { - if (isMounted && location.pathname === '/') { + if (isMounted && isRootRoute) { if (currentProject) { // Project is selected, go to board navigate({ to: '/board' }); @@ -437,14 +611,64 @@ function RootLayoutContent() { navigate({ to: '/dashboard' }); } } - }, [isMounted, currentProject, location.pathname, navigate]); + }, [isMounted, currentProject, isRootRoute, navigate]); + + // Auto-open the most recent project on startup + useEffect(() => { + if (autoOpenAttemptedRef.current) return; + if (!authChecked || !isAuthenticated || !settingsLoaded) return; + if (!setupComplete) return; + if (isLoginRoute || isLoggedOutRoute || isSetupRoute) return; + if (isBoardRoute) return; + + const projectToOpen = selectAutoOpenProject(currentProject, projects, projectHistory); + if (!projectToOpen) return; + + autoOpenAttemptedRef.current = true; + + const openProject = async () => { + const initResult = await initializeProject(projectToOpen.path); + if (!initResult.success) { + logger.warn('Auto-open project failed:', initResult.error); + if (isRootRoute) { + navigate({ to: '/dashboard' }); + } + return; + } + + if (!currentProject || currentProject.id !== projectToOpen.id) { + upsertAndSetCurrentProject(projectToOpen.path, projectToOpen.name, projectToOpen.theme); + } + + if (!isBoardRoute) { + navigate({ to: '/board' }); + } + }; + + void openProject(); + }, [ + authChecked, + isAuthenticated, + settingsLoaded, + setupComplete, + isLoginRoute, + isLoggedOutRoute, + isSetupRoute, + isBoardRoute, + isRootRoute, + currentProject, + projects, + projectHistory, + navigate, + upsertAndSetCurrentProject, + ]); // 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?.auth?.authenticated; + const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.hasApiKey; if (!isCodexAvailable) return; // Fetch models in the background diff --git a/apps/ui/src/store/auth-store.ts b/apps/ui/src/store/auth-store.ts index 7c594d0d..9b58d8af 100644 --- a/apps/ui/src/store/auth-store.ts +++ b/apps/ui/src/store/auth-store.ts @@ -5,6 +5,8 @@ interface AuthState { authChecked: boolean; /** Whether the user is currently authenticated (web mode: valid session cookie) */ isAuthenticated: boolean; + /** Whether settings have been loaded and hydrated from server */ + settingsLoaded: boolean; } interface AuthActions { @@ -15,15 +17,18 @@ interface AuthActions { const initialState: AuthState = { authChecked: false, isAuthenticated: false, + settingsLoaded: false, }; /** * Web authentication state. * - * Intentionally NOT persisted: source of truth is the server session cookie. + * Intentionally NOT persisted: source of truth is server session cookie. */ export const useAuthStore = create((set) => ({ ...initialState, - setAuthState: (state) => set(state), + setAuthState: (state) => { + set({ ...state }); + }, resetAuth: () => set(initialState), })); diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 6b872819..277eeb7e 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -7,6 +7,7 @@ export interface CliStatus { path: string | null; version: string | null; method: string; + hasApiKey?: boolean; error?: string; }