This document summarizes the migration from localStorage-based settings persistence to an API-first approach. The goal was to ensure settings are consistent between Electron and web modes by using the server's settings.json as the single source of truth.
Previously, settings were stored in two places:
- Browser localStorage (via Zustand persist middleware) - isolated per browser/Electron instance
- Server files (
{DATA_DIR}/settings.json)
This caused settings drift between Electron and web modes since each had its own localStorage.
All settings are now:
- Fetched from the server API on app startup
- Synced back to the server API when changed (with debouncing)
- No longer cached in localStorage (persist middleware removed)
New hook that:
- Waits for migration to complete before starting
- Subscribes to Zustand store changes
- Debounces sync to server (1000ms delay)
- Handles special case for
currentProjectId(extracted fromcurrentProjectobject)
- Removed
persistmiddleware from Zustand store - Added new state fields:
worktreePanelCollapsed: booleanlastProjectDir: stringrecentFolders: string[]
- Added corresponding setter actions
- Removed
persistmiddleware from Zustand store
Complete rewrite to:
- Run in both Electron and web modes (not just Electron)
- Parse localStorage data and merge with server data
- Prefer server data, but use localStorage for missing arrays (projects, profiles, etc.)
- Export
waitForMigrationComplete()for coordination with sync hook - Handle
currentProjectIdto restore the currently open project
- Added
useSettingsSynchook - Wait for migration to complete before rendering router (prevents race condition)
- Show loading state while settings are being fetched
- Removed persist middleware hydration checks (no longer needed)
- Set
setupHydratedtotrueby default
- Changed from localStorage to app store for
worktreePanelCollapsed
- Changed from localStorage to app store for
recentFolders
- Changed from localStorage to app store for
lastProjectDir
- Added
currentProjectId: string | nulltoGlobalSettingsinterface - Added to
DEFAULT_GLOBAL_SETTINGS
The following fields are synced to the server when they change:
const SETTINGS_FIELDS_TO_SYNC = [
'theme',
'sidebarOpen',
'chatHistoryOpen',
'maxConcurrency',
'defaultSkipTests',
'enableDependencyBlocking',
'skipVerificationInAutoMode',
'useWorktrees',
'defaultPlanningMode',
'defaultRequirePlanApproval',
'muteDoneSound',
'enhancementModel',
'validationModel',
'phaseModels',
'enabledCursorModels',
'cursorDefaultModel',
'autoLoadClaudeMd',
'keyboardShortcuts',
'mcpServers',
'promptCustomization',
'projects',
'trashedProjects',
'currentProjectId',
'projectHistory',
'projectHistoryIndex',
'lastSelectedSessionByProject',
'worktreePanelCollapsed',
'lastProjectDir',
'recentFolders',
];1. App mounts
└── Shows "Loading settings..." screen
2. useSettingsMigration runs
├── Waits for API key initialization
├── Reads localStorage data (if any)
├── Fetches settings from server API
├── Merges data (prefers server, uses localStorage for missing arrays)
├── Hydrates Zustand store (including currentProject from currentProjectId)
├── Syncs merged data back to server (if needed)
└── Signals completion via waitForMigrationComplete()
3. useSettingsSync initializes
├── Waits for migration to complete
├── Stores initial state hash
└── Starts subscribing to store changes
4. Router renders
├── Root layout reads currentProject (now properly set)
└── Navigates to /board if project was open
1. User changes a setting
└── Zustand store updates
2. useSettingsSync detects change
├── Debounces for 1000ms
└── Syncs to server via API
3. Server writes to settings.json
When merging localStorage with server data:
- Server has data → Use server data as base
- Server missing arrays (projects, mcpServers, etc.) → Use localStorage arrays
- Server missing objects (lastSelectedSessionByProject) → Use localStorage objects
- Simple values (lastProjectDir, currentProjectId) → Use localStorage if server is empty
Hook that handles initial settings hydration. Returns:
checked: boolean- Whether hydration is completemigrated: boolean- Whether data was migrated from localStorageerror: string | null- Error message if failed
Hook that handles ongoing sync. Returns:
loaded: boolean- Whether sync is initializedsyncing: boolean- Whether currently syncingerror: string | null- Error message if failed
Returns a Promise that resolves when migration is complete. Used for coordination.
Manually triggers an immediate sync to server.
Fetches latest settings from server and updates store.
All 1001 server tests pass after these changes.
- sessionStorage is still used for session-specific state (splash screen shown, auto-mode state)
- Terminal layouts are stored in the app store per-project (not synced to API - considered transient UI state)
- The server's
{DATA_DIR}/settings.jsonis the single source of truth