/** * UI Cache Store - Persisted UI State for Instant Restore * * This lightweight Zustand store persists critical UI state to localStorage * so that after a tab discard, the user sees their previous UI configuration * instantly without waiting for the server. * * This is NOT a replacement for the app-store or the API-first settings sync. * It's a fast cache layer that provides instant visual continuity during: * - Tab discard recovery * - Page reloads * - App restarts * * The app-store remains the source of truth. This cache is reconciled * when server settings are loaded (hydrateStoreFromSettings overwrites everything). * * Only stores UI-visual state that affects what the user sees immediately: * - Selected project ID (to restore board context) * - Sidebar state (open/closed, style) * - View preferences (board view mode, collapsed sections) */ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import { useAppStore } from '@/store/app-store'; interface UICacheState { /** ID of the currently selected project */ cachedProjectId: string | null; /** Whether sidebar is open */ cachedSidebarOpen: boolean; /** Sidebar style (unified or discord) */ cachedSidebarStyle: 'unified' | 'discord'; /** Whether worktree panel is collapsed */ cachedWorktreePanelCollapsed: boolean; /** Collapsed nav sections */ cachedCollapsedNavSections: Record; /** Selected worktree per project (path + branch) for instant restore on PWA reload */ cachedCurrentWorktreeByProject: Record; } interface UICacheActions { /** Update the cached UI state from the main app store */ updateFromAppStore: (state: Partial) => void; } const STORE_NAME = 'automaker-ui-cache'; export const useUICacheStore = create()( persist( (set) => ({ cachedProjectId: null, cachedSidebarOpen: true, cachedSidebarStyle: 'unified', cachedWorktreePanelCollapsed: false, cachedCollapsedNavSections: {}, cachedCurrentWorktreeByProject: {}, updateFromAppStore: (state) => set(state), }), { name: STORE_NAME, version: 2, partialize: (state) => ({ cachedProjectId: state.cachedProjectId, cachedSidebarOpen: state.cachedSidebarOpen, cachedSidebarStyle: state.cachedSidebarStyle, cachedWorktreePanelCollapsed: state.cachedWorktreePanelCollapsed, cachedCollapsedNavSections: state.cachedCollapsedNavSections, cachedCurrentWorktreeByProject: state.cachedCurrentWorktreeByProject, }), migrate: (persistedState: unknown, version: number) => { const state = persistedState as Record; if (version < 2) { // Migration from v1: add cachedCurrentWorktreeByProject state.cachedCurrentWorktreeByProject = {}; } return state as unknown as UICacheState & UICacheActions; }, } ) ); /** * Sync critical UI state from the main app store to the UI cache. * Call this whenever the app store changes to keep the cache up to date. * * This is intentionally a function (not a hook) so it can be called * from store subscriptions without React. */ export function syncUICache(appState: { currentProject?: { id: string } | null; sidebarOpen?: boolean; sidebarStyle?: 'unified' | 'discord'; worktreePanelCollapsed?: boolean; collapsedNavSections?: Record; currentWorktreeByProject?: Record; }): void { const update: Partial = {}; if ('currentProject' in appState) { update.cachedProjectId = appState.currentProject?.id ?? null; } if ('sidebarOpen' in appState) { update.cachedSidebarOpen = appState.sidebarOpen; } if ('sidebarStyle' in appState) { update.cachedSidebarStyle = appState.sidebarStyle; } if ('worktreePanelCollapsed' in appState) { update.cachedWorktreePanelCollapsed = appState.worktreePanelCollapsed; } if ('collapsedNavSections' in appState) { update.cachedCollapsedNavSections = appState.collapsedNavSections; } if ('currentWorktreeByProject' in appState) { update.cachedCurrentWorktreeByProject = appState.currentWorktreeByProject; } if (Object.keys(update).length > 0) { useUICacheStore.getState().updateFromAppStore(update); } } /** * Restore cached UI state into the main app store. * Call this early during initialization — before server settings arrive — * so the user sees their previous UI layout instantly on tab discard recovery * or page reload, instead of a flash of default state. * * This is reconciled later when hydrateStoreFromSettings() overwrites * the app store with authoritative server data. * * @param appStoreSetState - The setState function from the app store */ export function restoreFromUICache( appStoreSetState: (state: Record) => void ): boolean { const cache = useUICacheStore.getState(); // Only restore if we have meaningful cached data (not just defaults) if (cache.cachedProjectId === null) { return false; } // Attempt to resolve the cached project ID to a full project object. // At early startup the projects array may be empty (server data not yet loaded), // but if projects are already in the store (e.g. optimistic hydration has run) // this will restore the project context immediately so tab-discard recovery // does not lose the selected project when cached settings are missing. const existingProjects = useAppStore.getState().projects; const cachedProject = existingProjects.find((p) => p.id === cache.cachedProjectId) ?? null; const stateUpdate: Record = { sidebarOpen: cache.cachedSidebarOpen, sidebarStyle: cache.cachedSidebarStyle, worktreePanelCollapsed: cache.cachedWorktreePanelCollapsed, collapsedNavSections: cache.cachedCollapsedNavSections, }; // Restore last selected worktree per project so the board doesn't // reset to main branch after PWA memory eviction or tab discard. if ( cache.cachedCurrentWorktreeByProject && Object.keys(cache.cachedCurrentWorktreeByProject).length > 0 ) { stateUpdate.currentWorktreeByProject = cache.cachedCurrentWorktreeByProject; } // Restore the project context when the project object is available. // When projects are not yet loaded (empty array), currentProject remains // null and will be properly set later by hydrateStoreFromSettings(). if (cachedProject !== null) { stateUpdate.currentProject = cachedProject; } appStoreSetState(stateUpdate); return true; }