Merge remote-tracking branch 'origin/v0.10.0rc' into stefandevo/main

This commit is contained in:
Kacper
2026-01-11 17:34:19 +01:00
156 changed files with 8389 additions and 5916 deletions

View File

@@ -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);

View File

@@ -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,

View File

@@ -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 ?? {},