diff --git a/.gitignore b/.gitignore index 2904e438..be8843e0 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ check-sync.sh # API key files data/.api-key data/credentials.json +data/ diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index f5d421e0..4b61b4a4 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -517,8 +517,9 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void { } // Save theme to localStorage for fallback when server settings aren't available - if (settings.theme) { - setItem(THEME_STORAGE_KEY, settings.theme); + const storedTheme = (currentProject?.theme as string | undefined) || settings.theme; + if (storedTheme) { + setItem(THEME_STORAGE_KEY, storedTheme); } useAppStore.setState({ diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index ac98044d..6246b84e 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -43,6 +43,12 @@ 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; +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 @@ -160,6 +166,7 @@ function RootLayoutContent() { 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 @@ -171,6 +178,20 @@ function RootLayoutContent() { const isDashboardRoute = location.pathname === '/dashboard'; const isBoardRoute = location.pathname === '/board'; 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; + const shouldAutoOpen = canAutoOpen && autoOpenStatus !== AUTO_OPEN_STATUS.done; + const shouldBlockForSettings = + authChecked && isAuthenticated && !settingsLoaded && !isLoginRoute && !isLoggedOutRoute; // Sandbox environment check state type SandboxStatus = 'pending' | 'containerized' | 'needs-confirmation' | 'denied' | 'confirmed'; @@ -298,7 +319,6 @@ 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. @@ -479,9 +499,6 @@ 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 @@ -603,6 +620,9 @@ function RootLayoutContent() { // 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' }); @@ -611,56 +631,58 @@ function RootLayoutContent() { navigate({ to: '/dashboard' }); } } - }, [isMounted, currentProject, isRootRoute, navigate]); + }, [isMounted, currentProject, isRootRoute, navigate, shouldAutoOpen, settingsLoaded]); // 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; + if (!canAutoOpen) return; + if (autoOpenStatus !== AUTO_OPEN_STATUS.idle) return; - const projectToOpen = selectAutoOpenProject(currentProject, projects, projectHistory); - if (!projectToOpen) return; + if (!autoOpenCandidate) return; - autoOpenAttemptedRef.current = true; + setAutoOpenStatus(AUTO_OPEN_STATUS.opening); const openProject = async () => { - const initResult = await initializeProject(projectToOpen.path); - if (!initResult.success) { - logger.warn('Auto-open project failed:', initResult.error); + 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 + ); + } + + if (isRootRoute) { + navigate({ to: '/board' }); + } + } catch (error) { + logger.error('Auto-open project crashed:', 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' }); + } finally { + setAutoOpenStatus(AUTO_OPEN_STATUS.done); } }; void openProject(); }, [ - authChecked, - isAuthenticated, - settingsLoaded, - setupComplete, - isLoginRoute, - isLoggedOutRoute, - isSetupRoute, - isBoardRoute, - isRootRoute, + canAutoOpen, + autoOpenStatus, + autoOpenCandidate, currentProject, - projects, - projectHistory, navigate, upsertAndSetCurrentProject, + isRootRoute, ]); // Bootstrap Codex models on app startup (after auth completes) @@ -736,6 +758,22 @@ function RootLayoutContent() { ); } + if (shouldBlockForSettings) { + return ( +
+ +
+ ); + } + + if (shouldAutoOpen) { + return ( +
+ +
+ ); + } + // Show setup page (full screen, no sidebar) - authenticated only if (isSetupRoute) { return ( diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index b57f3736..b147e6c2 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -114,6 +114,12 @@ function saveThemeToStorage(theme: ThemeMode): void { setItem(THEME_STORAGE_KEY, theme); } +function persistEffectiveThemeForProject(project: Project | null, fallbackTheme: ThemeMode): void { + const projectTheme = project?.theme as ThemeMode | undefined; + const themeToStore = projectTheme ?? fallbackTheme; + saveThemeToStorage(themeToStore); +} + export type BoardViewMode = 'kanban' | 'graph'; export interface ApiKeys { @@ -1241,13 +1247,16 @@ export const useAppStore = create()((set, get) => ({ }; const isCurrent = get().currentProject?.id === projectId; + const nextCurrentProject = isCurrent ? null : get().currentProject; set({ projects: remainingProjects, trashedProjects: [trashedProject, ...existingTrash], - currentProject: isCurrent ? null : get().currentProject, + currentProject: nextCurrentProject, currentView: isCurrent ? 'welcome' : get().currentView, }); + + persistEffectiveThemeForProject(nextCurrentProject, get().theme); }, restoreTrashedProject: (projectId) => { @@ -1266,6 +1275,7 @@ export const useAppStore = create()((set, get) => ({ currentProject: samePathProject, currentView: 'board', }); + persistEffectiveThemeForProject(samePathProject, get().theme); return; } @@ -1283,6 +1293,7 @@ export const useAppStore = create()((set, get) => ({ currentProject: restoredProject, currentView: 'board', }); + persistEffectiveThemeForProject(restoredProject, get().theme); }, deleteTrashedProject: (projectId) => { @@ -1302,6 +1313,7 @@ export const useAppStore = create()((set, get) => ({ setCurrentProject: (project) => { set({ currentProject: project }); + persistEffectiveThemeForProject(project, get().theme); if (project) { set({ currentView: 'board' }); // Add to project history (MRU order) @@ -1385,6 +1397,7 @@ export const useAppStore = create()((set, get) => ({ projectHistoryIndex: newIndex, currentView: 'board', }); + persistEffectiveThemeForProject(targetProject, get().theme); } }, @@ -1418,6 +1431,7 @@ export const useAppStore = create()((set, get) => ({ projectHistoryIndex: newIndex, currentView: 'board', }); + persistEffectiveThemeForProject(targetProject, get().theme); } }, @@ -1477,12 +1491,14 @@ export const useAppStore = create()((set, get) => ({ // Also update currentProject if it's the same project const currentProject = get().currentProject; if (currentProject?.id === projectId) { + const updatedTheme = theme === null ? undefined : theme; set({ currentProject: { ...currentProject, - theme: theme === null ? undefined : theme, + theme: updatedTheme, }, }); + persistEffectiveThemeForProject({ ...currentProject, theme: updatedTheme }, get().theme); } }, diff --git a/apps/ui/src/utils/router.ts b/apps/ui/src/utils/router.ts index 91ec5ffc..58e57601 100644 --- a/apps/ui/src/utils/router.ts +++ b/apps/ui/src/utils/router.ts @@ -3,9 +3,10 @@ import { routeTree } from '../routeTree.gen'; // Use browser history in web mode (for e2e tests and dev), memory history in Electron const isElectron = typeof window !== 'undefined' && window.electronAPI !== undefined; +const BOARD_ROUTE_PATH = '/board'; const history = isElectron - ? createMemoryHistory({ initialEntries: [window.location.pathname || '/'] }) + ? createMemoryHistory({ initialEntries: [BOARD_ROUTE_PATH] }) : createBrowserHistory(); export const router = createRouter({