Merge branch 'v0.13.0rc' into feat/react-query

Merged latest changes from v0.13.0rc into feat/react-query while preserving
React Query migration. Key merge decisions:

- Kept React Query hooks for data fetching (useRunningAgents, useStopFeature, etc.)
- Added backlog plan handling to running-agents-view stop functionality
- Imported both SkeletonPulse and Spinner for CLI status components
- Used Spinner for refresh buttons across all settings sections
- Preserved isBacklogPlan check in agent-output-modal TaskProgressPanel
- Added handleOpenInIntegratedTerminal to worktree actions while keeping React Query mutations
This commit is contained in:
Shirone
2026-01-19 13:28:43 +01:00
387 changed files with 28102 additions and 6881 deletions

View File

@@ -94,21 +94,33 @@ export function useAutoMode() {
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// Restore auto-mode toggle after a renderer refresh (e.g. dev HMR reload).
// This is intentionally session-scoped to avoid auto-running features after a full app restart.
// On mount, query backend for current auto loop status and sync UI state.
// This handles cases where the backend is still running after a page refresh.
useEffect(() => {
if (!currentProject) return;
const session = readAutoModeSession();
const desired = session[currentProject.path];
if (typeof desired !== 'boolean') return;
const syncWithBackend = async () => {
try {
const api = getElectronAPI();
if (!api?.autoMode?.status) return;
if (desired !== isAutoModeRunning) {
logger.info(
`[AutoMode] Restoring session state for ${currentProject.path}: ${desired ? 'ON' : 'OFF'}`
);
setAutoModeRunning(currentProject.id, desired);
}
const result = await api.autoMode.status(currentProject.path);
if (result.success && result.isAutoLoopRunning !== undefined) {
const backendIsRunning = result.isAutoLoopRunning;
if (backendIsRunning !== isAutoModeRunning) {
logger.info(
`[AutoMode] Syncing UI state with backend for ${currentProject.path}: ${backendIsRunning ? 'ON' : 'OFF'}`
);
setAutoModeRunning(currentProject.id, backendIsRunning);
setAutoModeSessionForProjectPath(currentProject.path, backendIsRunning);
}
}
} catch (error) {
logger.error('Error syncing auto mode state with backend:', error);
}
};
syncWithBackend();
}, [currentProject, isAutoModeRunning, setAutoModeRunning]);
// Handle auto mode events - listen globally for all projects
@@ -139,6 +151,22 @@ export function useAutoMode() {
}
switch (event.type) {
case 'auto_mode_started':
// Backend started auto loop - update UI state
logger.info('[AutoMode] Backend started auto loop for project');
if (eventProjectId) {
setAutoModeRunning(eventProjectId, true);
}
break;
case 'auto_mode_stopped':
// Backend stopped auto loop - update UI state
logger.info('[AutoMode] Backend stopped auto loop for project');
if (eventProjectId) {
setAutoModeRunning(eventProjectId, false);
}
break;
case 'auto_mode_feature_start':
if (event.featureId) {
addRunningTask(eventProjectId, event.featureId);
@@ -374,35 +402,92 @@ export function useAutoMode() {
addAutoModeActivity,
getProjectIdFromPath,
setPendingPlanApproval,
setAutoModeRunning,
currentProject?.path,
]);
// Start auto mode - UI only, feature pickup is handled in board-view.tsx
const start = useCallback(() => {
// Start auto mode - calls backend to start the auto loop
const start = useCallback(async () => {
if (!currentProject) {
logger.error('No project selected');
return;
}
setAutoModeSessionForProjectPath(currentProject.path, true);
setAutoModeRunning(currentProject.id, true);
logger.debug(`[AutoMode] Started with maxConcurrency: ${maxConcurrency}`);
try {
const api = getElectronAPI();
if (!api?.autoMode?.start) {
throw new Error('Start auto mode API not available');
}
logger.info(
`[AutoMode] Starting auto loop for ${currentProject.path} with maxConcurrency: ${maxConcurrency}`
);
// Optimistically update UI state (backend will confirm via event)
setAutoModeSessionForProjectPath(currentProject.path, true);
setAutoModeRunning(currentProject.id, true);
// Call backend to start the auto loop
const result = await api.autoMode.start(currentProject.path, maxConcurrency);
if (!result.success) {
// Revert UI state on failure
setAutoModeSessionForProjectPath(currentProject.path, false);
setAutoModeRunning(currentProject.id, false);
logger.error('Failed to start auto mode:', result.error);
throw new Error(result.error || 'Failed to start auto mode');
}
logger.debug(`[AutoMode] Started successfully`);
} catch (error) {
// Revert UI state on error
setAutoModeSessionForProjectPath(currentProject.path, false);
setAutoModeRunning(currentProject.id, false);
logger.error('Error starting auto mode:', error);
throw error;
}
}, [currentProject, setAutoModeRunning, maxConcurrency]);
// Stop auto mode - UI only, running tasks continue until natural completion
const stop = useCallback(() => {
// Stop auto mode - calls backend to stop the auto loop
const stop = useCallback(async () => {
if (!currentProject) {
logger.error('No project selected');
return;
}
setAutoModeSessionForProjectPath(currentProject.path, false);
setAutoModeRunning(currentProject.id, false);
// NOTE: We intentionally do NOT clear running tasks here.
// Stopping auto mode only turns off the toggle to prevent new features
// from being picked up. Running tasks will complete naturally and be
// removed via the auto_mode_feature_complete event.
logger.info('Stopped - running tasks will continue');
try {
const api = getElectronAPI();
if (!api?.autoMode?.stop) {
throw new Error('Stop auto mode API not available');
}
logger.info(`[AutoMode] Stopping auto loop for ${currentProject.path}`);
// Optimistically update UI state (backend will confirm via event)
setAutoModeSessionForProjectPath(currentProject.path, false);
setAutoModeRunning(currentProject.id, false);
// Call backend to stop the auto loop
const result = await api.autoMode.stop(currentProject.path);
if (!result.success) {
// Revert UI state on failure
setAutoModeSessionForProjectPath(currentProject.path, true);
setAutoModeRunning(currentProject.id, true);
logger.error('Failed to stop auto mode:', result.error);
throw new Error(result.error || 'Failed to stop auto mode');
}
// NOTE: Running tasks will continue until natural completion.
// The backend stops picking up new features but doesn't abort running ones.
logger.info('Stopped - running tasks will continue');
} catch (error) {
// Revert UI state on error
setAutoModeSessionForProjectPath(currentProject.path, true);
setAutoModeRunning(currentProject.id, true);
logger.error('Error stopping auto mode:', error);
throw error;
}
}, [currentProject, setAutoModeRunning]);
// Stop a specific feature

View File

@@ -56,3 +56,12 @@ export function useIsMobile(): boolean {
export function useIsTablet(): boolean {
return useMediaQuery('(max-width: 1024px)');
}
/**
* Hook to detect compact layout (screen width <= 1240px)
* Used for collapsing top bar controls into mobile menu
* @returns boolean indicating if compact layout should be used
*/
export function useIsCompact(): boolean {
return useMediaQuery('(max-width: 1240px)');
}

View File

@@ -0,0 +1,78 @@
/**
* Hook to subscribe to notification WebSocket events and update the store.
*/
import { useEffect } from 'react';
import { useNotificationsStore } from '@/store/notifications-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import { pathsEqual } from '@/lib/utils';
import type { Notification } from '@automaker/types';
/**
* Hook to subscribe to notification events and update the store.
* Should be used in a component that's always mounted when a project is open.
*/
export function useNotificationEvents(projectPath: string | null) {
const addNotification = useNotificationsStore((s) => s.addNotification);
useEffect(() => {
if (!projectPath) return;
const api = getHttpApiClient();
const unsubscribe = api.notifications.onNotificationCreated((notification: Notification) => {
// Only handle notifications for the current project
if (!pathsEqual(notification.projectPath, projectPath)) return;
addNotification(notification);
});
return unsubscribe;
}, [projectPath, addNotification]);
}
/**
* Hook to load notifications for a project.
* Should be called when switching projects or on initial load.
*/
export function useLoadNotifications(projectPath: string | null) {
const setNotifications = useNotificationsStore((s) => s.setNotifications);
const setUnreadCount = useNotificationsStore((s) => s.setUnreadCount);
const setLoading = useNotificationsStore((s) => s.setLoading);
const setError = useNotificationsStore((s) => s.setError);
const reset = useNotificationsStore((s) => s.reset);
useEffect(() => {
if (!projectPath) {
reset();
return;
}
const loadNotifications = async () => {
setLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const [listResult, countResult] = await Promise.all([
api.notifications.list(projectPath),
api.notifications.getUnreadCount(projectPath),
]);
if (listResult.success && listResult.notifications) {
setNotifications(listResult.notifications);
}
if (countResult.success && countResult.count !== undefined) {
setUnreadCount(countResult.count);
}
} catch (error) {
setError(error instanceof Error ? error.message : 'Failed to load notifications');
} finally {
setLoading(false);
}
};
loadNotifications();
}, [projectPath, setNotifications, setUnreadCount, setLoading, setError, reset]);
}

View File

@@ -31,7 +31,11 @@ import { useSetupStore } from '@/store/setup-store';
import {
DEFAULT_OPENCODE_MODEL,
getAllOpencodeModelIds,
getAllCursorModelIds,
migrateCursorModelIds,
migratePhaseModelEntry,
type GlobalSettings,
type CursorModelId,
} from '@automaker/types';
const logger = createLogger('SettingsMigration');
@@ -111,9 +115,34 @@ export function resetMigrationState(): void {
/**
* Parse localStorage data into settings object
*
* Checks for settings in multiple locations:
* 1. automaker-settings-cache: Fresh server settings cached from last fetch
* 2. automaker-storage: Zustand-persisted app store state (legacy)
* 3. automaker-setup: Setup wizard state (legacy)
* 4. Standalone keys: worktree-panel-collapsed, file-browser-recent-folders, etc.
*
* @returns Merged settings object or null if no settings found
*/
export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
try {
// First, check for fresh server settings cache (updated whenever server settings are fetched)
// This prevents stale data when switching between modes
const settingsCache = getItem('automaker-settings-cache');
if (settingsCache) {
try {
const cached = JSON.parse(settingsCache) as GlobalSettings;
const cacheProjectCount = cached?.projects?.length ?? 0;
logger.info(`[CACHE_LOADED] projects=${cacheProjectCount}, theme=${cached?.theme}`);
return cached;
} catch (e) {
logger.warn('Failed to parse settings cache, falling back to old storage');
}
} else {
logger.info('[CACHE_EMPTY] No settings cache found in localStorage');
}
// Fall back to old Zustand persisted storage
const automakerStorage = getItem('automaker-storage');
if (!automakerStorage) {
return null;
@@ -160,6 +189,7 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
opencodeDefaultModel: state.opencodeDefaultModel as GlobalSettings['opencodeDefaultModel'],
enabledDynamicModelIds:
state.enabledDynamicModelIds as GlobalSettings['enabledDynamicModelIds'],
disabledProviders: (state.disabledProviders ?? []) as GlobalSettings['disabledProviders'],
autoLoadClaudeMd: state.autoLoadClaudeMd as boolean,
keyboardShortcuts: state.keyboardShortcuts as GlobalSettings['keyboardShortcuts'],
mcpServers: state.mcpServers as GlobalSettings['mcpServers'],
@@ -185,7 +215,14 @@ export function parseLocalStorageSettings(): Partial<GlobalSettings> | null {
/**
* Check if localStorage has more complete data than server
* Returns true if localStorage has projects but server doesn't
*
* Compares the completeness of data to determine if a migration is needed.
* Returns true if localStorage has projects but server doesn't, indicating
* the localStorage data should be merged with server settings.
*
* @param localSettings Settings loaded from localStorage
* @param serverSettings Settings loaded from server
* @returns true if localStorage has more data that should be preserved
*/
export function localStorageHasMoreData(
localSettings: Partial<GlobalSettings> | null,
@@ -208,7 +245,15 @@ export function localStorageHasMoreData(
/**
* Merge localStorage settings with server settings
* Prefers server data, but uses localStorage for missing arrays/objects
*
* Intelligently combines settings from both sources:
* - Prefers server data as the base
* - Uses localStorage values when server has empty arrays/objects
* - Specific handling for: projects, trashedProjects, mcpServers, recentFolders, etc.
*
* @param serverSettings Settings from server API (base)
* @param localSettings Settings from localStorage (fallback)
* @returns Merged GlobalSettings object ready to hydrate the store
*/
export function mergeSettings(
serverSettings: GlobalSettings,
@@ -290,20 +335,33 @@ export function mergeSettings(
* This is the core migration logic extracted for use outside of React hooks.
* Call this from __root.tsx during app initialization.
*
* @param serverSettings - Settings fetched from the server API
* @returns Promise resolving to the final settings to use (merged if migration needed)
* Flow:
* 1. If server has localStorageMigrated flag, skip migration (already done)
* 2. Check if localStorage has more data than server
* 3. If yes, merge them and sync merged state back to server
* 4. Set localStorageMigrated flag to prevent re-migration
*
* @param serverSettings Settings fetched from the server API
* @returns Promise resolving to {settings, migrated} - final settings and whether migration occurred
*/
export async function performSettingsMigration(
serverSettings: GlobalSettings
): Promise<{ settings: GlobalSettings; migrated: boolean }> {
// Get localStorage data
const localSettings = parseLocalStorageSettings();
logger.info(`localStorage has ${localSettings?.projects?.length ?? 0} projects`);
logger.info(`Server has ${serverSettings.projects?.length ?? 0} projects`);
const localProjects = localSettings?.projects?.length ?? 0;
const serverProjects = serverSettings.projects?.length ?? 0;
logger.info('[MIGRATION_CHECK]', {
localStorageProjects: localProjects,
serverProjects: serverProjects,
localStorageMigrated: serverSettings.localStorageMigrated,
dataSourceMismatch: localProjects !== serverProjects,
});
// Check if migration has already been completed
if (serverSettings.localStorageMigrated) {
logger.info('localStorage migration already completed, using server settings only');
logger.info('[MIGRATION_SKIP] Using server settings only (migration already completed)');
return { settings: serverSettings, migrated: false };
}
@@ -411,6 +469,15 @@ export function useSettingsMigration(): MigrationState {
if (global.success && global.settings) {
serverSettings = global.settings as unknown as GlobalSettings;
logger.info(`Server has ${serverSettings.projects?.length ?? 0} projects`);
// Update localStorage with fresh server data to keep cache in sync
// This prevents stale localStorage data from being used when switching between modes
try {
setItem('automaker-settings-cache', JSON.stringify(serverSettings));
logger.debug('Updated localStorage with fresh server settings');
} catch (storageError) {
logger.warn('Failed to update localStorage cache:', storageError);
}
}
} catch (error) {
logger.error('Failed to fetch server settings:', error);
@@ -503,6 +570,19 @@ export function useSettingsMigration(): MigrationState {
*/
export function hydrateStoreFromSettings(settings: GlobalSettings): void {
const current = useAppStore.getState();
// Migrate Cursor models to canonical format
// IMPORTANT: Always use ALL available Cursor models to ensure new models are visible
// Users who had old settings with a subset of models should still see all available models
const allCursorModels = getAllCursorModelIds();
const migratedCursorDefault = migrateCursorModelIds([
settings.cursorDefaultModel ?? current.cursorDefaultModel ?? 'cursor-auto',
])[0];
const validCursorModelIds = new Set(allCursorModels);
const sanitizedCursorDefaultModel = validCursorModelIds.has(migratedCursorDefault)
? migratedCursorDefault
: ('cursor-auto' as CursorModelId);
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
const incomingEnabledOpencodeModels =
settings.enabledOpencodeModels ?? current.enabledOpencodeModels;
@@ -532,6 +612,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
path: ref.path,
lastOpened: ref.lastOpened,
theme: ref.theme,
fontFamilySans: ref.fontFamilySans,
fontFamilyMono: ref.fontFamilyMono,
isFavorite: ref.isFavorite,
icon: ref.icon,
customIconPath: ref.customIconPath,
@@ -555,6 +637,8 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
useAppStore.setState({
theme: settings.theme as unknown as import('@/store/app-store').ThemeMode,
fontFamilySans: settings.fontFamilySans ?? null,
fontFamilyMono: settings.fontFamilyMono ?? null,
sidebarOpen: settings.sidebarOpen ?? true,
chatHistoryOpen: settings.chatHistoryOpen ?? false,
maxConcurrency: settings.maxConcurrency ?? 3,
@@ -564,16 +648,21 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
useWorktrees: settings.useWorktrees ?? true,
defaultPlanningMode: settings.defaultPlanningMode ?? 'skip',
defaultRequirePlanApproval: settings.defaultRequirePlanApproval ?? false,
defaultFeatureModel: settings.defaultFeatureModel ?? { model: 'opus' },
defaultFeatureModel: migratePhaseModelEntry(settings.defaultFeatureModel) ?? {
model: 'claude-opus',
},
muteDoneSound: settings.muteDoneSound ?? false,
enhancementModel: settings.enhancementModel ?? 'sonnet',
validationModel: settings.validationModel ?? 'opus',
serverLogLevel: settings.serverLogLevel ?? 'info',
enableRequestLogging: settings.enableRequestLogging ?? true,
enhancementModel: settings.enhancementModel ?? 'claude-sonnet',
validationModel: settings.validationModel ?? 'claude-opus',
phaseModels: settings.phaseModels ?? current.phaseModels,
enabledCursorModels: settings.enabledCursorModels ?? current.enabledCursorModels,
cursorDefaultModel: settings.cursorDefaultModel ?? 'auto',
enabledCursorModels: allCursorModels, // Always use ALL cursor models
cursorDefaultModel: sanitizedCursorDefaultModel,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
disabledProviders: settings.disabledProviders ?? [],
autoLoadClaudeMd: settings.autoLoadClaudeMd ?? false,
skipSandboxWarning: settings.skipSandboxWarning ?? false,
keyboardShortcuts: {
@@ -592,6 +681,13 @@ export function hydrateStoreFromSettings(settings: GlobalSettings): void {
worktreePanelCollapsed: settings.worktreePanelCollapsed ?? false,
lastProjectDir: settings.lastProjectDir ?? '',
recentFolders: settings.recentFolders ?? [],
// Terminal font (nested in terminalState)
...(settings.terminalFontFamily && {
terminalState: {
...current.terminalState,
fontFamily: settings.terminalFontFamily,
},
}),
});
// Hydrate setup wizard state from global settings (API-backed)
@@ -624,10 +720,13 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
defaultPlanningMode: state.defaultPlanningMode,
defaultRequirePlanApproval: state.defaultRequirePlanApproval,
muteDoneSound: state.muteDoneSound,
serverLogLevel: state.serverLogLevel,
enableRequestLogging: state.enableRequestLogging,
enhancementModel: state.enhancementModel,
validationModel: state.validationModel,
phaseModels: state.phaseModels,
enabledDynamicModelIds: state.enabledDynamicModelIds,
disabledProviders: state.disabledProviders,
autoLoadClaudeMd: state.autoLoadClaudeMd,
skipSandboxWarning: state.skipSandboxWarning,
keyboardShortcuts: state.keyboardShortcuts,
@@ -642,6 +741,7 @@ function buildSettingsUpdateFromStore(): Record<string, unknown> {
worktreePanelCollapsed: state.worktreePanelCollapsed,
lastProjectDir: state.lastProjectDir,
recentFolders: state.recentFolders,
terminalFontFamily: state.terminalState.fontFamily,
};
}

View File

@@ -22,7 +22,13 @@ import { waitForMigrationComplete, resetMigrationState } from './use-settings-mi
import {
DEFAULT_OPENCODE_MODEL,
getAllOpencodeModelIds,
getAllCursorModelIds,
migrateCursorModelIds,
migrateOpencodeModelIds,
migratePhaseModelEntry,
type GlobalSettings,
type CursorModelId,
type OpencodeModelId,
} from '@automaker/types';
const logger = createLogger('SettingsSync');
@@ -33,6 +39,10 @@ const SYNC_DEBOUNCE_MS = 1000;
// Fields to sync to server (subset of AppState that should be persisted)
const SETTINGS_FIELDS_TO_SYNC = [
'theme',
'fontFamilySans',
'fontFamilyMono',
'terminalFontFamily', // Maps to terminalState.fontFamily
'openTerminalMode', // Maps to terminalState.openTerminalMode
'sidebarOpen',
'chatHistoryOpen',
'maxConcurrency',
@@ -44,6 +54,8 @@ const SETTINGS_FIELDS_TO_SYNC = [
'defaultRequirePlanApproval',
'defaultFeatureModel',
'muteDoneSound',
'serverLogLevel',
'enableRequestLogging',
'enhancementModel',
'validationModel',
'phaseModels',
@@ -52,11 +64,14 @@ const SETTINGS_FIELDS_TO_SYNC = [
'enabledOpencodeModels',
'opencodeDefaultModel',
'enabledDynamicModelIds',
'disabledProviders',
'autoLoadClaudeMd',
'keyboardShortcuts',
'mcpServers',
'defaultEditorCommand',
'defaultTerminalId',
'promptCustomization',
'eventHooks',
'projects',
'trashedProjects',
'currentProjectId', // ID of currently open project
@@ -72,6 +87,65 @@ const SETTINGS_FIELDS_TO_SYNC = [
// Fields from setup store to sync
const SETUP_FIELDS_TO_SYNC = ['isFirstRun', 'setupComplete', 'skipClaudeSetup'] as const;
/**
* Helper to extract a settings field value from app state
*
* Handles special cases where store fields don't map directly to settings:
* - currentProjectId: Extract from currentProject?.id
* - terminalFontFamily: Extract from terminalState.fontFamily
* - Other fields: Direct access
*
* @param field The settings field to extract
* @param appState Current app store state
* @returns The value of the field in the app state
*/
function getSettingsFieldValue(
field: (typeof SETTINGS_FIELDS_TO_SYNC)[number],
appState: ReturnType<typeof useAppStore.getState>
): unknown {
if (field === 'currentProjectId') {
return appState.currentProject?.id ?? null;
}
if (field === 'terminalFontFamily') {
return appState.terminalState.fontFamily;
}
if (field === 'openTerminalMode') {
return appState.terminalState.openTerminalMode;
}
return appState[field as keyof typeof appState];
}
/**
* Helper to check if a settings field changed between states
*
* Compares field values between old and new state, handling special cases:
* - currentProjectId: Compare currentProject?.id values
* - terminalFontFamily: Compare terminalState.fontFamily values
* - Other fields: Direct reference equality check
*
* @param field The settings field to check
* @param newState New app store state
* @param prevState Previous app store state
* @returns true if the field value changed between states
*/
function hasSettingsFieldChanged(
field: (typeof SETTINGS_FIELDS_TO_SYNC)[number],
newState: ReturnType<typeof useAppStore.getState>,
prevState: ReturnType<typeof useAppStore.getState>
): boolean {
if (field === 'currentProjectId') {
return newState.currentProject?.id !== prevState.currentProject?.id;
}
if (field === 'terminalFontFamily') {
return newState.terminalState.fontFamily !== prevState.terminalState.fontFamily;
}
if (field === 'openTerminalMode') {
return newState.terminalState.openTerminalMode !== prevState.terminalState.openTerminalMode;
}
const key = field as keyof typeof newState;
return newState[key] !== prevState[key];
}
interface SettingsSyncState {
/** Whether initial settings have been loaded from API */
loaded: boolean;
@@ -130,14 +204,18 @@ export function useSettingsSync(): SettingsSyncState {
// 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();
logger.debug('syncToServer check:', {
logger.debug('[SYNC_CHECK] Auth state:', {
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');
logger.warn('[SYNC_SKIPPED] Not ready:', {
authChecked: auth.authChecked,
isAuthenticated: auth.isAuthenticated,
settingsLoaded: auth.settingsLoaded,
});
return;
}
@@ -145,17 +223,14 @@ export function useSettingsSync(): SettingsSyncState {
const api = getHttpApiClient();
const appState = useAppStore.getState();
logger.debug('Syncing to server:', { projectsCount: appState.projects?.length ?? 0 });
logger.info('[SYNC_START] 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) {
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];
}
updates[field] = getSettingsFieldValue(field, appState);
}
// Include setup wizard state (lives in a separate store)
@@ -167,17 +242,30 @@ 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');
logger.debug('[SYNC_SKIP_IDENTICAL] No changes from last sync');
setState((s) => ({ ...s, syncing: false }));
return;
}
logger.info('Sending settings update:', { projects: updates.projects });
logger.info('[SYNC_SEND] Sending settings update to server:', {
projects: (updates.projects as any)?.length ?? 0,
trashedProjects: (updates.trashedProjects as any)?.length ?? 0,
});
const result = await api.settings.updateGlobal(updates);
logger.info('[SYNC_RESPONSE] Server response:', { success: result.success });
if (result.success) {
lastSyncedRef.current = updateHash;
logger.debug('Settings synced to server');
// Update localStorage cache with synced settings to keep it fresh
// This prevents stale data when switching between Electron and web modes
try {
setItem('automaker-settings-cache', JSON.stringify(updates));
logger.debug('Updated localStorage cache after sync');
} catch (storageError) {
logger.warn('Failed to update localStorage cache after sync:', storageError);
}
} else {
logger.error('Failed to sync settings:', result.error);
}
@@ -252,11 +340,7 @@ export function useSettingsSync(): SettingsSyncState {
// (migration has already hydrated the store from server/localStorage)
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];
}
updates[field] = getSettingsFieldValue(field, appState);
}
for (const field of SETUP_FIELDS_TO_SYNC) {
updates[field] = setupState[field as keyof typeof setupState];
@@ -307,21 +391,27 @@ export function useSettingsSync(): SettingsSyncState {
return;
}
// Check if any synced field changed
// If projects array changed (by reference, meaning content changed), sync immediately
// This is critical - projects list changes must sync right away to prevent loss
// when switching between Electron and web modes or closing the app
if (newState.projects !== prevState.projects) {
logger.info('[PROJECTS_CHANGED] Projects array changed, syncing immediately', {
prevCount: prevState.projects?.length ?? 0,
newCount: newState.projects?.length ?? 0,
prevProjects: prevState.projects?.map((p) => p.name) ?? [],
newProjects: newState.projects?.map((p) => p.name) ?? [],
});
syncNow();
return;
}
// Check if any other 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 (field === 'projects') continue; // Already handled above
if (hasSettingsFieldChanged(field, newState, prevState)) {
changed = true;
break;
}
}
@@ -395,11 +485,7 @@ export async function forceSyncSettingsToServer(): Promise<boolean> {
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];
}
updates[field] = getSettingsFieldValue(field, appState);
}
const setupState = useSetupStore.getState();
for (const field of SETUP_FIELDS_TO_SYNC) {
@@ -429,17 +515,35 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
const serverSettings = result.settings as unknown as GlobalSettings;
const currentAppState = useAppStore.getState();
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
const incomingEnabledOpencodeModels =
serverSettings.enabledOpencodeModels ?? currentAppState.enabledOpencodeModels;
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(
serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel
)
? (serverSettings.opencodeDefaultModel ?? currentAppState.opencodeDefaultModel)
: DEFAULT_OPENCODE_MODEL;
const sanitizedEnabledOpencodeModels = Array.from(
new Set(incomingEnabledOpencodeModels.filter((modelId) => validOpencodeModelIds.has(modelId)))
// Cursor models - ALWAYS use ALL available models to ensure new models are visible
const allCursorModels = getAllCursorModelIds();
const validCursorModelIds = new Set(allCursorModels);
// Migrate Cursor default model
const migratedCursorDefault = migrateCursorModelIds([
serverSettings.cursorDefaultModel ?? 'cursor-auto',
])[0];
const sanitizedCursorDefault = validCursorModelIds.has(migratedCursorDefault)
? migratedCursorDefault
: ('cursor-auto' as CursorModelId);
// Migrate OpenCode models to canonical format
const migratedOpencodeModels = migrateOpencodeModelIds(
serverSettings.enabledOpencodeModels ?? []
);
const validOpencodeModelIds = new Set(getAllOpencodeModelIds());
const sanitizedEnabledOpencodeModels = migratedOpencodeModels.filter((id) =>
validOpencodeModelIds.has(id)
);
// Migrate OpenCode default model
const migratedOpencodeDefault = migrateOpencodeModelIds([
serverSettings.opencodeDefaultModel ?? DEFAULT_OPENCODE_MODEL,
])[0];
const sanitizedOpencodeDefaultModel = validOpencodeModelIds.has(migratedOpencodeDefault)
? migratedOpencodeDefault
: DEFAULT_OPENCODE_MODEL;
if (!sanitizedEnabledOpencodeModels.includes(sanitizedOpencodeDefaultModel)) {
sanitizedEnabledOpencodeModels.push(sanitizedOpencodeDefaultModel);
@@ -451,6 +555,37 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
(modelId) => !modelId.startsWith('amazon-bedrock/')
);
// Migrate phase models to canonical format
const migratedPhaseModels = serverSettings.phaseModels
? {
enhancementModel: migratePhaseModelEntry(serverSettings.phaseModels.enhancementModel),
fileDescriptionModel: migratePhaseModelEntry(
serverSettings.phaseModels.fileDescriptionModel
),
imageDescriptionModel: migratePhaseModelEntry(
serverSettings.phaseModels.imageDescriptionModel
),
validationModel: migratePhaseModelEntry(serverSettings.phaseModels.validationModel),
specGenerationModel: migratePhaseModelEntry(
serverSettings.phaseModels.specGenerationModel
),
featureGenerationModel: migratePhaseModelEntry(
serverSettings.phaseModels.featureGenerationModel
),
backlogPlanningModel: migratePhaseModelEntry(
serverSettings.phaseModels.backlogPlanningModel
),
projectAnalysisModel: migratePhaseModelEntry(
serverSettings.phaseModels.projectAnalysisModel
),
suggestionsModel: migratePhaseModelEntry(serverSettings.phaseModels.suggestionsModel),
memoryExtractionModel: migratePhaseModelEntry(
serverSettings.phaseModels.memoryExtractionModel
),
commitMessageModel: migratePhaseModelEntry(serverSettings.phaseModels.commitMessageModel),
}
: undefined;
// Save theme to localStorage for fallback when server settings aren't available
if (serverSettings.theme) {
setItem(THEME_STORAGE_KEY, serverSettings.theme);
@@ -467,16 +602,21 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
useWorktrees: serverSettings.useWorktrees,
defaultPlanningMode: serverSettings.defaultPlanningMode,
defaultRequirePlanApproval: serverSettings.defaultRequirePlanApproval,
defaultFeatureModel: serverSettings.defaultFeatureModel ?? { model: 'opus' },
defaultFeatureModel: serverSettings.defaultFeatureModel
? migratePhaseModelEntry(serverSettings.defaultFeatureModel)
: { model: 'claude-opus' },
muteDoneSound: serverSettings.muteDoneSound,
serverLogLevel: serverSettings.serverLogLevel ?? 'info',
enableRequestLogging: serverSettings.enableRequestLogging ?? true,
enhancementModel: serverSettings.enhancementModel,
validationModel: serverSettings.validationModel,
phaseModels: serverSettings.phaseModels,
enabledCursorModels: serverSettings.enabledCursorModels,
cursorDefaultModel: serverSettings.cursorDefaultModel,
phaseModels: migratedPhaseModels ?? serverSettings.phaseModels,
enabledCursorModels: allCursorModels, // Always use ALL cursor models
cursorDefaultModel: sanitizedCursorDefault,
enabledOpencodeModels: sanitizedEnabledOpencodeModels,
opencodeDefaultModel: sanitizedOpencodeDefaultModel,
enabledDynamicModelIds: sanitizedDynamicModelIds,
disabledProviders: serverSettings.disabledProviders ?? [],
autoLoadClaudeMd: serverSettings.autoLoadClaudeMd ?? false,
keyboardShortcuts: {
...currentAppState.keyboardShortcuts,
@@ -486,6 +626,7 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
},
mcpServers: serverSettings.mcpServers,
defaultEditorCommand: serverSettings.defaultEditorCommand ?? null,
defaultTerminalId: serverSettings.defaultTerminalId ?? null,
promptCustomization: serverSettings.promptCustomization ?? {},
projects: serverSettings.projects,
trashedProjects: serverSettings.trashedProjects,
@@ -496,6 +637,18 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
worktreePanelCollapsed: serverSettings.worktreePanelCollapsed ?? false,
lastProjectDir: serverSettings.lastProjectDir ?? '',
recentFolders: serverSettings.recentFolders ?? [],
// Terminal settings (nested in terminalState)
...((serverSettings.terminalFontFamily || serverSettings.openTerminalMode) && {
terminalState: {
...currentAppState.terminalState,
...(serverSettings.terminalFontFamily && {
fontFamily: serverSettings.terminalFontFamily,
}),
...(serverSettings.openTerminalMode && {
openTerminalMode: serverSettings.openTerminalMode,
}),
},
}),
});
// Also refresh setup wizard state