mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
feat: implement API-first settings management and description history tracking
- Migrated settings persistence from localStorage to an API-first approach, ensuring consistency between Electron and web modes. - Introduced `useSettingsSync` hook for automatic synchronization of settings to the server with debouncing. - Enhanced feature update logic to track description changes with a history, allowing for better management of feature descriptions. - Updated various components and services to utilize the new settings structure and description history functionality. - Removed persist middleware from Zustand store, streamlining state management and improving performance.
This commit is contained in:
@@ -6,10 +6,10 @@
|
||||
* categories to the server.
|
||||
*
|
||||
* Migration flow:
|
||||
* 1. useSettingsMigration() hook checks server for existing settings files
|
||||
* 2. If none exist, collects localStorage data and sends to /api/settings/migrate
|
||||
* 3. After successful migration, clears deprecated localStorage keys
|
||||
* 4. Maintains automaker-storage in localStorage as fast cache for Zustand
|
||||
* 1. useSettingsMigration() hook fetches settings from the server API
|
||||
* 2. Merges localStorage data (if any) with server data, preferring more complete data
|
||||
* 3. Hydrates the Zustand store with the merged settings
|
||||
* 4. Returns a promise that resolves when hydration is complete
|
||||
*
|
||||
* Sync functions for incremental updates:
|
||||
* - syncSettingsToServer: Writes global settings to file
|
||||
@@ -20,9 +20,9 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
|
||||
import { isElectron } from '@/lib/electron';
|
||||
import { getItem, removeItem } from '@/lib/storage';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import type { GlobalSettings } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsMigration');
|
||||
@@ -31,9 +31,9 @@ const logger = createLogger('SettingsMigration');
|
||||
* State returned by useSettingsMigration hook
|
||||
*/
|
||||
interface MigrationState {
|
||||
/** Whether migration check has completed */
|
||||
/** Whether migration/hydration has completed */
|
||||
checked: boolean;
|
||||
/** Whether migration actually occurred */
|
||||
/** Whether migration actually occurred (localStorage -> server) */
|
||||
migrated: boolean;
|
||||
/** Error message if migration failed (null if success/no-op) */
|
||||
error: string | null;
|
||||
@@ -41,9 +41,6 @@ interface MigrationState {
|
||||
|
||||
/**
|
||||
* localStorage keys that may contain settings to migrate
|
||||
*
|
||||
* These keys are collected and sent to the server for migration.
|
||||
* The automaker-storage key is handled specially as it's still used by Zustand.
|
||||
*/
|
||||
const LOCALSTORAGE_KEYS = [
|
||||
'automaker-storage',
|
||||
@@ -55,30 +52,248 @@ const LOCALSTORAGE_KEYS = [
|
||||
|
||||
/**
|
||||
* localStorage keys to remove after successful migration
|
||||
*
|
||||
* automaker-storage is intentionally NOT in this list because Zustand still uses it
|
||||
* as a cache. These other keys have been migrated and are no longer needed.
|
||||
*/
|
||||
const KEYS_TO_CLEAR_AFTER_MIGRATION = [
|
||||
'worktree-panel-collapsed',
|
||||
'file-browser-recent-folders',
|
||||
'automaker:lastProjectDir',
|
||||
// Legacy keys from older versions
|
||||
'automaker_projects',
|
||||
'automaker_current_project',
|
||||
'automaker_trashed_projects',
|
||||
'automaker-setup',
|
||||
] as const;
|
||||
|
||||
// Global promise that resolves when migration is complete
|
||||
// This allows useSettingsSync to wait for hydration before starting sync
|
||||
let migrationCompleteResolve: (() => void) | null = null;
|
||||
let migrationCompletePromise: Promise<void> | null = null;
|
||||
let migrationCompleted = false;
|
||||
|
||||
function signalMigrationComplete(): void {
|
||||
migrationCompleted = true;
|
||||
if (migrationCompleteResolve) {
|
||||
migrationCompleteResolve();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook to handle settings migration from localStorage to file-based storage
|
||||
* Get a promise that resolves when migration/hydration is complete
|
||||
* Used by useSettingsSync to coordinate timing
|
||||
*/
|
||||
export function waitForMigrationComplete(): Promise<void> {
|
||||
// If migration already completed before anything started waiting, resolve immediately.
|
||||
if (migrationCompleted) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!migrationCompletePromise) {
|
||||
migrationCompletePromise = new Promise((resolve) => {
|
||||
migrationCompleteResolve = resolve;
|
||||
});
|
||||
}
|
||||
return migrationCompletePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse localStorage data into settings object
|
||||
*/
|
||||
function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
try {
|
||||
const automakerStorage = getItem('automaker-storage');
|
||||
if (!automakerStorage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(automakerStorage) as Record<string, unknown>;
|
||||
// Zustand persist stores state under 'state' key
|
||||
const state = (parsed.state as Record<string, unknown> | undefined) || parsed;
|
||||
|
||||
// Setup wizard state (previously stored in its own persist key)
|
||||
const automakerSetup = getItem('automaker-setup');
|
||||
const setupParsed = automakerSetup
|
||||
? (JSON.parse(automakerSetup) as Record<string, unknown>)
|
||||
: null;
|
||||
const setupState =
|
||||
(setupParsed?.state as Record<string, unknown> | undefined) || setupParsed || {};
|
||||
|
||||
// Also check for standalone localStorage keys
|
||||
const worktreePanelCollapsed = getItem('worktree-panel-collapsed');
|
||||
const recentFolders = getItem('file-browser-recent-folders');
|
||||
const lastProjectDir = getItem('automaker:lastProjectDir');
|
||||
|
||||
return {
|
||||
setupComplete: setupState.setupComplete as boolean,
|
||||
isFirstRun: setupState.isFirstRun as boolean,
|
||||
skipClaudeSetup: setupState.skipClaudeSetup as boolean,
|
||||
theme: state.theme as GlobalSettings['theme'],
|
||||
sidebarOpen: state.sidebarOpen as boolean,
|
||||
chatHistoryOpen: state.chatHistoryOpen as boolean,
|
||||
kanbanCardDetailLevel: state.kanbanCardDetailLevel as GlobalSettings['kanbanCardDetailLevel'],
|
||||
maxConcurrency: state.maxConcurrency as number,
|
||||
defaultSkipTests: state.defaultSkipTests as boolean,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking as boolean,
|
||||
skipVerificationInAutoMode: state.skipVerificationInAutoMode as boolean,
|
||||
useWorktrees: state.useWorktrees as boolean,
|
||||
showProfilesOnly: state.showProfilesOnly as boolean,
|
||||
defaultPlanningMode: state.defaultPlanningMode as GlobalSettings['defaultPlanningMode'],
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval as boolean,
|
||||
defaultAIProfileId: state.defaultAIProfileId as string | null,
|
||||
muteDoneSound: state.muteDoneSound as boolean,
|
||||
enhancementModel: state.enhancementModel as GlobalSettings['enhancementModel'],
|
||||
validationModel: state.validationModel as GlobalSettings['validationModel'],
|
||||
phaseModels: state.phaseModels as GlobalSettings['phaseModels'],
|
||||
enabledCursorModels: state.enabledCursorModels as GlobalSettings['enabledCursorModels'],
|
||||
cursorDefaultModel: state.cursorDefaultModel as GlobalSettings['cursorDefaultModel'],
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
|
||||
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
|
||||
aiProfiles: state.aiProfiles as GlobalSettings['aiProfiles'],
|
||||
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
|
||||
promptCustomization: state.promptCustomization as GlobalSettings['promptCustomization'],
|
||||
projects: state.projects as GlobalSettings['projects'],
|
||||
trashedProjects: state.trashedProjects as GlobalSettings['trashedProjects'],
|
||||
currentProjectId: (state.currentProject as { id?: string } | null)?.id ?? null,
|
||||
projectHistory: state.projectHistory as GlobalSettings['projectHistory'],
|
||||
projectHistoryIndex: state.projectHistoryIndex as number,
|
||||
lastSelectedSessionByProject:
|
||||
state.lastSelectedSessionByProject as GlobalSettings['lastSelectedSessionByProject'],
|
||||
// UI State from standalone localStorage keys or Zustand state
|
||||
worktreePanelCollapsed:
|
||||
worktreePanelCollapsed === 'true' || (state.worktreePanelCollapsed as boolean),
|
||||
lastProjectDir: lastProjectDir || (state.lastProjectDir as string),
|
||||
recentFolders: recentFolders ? JSON.parse(recentFolders) : (state.recentFolders as string[]),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse localStorage settings:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if localStorage has more complete data than server
|
||||
* Returns true if localStorage has projects but server doesn't
|
||||
*/
|
||||
function localStorageHasMoreData(
|
||||
localSettings: Partial<GlobalSettings> | null,
|
||||
serverSettings: GlobalSettings | null
|
||||
): boolean {
|
||||
if (!localSettings) return false;
|
||||
if (!serverSettings) return true;
|
||||
|
||||
// Check if localStorage has projects that server doesn't
|
||||
const localProjects = localSettings.projects || [];
|
||||
const serverProjects = serverSettings.projects || [];
|
||||
|
||||
if (localProjects.length > 0 && serverProjects.length === 0) {
|
||||
logger.info(`localStorage has ${localProjects.length} projects, server has none - will merge`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if localStorage has AI profiles that server doesn't
|
||||
const localProfiles = localSettings.aiProfiles || [];
|
||||
const serverProfiles = serverSettings.aiProfiles || [];
|
||||
|
||||
if (localProfiles.length > 0 && serverProfiles.length === 0) {
|
||||
logger.info(
|
||||
`localStorage has ${localProfiles.length} AI profiles, server has none - will merge`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge localStorage settings with server settings
|
||||
* Prefers server data, but uses localStorage for missing arrays/objects
|
||||
*/
|
||||
function mergeSettings(
|
||||
serverSettings: GlobalSettings,
|
||||
localSettings: Partial<GlobalSettings> | null
|
||||
): GlobalSettings {
|
||||
if (!localSettings) return serverSettings;
|
||||
|
||||
// Start with server settings
|
||||
const merged = { ...serverSettings };
|
||||
|
||||
// For arrays, prefer the one with more items (if server is empty, use local)
|
||||
if (
|
||||
(!serverSettings.projects || serverSettings.projects.length === 0) &&
|
||||
localSettings.projects &&
|
||||
localSettings.projects.length > 0
|
||||
) {
|
||||
merged.projects = localSettings.projects;
|
||||
}
|
||||
|
||||
if (
|
||||
(!serverSettings.aiProfiles || serverSettings.aiProfiles.length === 0) &&
|
||||
localSettings.aiProfiles &&
|
||||
localSettings.aiProfiles.length > 0
|
||||
) {
|
||||
merged.aiProfiles = localSettings.aiProfiles;
|
||||
}
|
||||
|
||||
if (
|
||||
(!serverSettings.trashedProjects || serverSettings.trashedProjects.length === 0) &&
|
||||
localSettings.trashedProjects &&
|
||||
localSettings.trashedProjects.length > 0
|
||||
) {
|
||||
merged.trashedProjects = localSettings.trashedProjects;
|
||||
}
|
||||
|
||||
if (
|
||||
(!serverSettings.mcpServers || serverSettings.mcpServers.length === 0) &&
|
||||
localSettings.mcpServers &&
|
||||
localSettings.mcpServers.length > 0
|
||||
) {
|
||||
merged.mcpServers = localSettings.mcpServers;
|
||||
}
|
||||
|
||||
if (
|
||||
(!serverSettings.recentFolders || serverSettings.recentFolders.length === 0) &&
|
||||
localSettings.recentFolders &&
|
||||
localSettings.recentFolders.length > 0
|
||||
) {
|
||||
merged.recentFolders = localSettings.recentFolders;
|
||||
}
|
||||
|
||||
if (
|
||||
(!serverSettings.projectHistory || serverSettings.projectHistory.length === 0) &&
|
||||
localSettings.projectHistory &&
|
||||
localSettings.projectHistory.length > 0
|
||||
) {
|
||||
merged.projectHistory = localSettings.projectHistory;
|
||||
merged.projectHistoryIndex = localSettings.projectHistoryIndex ?? -1;
|
||||
}
|
||||
|
||||
// For objects, merge if server is empty
|
||||
if (
|
||||
(!serverSettings.lastSelectedSessionByProject ||
|
||||
Object.keys(serverSettings.lastSelectedSessionByProject).length === 0) &&
|
||||
localSettings.lastSelectedSessionByProject &&
|
||||
Object.keys(localSettings.lastSelectedSessionByProject).length > 0
|
||||
) {
|
||||
merged.lastSelectedSessionByProject = localSettings.lastSelectedSessionByProject;
|
||||
}
|
||||
|
||||
// For simple values, use localStorage if server value is default/undefined
|
||||
if (!serverSettings.lastProjectDir && localSettings.lastProjectDir) {
|
||||
merged.lastProjectDir = localSettings.lastProjectDir;
|
||||
}
|
||||
|
||||
// Preserve current project ID from localStorage if server doesn't have one
|
||||
if (!serverSettings.currentProjectId && localSettings.currentProjectId) {
|
||||
merged.currentProjectId = localSettings.currentProjectId;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook to handle settings hydration from server on startup
|
||||
*
|
||||
* Runs automatically once on component mount. Returns state indicating whether
|
||||
* migration check is complete, whether migration occurred, and any errors.
|
||||
* hydration is complete, whether data was migrated from localStorage, and any errors.
|
||||
*
|
||||
* Only runs in Electron mode (isElectron() must be true). Web mode uses different
|
||||
* storage mechanisms.
|
||||
*
|
||||
* The hook uses a ref to ensure it only runs once despite multiple mounts.
|
||||
* Works in both Electron and web modes - both need to hydrate from the server API.
|
||||
*
|
||||
* @returns MigrationState with checked, migrated, and error fields
|
||||
*/
|
||||
@@ -96,24 +311,32 @@ export function useSettingsMigration(): MigrationState {
|
||||
migrationAttempted.current = true;
|
||||
|
||||
async function checkAndMigrate() {
|
||||
// Only run migration in Electron mode (web mode uses different storage)
|
||||
if (!isElectron()) {
|
||||
setState({ checked: true, migrated: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for API key to be initialized before making any API calls
|
||||
// This prevents 401 errors on startup in Electron mode
|
||||
await waitForApiKeyInit();
|
||||
|
||||
const api = getHttpApiClient();
|
||||
|
||||
// Always try to get localStorage data first (in case we need to merge/migrate)
|
||||
const localSettings = parseLocalStorageSettings();
|
||||
logger.info(
|
||||
`localStorage has ${localSettings?.projects?.length ?? 0} projects, ${localSettings?.aiProfiles?.length ?? 0} profiles`
|
||||
);
|
||||
|
||||
// Check if server has settings files
|
||||
const status = await api.settings.getStatus();
|
||||
|
||||
if (!status.success) {
|
||||
logger.error('Failed to get status:', status);
|
||||
logger.error('Failed to get settings status:', status);
|
||||
|
||||
// Even if status check fails, try to use localStorage data if available
|
||||
if (localSettings) {
|
||||
logger.info('Using localStorage data as fallback');
|
||||
hydrateStoreFromSettings(localSettings as GlobalSettings);
|
||||
}
|
||||
|
||||
signalMigrationComplete();
|
||||
|
||||
setState({
|
||||
checked: true,
|
||||
migrated: false,
|
||||
@@ -122,114 +345,80 @@ export function useSettingsMigration(): MigrationState {
|
||||
return;
|
||||
}
|
||||
|
||||
// If settings files already exist, no migration needed
|
||||
if (!status.needsMigration) {
|
||||
logger.info('Settings files exist - hydrating UI store from server');
|
||||
// Try to get global settings from server
|
||||
let serverSettings: GlobalSettings | null = null;
|
||||
try {
|
||||
const global = await api.settings.getGlobal();
|
||||
if (global.success && global.settings) {
|
||||
serverSettings = global.settings as unknown as GlobalSettings;
|
||||
logger.info(
|
||||
`Server has ${serverSettings.projects?.length ?? 0} projects, ${serverSettings.aiProfiles?.length ?? 0} profiles`
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch server settings:', error);
|
||||
}
|
||||
|
||||
// IMPORTANT: the server settings file is now the source of truth.
|
||||
// If localStorage/Zustand get out of sync (e.g. cleared localStorage),
|
||||
// the UI can show stale values even though the server will execute with
|
||||
// the file-based settings. Hydrate the store from the server on startup.
|
||||
// Determine what settings to use
|
||||
let finalSettings: GlobalSettings;
|
||||
let needsSync = false;
|
||||
|
||||
if (serverSettings) {
|
||||
// Check if we need to merge localStorage data
|
||||
if (localStorageHasMoreData(localSettings, serverSettings)) {
|
||||
finalSettings = mergeSettings(serverSettings, localSettings);
|
||||
needsSync = true;
|
||||
logger.info('Merged localStorage data with server settings');
|
||||
} else {
|
||||
finalSettings = serverSettings;
|
||||
}
|
||||
} else if (localSettings) {
|
||||
// No server settings, use localStorage
|
||||
finalSettings = localSettings as GlobalSettings;
|
||||
needsSync = true;
|
||||
logger.info('Using localStorage settings (no server settings found)');
|
||||
} else {
|
||||
// No settings anywhere, use defaults
|
||||
logger.info('No settings found, using defaults');
|
||||
signalMigrationComplete();
|
||||
setState({ checked: true, migrated: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Hydrate the store
|
||||
hydrateStoreFromSettings(finalSettings);
|
||||
logger.info('Store hydrated with settings');
|
||||
|
||||
// If we merged data or used localStorage, sync to server
|
||||
if (needsSync) {
|
||||
try {
|
||||
const global = await api.settings.getGlobal();
|
||||
if (global.success && global.settings) {
|
||||
const serverSettings = global.settings as unknown as GlobalSettings;
|
||||
const current = useAppStore.getState();
|
||||
const updates = buildSettingsUpdateFromStore();
|
||||
const result = await api.settings.updateGlobal(updates);
|
||||
if (result.success) {
|
||||
logger.info('Synced merged settings to server');
|
||||
|
||||
useAppStore.setState({
|
||||
theme: serverSettings.theme as unknown as import('@/store/app-store').ThemeMode,
|
||||
sidebarOpen: serverSettings.sidebarOpen,
|
||||
chatHistoryOpen: serverSettings.chatHistoryOpen,
|
||||
kanbanCardDetailLevel: serverSettings.kanbanCardDetailLevel,
|
||||
maxConcurrency: serverSettings.maxConcurrency,
|
||||
defaultSkipTests: serverSettings.defaultSkipTests,
|
||||
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
|
||||
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
|
||||
useWorktrees: serverSettings.useWorktrees,
|
||||
showProfilesOnly: serverSettings.showProfilesOnly,
|
||||
defaultPlanningMode: serverSettings.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
|
||||
defaultAIProfileId: serverSettings.defaultAIProfileId,
|
||||
muteDoneSound: serverSettings.muteDoneSound,
|
||||
enhancementModel: serverSettings.enhancementModel,
|
||||
validationModel: serverSettings.validationModel,
|
||||
phaseModels: serverSettings.phaseModels,
|
||||
enabledCursorModels: serverSettings.enabledCursorModels,
|
||||
cursorDefaultModel: serverSettings.cursorDefaultModel,
|
||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
|
||||
keyboardShortcuts: {
|
||||
...current.keyboardShortcuts,
|
||||
...(serverSettings.keyboardShortcuts as unknown as Partial<
|
||||
typeof current.keyboardShortcuts
|
||||
>),
|
||||
},
|
||||
aiProfiles: serverSettings.aiProfiles,
|
||||
mcpServers: serverSettings.mcpServers,
|
||||
promptCustomization: serverSettings.promptCustomization ?? {},
|
||||
projects: serverSettings.projects,
|
||||
trashedProjects: serverSettings.trashedProjects,
|
||||
projectHistory: serverSettings.projectHistory,
|
||||
projectHistoryIndex: serverSettings.projectHistoryIndex,
|
||||
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
|
||||
});
|
||||
|
||||
logger.info('Hydrated UI settings from server settings file');
|
||||
// Clear old localStorage keys after successful sync
|
||||
for (const key of KEYS_TO_CLEAR_AFTER_MIGRATION) {
|
||||
removeItem(key);
|
||||
}
|
||||
} else {
|
||||
logger.warn('Failed to load global settings from server:', global);
|
||||
logger.warn('Failed to sync merged settings to server:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to hydrate UI settings from server:', error);
|
||||
}
|
||||
|
||||
setState({ checked: true, migrated: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we have localStorage data to migrate
|
||||
const automakerStorage = getItem('automaker-storage');
|
||||
if (!automakerStorage) {
|
||||
logger.info('No localStorage data to migrate');
|
||||
setState({ checked: true, migrated: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Starting migration...');
|
||||
|
||||
// Collect all localStorage data
|
||||
const localStorageData: Record<string, string> = {};
|
||||
for (const key of LOCALSTORAGE_KEYS) {
|
||||
const value = getItem(key);
|
||||
if (value) {
|
||||
localStorageData[key] = value;
|
||||
logger.error('Failed to sync merged settings:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Send to server for migration
|
||||
const result = await api.settings.migrate(localStorageData);
|
||||
// Signal that migration is complete
|
||||
signalMigrationComplete();
|
||||
|
||||
if (result.success) {
|
||||
logger.info('Migration successful:', {
|
||||
globalSettings: result.migratedGlobalSettings,
|
||||
credentials: result.migratedCredentials,
|
||||
projects: result.migratedProjectCount,
|
||||
});
|
||||
|
||||
// Clear old localStorage keys (but keep automaker-storage for Zustand)
|
||||
for (const key of KEYS_TO_CLEAR_AFTER_MIGRATION) {
|
||||
removeItem(key);
|
||||
}
|
||||
|
||||
setState({ checked: true, migrated: true, error: null });
|
||||
} else {
|
||||
logger.warn('Migration had errors:', result.errors);
|
||||
setState({
|
||||
checked: true,
|
||||
migrated: false,
|
||||
error: result.errors.join(', '),
|
||||
});
|
||||
}
|
||||
setState({ checked: true, migrated: needsSync, error: null });
|
||||
} catch (error) {
|
||||
logger.error('Migration failed:', error);
|
||||
logger.error('Migration/hydration failed:', error);
|
||||
|
||||
// Signal that migration is complete (even on error)
|
||||
signalMigrationComplete();
|
||||
|
||||
setState({
|
||||
checked: true,
|
||||
migrated: false,
|
||||
@@ -244,74 +433,136 @@ export function useSettingsMigration(): MigrationState {
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate the Zustand store from settings object
|
||||
*/
|
||||
function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
const current = useAppStore.getState();
|
||||
|
||||
// Convert ProjectRef[] to Project[] (minimal data, features will be loaded separately)
|
||||
const projects = (settings.projects ?? []).map((ref) => ({
|
||||
id: ref.id,
|
||||
name: ref.name,
|
||||
path: ref.path,
|
||||
lastOpened: ref.lastOpened,
|
||||
theme: ref.theme,
|
||||
features: [], // Features are loaded separately when project is opened
|
||||
}));
|
||||
|
||||
// Find the current project by ID
|
||||
let currentProject = null;
|
||||
if (settings.currentProjectId) {
|
||||
currentProject = projects.find((p) => p.id === settings.currentProjectId) ?? null;
|
||||
if (currentProject) {
|
||||
logger.info(`Restoring current project: ${currentProject.name} (${currentProject.id})`);
|
||||
}
|
||||
}
|
||||
|
||||
useAppStore.setState({
|
||||
theme: settings.theme as unknown as import('@/store/app-store').ThemeMode,
|
||||
sidebarOpen: settings.sidebarOpen ?? true,
|
||||
chatHistoryOpen: settings.chatHistoryOpen ?? false,
|
||||
kanbanCardDetailLevel: settings.kanbanCardDetailLevel ?? 'standard',
|
||||
maxConcurrency: settings.maxConcurrency ?? 3,
|
||||
defaultSkipTests: settings.defaultSkipTests ?? true,
|
||||
enableDependencyBlocking: settings.enableDependencyBlocking ?? true,
|
||||
skipVerificationInAutoMode: settings.skipVerificationInAutoMode ?? false,
|
||||
useWorktrees: settings.useWorktrees ?? false,
|
||||
showProfilesOnly: settings.showProfilesOnly ?? false,
|
||||
defaultPlanningMode: settings.defaultPlanningMode ?? 'skip',
|
||||
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
|
||||
defaultAIProfileId: settings.defaultAIProfileId ?? null,
|
||||
muteDoneSound: settings.muteDoneSound ?? false,
|
||||
enhancementModel: settings.enhancementModel ?? 'sonnet',
|
||||
validationModel: settings.validationModel ?? 'opus',
|
||||
phaseModels: settings.phaseModels ?? current.phaseModels,
|
||||
enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels,
|
||||
cursorDefaultModel: settings.cursorDefaultModel ?? 'auto',
|
||||
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
|
||||
keyboardShortcuts: {
|
||||
...current.keyboardShortcuts,
|
||||
...(settings.keyboardShortcuts as unknown as Partial<typeof current.keyboardShortcuts>),
|
||||
},
|
||||
aiProfiles: settings.aiProfiles ?? [],
|
||||
mcpServers: settings.mcpServers ?? [],
|
||||
promptCustomization: settings.promptCustomization ?? {},
|
||||
projects,
|
||||
currentProject,
|
||||
trashedProjects: settings.trashedProjects ?? [],
|
||||
projectHistory: settings.projectHistory ?? [],
|
||||
projectHistoryIndex: settings.projectHistoryIndex ?? -1,
|
||||
lastSelectedSessionByProject: settings.lastSelectedSessionByProject ?? {},
|
||||
// UI State
|
||||
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
|
||||
lastProjectDir: settings.lastProjectDir ?? '',
|
||||
recentFolders: settings.recentFolders ?? [],
|
||||
});
|
||||
|
||||
// Hydrate setup wizard state from global settings (API-backed)
|
||||
useSetupStore.setState({
|
||||
setupComplete: settings.setupComplete ?? false,
|
||||
isFirstRun: settings.isFirstRun ?? true,
|
||||
skipClaudeSetup: settings.skipClaudeSetup ?? false,
|
||||
currentStep: settings.setupComplete ? 'complete' : 'welcome',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build settings update object from current store state
|
||||
*/
|
||||
function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
const state = useAppStore.getState();
|
||||
const setupState = useSetupStore.getState();
|
||||
return {
|
||||
setupComplete: setupState.setupComplete,
|
||||
isFirstRun: setupState.isFirstRun,
|
||||
skipClaudeSetup: setupState.skipClaudeSetup,
|
||||
theme: state.theme,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
chatHistoryOpen: state.chatHistoryOpen,
|
||||
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
|
||||
useWorktrees: state.useWorktrees,
|
||||
showProfilesOnly: state.showProfilesOnly,
|
||||
defaultPlanningMode: state.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||
defaultAIProfileId: state.defaultAIProfileId,
|
||||
muteDoneSound: state.muteDoneSound,
|
||||
enhancementModel: state.enhancementModel,
|
||||
validationModel: state.validationModel,
|
||||
phaseModels: state.phaseModels,
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
aiProfiles: state.aiProfiles,
|
||||
mcpServers: state.mcpServers,
|
||||
promptCustomization: state.promptCustomization,
|
||||
projects: state.projects,
|
||||
trashedProjects: state.trashedProjects,
|
||||
currentProjectId: state.currentProject?.id ?? null,
|
||||
projectHistory: state.projectHistory,
|
||||
projectHistoryIndex: state.projectHistoryIndex,
|
||||
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
||||
worktreePanelCollapsed: state.worktreePanelCollapsed,
|
||||
lastProjectDir: state.lastProjectDir,
|
||||
recentFolders: state.recentFolders,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync current global settings to file-based server storage
|
||||
*
|
||||
* Reads the current Zustand state from localStorage and sends all global settings
|
||||
* Reads the current Zustand state and sends all global settings
|
||||
* to the server to be written to {dataDir}/settings.json.
|
||||
*
|
||||
* Call this when important global settings change (theme, UI preferences, profiles, etc.)
|
||||
* Safe to call from store subscribers or change handlers.
|
||||
*
|
||||
* @returns Promise resolving to true if sync succeeded, false otherwise
|
||||
*/
|
||||
export async function syncSettingsToServer(): Promise<boolean> {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
// IMPORTANT:
|
||||
// Prefer the live Zustand state over localStorage to avoid race conditions
|
||||
// (Zustand persistence writes can lag behind `set(...)`, which would cause us
|
||||
// to sync stale values to the server).
|
||||
//
|
||||
// localStorage remains as a fallback for cases where the store isn't ready.
|
||||
let state: Record<string, unknown> | null = null;
|
||||
try {
|
||||
state = useAppStore.getState() as unknown as Record<string, unknown>;
|
||||
} catch {
|
||||
// Ignore and fall back to localStorage
|
||||
}
|
||||
|
||||
if (!state) {
|
||||
const automakerStorage = getItem('automaker-storage');
|
||||
if (!automakerStorage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(automakerStorage) as Record<string, unknown>;
|
||||
state = (parsed.state as Record<string, unknown> | undefined) || parsed;
|
||||
}
|
||||
|
||||
// Extract settings to sync
|
||||
const updates = {
|
||||
theme: state.theme,
|
||||
sidebarOpen: state.sidebarOpen,
|
||||
chatHistoryOpen: state.chatHistoryOpen,
|
||||
kanbanCardDetailLevel: state.kanbanCardDetailLevel,
|
||||
maxConcurrency: state.maxConcurrency,
|
||||
defaultSkipTests: state.defaultSkipTests,
|
||||
enableDependencyBlocking: state.enableDependencyBlocking,
|
||||
skipVerificationInAutoMode: state.skipVerificationInAutoMode,
|
||||
useWorktrees: state.useWorktrees,
|
||||
showProfilesOnly: state.showProfilesOnly,
|
||||
defaultPlanningMode: state.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
|
||||
defaultAIProfileId: state.defaultAIProfileId,
|
||||
muteDoneSound: state.muteDoneSound,
|
||||
enhancementModel: state.enhancementModel,
|
||||
validationModel: state.validationModel,
|
||||
phaseModels: state.phaseModels,
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
aiProfiles: state.aiProfiles,
|
||||
mcpServers: state.mcpServers,
|
||||
promptCustomization: state.promptCustomization,
|
||||
projects: state.projects,
|
||||
trashedProjects: state.trashedProjects,
|
||||
projectHistory: state.projectHistory,
|
||||
projectHistoryIndex: state.projectHistoryIndex,
|
||||
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
||||
};
|
||||
|
||||
const updates = buildSettingsUpdateFromStore();
|
||||
const result = await api.settings.updateGlobal(updates);
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
@@ -323,12 +574,6 @@ export async function syncSettingsToServer(): Promise<boolean> {
|
||||
/**
|
||||
* Sync API credentials to file-based server storage
|
||||
*
|
||||
* Sends API keys (partial update supported) to the server to be written to
|
||||
* {dataDir}/credentials.json. Credentials are kept separate from settings for security.
|
||||
*
|
||||
* Call this when API keys are added or updated in settings UI.
|
||||
* Only requires providing the keys that have changed.
|
||||
*
|
||||
* @param apiKeys - Partial credential object with optional anthropic, google, openai keys
|
||||
* @returns Promise resolving to true if sync succeeded, false otherwise
|
||||
*/
|
||||
@@ -350,16 +595,8 @@ export async function syncCredentialsToServer(apiKeys: {
|
||||
/**
|
||||
* Sync project-specific settings to file-based server storage
|
||||
*
|
||||
* Sends project settings (theme, worktree config, board customization) to the server
|
||||
* to be written to {projectPath}/.automaker/settings.json.
|
||||
*
|
||||
* These settings override global settings for specific projects.
|
||||
* Supports partial updates - only include fields that have changed.
|
||||
*
|
||||
* Call this when project settings are modified in the board or settings UI.
|
||||
*
|
||||
* @param projectPath - Absolute path to project directory
|
||||
* @param updates - Partial ProjectSettings with optional theme, worktree, and board settings
|
||||
* @param updates - Partial ProjectSettings
|
||||
* @returns Promise resolving to true if sync succeeded, false otherwise
|
||||
*/
|
||||
export async function syncProjectSettingsToServer(
|
||||
@@ -391,10 +628,6 @@ export async function syncProjectSettingsToServer(
|
||||
/**
|
||||
* Load MCP servers from server settings file into the store
|
||||
*
|
||||
* Fetches the global settings from the server and updates the store's
|
||||
* mcpServers state. Useful when settings were modified externally
|
||||
* (e.g., by editing the settings.json file directly).
|
||||
*
|
||||
* @returns Promise resolving to true if load succeeded, false otherwise
|
||||
*/
|
||||
export async function loadMCPServersFromServer(): Promise<boolean> {
|
||||
@@ -408,9 +641,6 @@ export async function loadMCPServersFromServer(): Promise<boolean> {
|
||||
}
|
||||
|
||||
const mcpServers = result.settings.mcpServers || [];
|
||||
|
||||
// Clear existing and add all from server
|
||||
// We need to update the store directly since we can't use hooks here
|
||||
useAppStore.setState({ mcpServers });
|
||||
|
||||
logger.info(`Loaded ${mcpServers.length} MCP servers from server`);
|
||||
|
||||
397
apps/ui/src/hooks/use-settings-sync.ts
Normal file
397
apps/ui/src/hooks/use-settings-sync.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Settings Sync Hook - API-First Settings Management
|
||||
*
|
||||
* This hook provides automatic settings synchronization to the server.
|
||||
* It subscribes to Zustand store changes and syncs to API with debouncing.
|
||||
*
|
||||
* IMPORTANT: This hook waits for useSettingsMigration to complete before
|
||||
* starting to sync. This prevents overwriting server data with empty state
|
||||
* during the initial hydration phase.
|
||||
*
|
||||
* The server's settings.json file is the single source of truth.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { getHttpApiClient, waitForApiKeyInit } from '@/lib/http-api-client';
|
||||
import { useAppStore, type ThemeMode } from '@/store/app-store';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { waitForMigrationComplete } from './use-settings-migration';
|
||||
import type { GlobalSettings } from '@automaker/types';
|
||||
|
||||
const logger = createLogger('SettingsSync');
|
||||
|
||||
// Debounce delay for syncing settings to server (ms)
|
||||
const SYNC_DEBOUNCE_MS = 1000;
|
||||
|
||||
// Fields to sync to server (subset of AppState that should be persisted)
|
||||
const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'theme',
|
||||
'sidebarOpen',
|
||||
'chatHistoryOpen',
|
||||
'kanbanCardDetailLevel',
|
||||
'maxConcurrency',
|
||||
'defaultSkipTests',
|
||||
'enableDependencyBlocking',
|
||||
'skipVerificationInAutoMode',
|
||||
'useWorktrees',
|
||||
'showProfilesOnly',
|
||||
'defaultPlanningMode',
|
||||
'defaultRequirePlanApproval',
|
||||
'defaultAIProfileId',
|
||||
'muteDoneSound',
|
||||
'enhancementModel',
|
||||
'validationModel',
|
||||
'phaseModels',
|
||||
'enabledCursorModels',
|
||||
'cursorDefaultModel',
|
||||
'autoLoadClaudeMd',
|
||||
'keyboardShortcuts',
|
||||
'aiProfiles',
|
||||
'mcpServers',
|
||||
'promptCustomization',
|
||||
'projects',
|
||||
'trashedProjects',
|
||||
'currentProjectId', // ID of currently open project
|
||||
'projectHistory',
|
||||
'projectHistoryIndex',
|
||||
'lastSelectedSessionByProject',
|
||||
// UI State (previously in localStorage)
|
||||
'worktreePanelCollapsed',
|
||||
'lastProjectDir',
|
||||
'recentFolders',
|
||||
] as const;
|
||||
|
||||
// Fields from setup store to sync
|
||||
const SETUP_FIELDS_TO_SYNC = ['isFirstRun', 'setupComplete', 'skipClaudeSetup'] as const;
|
||||
|
||||
interface SettingsSyncState {
|
||||
/** Whether initial settings have been loaded from API */
|
||||
loaded: boolean;
|
||||
/** Whether there was an error loading settings */
|
||||
error: string | null;
|
||||
/** Whether settings are currently being synced to server */
|
||||
syncing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to sync settings changes to server with debouncing
|
||||
*
|
||||
* Usage: Call this hook once at the app root level (e.g., in App.tsx)
|
||||
* AFTER useSettingsMigration.
|
||||
*
|
||||
* @returns SettingsSyncState with loaded, error, and syncing fields
|
||||
*/
|
||||
export function useSettingsSync(): SettingsSyncState {
|
||||
const [state, setState] = useState<SettingsSyncState>({
|
||||
loaded: false,
|
||||
error: null,
|
||||
syncing: false,
|
||||
});
|
||||
|
||||
const syncTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSyncedRef = useRef<string>('');
|
||||
const isInitializedRef = useRef(false);
|
||||
|
||||
// Debounced sync function
|
||||
const syncToServer = useCallback(async () => {
|
||||
try {
|
||||
setState((s) => ({ ...s, syncing: true }));
|
||||
const api = getHttpApiClient();
|
||||
const appState = useAppStore.getState();
|
||||
|
||||
// Build updates object from current state
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||
if (field === 'currentProjectId') {
|
||||
// Special handling: extract ID from currentProject object
|
||||
updates[field] = appState.currentProject?.id ?? null;
|
||||
} else {
|
||||
updates[field] = appState[field as keyof typeof appState];
|
||||
}
|
||||
}
|
||||
|
||||
// Include setup wizard state (lives in a separate store)
|
||||
const setupState = useSetupStore.getState();
|
||||
for (const field of SETUP_FIELDS_TO_SYNC) {
|
||||
updates[field] = setupState[field as keyof typeof setupState];
|
||||
}
|
||||
|
||||
// Create a hash of the updates to avoid redundant syncs
|
||||
const updateHash = JSON.stringify(updates);
|
||||
if (updateHash === lastSyncedRef.current) {
|
||||
setState((s) => ({ ...s, syncing: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await api.settings.updateGlobal(updates);
|
||||
if (result.success) {
|
||||
lastSyncedRef.current = updateHash;
|
||||
logger.debug('Settings synced to server');
|
||||
} else {
|
||||
logger.error('Failed to sync settings:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync settings to server:', error);
|
||||
} finally {
|
||||
setState((s) => ({ ...s, syncing: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Schedule debounced sync
|
||||
const scheduleSyncToServer = useCallback(() => {
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
syncTimeoutRef.current = setTimeout(() => {
|
||||
syncToServer();
|
||||
}, SYNC_DEBOUNCE_MS);
|
||||
}, [syncToServer]);
|
||||
|
||||
// Immediate sync helper for critical state (e.g., current project selection)
|
||||
const syncNow = useCallback(() => {
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
syncTimeoutRef.current = null;
|
||||
}
|
||||
void syncToServer();
|
||||
}, [syncToServer]);
|
||||
|
||||
// Initialize sync - WAIT for migration to complete first
|
||||
useEffect(() => {
|
||||
if (isInitializedRef.current) return;
|
||||
isInitializedRef.current = true;
|
||||
|
||||
async function initializeSync() {
|
||||
try {
|
||||
// Wait for API key to be ready
|
||||
await waitForApiKeyInit();
|
||||
|
||||
// CRITICAL: Wait for migration/hydration to complete before we start syncing
|
||||
// This prevents overwriting server data with empty/default state
|
||||
logger.info('Waiting for migration to complete before starting sync...');
|
||||
await waitForMigrationComplete();
|
||||
logger.info('Migration complete, initializing sync');
|
||||
|
||||
// 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<string, unknown> = {};
|
||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||
if (field === 'currentProjectId') {
|
||||
updates[field] = appState.currentProject?.id ?? null;
|
||||
} else {
|
||||
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];
|
||||
}
|
||||
lastSyncedRef.current = JSON.stringify(updates);
|
||||
|
||||
logger.info('Settings sync initialized');
|
||||
setState({ loaded: true, error: null, syncing: false });
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize settings sync:', error);
|
||||
setState({
|
||||
loaded: true,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
syncing: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
initializeSync();
|
||||
}, []);
|
||||
|
||||
// Subscribe to store changes and sync to server
|
||||
useEffect(() => {
|
||||
if (!state.loaded) return;
|
||||
|
||||
// Subscribe to app store changes
|
||||
const unsubscribeApp = useAppStore.subscribe((newState, prevState) => {
|
||||
// If the current project changed, sync immediately so we can restore on next launch
|
||||
if (newState.currentProject?.id !== prevState.currentProject?.id) {
|
||||
syncNow();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any synced field changed
|
||||
let changed = false;
|
||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||
if (field === 'currentProjectId') {
|
||||
// Special handling: compare currentProject IDs
|
||||
if (newState.currentProject?.id !== prevState.currentProject?.id) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const key = field as keyof typeof newState;
|
||||
if (newState[key] !== prevState[key]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
scheduleSyncToServer();
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to setup store changes
|
||||
const unsubscribeSetup = useSetupStore.subscribe((newState, prevState) => {
|
||||
let changed = false;
|
||||
for (const field of SETUP_FIELDS_TO_SYNC) {
|
||||
const key = field as keyof typeof newState;
|
||||
if (newState[key] !== prevState[key]) {
|
||||
changed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
// Setup store changes also trigger a sync of all settings
|
||||
scheduleSyncToServer();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeApp();
|
||||
unsubscribeSetup();
|
||||
if (syncTimeoutRef.current) {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [state.loaded, scheduleSyncToServer, syncNow]);
|
||||
|
||||
// Best-effort flush on tab close / backgrounding
|
||||
useEffect(() => {
|
||||
if (!state.loaded) return;
|
||||
|
||||
const handleBeforeUnload = () => {
|
||||
// Fire-and-forget; may not complete in all browsers, but helps in Electron/webview
|
||||
syncNow();
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
syncNow();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [state.loaded, syncNow]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a sync to server
|
||||
* Use this when you need immediate persistence (e.g., before app close)
|
||||
*/
|
||||
export async function forceSyncSettingsToServer(): Promise<boolean> {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const appState = useAppStore.getState();
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||
if (field === 'currentProjectId') {
|
||||
updates[field] = appState.currentProject?.id ?? null;
|
||||
} else {
|
||||
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];
|
||||
}
|
||||
|
||||
const result = await api.settings.updateGlobal(updates);
|
||||
return result.success;
|
||||
} catch (error) {
|
||||
logger.error('Failed to force sync settings:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest settings from server and update store
|
||||
* Use this to refresh settings if they may have been modified externally
|
||||
*/
|
||||
export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.settings.getGlobal();
|
||||
|
||||
if (!result.success || !result.settings) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const serverSettings = result.settings as unknown as GlobalSettings;
|
||||
const currentAppState = useAppStore.getState();
|
||||
|
||||
useAppStore.setState({
|
||||
theme: serverSettings.theme as unknown as ThemeMode,
|
||||
sidebarOpen: serverSettings.sidebarOpen,
|
||||
chatHistoryOpen: serverSettings.chatHistoryOpen,
|
||||
kanbanCardDetailLevel: serverSettings.kanbanCardDetailLevel,
|
||||
maxConcurrency: serverSettings.maxConcurrency,
|
||||
defaultSkipTests: serverSettings.defaultSkipTests,
|
||||
enableDependencyBlocking: serverSettings.enableDependencyBlocking,
|
||||
skipVerificationInAutoMode: serverSettings.skipVerificationInAutoMode,
|
||||
useWorktrees: serverSettings.useWorktrees,
|
||||
showProfilesOnly: serverSettings.showProfilesOnly,
|
||||
defaultPlanningMode: serverSettings.defaultPlanningMode,
|
||||
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
|
||||
defaultAIProfileId: serverSettings.defaultAIProfileId,
|
||||
muteDoneSound: serverSettings.muteDoneSound,
|
||||
enhancementModel: serverSettings.enhancementModel,
|
||||
validationModel: serverSettings.validationModel,
|
||||
phaseModels: serverSettings.phaseModels,
|
||||
enabledCursorModels: serverSettings.enabledCursorModels,
|
||||
cursorDefaultModel: serverSettings.cursorDefaultModel,
|
||||
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
|
||||
keyboardShortcuts: {
|
||||
...currentAppState.keyboardShortcuts,
|
||||
...(serverSettings.keyboardShortcuts as unknown as Partial<
|
||||
typeof currentAppState.keyboardShortcuts
|
||||
>),
|
||||
},
|
||||
aiProfiles: serverSettings.aiProfiles,
|
||||
mcpServers: serverSettings.mcpServers,
|
||||
promptCustomization: serverSettings.promptCustomization ?? {},
|
||||
projects: serverSettings.projects,
|
||||
trashedProjects: serverSettings.trashedProjects,
|
||||
projectHistory: serverSettings.projectHistory,
|
||||
projectHistoryIndex: serverSettings.projectHistoryIndex,
|
||||
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
|
||||
// UI State (previously in localStorage)
|
||||
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
|
||||
lastProjectDir: serverSettings.lastProjectDir ?? '',
|
||||
recentFolders: serverSettings.recentFolders ?? [],
|
||||
});
|
||||
|
||||
// Also refresh setup wizard state
|
||||
useSetupStore.setState({
|
||||
setupComplete: serverSettings.setupComplete ?? false,
|
||||
isFirstRun: serverSettings.isFirstRun ?? true,
|
||||
skipClaudeSetup: serverSettings.skipClaudeSetup ?? false,
|
||||
currentStep: serverSettings.setupComplete ? 'complete' : 'welcome',
|
||||
});
|
||||
|
||||
logger.info('Settings refreshed from server');
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to refresh settings from server:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user