mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
Merge remote-tracking branch 'origin/v0.10.0rc' into stefandevo/main
This commit is contained in:
@@ -17,6 +17,7 @@ export function useProjectSettingsLoader() {
|
||||
const setCardBorderEnabled = useAppStore((state) => state.setCardBorderEnabled);
|
||||
const setCardBorderOpacity = useAppStore((state) => state.setCardBorderOpacity);
|
||||
const setHideScrollbar = useAppStore((state) => state.setHideScrollbar);
|
||||
const setWorktreePanelVisible = useAppStore((state) => state.setWorktreePanelVisible);
|
||||
|
||||
const loadingRef = useRef<string | null>(null);
|
||||
const currentProjectRef = useRef<string | null>(null);
|
||||
@@ -72,6 +73,11 @@ export function useProjectSettingsLoader() {
|
||||
(setter as (path: string, val: typeof value) => void)(requestedProjectPath, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply worktreePanelVisible if present
|
||||
if (result.settings.worktreePanelVisible !== undefined) {
|
||||
setWorktreePanelVisible(requestedProjectPath, result.settings.worktreePanelVisible);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load project settings:', error);
|
||||
|
||||
@@ -139,16 +139,13 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
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'],
|
||||
@@ -157,7 +154,6 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
|
||||
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'],
|
||||
@@ -199,17 +195,6 @@ export function localStorageHasMoreData(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -235,14 +220,6 @@ export function mergeSettings(
|
||||
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 &&
|
||||
@@ -313,12 +290,8 @@ export async function performSettingsMigration(
|
||||
): Promise<{ settings: GlobalSettings; migrated: boolean }> {
|
||||
// Get localStorage data
|
||||
const localSettings = parseLocalStorageSettings();
|
||||
logger.info(
|
||||
`localStorage has ${localSettings?.projects?.length ?? 0} projects, ${localSettings?.aiProfiles?.length ?? 0} profiles`
|
||||
);
|
||||
logger.info(
|
||||
`Server has ${serverSettings.projects?.length ?? 0} projects, ${serverSettings.aiProfiles?.length ?? 0} profiles`
|
||||
);
|
||||
logger.info(`localStorage has ${localSettings?.projects?.length ?? 0} projects`);
|
||||
logger.info(`Server has ${serverSettings.projects?.length ?? 0} projects`);
|
||||
|
||||
// Check if migration has already been completed
|
||||
if (serverSettings.localStorageMigrated) {
|
||||
@@ -399,9 +372,7 @@ export function useSettingsMigration(): MigrationState {
|
||||
|
||||
// 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`
|
||||
);
|
||||
logger.info(`localStorage has ${localSettings?.projects?.length ?? 0} projects`);
|
||||
|
||||
// Check if server has settings files
|
||||
const status = await api.settings.getStatus();
|
||||
@@ -431,9 +402,7 @@ export function useSettingsMigration(): MigrationState {
|
||||
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`
|
||||
);
|
||||
logger.info(`Server has ${serverSettings.projects?.length ?? 0} projects`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch server settings:', error);
|
||||
@@ -534,6 +503,7 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
path: ref.path,
|
||||
lastOpened: ref.lastOpened,
|
||||
theme: ref.theme,
|
||||
isFavorite: ref.isFavorite,
|
||||
features: [], // Features are loaded separately when project is opened
|
||||
}));
|
||||
|
||||
@@ -547,24 +517,22 @@ 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({
|
||||
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 ?? true,
|
||||
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',
|
||||
@@ -577,7 +545,6 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
|
||||
...current.keyboardShortcuts,
|
||||
...(settings.keyboardShortcuts as unknown as Partial<typeof current.keyboardShortcuts>),
|
||||
},
|
||||
aiProfiles: settings.aiProfiles ?? [],
|
||||
mcpServers: settings.mcpServers ?? [],
|
||||
promptCustomization: settings.promptCustomization ?? {},
|
||||
projects,
|
||||
@@ -614,16 +581,13 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
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,
|
||||
@@ -631,7 +595,6 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
|
||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||
skipSandboxWarning: state.skipSandboxWarning,
|
||||
keyboardShortcuts: state.keyboardShortcuts,
|
||||
aiProfiles: state.aiProfiles,
|
||||
mcpServers: state.mcpServers,
|
||||
promptCustomization: state.promptCustomization,
|
||||
projects: state.projects,
|
||||
|
||||
@@ -31,16 +31,13 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'theme',
|
||||
'sidebarOpen',
|
||||
'chatHistoryOpen',
|
||||
'kanbanCardDetailLevel',
|
||||
'maxConcurrency',
|
||||
'defaultSkipTests',
|
||||
'enableDependencyBlocking',
|
||||
'skipVerificationInAutoMode',
|
||||
'useWorktrees',
|
||||
'showProfilesOnly',
|
||||
'defaultPlanningMode',
|
||||
'defaultRequirePlanApproval',
|
||||
'defaultAIProfileId',
|
||||
'muteDoneSound',
|
||||
'enhancementModel',
|
||||
'validationModel',
|
||||
@@ -49,7 +46,6 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'cursorDefaultModel',
|
||||
'autoLoadClaudeMd',
|
||||
'keyboardShortcuts',
|
||||
'aiProfiles',
|
||||
'mcpServers',
|
||||
'defaultEditorCommand',
|
||||
'promptCustomization',
|
||||
@@ -94,6 +90,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<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSyncedRef = useRef<string>('');
|
||||
@@ -122,9 +119,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;
|
||||
}
|
||||
|
||||
@@ -132,6 +137,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<string, unknown> = {};
|
||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||
@@ -152,10 +159,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;
|
||||
@@ -189,11 +199,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;
|
||||
|
||||
@@ -203,14 +222,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<string, unknown> = {};
|
||||
for (const field of SETTINGS_FIELDS_TO_SYNC) {
|
||||
if (field === 'currentProjectId') {
|
||||
@@ -219,7 +250,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];
|
||||
}
|
||||
@@ -238,16 +268,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;
|
||||
}
|
||||
@@ -271,6 +318,7 @@ export function useSettingsSync(): SettingsSyncState {
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
logger.debug('Store changed, scheduling sync');
|
||||
scheduleSyncToServer();
|
||||
}
|
||||
});
|
||||
@@ -299,11 +347,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
|
||||
@@ -323,7 +371,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;
|
||||
}
|
||||
@@ -383,16 +431,13 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
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,
|
||||
@@ -406,7 +451,6 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
typeof currentAppState.keyboardShortcuts
|
||||
>),
|
||||
},
|
||||
aiProfiles: serverSettings.aiProfiles,
|
||||
mcpServers: serverSettings.mcpServers,
|
||||
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
|
||||
promptCustomization: serverSettings.promptCustomization ?? {},
|
||||
|
||||
Reference in New Issue
Block a user