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:
webdevcody
2026-01-07 10:05:54 -05:00
parent 1316ead8c8
commit 11accac5ae
22 changed files with 3177 additions and 2262 deletions

View File

@@ -10,11 +10,14 @@ import { getErrorMessage, logError } from '../common.js';
export function createUpdateHandler(featureLoader: FeatureLoader) {
return async (req: Request, res: Response): Promise<void> => {
try {
const { projectPath, featureId, updates } = req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
};
const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } =
req.body as {
projectPath: string;
featureId: string;
updates: Partial<Feature>;
descriptionHistorySource?: 'enhance' | 'edit';
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance';
};
if (!projectPath || !featureId || !updates) {
res.status(400).json({
@@ -24,7 +27,13 @@ export function createUpdateHandler(featureLoader: FeatureLoader) {
return;
}
const updated = await featureLoader.update(projectPath, featureId, updates);
const updated = await featureLoader.update(
projectPath,
featureId,
updates,
descriptionHistorySource,
enhancementMode
);
res.json({ success: true, feature: updated });
} catch (error) {
logError(error, 'Update feature failed');

View File

@@ -4,7 +4,7 @@
*/
import path from 'path';
import type { Feature } from '@automaker/types';
import type { Feature, DescriptionHistoryEntry } from '@automaker/types';
import { createLogger } from '@automaker/utils';
import * as secureFs from '../lib/secure-fs.js';
import {
@@ -274,6 +274,16 @@ export class FeatureLoader {
featureData.imagePaths
);
// Initialize description history with the initial description
const initialHistory: DescriptionHistoryEntry[] = [];
if (featureData.description && featureData.description.trim()) {
initialHistory.push({
description: featureData.description,
timestamp: new Date().toISOString(),
source: 'initial',
});
}
// Ensure feature has required fields
const feature: Feature = {
category: featureData.category || 'Uncategorized',
@@ -281,6 +291,7 @@ export class FeatureLoader {
...featureData,
id: featureId,
imagePaths: migratedImagePaths,
descriptionHistory: initialHistory,
};
// Write feature.json
@@ -292,11 +303,18 @@ export class FeatureLoader {
/**
* Update a feature (partial updates supported)
* @param projectPath - Path to the project
* @param featureId - ID of the feature to update
* @param updates - Partial feature updates
* @param descriptionHistorySource - Source of description change ('enhance' or 'edit')
* @param enhancementMode - Enhancement mode if source is 'enhance'
*/
async update(
projectPath: string,
featureId: string,
updates: Partial<Feature>
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
): Promise<Feature> {
const feature = await this.get(projectPath, featureId);
if (!feature) {
@@ -313,11 +331,28 @@ export class FeatureLoader {
updatedImagePaths = await this.migrateImages(projectPath, featureId, updates.imagePaths);
}
// Track description history if description changed
let updatedHistory = feature.descriptionHistory || [];
if (
updates.description !== undefined &&
updates.description !== feature.description &&
updates.description.trim()
) {
const historyEntry: DescriptionHistoryEntry = {
description: updates.description,
timestamp: new Date().toISOString(),
source: descriptionHistorySource || 'edit',
...(descriptionHistorySource === 'enhance' && enhancementMode ? { enhancementMode } : {}),
};
updatedHistory = [...updatedHistory, historyEntry];
}
// Merge updates
const updatedFeature: Feature = {
...feature,
...updates,
...(updatedImagePaths !== undefined ? { imagePaths: updatedImagePaths } : {}),
descriptionHistory: updatedHistory,
};
// Write back to file

View File

@@ -162,6 +162,16 @@ export class SettingsService {
needsSave = true;
}
// Migration v3 -> v4: Add onboarding/setup wizard state fields
// Older settings files never stored setup state in settings.json (it lived in localStorage),
// so default to "setup complete" for existing installs to avoid forcing re-onboarding.
if (storedVersion < 4) {
if (settings.setupComplete === undefined) result.setupComplete = true;
if (settings.isFirstRun === undefined) result.isFirstRun = false;
if (settings.skipClaudeSetup === undefined) result.skipClaudeSetup = false;
needsSave = true;
}
// Update version if any migration occurred
if (needsSave) {
result.version = SETTINGS_VERSION;
@@ -515,8 +525,26 @@ export class SettingsService {
}
}
// Parse setup wizard state (previously stored in localStorage)
let setupState: Record<string, unknown> = {};
if (localStorageData['automaker-setup']) {
try {
const parsed = JSON.parse(localStorageData['automaker-setup']);
setupState = parsed.state || parsed;
} catch (e) {
errors.push(`Failed to parse automaker-setup: ${e}`);
}
}
// Extract global settings
const globalSettings: Partial<GlobalSettings> = {
setupComplete:
setupState.setupComplete !== undefined ? (setupState.setupComplete as boolean) : false,
isFirstRun: setupState.isFirstRun !== undefined ? (setupState.isFirstRun as boolean) : true,
skipClaudeSetup:
setupState.skipClaudeSetup !== undefined
? (setupState.skipClaudeSetup as boolean)
: false,
theme: (appState.theme as GlobalSettings['theme']) || 'dark',
sidebarOpen: appState.sidebarOpen !== undefined ? (appState.sidebarOpen as boolean) : true,
chatHistoryOpen: (appState.chatHistoryOpen as boolean) || false,

View File

@@ -3,7 +3,9 @@ import { RouterProvider } from '@tanstack/react-router';
import { createLogger } from '@automaker/utils/logger';
import { router } from './utils/router';
import { SplashScreen } from './components/splash-screen';
import { LoadingState } from './components/ui/loading-state';
import { useSettingsMigration } from './hooks/use-settings-migration';
import { useSettingsSync } from './hooks/use-settings-sync';
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
import './styles/global.css';
import './styles/theme-imports';
@@ -33,11 +35,19 @@ export default function App() {
}, []);
// Run settings migration on startup (localStorage -> file storage)
// IMPORTANT: Wait for this to complete before rendering the router
// so that currentProject and other settings are available
const migrationState = useSettingsMigration();
if (migrationState.migrated) {
logger.info('Settings migrated to file storage');
}
// Sync settings changes back to server (API-first persistence)
const settingsSyncState = useSettingsSync();
if (settingsSyncState.error) {
logger.error('Settings sync error:', settingsSyncState.error);
}
// Initialize Cursor CLI status at startup
useCursorStatusInit();
@@ -46,6 +56,16 @@ export default function App() {
setShowSplash(false);
}, []);
// Wait for settings migration to complete before rendering the router
// This ensures currentProject and other settings are available
if (!migrationState.checked) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<LoadingState message="Loading settings..." />
</div>
);
}
return (
<>
<RouterProvider router={router} />

View File

@@ -11,10 +11,10 @@ import {
import { Button } from '@/components/ui/button';
import { PathInput } from '@/components/ui/path-input';
import { Kbd, KbdGroup } from '@/components/ui/kbd';
import { getJSON, setJSON } from '@/lib/storage';
import { getDefaultWorkspaceDirectory, saveLastProjectDirectory } from '@/lib/workspace-config';
import { useOSDetection } from '@/hooks';
import { apiPost } from '@/lib/api-fetch';
import { useAppStore } from '@/store/app-store';
interface DirectoryEntry {
name: string;
@@ -40,28 +40,8 @@ interface FileBrowserDialogProps {
initialPath?: string;
}
const RECENT_FOLDERS_KEY = 'file-browser-recent-folders';
const MAX_RECENT_FOLDERS = 5;
function getRecentFolders(): string[] {
return getJSON<string[]>(RECENT_FOLDERS_KEY) ?? [];
}
function addRecentFolder(path: string): void {
const recent = getRecentFolders();
// Remove if already exists, then add to front
const filtered = recent.filter((p) => p !== path);
const updated = [path, ...filtered].slice(0, MAX_RECENT_FOLDERS);
setJSON(RECENT_FOLDERS_KEY, updated);
}
function removeRecentFolder(path: string): string[] {
const recent = getRecentFolders();
const updated = recent.filter((p) => p !== path);
setJSON(RECENT_FOLDERS_KEY, updated);
return updated;
}
export function FileBrowserDialog({
open,
onOpenChange,
@@ -78,20 +58,20 @@ export function FileBrowserDialog({
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [warning, setWarning] = useState('');
const [recentFolders, setRecentFolders] = useState<string[]>([]);
// Load recent folders when dialog opens
useEffect(() => {
if (open) {
setRecentFolders(getRecentFolders());
}
}, [open]);
// Use recent folders from app store (synced via API)
const recentFolders = useAppStore((s) => s.recentFolders);
const setRecentFolders = useAppStore((s) => s.setRecentFolders);
const addRecentFolder = useAppStore((s) => s.addRecentFolder);
const handleRemoveRecent = useCallback((e: React.MouseEvent, path: string) => {
e.stopPropagation();
const updated = removeRecentFolder(path);
setRecentFolders(updated);
}, []);
const handleRemoveRecent = useCallback(
(e: React.MouseEvent, path: string) => {
e.stopPropagation();
const updated = recentFolders.filter((p) => p !== path);
setRecentFolders(updated);
},
[recentFolders, setRecentFolders]
);
const browseDirectory = useCallback(async (dirPath?: string) => {
setLoading(true);

View File

@@ -27,6 +27,7 @@ import {
Sparkles,
ChevronDown,
GitBranch,
History,
} from 'lucide-react';
import { toast } from 'sonner';
import { getElectronAPI } from '@/lib/electron';
@@ -55,6 +56,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { DescriptionHistoryEntry } from '@automaker/types';
import { DependencyTreeDialog } from './dependency-tree-dialog';
import { isCursorModel, PROVIDER_PREFIXES } from '@automaker/types';
@@ -78,7 +81,9 @@ interface EditFeatureDialogProps {
priority: number;
planningMode: PlanningMode;
requirePlanApproval: boolean;
}
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
) => void;
categorySuggestions: string[];
branchSuggestions: string[];
@@ -121,6 +126,14 @@ export function EditFeatureDialog({
const [requirePlanApproval, setRequirePlanApproval] = useState(
feature?.requirePlanApproval ?? false
);
// Track the source of description changes for history
const [descriptionChangeSource, setDescriptionChangeSource] = useState<
{ source: 'enhance'; mode: 'improve' | 'technical' | 'simplify' | 'acceptance' } | 'edit' | null
>(null);
// Track the original description when the dialog opened for comparison
const [originalDescription, setOriginalDescription] = useState(feature?.description ?? '');
// Track if history dropdown is open
const [showHistory, setShowHistory] = useState(false);
// Get worktrees setting from store
const { useWorktrees } = useAppStore();
@@ -135,9 +148,15 @@ export function EditFeatureDialog({
setRequirePlanApproval(feature.requirePlanApproval ?? false);
// If feature has no branchName, default to using current branch
setUseCurrentBranch(!feature.branchName);
// Reset history tracking state
setOriginalDescription(feature.description ?? '');
setDescriptionChangeSource(null);
setShowHistory(false);
} else {
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
setDescriptionChangeSource(null);
setShowHistory(false);
}
}, [feature]);
@@ -183,7 +202,21 @@ export function EditFeatureDialog({
requirePlanApproval,
};
onUpdate(editingFeature.id, updates);
// Determine if description changed and what source to use
const descriptionChanged = editingFeature.description !== originalDescription;
let historySource: 'enhance' | 'edit' | undefined;
let historyEnhancementMode: 'improve' | 'technical' | 'simplify' | 'acceptance' | undefined;
if (descriptionChanged && descriptionChangeSource) {
if (descriptionChangeSource === 'edit') {
historySource = 'edit';
} else {
historySource = 'enhance';
historyEnhancementMode = descriptionChangeSource.mode;
}
}
onUpdate(editingFeature.id, updates, historySource, historyEnhancementMode);
setEditFeaturePreviewMap(new Map());
setShowEditAdvancedOptions(false);
onClose();
@@ -247,6 +280,8 @@ export function EditFeatureDialog({
if (result?.success && result.enhancedText) {
const enhancedText = result.enhancedText;
setEditingFeature((prev) => (prev ? { ...prev, description: enhancedText } : prev));
// Track that this change was from enhancement
setDescriptionChangeSource({ source: 'enhance', mode: enhancementMode });
toast.success('Description enhanced!');
} else {
toast.error(result?.error || 'Failed to enhance description');
@@ -312,12 +347,16 @@ export function EditFeatureDialog({
<Label htmlFor="edit-description">Description</Label>
<DescriptionImageDropZone
value={editingFeature.description}
onChange={(value) =>
onChange={(value) => {
setEditingFeature({
...editingFeature,
description: value,
})
}
});
// Track that this change was a manual edit (unless already enhanced)
if (!descriptionChangeSource || descriptionChangeSource === 'edit') {
setDescriptionChangeSource('edit');
}
}}
images={editingFeature.imagePaths ?? []}
onImagesChange={(images) =>
setEditingFeature({
@@ -400,6 +439,80 @@ export function EditFeatureDialog({
size="sm"
variant="icon"
/>
{/* Version History Button */}
{feature?.descriptionHistory && feature.descriptionHistory.length > 0 && (
<Popover open={showHistory} onOpenChange={setShowHistory}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="sm" className="gap-2">
<History className="w-4 h-4" />
History ({feature.descriptionHistory.length})
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0" align="start">
<div className="p-3 border-b">
<h4 className="font-medium text-sm">Version History</h4>
<p className="text-xs text-muted-foreground mt-1">
Click a version to restore it
</p>
</div>
<div className="max-h-64 overflow-y-auto p-2 space-y-1">
{[...(feature.descriptionHistory || [])]
.reverse()
.map((entry: DescriptionHistoryEntry, index: number) => {
const isCurrentVersion = entry.description === editingFeature.description;
const date = new Date(entry.timestamp);
const formattedDate = date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const sourceLabel =
entry.source === 'initial'
? 'Original'
: entry.source === 'enhance'
? `Enhanced (${entry.enhancementMode || 'improve'})`
: 'Edited';
return (
<button
key={`${entry.timestamp}-${index}`}
onClick={() => {
setEditingFeature((prev) =>
prev ? { ...prev, description: entry.description } : prev
);
// Mark as edit since user is restoring from history
setDescriptionChangeSource('edit');
setShowHistory(false);
toast.success('Description restored from history');
}}
className={`w-full text-left p-2 rounded-md hover:bg-muted transition-colors ${
isCurrentVersion ? 'bg-muted/50 border border-primary/20' : ''
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium">{sourceLabel}</span>
<span className="text-xs text-muted-foreground">
{formattedDate}
</span>
</div>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{entry.description.slice(0, 100)}
{entry.description.length > 100 ? '...' : ''}
</p>
{isCurrentVersion && (
<span className="text-xs text-primary font-medium mt-1 block">
Current version
</span>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div>
<div className="space-y-2">
<Label htmlFor="edit-category">Category (optional)</Label>

View File

@@ -23,7 +23,12 @@ interface UseBoardActionsProps {
runningAutoTasks: string[];
loadFeatures: () => Promise<void>;
persistFeatureCreate: (feature: Feature) => Promise<void>;
persistFeatureUpdate: (featureId: string, updates: Partial<Feature>) => Promise<void>;
persistFeatureUpdate: (
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
) => Promise<void>;
persistFeatureDelete: (featureId: string) => Promise<void>;
saveCategory: (category: string) => Promise<void>;
setEditingFeature: (feature: Feature | null) => void;
@@ -221,7 +226,9 @@ export function useBoardActions({
priority: number;
planningMode?: PlanningMode;
requirePlanApproval?: boolean;
}
},
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
) => {
const finalBranchName = updates.branchName || undefined;
@@ -265,7 +272,7 @@ export function useBoardActions({
};
updateFeature(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates);
persistFeatureUpdate(featureId, finalUpdates, descriptionHistorySource, enhancementMode);
if (updates.category) {
saveCategory(updates.category);
}

View File

@@ -15,7 +15,12 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
// Persist feature update to API (replaces saveFeatures)
const persistFeatureUpdate = useCallback(
async (featureId: string, updates: Partial<Feature>) => {
async (
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
) => {
if (!currentProject) return;
try {
@@ -25,7 +30,13 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
return;
}
const result = await api.features.update(currentProject.path, featureId, updates);
const result = await api.features.update(
currentProject.path,
featureId,
updates,
descriptionHistorySource,
enhancementMode
);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature);
}

View File

@@ -1,8 +1,8 @@
import { useState, useEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { GitBranch, Plus, RefreshCw, PanelLeftOpen, PanelLeftClose } from 'lucide-react';
import { cn, pathsEqual } from '@/lib/utils';
import { getItem, setItem } from '@/lib/storage';
import { useAppStore } from '@/store/app-store';
import type { WorktreePanelProps, WorktreeInfo } from './types';
import {
useWorktrees,
@@ -14,8 +14,6 @@ import {
} from './hooks';
import { WorktreeTab } from './components';
const WORKTREE_PANEL_COLLAPSED_KEY = 'worktree-panel-collapsed';
export function WorktreePanel({
projectPath,
onCreateWorktree,
@@ -85,17 +83,11 @@ export function WorktreePanel({
features,
});
// Collapse state with localStorage persistence
const [isCollapsed, setIsCollapsed] = useState(() => {
const saved = getItem(WORKTREE_PANEL_COLLAPSED_KEY);
return saved === 'true';
});
// Collapse state from store (synced via API)
const isCollapsed = useAppStore((s) => s.worktreePanelCollapsed);
const setWorktreePanelCollapsed = useAppStore((s) => s.setWorktreePanelCollapsed);
useEffect(() => {
setItem(WORKTREE_PANEL_COLLAPSED_KEY, String(isCollapsed));
}, [isCollapsed]);
const toggleCollapsed = () => setIsCollapsed((prev) => !prev);
const toggleCollapsed = () => setWorktreePanelCollapsed(!isCollapsed);
// Periodic interval check (5 seconds) to detect branch changes on disk
// Reduced from 1s to 5s to minimize GPU/CPU usage from frequent re-renders

View File

@@ -358,10 +358,10 @@ export function PhaseModelSelector({
</PopoverTrigger>
<PopoverContent
side="right"
align="center"
avoidCollisions={false}
align="start"
className="w-[220px] p-1"
sideOffset={8}
collisionPadding={16}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-1">
@@ -474,10 +474,10 @@ export function PhaseModelSelector({
</PopoverTrigger>
<PopoverContent
side="right"
align="center"
avoidCollisions={false}
align="start"
className="w-[220px] p-1"
sideOffset={8}
collisionPadding={16}
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div className="space-y-1">

View File

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

View 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;
}
}

View File

@@ -459,7 +459,9 @@ export interface FeaturesAPI {
update: (
projectPath: string,
featureId: string,
updates: Partial<Feature>
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
) => Promise<{ success: boolean; feature?: Feature; error?: string }>;
delete: (projectPath: string, featureId: string) => Promise<{ success: boolean; error?: string }>;
getAgentOutput: (

View File

@@ -1183,8 +1183,20 @@ export class HttpApiClient implements ElectronAPI {
this.post('/api/features/get', { projectPath, featureId }),
create: (projectPath: string, feature: Feature) =>
this.post('/api/features/create', { projectPath, feature }),
update: (projectPath: string, featureId: string, updates: Partial<Feature>) =>
this.post('/api/features/update', { projectPath, featureId, updates }),
update: (
projectPath: string,
featureId: string,
updates: Partial<Feature>,
descriptionHistorySource?: 'enhance' | 'edit',
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'
) =>
this.post('/api/features/update', {
projectPath,
featureId,
updates,
descriptionHistorySource,
enhancementMode,
}),
delete: (projectPath: string, featureId: string) =>
this.post('/api/features/delete', { projectPath, featureId }),
getAgentOutput: (projectPath: string, featureId: string) =>

View File

@@ -6,12 +6,10 @@
import { createLogger } from '@automaker/utils/logger';
import { getHttpApiClient } from './http-api-client';
import { getElectronAPI } from './electron';
import { getItem, setItem } from './storage';
import { useAppStore } from '@/store/app-store';
const logger = createLogger('WorkspaceConfig');
const LAST_PROJECT_DIR_KEY = 'automaker:lastProjectDir';
/**
* Browser-compatible path join utility
* Works in both Node.js and browser environments
@@ -67,10 +65,10 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
}
// If ALLOWED_ROOT_DIRECTORY is not set, use priority:
// 1. Last used directory
// 1. Last used directory (from store, synced via API)
// 2. Documents/Automaker
// 3. DATA_DIR as fallback
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
const lastUsedDir = useAppStore.getState().lastProjectDir;
if (lastUsedDir) {
return lastUsedDir;
@@ -89,7 +87,7 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
}
// If API call failed, still try last used dir and Documents
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
const lastUsedDir = useAppStore.getState().lastProjectDir;
if (lastUsedDir) {
return lastUsedDir;
@@ -101,7 +99,7 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
logger.error('Failed to get default workspace directory:', error);
// On error, try last used dir and Documents
const lastUsedDir = getItem(LAST_PROJECT_DIR_KEY);
const lastUsedDir = useAppStore.getState().lastProjectDir;
if (lastUsedDir) {
return lastUsedDir;
@@ -113,9 +111,9 @@ export async function getDefaultWorkspaceDirectory(): Promise<string | null> {
}
/**
* Saves the last used project directory to localStorage
* Saves the last used project directory to the store (synced via API)
* @param path - The directory path to save
*/
export function saveLastProjectDirectory(path: string): void {
setItem(LAST_PROJECT_DIR_KEY, path);
useAppStore.getState().setLastProjectDir(path);
}

View File

@@ -33,9 +33,10 @@ function RootLayoutContent() {
const navigate = useNavigate();
const [isMounted, setIsMounted] = useState(false);
const [streamerPanelOpen, setStreamerPanelOpen] = useState(false);
const [setupHydrated, setSetupHydrated] = useState(
() => useSetupStore.persist?.hasHydrated?.() ?? false
);
// Since we removed persist middleware (settings now sync via API),
// we consider the store "hydrated" immediately - the useSettingsMigration
// hook in App.tsx handles loading settings from the API
const [setupHydrated, setSetupHydrated] = useState(true);
const authChecked = useAuthStore((s) => s.authChecked);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const { openFileBrowser } = useFileBrowser();
@@ -140,23 +141,8 @@ function RootLayoutContent() {
initAuth();
}, []); // Runs once per load; auth state drives routing rules
// Wait for setup store hydration before enforcing routing rules
useEffect(() => {
if (useSetupStore.persist?.hasHydrated?.()) {
setSetupHydrated(true);
return;
}
const unsubscribe = useSetupStore.persist?.onFinishHydration?.(() => {
setSetupHydrated(true);
});
return () => {
if (typeof unsubscribe === 'function') {
unsubscribe();
}
};
}, []);
// Note: Setup store hydration is handled by useSettingsMigration in App.tsx
// No need to wait for persist middleware hydration since we removed it
// Routing rules (web mode and external server mode):
// - If not authenticated: force /login (even /setup is protected)

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
// CLI Installation Status
export interface CliStatus {
@@ -144,66 +144,52 @@ const initialState: SetupState = {
skipClaudeSetup: shouldSkipSetup,
};
export const useSetupStore = create<SetupState & SetupActions>()(
persist(
(set, get) => ({
...initialState,
export const useSetupStore = create<SetupState & SetupActions>()((set, get) => ({
...initialState,
// Setup flow
setCurrentStep: (step) => set({ currentStep: step }),
// Setup flow
setCurrentStep: (step) => set({ currentStep: step }),
setSetupComplete: (complete) =>
set({
setupComplete: complete,
currentStep: complete ? 'complete' : 'welcome',
}),
completeSetup: () => set({ setupComplete: true, currentStep: 'complete' }),
resetSetup: () =>
set({
...initialState,
isFirstRun: false, // Don't reset first run flag
}),
setIsFirstRun: (isFirstRun) => set({ isFirstRun }),
// Claude CLI
setClaudeCliStatus: (status) => set({ claudeCliStatus: status }),
setClaudeAuthStatus: (status) => set({ claudeAuthStatus: status }),
setClaudeInstallProgress: (progress) =>
set({
claudeInstallProgress: {
...get().claudeInstallProgress,
...progress,
},
}),
resetClaudeInstallProgress: () =>
set({
claudeInstallProgress: { ...initialInstallProgress },
}),
// GitHub CLI
setGhCliStatus: (status) => set({ ghCliStatus: status }),
// Cursor CLI
setCursorCliStatus: (status) => set({ cursorCliStatus: status }),
// Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
setSetupComplete: (complete) =>
set({
setupComplete: complete,
currentStep: complete ? 'complete' : 'welcome',
}),
{
name: 'automaker-setup',
version: 1, // Add version field for proper hydration (matches app-store pattern)
partialize: (state) => ({
isFirstRun: state.isFirstRun,
setupComplete: state.setupComplete,
skipClaudeSetup: state.skipClaudeSetup,
claudeAuthStatus: state.claudeAuthStatus,
}),
}
)
);
completeSetup: () => set({ setupComplete: true, currentStep: 'complete' }),
resetSetup: () =>
set({
...initialState,
isFirstRun: false, // Don't reset first run flag
}),
setIsFirstRun: (isFirstRun) => set({ isFirstRun }),
// Claude CLI
setClaudeCliStatus: (status) => set({ claudeCliStatus: status }),
setClaudeAuthStatus: (status) => set({ claudeAuthStatus: status }),
setClaudeInstallProgress: (progress) =>
set({
claudeInstallProgress: {
...get().claudeInstallProgress,
...progress,
},
}),
resetClaudeInstallProgress: () =>
set({
claudeInstallProgress: { ...initialInstallProgress },
}),
// GitHub CLI
setGhCliStatus: (status) => set({ ghCliStatus: status }),
// Cursor CLI
setCursorCliStatus: (status) => set({ cursorCliStatus: status }),
// Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
}));

View File

@@ -0,0 +1,219 @@
# Settings API-First Migration
## Overview
This document summarizes the migration from localStorage-based settings persistence to an API-first approach. The goal was to ensure settings are consistent between Electron and web modes by using the server's `settings.json` as the single source of truth.
## Problem
Previously, settings were stored in two places:
1. **Browser localStorage** (via Zustand persist middleware) - isolated per browser/Electron instance
2. **Server files** (`{DATA_DIR}/settings.json`)
This caused settings drift between Electron and web modes since each had its own localStorage.
## Solution
All settings are now:
1. **Fetched from the server API** on app startup
2. **Synced back to the server API** when changed (with debouncing)
3. **No longer cached in localStorage** (persist middleware removed)
## Files Changed
### New Files
#### `apps/ui/src/hooks/use-settings-sync.ts`
New hook that:
- Waits for migration to complete before starting
- Subscribes to Zustand store changes
- Debounces sync to server (1000ms delay)
- Handles special case for `currentProjectId` (extracted from `currentProject` object)
### Modified Files
#### `apps/ui/src/store/app-store.ts`
- Removed `persist` middleware from Zustand store
- Added new state fields:
- `worktreePanelCollapsed: boolean`
- `lastProjectDir: string`
- `recentFolders: string[]`
- Added corresponding setter actions
#### `apps/ui/src/store/setup-store.ts`
- Removed `persist` middleware from Zustand store
#### `apps/ui/src/hooks/use-settings-migration.ts`
Complete rewrite to:
- Run in both Electron and web modes (not just Electron)
- Parse localStorage data and merge with server data
- Prefer server data, but use localStorage for missing arrays (projects, profiles, etc.)
- Export `waitForMigrationComplete()` for coordination with sync hook
- Handle `currentProjectId` to restore the currently open project
#### `apps/ui/src/App.tsx`
- Added `useSettingsSync` hook
- Wait for migration to complete before rendering router (prevents race condition)
- Show loading state while settings are being fetched
#### `apps/ui/src/routes/__root.tsx`
- Removed persist middleware hydration checks (no longer needed)
- Set `setupHydrated` to `true` by default
#### `apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx`
- Changed from localStorage to app store for `worktreePanelCollapsed`
#### `apps/ui/src/components/dialogs/file-browser-dialog.tsx`
- Changed from localStorage to app store for `recentFolders`
#### `apps/ui/src/lib/workspace-config.ts`
- Changed from localStorage to app store for `lastProjectDir`
#### `libs/types/src/settings.ts`
- Added `currentProjectId: string | null` to `GlobalSettings` interface
- Added to `DEFAULT_GLOBAL_SETTINGS`
## Settings Synced to Server
The following fields are synced to the server when they change:
```typescript
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',
'projectHistory',
'projectHistoryIndex',
'lastSelectedSessionByProject',
'worktreePanelCollapsed',
'lastProjectDir',
'recentFolders',
];
```
## Data Flow
### On App Startup
```
1. App mounts
└── Shows "Loading settings..." screen
2. useSettingsMigration runs
├── Waits for API key initialization
├── Reads localStorage data (if any)
├── Fetches settings from server API
├── Merges data (prefers server, uses localStorage for missing arrays)
├── Hydrates Zustand store (including currentProject from currentProjectId)
├── Syncs merged data back to server (if needed)
└── Signals completion via waitForMigrationComplete()
3. useSettingsSync initializes
├── Waits for migration to complete
├── Stores initial state hash
└── Starts subscribing to store changes
4. Router renders
├── Root layout reads currentProject (now properly set)
└── Navigates to /board if project was open
```
### On Settings Change
```
1. User changes a setting
└── Zustand store updates
2. useSettingsSync detects change
├── Debounces for 1000ms
└── Syncs to server via API
3. Server writes to settings.json
```
## Migration Logic
When merging localStorage with server data:
1. **Server has data** → Use server data as base
2. **Server missing arrays** (projects, aiProfiles, etc.) → Use localStorage arrays
3. **Server missing objects** (lastSelectedSessionByProject) → Use localStorage objects
4. **Simple values** (lastProjectDir, currentProjectId) → Use localStorage if server is empty
## Exported Functions
### `useSettingsMigration()`
Hook that handles initial settings hydration. Returns:
- `checked: boolean` - Whether hydration is complete
- `migrated: boolean` - Whether data was migrated from localStorage
- `error: string | null` - Error message if failed
### `useSettingsSync()`
Hook that handles ongoing sync. Returns:
- `loaded: boolean` - Whether sync is initialized
- `syncing: boolean` - Whether currently syncing
- `error: string | null` - Error message if failed
### `waitForMigrationComplete()`
Returns a Promise that resolves when migration is complete. Used for coordination.
### `forceSyncSettingsToServer()`
Manually triggers an immediate sync to server.
### `refreshSettingsFromServer()`
Fetches latest settings from server and updates store.
## Testing
All 1001 server tests pass after these changes.
## Notes
- **sessionStorage** is still used for session-specific state (splash screen shown, auto-mode state)
- **Terminal layouts** are stored in the app store per-project (not synced to API - considered transient UI state)
- The server's `{DATA_DIR}/settings.json` is the single source of truth

View File

@@ -4,6 +4,16 @@
import type { PlanningMode, ThinkingLevel } from './settings.js';
/**
* A single entry in the description history
*/
export interface DescriptionHistoryEntry {
description: string;
timestamp: string; // ISO date string
source: 'initial' | 'enhance' | 'edit'; // What triggered this version
enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance'; // Only for 'enhance' source
}
export interface FeatureImagePath {
id: string;
path: string;
@@ -54,6 +64,7 @@ export interface Feature {
error?: string;
summary?: string;
startedAt?: string;
descriptionHistory?: DescriptionHistoryEntry[]; // History of description changes
[key: string]: unknown; // Keep catch-all for extensibility
}

View File

@@ -20,7 +20,13 @@ export type {
} from './provider.js';
// Feature types
export type { Feature, FeatureImagePath, FeatureTextFilePath, FeatureStatus } from './feature.js';
export type {
Feature,
FeatureImagePath,
FeatureTextFilePath,
FeatureStatus,
DescriptionHistoryEntry,
} from './feature.js';
// Session types
export type {

View File

@@ -387,6 +387,14 @@ export interface GlobalSettings {
/** Version number for schema migration */
version: number;
// Onboarding / Setup Wizard
/** Whether the initial setup wizard has been completed */
setupComplete: boolean;
/** Whether this is the first run experience (used by UI onboarding) */
isFirstRun: boolean;
/** Whether Claude setup was skipped during onboarding */
skipClaudeSetup: boolean;
// Theme Configuration
/** Currently selected theme */
theme: ThemeMode;
@@ -452,6 +460,8 @@ export interface GlobalSettings {
projects: ProjectRef[];
/** Projects in trash/recycle bin */
trashedProjects: TrashedProjectRef[];
/** ID of the currently open project (null if none) */
currentProjectId: string | null;
/** History of recently opened project IDs */
projectHistory: string[];
/** Current position in project history for navigation */
@@ -608,7 +618,7 @@ export const DEFAULT_PHASE_MODELS: PhaseModelConfig = {
};
/** Current version of the global settings schema */
export const SETTINGS_VERSION = 3;
export const SETTINGS_VERSION = 4;
/** Current version of the credentials schema */
export const CREDENTIALS_VERSION = 1;
/** Current version of the project settings schema */
@@ -641,6 +651,9 @@ export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
/** Default global settings used when no settings file exists */
export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
version: SETTINGS_VERSION,
setupComplete: false,
isFirstRun: true,
skipClaudeSetup: false,
theme: 'dark',
sidebarOpen: true,
chatHistoryOpen: false,
@@ -664,6 +677,7 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
aiProfiles: [],
projects: [],
trashedProjects: [],
currentProjectId: null,
projectHistory: [],
projectHistoryIndex: -1,
lastProjectDir: undefined,