diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index 830fb21a..2e960a62 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -10,11 +10,14 @@ import { getErrorMessage, logError } from '../common.js'; export function createUpdateHandler(featureLoader: FeatureLoader) { return async (req: Request, res: Response): Promise => { try { - const { projectPath, featureId, updates } = req.body as { - projectPath: string; - featureId: string; - updates: Partial; - }; + const { projectPath, featureId, updates, descriptionHistorySource, enhancementMode } = + req.body as { + projectPath: string; + featureId: string; + updates: Partial; + 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'); diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 562ccc66..93cff796 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -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 + updates: Partial, + descriptionHistorySource?: 'enhance' | 'edit', + enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' ): Promise { 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 diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 4de7231c..eb7cd0be 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -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 = {}; + 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 = { + 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, diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 47dbc647..bf9b1086 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -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 ( +
+ +
+ ); + } + return ( <> diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index ce09f63b..53c20daa 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -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(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([]); - // 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); diff --git a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx index e5856194..3a34f0fa 100644 --- a/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/edit-feature-dialog.tsx @@ -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({ + 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 && ( + + + + + +
+

Version History

+

+ Click a version to restore it +

+
+
+ {[...(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 ( + + ); + })} +
+
+
+ )}
diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 4f03f3ce..48906045 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -23,7 +23,12 @@ interface UseBoardActionsProps { runningAutoTasks: string[]; loadFeatures: () => Promise; persistFeatureCreate: (feature: Feature) => Promise; - persistFeatureUpdate: (featureId: string, updates: Partial) => Promise; + persistFeatureUpdate: ( + featureId: string, + updates: Partial, + descriptionHistorySource?: 'enhance' | 'edit', + enhancementMode?: 'improve' | 'technical' | 'simplify' | 'acceptance' + ) => Promise; persistFeatureDelete: (featureId: string) => Promise; saveCategory: (category: string) => Promise; 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); } diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index 4a25de7e..826f4d7c 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts @@ -15,7 +15,12 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps // Persist feature update to API (replaces saveFeatures) const persistFeatureUpdate = useCallback( - async (featureId: string, updates: Partial) => { + async ( + featureId: string, + updates: Partial, + 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); } diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 0f4a1765..e0030d09 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -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 diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index 8294c9fb..fcd2e16d 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -358,10 +358,10 @@ export function PhaseModelSelector({ e.preventDefault()} >
@@ -474,10 +474,10 @@ export function PhaseModelSelector({ e.preventDefault()} >
diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 0ab0d9fe..6c0d096d 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -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 | 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 { + // 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 | null { + try { + const automakerStorage = getItem('automaker-storage'); + if (!automakerStorage) { + return null; + } + + const parsed = JSON.parse(automakerStorage) as Record; + // Zustand persist stores state under 'state' key + const state = (parsed.state as Record | 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) + : null; + const setupState = + (setupParsed?.state as Record | 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 | 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 | 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 = {}; - 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), + }, + 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 { + 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 { 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 | null = null; - try { - state = useAppStore.getState() as unknown as Record; - } 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; - state = (parsed.state as Record | 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 { /** * 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 { @@ -408,9 +641,6 @@ export async function loadMCPServersFromServer(): Promise { } 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`); diff --git a/apps/ui/src/hooks/use-settings-sync.ts b/apps/ui/src/hooks/use-settings-sync.ts new file mode 100644 index 00000000..90bc4168 --- /dev/null +++ b/apps/ui/src/hooks/use-settings-sync.ts @@ -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({ + loaded: false, + error: null, + syncing: false, + }); + + const syncTimeoutRef = useRef | null>(null); + const lastSyncedRef = useRef(''); + 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 = {}; + 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 = {}; + 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 { + try { + const api = getHttpApiClient(); + const appState = useAppStore.getState(); + + const updates: Record = {}; + 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 { + 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; + } +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index d81b46b6..7022d830 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -459,7 +459,9 @@ export interface FeaturesAPI { update: ( projectPath: string, featureId: string, - updates: Partial + updates: Partial, + 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: ( diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index d8cb073a..8d4188ff 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -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) => - this.post('/api/features/update', { projectPath, featureId, updates }), + update: ( + projectPath: string, + featureId: string, + updates: Partial, + 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) => diff --git a/apps/ui/src/lib/workspace-config.ts b/apps/ui/src/lib/workspace-config.ts index effd442c..d92bd671 100644 --- a/apps/ui/src/lib/workspace-config.ts +++ b/apps/ui/src/lib/workspace-config.ts @@ -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 { } // 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 { } // 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 { 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 { } /** - * 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); } diff --git a/apps/ui/src/routes/__root.tsx b/apps/ui/src/routes/__root.tsx index f050c39f..c253ffa2 100644 --- a/apps/ui/src/routes/__root.tsx +++ b/apps/ui/src/routes/__root.tsx @@ -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) diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 9fe64004..03cee293 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -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) import type { Project, TrashedProject } from '@/lib/electron'; import { createLogger } from '@automaker/utils/logger'; import type { @@ -572,6 +572,14 @@ export interface AppState { // Pipeline Configuration (per-project, keyed by project path) pipelineConfigByProject: Record; + + // UI State (previously in localStorage, now synced via API) + /** Whether worktree panel is collapsed in board view */ + worktreePanelCollapsed: boolean; + /** Last directory opened in file picker */ + lastProjectDir: string; + /** Recently accessed folders for quick access */ + recentFolders: string[]; } // Claude Usage interface matching the server response @@ -930,6 +938,12 @@ export interface AppActions { deletePipelineStep: (projectPath: string, stepId: string) => void; reorderPipelineSteps: (projectPath: string, stepIds: string[]) => void; + // UI State actions (previously in localStorage, now synced via API) + setWorktreePanelCollapsed: (collapsed: boolean) => void; + setLastProjectDir: (dir: string) => void; + setRecentFolders: (folders: string[]) => void; + addRecentFolder: (folder: string) => void; + // Reset reset: () => void; } @@ -1055,1988 +1069,1833 @@ const initialState: AppState = { claudeUsage: null, claudeUsageLastUpdated: null, pipelineConfigByProject: {}, + // UI State (previously in localStorage, now synced via API) + worktreePanelCollapsed: false, + lastProjectDir: '', + recentFolders: [], }; -export const useAppStore = create()( - persist( - (set, get) => ({ - ...initialState, +export const useAppStore = create()((set, get) => ({ + ...initialState, - // Project actions - setProjects: (projects) => set({ projects }), + // Project actions + setProjects: (projects) => set({ projects }), - addProject: (project) => { - const projects = get().projects; - const existing = projects.findIndex((p) => p.path === project.path); - if (existing >= 0) { - const updated = [...projects]; - updated[existing] = { - ...project, - lastOpened: new Date().toISOString(), - }; - set({ projects: updated }); - } else { - set({ - projects: [...projects, { ...project, lastOpened: new Date().toISOString() }], - }); - } - }, + addProject: (project) => { + const projects = get().projects; + const existing = projects.findIndex((p) => p.path === project.path); + if (existing >= 0) { + const updated = [...projects]; + updated[existing] = { + ...project, + lastOpened: new Date().toISOString(), + }; + set({ projects: updated }); + } else { + set({ + projects: [...projects, { ...project, lastOpened: new Date().toISOString() }], + }); + } + }, - removeProject: (projectId) => { - set({ projects: get().projects.filter((p) => p.id !== projectId) }); - }, + removeProject: (projectId) => { + set({ projects: get().projects.filter((p) => p.id !== projectId) }); + }, - moveProjectToTrash: (projectId) => { - const project = get().projects.find((p) => p.id === projectId); - if (!project) return; + moveProjectToTrash: (projectId) => { + const project = get().projects.find((p) => p.id === projectId); + if (!project) return; - const remainingProjects = get().projects.filter((p) => p.id !== projectId); - const existingTrash = get().trashedProjects.filter((p) => p.id !== projectId); - const trashedProject: TrashedProject = { - ...project, - trashedAt: new Date().toISOString(), - deletedFromDisk: false, - }; + const remainingProjects = get().projects.filter((p) => p.id !== projectId); + const existingTrash = get().trashedProjects.filter((p) => p.id !== projectId); + const trashedProject: TrashedProject = { + ...project, + trashedAt: new Date().toISOString(), + deletedFromDisk: false, + }; - const isCurrent = get().currentProject?.id === projectId; + const isCurrent = get().currentProject?.id === projectId; + set({ + projects: remainingProjects, + trashedProjects: [trashedProject, ...existingTrash], + currentProject: isCurrent ? null : get().currentProject, + currentView: isCurrent ? 'welcome' : get().currentView, + }); + }, + + restoreTrashedProject: (projectId) => { + const trashed = get().trashedProjects.find((p) => p.id === projectId); + if (!trashed) return; + + const remainingTrash = get().trashedProjects.filter((p) => p.id !== projectId); + const existingProjects = get().projects; + const samePathProject = existingProjects.find((p) => p.path === trashed.path); + const projectsWithoutId = existingProjects.filter((p) => p.id !== projectId); + + // If a project with the same path already exists, keep it and just remove from trash + if (samePathProject) { + set({ + trashedProjects: remainingTrash, + currentProject: samePathProject, + currentView: 'board', + }); + return; + } + + const restoredProject: Project = { + id: trashed.id, + name: trashed.name, + path: trashed.path, + lastOpened: new Date().toISOString(), + theme: trashed.theme, // Preserve theme from trashed project + }; + + set({ + trashedProjects: remainingTrash, + projects: [...projectsWithoutId, restoredProject], + currentProject: restoredProject, + currentView: 'board', + }); + }, + + deleteTrashedProject: (projectId) => { + set({ + trashedProjects: get().trashedProjects.filter((p) => p.id !== projectId), + }); + }, + + emptyTrash: () => set({ trashedProjects: [] }), + + reorderProjects: (oldIndex, newIndex) => { + const projects = [...get().projects]; + const [movedProject] = projects.splice(oldIndex, 1); + projects.splice(newIndex, 0, movedProject); + set({ projects }); + }, + + setCurrentProject: (project) => { + set({ currentProject: project }); + if (project) { + set({ currentView: 'board' }); + // Add to project history (MRU order) + const currentHistory = get().projectHistory; + // Remove this project if it's already in history + const filteredHistory = currentHistory.filter((id) => id !== project.id); + // Add to the front (most recent) + const newHistory = [project.id, ...filteredHistory]; + // Reset history index to 0 (current project) + set({ projectHistory: newHistory, projectHistoryIndex: 0 }); + } else { + set({ currentView: 'welcome' }); + } + }, + + upsertAndSetCurrentProject: (path, name, theme) => { + const { projects, trashedProjects, currentProject, theme: globalTheme } = get(); + const existingProject = projects.find((p) => p.path === path); + let project: Project; + + if (existingProject) { + // Update existing project, preserving theme and other properties + project = { + ...existingProject, + name, // Update name in case it changed + lastOpened: new Date().toISOString(), + }; + // Update the project in the store + const updatedProjects = projects.map((p) => (p.id === existingProject.id ? project : p)); + set({ projects: updatedProjects }); + } else { + // Create new project - check for trashed project with same path first (preserves theme if deleted/recreated) + // Then fall back to provided theme, then current project theme, then global theme + const trashedProject = trashedProjects.find((p) => p.path === path); + const effectiveTheme = theme || trashedProject?.theme || currentProject?.theme || globalTheme; + project = { + id: `project-${Date.now()}`, + name, + path, + lastOpened: new Date().toISOString(), + theme: effectiveTheme, + }; + // Add the new project to the store + set({ + projects: [...projects, { ...project, lastOpened: new Date().toISOString() }], + }); + } + + // Set as current project (this will also update history and view) + get().setCurrentProject(project); + return project; + }, + + cyclePrevProject: () => { + const { projectHistory, projectHistoryIndex, projects } = get(); + + // Filter history to only include valid projects + const validHistory = projectHistory.filter((id) => projects.some((p) => p.id === id)); + + if (validHistory.length <= 1) return; // Need at least 2 valid projects to cycle + + // Find current position in valid history + const currentProjectId = get().currentProject?.id; + let currentIndex = currentProjectId + ? validHistory.indexOf(currentProjectId) + : projectHistoryIndex; + + // If current project not found in valid history, start from 0 + if (currentIndex === -1) currentIndex = 0; + + // Move to the next index (going back in history = higher index), wrapping around + const newIndex = (currentIndex + 1) % validHistory.length; + const targetProjectId = validHistory[newIndex]; + const targetProject = projects.find((p) => p.id === targetProjectId); + + if (targetProject) { + // Update history to only include valid projects and set new index + set({ + currentProject: targetProject, + projectHistory: validHistory, + projectHistoryIndex: newIndex, + currentView: 'board', + }); + } + }, + + cycleNextProject: () => { + const { projectHistory, projectHistoryIndex, projects } = get(); + + // Filter history to only include valid projects + const validHistory = projectHistory.filter((id) => projects.some((p) => p.id === id)); + + if (validHistory.length <= 1) return; // Need at least 2 valid projects to cycle + + // Find current position in valid history + const currentProjectId = get().currentProject?.id; + let currentIndex = currentProjectId + ? validHistory.indexOf(currentProjectId) + : projectHistoryIndex; + + // If current project not found in valid history, start from 0 + if (currentIndex === -1) currentIndex = 0; + + // Move to the previous index (going forward = lower index), wrapping around + const newIndex = currentIndex <= 0 ? validHistory.length - 1 : currentIndex - 1; + const targetProjectId = validHistory[newIndex]; + const targetProject = projects.find((p) => p.id === targetProjectId); + + if (targetProject) { + // Update history to only include valid projects and set new index + set({ + currentProject: targetProject, + projectHistory: validHistory, + projectHistoryIndex: newIndex, + currentView: 'board', + }); + } + }, + + clearProjectHistory: () => { + const currentProject = get().currentProject; + if (currentProject) { + // Keep only the current project in history + set({ + projectHistory: [currentProject.id], + projectHistoryIndex: 0, + }); + } else { + // No current project, clear everything + set({ + projectHistory: [], + projectHistoryIndex: -1, + }); + } + }, + + // View actions + setCurrentView: (view) => set({ currentView: view }), + toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }), + setSidebarOpen: (open) => set({ sidebarOpen: open }), + + // Theme actions + setTheme: (theme) => set({ theme }), + + setProjectTheme: (projectId, theme) => { + // Update the project's theme property + const projects = get().projects.map((p) => + p.id === projectId ? { ...p, theme: theme === null ? undefined : theme } : p + ); + set({ projects }); + + // Also update currentProject if it's the same project + const currentProject = get().currentProject; + if (currentProject?.id === projectId) { + set({ + currentProject: { + ...currentProject, + theme: theme === null ? undefined : theme, + }, + }); + } + }, + + getEffectiveTheme: () => { + // If preview theme is set, use it (for hover preview) + const previewTheme = get().previewTheme; + if (previewTheme) { + return previewTheme; + } + const currentProject = get().currentProject; + // If current project has a theme set, use it + if (currentProject?.theme) { + return currentProject.theme as ThemeMode; + } + // Otherwise fall back to global theme + return get().theme; + }, + + setPreviewTheme: (theme) => set({ previewTheme: theme }), + + // Feature actions + setFeatures: (features) => set({ features }), + + updateFeature: (id, updates) => { + set({ + features: get().features.map((f) => (f.id === id ? { ...f, ...updates } : f)), + }); + }, + + addFeature: (feature) => { + const id = feature.id || `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const featureWithId = { ...feature, id } as unknown as Feature; + set({ features: [...get().features, featureWithId] }); + return featureWithId; + }, + + removeFeature: (id) => { + set({ features: get().features.filter((f) => f.id !== id) }); + }, + + moveFeature: (id, newStatus) => { + set({ + features: get().features.map((f) => (f.id === id ? { ...f, status: newStatus } : f)), + }); + }, + + // App spec actions + setAppSpec: (spec) => set({ appSpec: spec }), + + // IPC actions + setIpcConnected: (connected) => set({ ipcConnected: connected }), + + // API Keys actions + setApiKeys: (keys) => set({ apiKeys: { ...get().apiKeys, ...keys } }), + + // Chat Session actions + createChatSession: (title) => { + const currentProject = get().currentProject; + if (!currentProject) { + throw new Error('No project selected'); + } + + const now = new Date(); + const session: ChatSession = { + id: `chat-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + title: title || `Chat ${new Date().toLocaleDateString()} ${new Date().toLocaleTimeString()}`, + projectId: currentProject.id, + messages: [ + { + id: 'welcome', + role: 'assistant', + content: + "Hello! I'm the Automaker Agent. I can help you build software autonomously. What would you like to create today?", + timestamp: now, + }, + ], + createdAt: now, + updatedAt: now, + archived: false, + }; + + set({ + chatSessions: [...get().chatSessions, session], + currentChatSession: session, + }); + + return session; + }, + + updateChatSession: (sessionId, updates) => { + set({ + chatSessions: get().chatSessions.map((session) => + session.id === sessionId ? { ...session, ...updates, updatedAt: new Date() } : session + ), + }); + + // Update current session if it's the one being updated + const currentSession = get().currentChatSession; + if (currentSession && currentSession.id === sessionId) { + set({ + currentChatSession: { + ...currentSession, + ...updates, + updatedAt: new Date(), + }, + }); + } + }, + + addMessageToSession: (sessionId, message) => { + const sessions = get().chatSessions; + const sessionIndex = sessions.findIndex((s) => s.id === sessionId); + + if (sessionIndex >= 0) { + const updatedSessions = [...sessions]; + updatedSessions[sessionIndex] = { + ...updatedSessions[sessionIndex], + messages: [...updatedSessions[sessionIndex].messages, message], + updatedAt: new Date(), + }; + + set({ chatSessions: updatedSessions }); + + // Update current session if it's the one being updated + const currentSession = get().currentChatSession; + if (currentSession && currentSession.id === sessionId) { set({ - projects: remainingProjects, - trashedProjects: [trashedProject, ...existingTrash], - currentProject: isCurrent ? null : get().currentProject, - currentView: isCurrent ? 'welcome' : get().currentView, + currentChatSession: updatedSessions[sessionIndex], }); + } + } + }, + + setCurrentChatSession: (session) => { + set({ currentChatSession: session }); + }, + + archiveChatSession: (sessionId) => { + get().updateChatSession(sessionId, { archived: true }); + }, + + unarchiveChatSession: (sessionId) => { + get().updateChatSession(sessionId, { archived: false }); + }, + + deleteChatSession: (sessionId) => { + const currentSession = get().currentChatSession; + set({ + chatSessions: get().chatSessions.filter((s) => s.id !== sessionId), + currentChatSession: currentSession?.id === sessionId ? null : currentSession, + }); + }, + + setChatHistoryOpen: (open) => set({ chatHistoryOpen: open }), + + toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }), + + // Auto Mode actions (per-project) + setAutoModeRunning: (projectId, running) => { + const current = get().autoModeByProject; + const projectState = current[projectId] || { + isRunning: false, + runningTasks: [], + }; + set({ + autoModeByProject: { + ...current, + [projectId]: { ...projectState, isRunning: running }, }, + }); + }, - restoreTrashedProject: (projectId) => { - const trashed = get().trashedProjects.find((p) => p.id === projectId); - if (!trashed) return; + addRunningTask: (projectId, taskId) => { + const current = get().autoModeByProject; + const projectState = current[projectId] || { + isRunning: false, + runningTasks: [], + }; + if (!projectState.runningTasks.includes(taskId)) { + set({ + autoModeByProject: { + ...current, + [projectId]: { + ...projectState, + runningTasks: [...projectState.runningTasks, taskId], + }, + }, + }); + } + }, - const remainingTrash = get().trashedProjects.filter((p) => p.id !== projectId); - const existingProjects = get().projects; - const samePathProject = existingProjects.find((p) => p.path === trashed.path); - const projectsWithoutId = existingProjects.filter((p) => p.id !== projectId); - - // If a project with the same path already exists, keep it and just remove from trash - if (samePathProject) { - set({ - trashedProjects: remainingTrash, - currentProject: samePathProject, - currentView: 'board', - }); - return; - } - - const restoredProject: Project = { - id: trashed.id, - name: trashed.name, - path: trashed.path, - lastOpened: new Date().toISOString(), - theme: trashed.theme, // Preserve theme from trashed project - }; - - set({ - trashedProjects: remainingTrash, - projects: [...projectsWithoutId, restoredProject], - currentProject: restoredProject, - currentView: 'board', - }); + removeRunningTask: (projectId, taskId) => { + const current = get().autoModeByProject; + const projectState = current[projectId] || { + isRunning: false, + runningTasks: [], + }; + set({ + autoModeByProject: { + ...current, + [projectId]: { + ...projectState, + runningTasks: projectState.runningTasks.filter((id) => id !== taskId), + }, }, + }); + }, - deleteTrashedProject: (projectId) => { - set({ - trashedProjects: get().trashedProjects.filter((p) => p.id !== projectId), - }); + clearRunningTasks: (projectId) => { + const current = get().autoModeByProject; + const projectState = current[projectId] || { + isRunning: false, + runningTasks: [], + }; + set({ + autoModeByProject: { + ...current, + [projectId]: { ...projectState, runningTasks: [] }, }, + }); + }, - emptyTrash: () => set({ trashedProjects: [] }), + getAutoModeState: (projectId) => { + const projectState = get().autoModeByProject[projectId]; + return projectState || { isRunning: false, runningTasks: [] }; + }, - reorderProjects: (oldIndex, newIndex) => { - const projects = [...get().projects]; - const [movedProject] = projects.splice(oldIndex, 1); - projects.splice(newIndex, 0, movedProject); - set({ projects }); + addAutoModeActivity: (activity) => { + const id = `activity-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const newActivity: AutoModeActivity = { + ...activity, + id, + timestamp: new Date(), + }; + + // Keep only the last 100 activities to avoid memory issues + const currentLog = get().autoModeActivityLog; + const updatedLog = [...currentLog, newActivity].slice(-100); + + set({ autoModeActivityLog: updatedLog }); + }, + + clearAutoModeActivity: () => set({ autoModeActivityLog: [] }), + + setMaxConcurrency: (max) => set({ maxConcurrency: max }), + + // Kanban Card Settings actions + setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }), + setBoardViewMode: (mode) => set({ boardViewMode: mode }), + + // Feature Default Settings actions + setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }), + setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }), + setSkipVerificationInAutoMode: async (enabled) => { + set({ skipVerificationInAutoMode: enabled }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + + // Worktree Settings actions + setUseWorktrees: (enabled) => set({ useWorktrees: enabled }), + + setCurrentWorktree: (projectPath, worktreePath, branch) => { + const current = get().currentWorktreeByProject; + set({ + currentWorktreeByProject: { + ...current, + [projectPath]: { path: worktreePath, branch }, }, + }); + }, - setCurrentProject: (project) => { - set({ currentProject: project }); - if (project) { - set({ currentView: 'board' }); - // Add to project history (MRU order) - const currentHistory = get().projectHistory; - // Remove this project if it's already in history - const filteredHistory = currentHistory.filter((id) => id !== project.id); - // Add to the front (most recent) - const newHistory = [project.id, ...filteredHistory]; - // Reset history index to 0 (current project) - set({ projectHistory: newHistory, projectHistoryIndex: 0 }); - } else { - set({ currentView: 'welcome' }); - } + setWorktrees: (projectPath, worktrees) => { + const current = get().worktreesByProject; + set({ + worktreesByProject: { + ...current, + [projectPath]: worktrees, }, + }); + }, - upsertAndSetCurrentProject: (path, name, theme) => { - const { projects, trashedProjects, currentProject, theme: globalTheme } = get(); - const existingProject = projects.find((p) => p.path === path); - let project: Project; + getCurrentWorktree: (projectPath) => { + return get().currentWorktreeByProject[projectPath] ?? null; + }, - if (existingProject) { - // Update existing project, preserving theme and other properties - project = { - ...existingProject, - name, // Update name in case it changed - lastOpened: new Date().toISOString(), - }; - // Update the project in the store - const updatedProjects = projects.map((p) => (p.id === existingProject.id ? project : p)); - set({ projects: updatedProjects }); - } else { - // Create new project - check for trashed project with same path first (preserves theme if deleted/recreated) - // Then fall back to provided theme, then current project theme, then global theme - const trashedProject = trashedProjects.find((p) => p.path === path); - const effectiveTheme = - theme || trashedProject?.theme || currentProject?.theme || globalTheme; - project = { - id: `project-${Date.now()}`, - name, - path, - lastOpened: new Date().toISOString(), - theme: effectiveTheme, - }; - // Add the new project to the store - set({ - projects: [...projects, { ...project, lastOpened: new Date().toISOString() }], - }); - } + getWorktrees: (projectPath) => { + return get().worktreesByProject[projectPath] ?? []; + }, - // Set as current project (this will also update history and view) - get().setCurrentProject(project); - return project; + isPrimaryWorktreeBranch: (projectPath, branchName) => { + const worktrees = get().worktreesByProject[projectPath] ?? []; + const primary = worktrees.find((w) => w.isMain); + return primary?.branch === branchName; + }, + + getPrimaryWorktreeBranch: (projectPath) => { + const worktrees = get().worktreesByProject[projectPath] ?? []; + const primary = worktrees.find((w) => w.isMain); + return primary?.branch ?? null; + }, + + // Profile Display Settings actions + setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }), + + // Keyboard Shortcuts actions + setKeyboardShortcut: (key, value) => { + set({ + keyboardShortcuts: { + ...get().keyboardShortcuts, + [key]: value, }, + }); + }, - cyclePrevProject: () => { - const { projectHistory, projectHistoryIndex, projects } = get(); - - // Filter history to only include valid projects - const validHistory = projectHistory.filter((id) => projects.some((p) => p.id === id)); - - if (validHistory.length <= 1) return; // Need at least 2 valid projects to cycle - - // Find current position in valid history - const currentProjectId = get().currentProject?.id; - let currentIndex = currentProjectId - ? validHistory.indexOf(currentProjectId) - : projectHistoryIndex; - - // If current project not found in valid history, start from 0 - if (currentIndex === -1) currentIndex = 0; - - // Move to the next index (going back in history = higher index), wrapping around - const newIndex = (currentIndex + 1) % validHistory.length; - const targetProjectId = validHistory[newIndex]; - const targetProject = projects.find((p) => p.id === targetProjectId); - - if (targetProject) { - // Update history to only include valid projects and set new index - set({ - currentProject: targetProject, - projectHistory: validHistory, - projectHistoryIndex: newIndex, - currentView: 'board', - }); - } + setKeyboardShortcuts: (shortcuts) => { + set({ + keyboardShortcuts: { + ...get().keyboardShortcuts, + ...shortcuts, }, + }); + }, - cycleNextProject: () => { - const { projectHistory, projectHistoryIndex, projects } = get(); + resetKeyboardShortcuts: () => { + set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS }); + }, - // Filter history to only include valid projects - const validHistory = projectHistory.filter((id) => projects.some((p) => p.id === id)); + // Audio Settings actions + setMuteDoneSound: (muted) => set({ muteDoneSound: muted }), - if (validHistory.length <= 1) return; // Need at least 2 valid projects to cycle + // Enhancement Model actions + setEnhancementModel: (model) => set({ enhancementModel: model }), - // Find current position in valid history - const currentProjectId = get().currentProject?.id; - let currentIndex = currentProjectId - ? validHistory.indexOf(currentProjectId) - : projectHistoryIndex; + // Validation Model actions + setValidationModel: (model) => set({ validationModel: model }), - // If current project not found in valid history, start from 0 - if (currentIndex === -1) currentIndex = 0; - - // Move to the previous index (going forward = lower index), wrapping around - const newIndex = currentIndex <= 0 ? validHistory.length - 1 : currentIndex - 1; - const targetProjectId = validHistory[newIndex]; - const targetProject = projects.find((p) => p.id === targetProjectId); - - if (targetProject) { - // Update history to only include valid projects and set new index - set({ - currentProject: targetProject, - projectHistory: validHistory, - projectHistoryIndex: newIndex, - currentView: 'board', - }); - } + // Phase Model actions + setPhaseModel: async (phase, entry) => { + set((state) => ({ + phaseModels: { + ...state.phaseModels, + [phase]: entry, }, - - clearProjectHistory: () => { - const currentProject = get().currentProject; - if (currentProject) { - // Keep only the current project in history - set({ - projectHistory: [currentProject.id], - projectHistoryIndex: 0, - }); - } else { - // No current project, clear everything - set({ - projectHistory: [], - projectHistoryIndex: -1, - }); - } + })); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + setPhaseModels: async (models) => { + set((state) => ({ + phaseModels: { + ...state.phaseModels, + ...models, }, + })); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + resetPhaseModels: async () => { + set({ phaseModels: DEFAULT_PHASE_MODELS }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + toggleFavoriteModel: (modelId) => { + const current = get().favoriteModels; + if (current.includes(modelId)) { + set({ favoriteModels: current.filter((id) => id !== modelId) }); + } else { + set({ favoriteModels: [...current, modelId] }); + } + }, - // View actions - setCurrentView: (view) => set({ currentView: view }), - toggleSidebar: () => set({ sidebarOpen: !get().sidebarOpen }), - setSidebarOpen: (open) => set({ sidebarOpen: open }), + // Cursor CLI Settings actions + setEnabledCursorModels: (models) => set({ enabledCursorModels: models }), + setCursorDefaultModel: (model) => set({ cursorDefaultModel: model }), + toggleCursorModel: (model, enabled) => + set((state) => ({ + enabledCursorModels: enabled + ? [...state.enabledCursorModels, model] + : state.enabledCursorModels.filter((m) => m !== model), + })), - // Theme actions - setTheme: (theme) => set({ theme }), + // Claude Agent SDK Settings actions + setAutoLoadClaudeMd: async (enabled) => { + const previous = get().autoLoadClaudeMd; + set({ autoLoadClaudeMd: enabled }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + const ok = await syncSettingsToServer(); + if (!ok) { + logger.error('Failed to sync autoLoadClaudeMd setting to server - reverting'); + set({ autoLoadClaudeMd: previous }); + } + }, + // Prompt Customization actions + setPromptCustomization: async (customization) => { + set({ promptCustomization: customization }); + // Sync to server settings file + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, - setProjectTheme: (projectId, theme) => { - // Update the project's theme property - const projects = get().projects.map((p) => - p.id === projectId ? { ...p, theme: theme === null ? undefined : theme } : p - ); - set({ projects }); + // AI Profile actions + addAIProfile: (profile) => { + const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + set({ aiProfiles: [...get().aiProfiles, { ...profile, id }] }); + }, - // Also update currentProject if it's the same project - const currentProject = get().currentProject; - if (currentProject?.id === projectId) { - set({ - currentProject: { - ...currentProject, - theme: theme === null ? undefined : theme, - }, - }); - } + updateAIProfile: (id, updates) => { + set({ + aiProfiles: get().aiProfiles.map((p) => (p.id === id ? { ...p, ...updates } : p)), + }); + }, + + removeAIProfile: (id) => { + // Only allow removing non-built-in profiles + const profile = get().aiProfiles.find((p) => p.id === id); + if (profile && !profile.isBuiltIn) { + // Clear default if this profile was selected + if (get().defaultAIProfileId === id) { + set({ defaultAIProfileId: null }); + } + set({ aiProfiles: get().aiProfiles.filter((p) => p.id !== id) }); + } + }, + + reorderAIProfiles: (oldIndex, newIndex) => { + const profiles = [...get().aiProfiles]; + const [movedProfile] = profiles.splice(oldIndex, 1); + profiles.splice(newIndex, 0, movedProfile); + set({ aiProfiles: profiles }); + }, + + resetAIProfiles: () => { + // Merge: keep user-created profiles, but refresh all built-in profiles to latest defaults + const defaultProfileIds = new Set(DEFAULT_AI_PROFILES.map((p) => p.id)); + const userProfiles = get().aiProfiles.filter( + (p) => !p.isBuiltIn && !defaultProfileIds.has(p.id) + ); + set({ aiProfiles: [...DEFAULT_AI_PROFILES, ...userProfiles] }); + }, + + // MCP Server actions + addMCPServer: (server) => { + const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + set({ mcpServers: [...get().mcpServers, { ...server, id, enabled: true }] }); + }, + + updateMCPServer: (id, updates) => { + set({ + mcpServers: get().mcpServers.map((s) => (s.id === id ? { ...s, ...updates } : s)), + }); + }, + + removeMCPServer: (id) => { + set({ mcpServers: get().mcpServers.filter((s) => s.id !== id) }); + }, + + reorderMCPServers: (oldIndex, newIndex) => { + const servers = [...get().mcpServers]; + const [movedServer] = servers.splice(oldIndex, 1); + servers.splice(newIndex, 0, movedServer); + set({ mcpServers: servers }); + }, + + // Project Analysis actions + setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }), + setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }), + clearAnalysis: () => set({ projectAnalysis: null }), + + // Agent Session actions + setLastSelectedSession: (projectPath, sessionId) => { + const current = get().lastSelectedSessionByProject; + if (sessionId === null) { + // Remove the entry for this project + const rest = Object.fromEntries( + Object.entries(current).filter(([key]) => key !== projectPath) + ); + set({ lastSelectedSessionByProject: rest }); + } else { + set({ + lastSelectedSessionByProject: { + ...current, + [projectPath]: sessionId, + }, + }); + } + }, + + getLastSelectedSession: (projectPath) => { + return get().lastSelectedSessionByProject[projectPath] || null; + }, + + // Board Background actions + setBoardBackground: (projectPath, imagePath) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || { + imagePath: null, + cardOpacity: 100, + columnOpacity: 100, + columnBorderEnabled: true, + cardGlassmorphism: true, + cardBorderEnabled: true, + cardBorderOpacity: 100, + hideScrollbar: false, + }; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + imagePath, + // Update imageVersion timestamp to bust browser cache when image changes + imageVersion: imagePath ? Date.now() : undefined, + }, }, + }); + }, - getEffectiveTheme: () => { - // If preview theme is set, use it (for hover preview) - const previewTheme = get().previewTheme; - if (previewTheme) { - return previewTheme; - } - const currentProject = get().currentProject; - // If current project has a theme set, use it - if (currentProject?.theme) { - return currentProject.theme as ThemeMode; - } - // Otherwise fall back to global theme - return get().theme; + setCardOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardOpacity: opacity, + }, }, + }); + }, - setPreviewTheme: (theme) => set({ previewTheme: theme }), - - // Feature actions - setFeatures: (features) => set({ features }), - - updateFeature: (id, updates) => { - set({ - features: get().features.map((f) => (f.id === id ? { ...f, ...updates } : f)), - }); + setColumnOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + columnOpacity: opacity, + }, }, + }); + }, - addFeature: (feature) => { - const id = feature.id || `feature-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const featureWithId = { ...feature, id } as unknown as Feature; - set({ features: [...get().features, featureWithId] }); - return featureWithId; + getBoardBackground: (projectPath) => { + const settings = get().boardBackgroundByProject[projectPath]; + return settings || defaultBackgroundSettings; + }, + + setColumnBorderEnabled: (projectPath, enabled) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + columnBorderEnabled: enabled, + }, }, + }); + }, - removeFeature: (id) => { - set({ features: get().features.filter((f) => f.id !== id) }); + setCardGlassmorphism: (projectPath, enabled) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardGlassmorphism: enabled, + }, }, + }); + }, - moveFeature: (id, newStatus) => { - set({ - features: get().features.map((f) => (f.id === id ? { ...f, status: newStatus } : f)), - }); + setCardBorderEnabled: (projectPath, enabled) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardBorderEnabled: enabled, + }, }, + }); + }, - // App spec actions - setAppSpec: (spec) => set({ appSpec: spec }), + setCardBorderOpacity: (projectPath, opacity) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + cardBorderOpacity: opacity, + }, + }, + }); + }, - // IPC actions - setIpcConnected: (connected) => set({ ipcConnected: connected }), + setHideScrollbar: (projectPath, hide) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + hideScrollbar: hide, + }, + }, + }); + }, - // API Keys actions - setApiKeys: (keys) => set({ apiKeys: { ...get().apiKeys, ...keys } }), + clearBoardBackground: (projectPath) => { + const current = get().boardBackgroundByProject; + const existing = current[projectPath] || defaultBackgroundSettings; + set({ + boardBackgroundByProject: { + ...current, + [projectPath]: { + ...existing, + imagePath: null, // Only clear the image, preserve other settings + imageVersion: undefined, // Clear version when clearing image + }, + }, + }); + }, - // Chat Session actions - createChatSession: (title) => { - const currentProject = get().currentProject; - if (!currentProject) { - throw new Error('No project selected'); - } + // Terminal actions + setTerminalUnlocked: (unlocked, token) => { + set({ + terminalState: { + ...get().terminalState, + isUnlocked: unlocked, + authToken: token || null, + }, + }); + }, - const now = new Date(); - const session: ChatSession = { - id: `chat-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - title: - title || `Chat ${new Date().toLocaleDateString()} ${new Date().toLocaleTimeString()}`, - projectId: currentProject.id, - messages: [ + setActiveTerminalSession: (sessionId) => { + set({ + terminalState: { + ...get().terminalState, + activeSessionId: sessionId, + }, + }); + }, + + toggleTerminalMaximized: (sessionId) => { + const current = get().terminalState; + const newMaximized = current.maximizedSessionId === sessionId ? null : sessionId; + set({ + terminalState: { + ...current, + maximizedSessionId: newMaximized, + // Also set as active when maximizing + activeSessionId: newMaximized ?? current.activeSessionId, + }, + }); + }, + + addTerminalToLayout: (sessionId, direction = 'horizontal', targetSessionId) => { + const current = get().terminalState; + const newTerminal: TerminalPanelContent = { + type: 'terminal', + sessionId, + size: 50, + }; + + // If no tabs, create first tab + if (current.tabs.length === 0) { + const newTabId = `tab-${Date.now()}`; + set({ + terminalState: { + ...current, + tabs: [ { - id: 'welcome', - role: 'assistant', - content: - "Hello! I'm the Automaker Agent. I can help you build software autonomously. What would you like to create today?", - timestamp: now, + id: newTabId, + name: 'Terminal 1', + layout: { type: 'terminal', sessionId, size: 100 }, }, ], - createdAt: now, - updatedAt: now, - archived: false, - }; - - set({ - chatSessions: [...get().chatSessions, session], - currentChatSession: session, - }); - - return session; - }, - - updateChatSession: (sessionId, updates) => { - set({ - chatSessions: get().chatSessions.map((session) => - session.id === sessionId ? { ...session, ...updates, updatedAt: new Date() } : session - ), - }); - - // Update current session if it's the one being updated - const currentSession = get().currentChatSession; - if (currentSession && currentSession.id === sessionId) { - set({ - currentChatSession: { - ...currentSession, - ...updates, - updatedAt: new Date(), - }, - }); - } - }, - - addMessageToSession: (sessionId, message) => { - const sessions = get().chatSessions; - const sessionIndex = sessions.findIndex((s) => s.id === sessionId); - - if (sessionIndex >= 0) { - const updatedSessions = [...sessions]; - updatedSessions[sessionIndex] = { - ...updatedSessions[sessionIndex], - messages: [...updatedSessions[sessionIndex].messages, message], - updatedAt: new Date(), - }; - - set({ chatSessions: updatedSessions }); - - // Update current session if it's the one being updated - const currentSession = get().currentChatSession; - if (currentSession && currentSession.id === sessionId) { - set({ - currentChatSession: updatedSessions[sessionIndex], - }); - } - } - }, - - setCurrentChatSession: (session) => { - set({ currentChatSession: session }); - }, - - archiveChatSession: (sessionId) => { - get().updateChatSession(sessionId, { archived: true }); - }, - - unarchiveChatSession: (sessionId) => { - get().updateChatSession(sessionId, { archived: false }); - }, - - deleteChatSession: (sessionId) => { - const currentSession = get().currentChatSession; - set({ - chatSessions: get().chatSessions.filter((s) => s.id !== sessionId), - currentChatSession: currentSession?.id === sessionId ? null : currentSession, - }); - }, - - setChatHistoryOpen: (open) => set({ chatHistoryOpen: open }), - - toggleChatHistory: () => set({ chatHistoryOpen: !get().chatHistoryOpen }), - - // Auto Mode actions (per-project) - setAutoModeRunning: (projectId, running) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { - isRunning: false, - runningTasks: [], - }; - set({ - autoModeByProject: { - ...current, - [projectId]: { ...projectState, isRunning: running }, - }, - }); - }, - - addRunningTask: (projectId, taskId) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { - isRunning: false, - runningTasks: [], - }; - if (!projectState.runningTasks.includes(taskId)) { - set({ - autoModeByProject: { - ...current, - [projectId]: { - ...projectState, - runningTasks: [...projectState.runningTasks, taskId], - }, - }, - }); - } - }, - - removeRunningTask: (projectId, taskId) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { - isRunning: false, - runningTasks: [], - }; - set({ - autoModeByProject: { - ...current, - [projectId]: { - ...projectState, - runningTasks: projectState.runningTasks.filter((id) => id !== taskId), - }, - }, - }); - }, - - clearRunningTasks: (projectId) => { - const current = get().autoModeByProject; - const projectState = current[projectId] || { - isRunning: false, - runningTasks: [], - }; - set({ - autoModeByProject: { - ...current, - [projectId]: { ...projectState, runningTasks: [] }, - }, - }); - }, - - getAutoModeState: (projectId) => { - const projectState = get().autoModeByProject[projectId]; - return projectState || { isRunning: false, runningTasks: [] }; - }, - - addAutoModeActivity: (activity) => { - const id = `activity-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const newActivity: AutoModeActivity = { - ...activity, - id, - timestamp: new Date(), - }; - - // Keep only the last 100 activities to avoid memory issues - const currentLog = get().autoModeActivityLog; - const updatedLog = [...currentLog, newActivity].slice(-100); - - set({ autoModeActivityLog: updatedLog }); - }, - - clearAutoModeActivity: () => set({ autoModeActivityLog: [] }), - - setMaxConcurrency: (max) => set({ maxConcurrency: max }), - - // Kanban Card Settings actions - setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }), - setBoardViewMode: (mode) => set({ boardViewMode: mode }), - - // Feature Default Settings actions - setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }), - setEnableDependencyBlocking: (enabled) => set({ enableDependencyBlocking: enabled }), - setSkipVerificationInAutoMode: async (enabled) => { - set({ skipVerificationInAutoMode: enabled }); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); - }, - - // Worktree Settings actions - setUseWorktrees: (enabled) => set({ useWorktrees: enabled }), - - setCurrentWorktree: (projectPath, worktreePath, branch) => { - const current = get().currentWorktreeByProject; - set({ - currentWorktreeByProject: { - ...current, - [projectPath]: { path: worktreePath, branch }, - }, - }); - }, - - setWorktrees: (projectPath, worktrees) => { - const current = get().worktreesByProject; - set({ - worktreesByProject: { - ...current, - [projectPath]: worktrees, - }, - }); - }, - - getCurrentWorktree: (projectPath) => { - return get().currentWorktreeByProject[projectPath] ?? null; - }, - - getWorktrees: (projectPath) => { - return get().worktreesByProject[projectPath] ?? []; - }, - - isPrimaryWorktreeBranch: (projectPath, branchName) => { - const worktrees = get().worktreesByProject[projectPath] ?? []; - const primary = worktrees.find((w) => w.isMain); - return primary?.branch === branchName; - }, - - getPrimaryWorktreeBranch: (projectPath) => { - const worktrees = get().worktreesByProject[projectPath] ?? []; - const primary = worktrees.find((w) => w.isMain); - return primary?.branch ?? null; - }, - - // Profile Display Settings actions - setShowProfilesOnly: (enabled) => set({ showProfilesOnly: enabled }), - - // Keyboard Shortcuts actions - setKeyboardShortcut: (key, value) => { - set({ - keyboardShortcuts: { - ...get().keyboardShortcuts, - [key]: value, - }, - }); - }, - - setKeyboardShortcuts: (shortcuts) => { - set({ - keyboardShortcuts: { - ...get().keyboardShortcuts, - ...shortcuts, - }, - }); - }, - - resetKeyboardShortcuts: () => { - set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS }); - }, - - // Audio Settings actions - setMuteDoneSound: (muted) => set({ muteDoneSound: muted }), - - // Enhancement Model actions - setEnhancementModel: (model) => set({ enhancementModel: model }), - - // Validation Model actions - setValidationModel: (model) => set({ validationModel: model }), - - // Phase Model actions - setPhaseModel: async (phase, entry) => { - set((state) => ({ - phaseModels: { - ...state.phaseModels, - [phase]: entry, - }, - })); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); - }, - setPhaseModels: async (models) => { - set((state) => ({ - phaseModels: { - ...state.phaseModels, - ...models, - }, - })); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); - }, - resetPhaseModels: async () => { - set({ phaseModels: DEFAULT_PHASE_MODELS }); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); - }, - toggleFavoriteModel: (modelId) => { - const current = get().favoriteModels; - if (current.includes(modelId)) { - set({ favoriteModels: current.filter((id) => id !== modelId) }); - } else { - set({ favoriteModels: [...current, modelId] }); - } - }, - - // Cursor CLI Settings actions - setEnabledCursorModels: (models) => set({ enabledCursorModels: models }), - setCursorDefaultModel: (model) => set({ cursorDefaultModel: model }), - toggleCursorModel: (model, enabled) => - set((state) => ({ - enabledCursorModels: enabled - ? [...state.enabledCursorModels, model] - : state.enabledCursorModels.filter((m) => m !== model), - })), - - // Claude Agent SDK Settings actions - setAutoLoadClaudeMd: async (enabled) => { - const previous = get().autoLoadClaudeMd; - set({ autoLoadClaudeMd: enabled }); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - const ok = await syncSettingsToServer(); - if (!ok) { - logger.error('Failed to sync autoLoadClaudeMd setting to server - reverting'); - set({ autoLoadClaudeMd: previous }); - } - }, - // Prompt Customization actions - setPromptCustomization: async (customization) => { - set({ promptCustomization: customization }); - // Sync to server settings file - const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); - await syncSettingsToServer(); - }, - - // AI Profile actions - addAIProfile: (profile) => { - const id = `profile-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - set({ aiProfiles: [...get().aiProfiles, { ...profile, id }] }); - }, - - updateAIProfile: (id, updates) => { - set({ - aiProfiles: get().aiProfiles.map((p) => (p.id === id ? { ...p, ...updates } : p)), - }); - }, - - removeAIProfile: (id) => { - // Only allow removing non-built-in profiles - const profile = get().aiProfiles.find((p) => p.id === id); - if (profile && !profile.isBuiltIn) { - // Clear default if this profile was selected - if (get().defaultAIProfileId === id) { - set({ defaultAIProfileId: null }); - } - set({ aiProfiles: get().aiProfiles.filter((p) => p.id !== id) }); - } - }, - - reorderAIProfiles: (oldIndex, newIndex) => { - const profiles = [...get().aiProfiles]; - const [movedProfile] = profiles.splice(oldIndex, 1); - profiles.splice(newIndex, 0, movedProfile); - set({ aiProfiles: profiles }); - }, - - resetAIProfiles: () => { - // Merge: keep user-created profiles, but refresh all built-in profiles to latest defaults - const defaultProfileIds = new Set(DEFAULT_AI_PROFILES.map((p) => p.id)); - const userProfiles = get().aiProfiles.filter( - (p) => !p.isBuiltIn && !defaultProfileIds.has(p.id) - ); - set({ aiProfiles: [...DEFAULT_AI_PROFILES, ...userProfiles] }); - }, - - // MCP Server actions - addMCPServer: (server) => { - const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - set({ mcpServers: [...get().mcpServers, { ...server, id, enabled: true }] }); - }, - - updateMCPServer: (id, updates) => { - set({ - mcpServers: get().mcpServers.map((s) => (s.id === id ? { ...s, ...updates } : s)), - }); - }, - - removeMCPServer: (id) => { - set({ mcpServers: get().mcpServers.filter((s) => s.id !== id) }); - }, - - reorderMCPServers: (oldIndex, newIndex) => { - const servers = [...get().mcpServers]; - const [movedServer] = servers.splice(oldIndex, 1); - servers.splice(newIndex, 0, movedServer); - set({ mcpServers: servers }); - }, - - // Project Analysis actions - setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }), - setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }), - clearAnalysis: () => set({ projectAnalysis: null }), - - // Agent Session actions - setLastSelectedSession: (projectPath, sessionId) => { - const current = get().lastSelectedSessionByProject; - if (sessionId === null) { - // Remove the entry for this project - const rest = Object.fromEntries( - Object.entries(current).filter(([key]) => key !== projectPath) - ); - set({ lastSelectedSessionByProject: rest }); - } else { - set({ - lastSelectedSessionByProject: { - ...current, - [projectPath]: sessionId, - }, - }); - } - }, - - getLastSelectedSession: (projectPath) => { - return get().lastSelectedSessionByProject[projectPath] || null; - }, - - // Board Background actions - setBoardBackground: (projectPath, imagePath) => { - const current = get().boardBackgroundByProject; - const existing = current[projectPath] || { - imagePath: null, - cardOpacity: 100, - columnOpacity: 100, - columnBorderEnabled: true, - cardGlassmorphism: true, - cardBorderEnabled: true, - cardBorderOpacity: 100, - hideScrollbar: false, - }; - set({ - boardBackgroundByProject: { - ...current, - [projectPath]: { - ...existing, - imagePath, - // Update imageVersion timestamp to bust browser cache when image changes - imageVersion: imagePath ? Date.now() : undefined, - }, - }, - }); - }, - - setCardOpacity: (projectPath, opacity) => { - const current = get().boardBackgroundByProject; - const existing = current[projectPath] || defaultBackgroundSettings; - set({ - boardBackgroundByProject: { - ...current, - [projectPath]: { - ...existing, - cardOpacity: opacity, - }, - }, - }); - }, - - setColumnOpacity: (projectPath, opacity) => { - const current = get().boardBackgroundByProject; - const existing = current[projectPath] || defaultBackgroundSettings; - set({ - boardBackgroundByProject: { - ...current, - [projectPath]: { - ...existing, - columnOpacity: opacity, - }, - }, - }); - }, - - getBoardBackground: (projectPath) => { - const settings = get().boardBackgroundByProject[projectPath]; - return settings || defaultBackgroundSettings; - }, - - setColumnBorderEnabled: (projectPath, enabled) => { - const current = get().boardBackgroundByProject; - const existing = current[projectPath] || defaultBackgroundSettings; - set({ - boardBackgroundByProject: { - ...current, - [projectPath]: { - ...existing, - columnBorderEnabled: enabled, - }, - }, - }); - }, - - setCardGlassmorphism: (projectPath, enabled) => { - const current = get().boardBackgroundByProject; - const existing = current[projectPath] || defaultBackgroundSettings; - set({ - boardBackgroundByProject: { - ...current, - [projectPath]: { - ...existing, - cardGlassmorphism: enabled, - }, - }, - }); - }, - - setCardBorderEnabled: (projectPath, enabled) => { - const current = get().boardBackgroundByProject; - const existing = current[projectPath] || defaultBackgroundSettings; - set({ - boardBackgroundByProject: { - ...current, - [projectPath]: { - ...existing, - cardBorderEnabled: enabled, - }, - }, - }); - }, - - setCardBorderOpacity: (projectPath, opacity) => { - const current = get().boardBackgroundByProject; - const existing = current[projectPath] || defaultBackgroundSettings; - set({ - boardBackgroundByProject: { - ...current, - [projectPath]: { - ...existing, - cardBorderOpacity: opacity, - }, - }, - }); - }, - - setHideScrollbar: (projectPath, hide) => { - const current = get().boardBackgroundByProject; - const existing = current[projectPath] || defaultBackgroundSettings; - set({ - boardBackgroundByProject: { - ...current, - [projectPath]: { - ...existing, - hideScrollbar: hide, - }, - }, - }); - }, - - clearBoardBackground: (projectPath) => { - const current = get().boardBackgroundByProject; - const existing = current[projectPath] || defaultBackgroundSettings; - set({ - boardBackgroundByProject: { - ...current, - [projectPath]: { - ...existing, - imagePath: null, // Only clear the image, preserve other settings - imageVersion: undefined, // Clear version when clearing image - }, - }, - }); - }, - - // Terminal actions - setTerminalUnlocked: (unlocked, token) => { - set({ - terminalState: { - ...get().terminalState, - isUnlocked: unlocked, - authToken: token || null, - }, - }); - }, - - setActiveTerminalSession: (sessionId) => { - set({ - terminalState: { - ...get().terminalState, - activeSessionId: sessionId, - }, - }); - }, - - toggleTerminalMaximized: (sessionId) => { - const current = get().terminalState; - const newMaximized = current.maximizedSessionId === sessionId ? null : sessionId; - set({ - terminalState: { - ...current, - maximizedSessionId: newMaximized, - // Also set as active when maximizing - activeSessionId: newMaximized ?? current.activeSessionId, - }, - }); - }, - - addTerminalToLayout: (sessionId, direction = 'horizontal', targetSessionId) => { - const current = get().terminalState; - const newTerminal: TerminalPanelContent = { - type: 'terminal', - sessionId, - size: 50, - }; - - // If no tabs, create first tab - if (current.tabs.length === 0) { - const newTabId = `tab-${Date.now()}`; - set({ - terminalState: { - ...current, - tabs: [ - { - id: newTabId, - name: 'Terminal 1', - layout: { type: 'terminal', sessionId, size: 100 }, - }, - ], - activeTabId: newTabId, - activeSessionId: sessionId, - }, - }); - return; - } - - // Add to active tab's layout - const activeTab = current.tabs.find((t) => t.id === current.activeTabId); - if (!activeTab) return; - - // If targetSessionId is provided, find and split that specific terminal - const splitTargetTerminal = ( - node: TerminalPanelContent, - targetId: string, - targetDirection: 'horizontal' | 'vertical' - ): TerminalPanelContent => { - if (node.type === 'terminal') { - if (node.sessionId === targetId) { - // Found the target - split it - return { - type: 'split', - id: generateSplitId(), - direction: targetDirection, - panels: [{ ...node, size: 50 }, newTerminal], - }; - } - // Not the target, return unchanged - return node; - } - // It's a split - recurse into panels - return { - ...node, - panels: node.panels.map((p) => splitTargetTerminal(p, targetId, targetDirection)), - }; - }; - - // Legacy behavior: add to root layout (when no targetSessionId) - const addToRootLayout = ( - node: TerminalPanelContent, - targetDirection: 'horizontal' | 'vertical' - ): TerminalPanelContent => { - if (node.type === 'terminal') { - return { - type: 'split', - id: generateSplitId(), - direction: targetDirection, - panels: [{ ...node, size: 50 }, newTerminal], - }; - } - // If same direction, add to existing split - if (node.direction === targetDirection) { - const newSize = 100 / (node.panels.length + 1); - return { - ...node, - panels: [ - ...node.panels.map((p) => ({ ...p, size: newSize })), - { ...newTerminal, size: newSize }, - ], - }; - } - // Different direction, wrap in new split + activeTabId: newTabId, + activeSessionId: sessionId, + }, + }); + return; + } + + // Add to active tab's layout + const activeTab = current.tabs.find((t) => t.id === current.activeTabId); + if (!activeTab) return; + + // If targetSessionId is provided, find and split that specific terminal + const splitTargetTerminal = ( + node: TerminalPanelContent, + targetId: string, + targetDirection: 'horizontal' | 'vertical' + ): TerminalPanelContent => { + if (node.type === 'terminal') { + if (node.sessionId === targetId) { + // Found the target - split it return { type: 'split', id: generateSplitId(), direction: targetDirection, panels: [{ ...node, size: 50 }, newTerminal], }; - }; - - let newLayout: TerminalPanelContent; - if (!activeTab.layout) { - newLayout = { type: 'terminal', sessionId, size: 100 }; - } else if (targetSessionId) { - newLayout = splitTargetTerminal(activeTab.layout, targetSessionId, direction); - } else { - newLayout = addToRootLayout(activeTab.layout, direction); } + // Not the target, return unchanged + return node; + } + // It's a split - recurse into panels + return { + ...node, + panels: node.panels.map((p) => splitTargetTerminal(p, targetId, targetDirection)), + }; + }; - const newTabs = current.tabs.map((t) => - t.id === current.activeTabId ? { ...t, layout: newLayout } : t - ); - - set({ - terminalState: { - ...current, - tabs: newTabs, - activeSessionId: sessionId, - }, - }); - }, - - removeTerminalFromLayout: (sessionId) => { - const current = get().terminalState; - if (current.tabs.length === 0) return; - - // Find which tab contains this session - const findFirstTerminal = (node: TerminalPanelContent | null): string | null => { - if (!node) return null; - if (node.type === 'terminal') return node.sessionId; - for (const panel of node.panels) { - const found = findFirstTerminal(panel); - if (found) return found; - } - return null; + // Legacy behavior: add to root layout (when no targetSessionId) + const addToRootLayout = ( + node: TerminalPanelContent, + targetDirection: 'horizontal' | 'vertical' + ): TerminalPanelContent => { + if (node.type === 'terminal') { + return { + type: 'split', + id: generateSplitId(), + direction: targetDirection, + panels: [{ ...node, size: 50 }, newTerminal], }; - - const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { - if (node.type === 'terminal') { - return node.sessionId === sessionId ? null : node; - } - const newPanels: TerminalPanelContent[] = []; - for (const panel of node.panels) { - const result = removeAndCollapse(panel); - if (result !== null) newPanels.push(result); - } - if (newPanels.length === 0) return null; - if (newPanels.length === 1) return newPanels[0]; - // Normalize sizes to sum to 100% - const totalSize = newPanels.reduce((sum, p) => sum + (p.size || 0), 0); - const normalizedPanels = - totalSize > 0 - ? newPanels.map((p) => ({ ...p, size: ((p.size || 0) / totalSize) * 100 })) - : newPanels.map((p) => ({ ...p, size: 100 / newPanels.length })); - return { ...node, panels: normalizedPanels }; + } + // If same direction, add to existing split + if (node.direction === targetDirection) { + const newSize = 100 / (node.panels.length + 1); + return { + ...node, + panels: [ + ...node.panels.map((p) => ({ ...p, size: newSize })), + { ...newTerminal, size: newSize }, + ], }; + } + // Different direction, wrap in new split + return { + type: 'split', + id: generateSplitId(), + direction: targetDirection, + panels: [{ ...node, size: 50 }, newTerminal], + }; + }; - let newTabs = current.tabs.map((tab) => { - if (!tab.layout) return tab; - const newLayout = removeAndCollapse(tab.layout); - return { ...tab, layout: newLayout }; - }); + let newLayout: TerminalPanelContent; + if (!activeTab.layout) { + newLayout = { type: 'terminal', sessionId, size: 100 }; + } else if (targetSessionId) { + newLayout = splitTargetTerminal(activeTab.layout, targetSessionId, direction); + } else { + newLayout = addToRootLayout(activeTab.layout, direction); + } - // Remove empty tabs - newTabs = newTabs.filter((tab) => tab.layout !== null); + const newTabs = current.tabs.map((t) => + t.id === current.activeTabId ? { ...t, layout: newLayout } : t + ); - // Determine new active session - const newActiveTabId = - newTabs.length > 0 - ? current.activeTabId && newTabs.find((t) => t.id === current.activeTabId) - ? current.activeTabId - : newTabs[0].id - : null; - const newActiveSessionId = newActiveTabId - ? findFirstTerminal(newTabs.find((t) => t.id === newActiveTabId)?.layout || null) - : null; - - set({ - terminalState: { - ...current, - tabs: newTabs, - activeTabId: newActiveTabId, - activeSessionId: newActiveSessionId, - }, - }); + set({ + terminalState: { + ...current, + tabs: newTabs, + activeSessionId: sessionId, }, + }); + }, - swapTerminals: (sessionId1, sessionId2) => { - const current = get().terminalState; - if (current.tabs.length === 0) return; + removeTerminalFromLayout: (sessionId) => { + const current = get().terminalState; + if (current.tabs.length === 0) return; - const swapInLayout = (node: TerminalPanelContent): TerminalPanelContent => { - if (node.type === 'terminal') { - if (node.sessionId === sessionId1) return { ...node, sessionId: sessionId2 }; - if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 }; - return node; - } - return { ...node, panels: node.panels.map(swapInLayout) }; - }; + // Find which tab contains this session + const findFirstTerminal = (node: TerminalPanelContent | null): string | null => { + if (!node) return null; + if (node.type === 'terminal') return node.sessionId; + for (const panel of node.panels) { + const found = findFirstTerminal(panel); + if (found) return found; + } + return null; + }; - const newTabs = current.tabs.map((tab) => ({ - ...tab, - layout: tab.layout ? swapInLayout(tab.layout) : null, - })); + const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { + if (node.type === 'terminal') { + return node.sessionId === sessionId ? null : node; + } + const newPanels: TerminalPanelContent[] = []; + for (const panel of node.panels) { + const result = removeAndCollapse(panel); + if (result !== null) newPanels.push(result); + } + if (newPanels.length === 0) return null; + if (newPanels.length === 1) return newPanels[0]; + // Normalize sizes to sum to 100% + const totalSize = newPanels.reduce((sum, p) => sum + (p.size || 0), 0); + const normalizedPanels = + totalSize > 0 + ? newPanels.map((p) => ({ ...p, size: ((p.size || 0) / totalSize) * 100 })) + : newPanels.map((p) => ({ ...p, size: 100 / newPanels.length })); + return { ...node, panels: normalizedPanels }; + }; - set({ - terminalState: { ...current, tabs: newTabs }, - }); + let newTabs = current.tabs.map((tab) => { + if (!tab.layout) return tab; + const newLayout = removeAndCollapse(tab.layout); + return { ...tab, layout: newLayout }; + }); + + // Remove empty tabs + newTabs = newTabs.filter((tab) => tab.layout !== null); + + // Determine new active session + const newActiveTabId = + newTabs.length > 0 + ? current.activeTabId && newTabs.find((t) => t.id === current.activeTabId) + ? current.activeTabId + : newTabs[0].id + : null; + const newActiveSessionId = newActiveTabId + ? findFirstTerminal(newTabs.find((t) => t.id === newActiveTabId)?.layout || null) + : null; + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: newActiveTabId, + activeSessionId: newActiveSessionId, }, + }); + }, - clearTerminalState: () => { - const current = get().terminalState; - set({ - terminalState: { - // Preserve auth state - user shouldn't need to re-authenticate - isUnlocked: current.isUnlocked, - authToken: current.authToken, - // Clear session-specific state only - tabs: [], - activeTabId: null, - activeSessionId: null, - maximizedSessionId: null, - // Preserve user preferences - these should persist across projects - defaultFontSize: current.defaultFontSize, - defaultRunScript: current.defaultRunScript, - screenReaderMode: current.screenReaderMode, - fontFamily: current.fontFamily, - scrollbackLines: current.scrollbackLines, - lineHeight: current.lineHeight, - maxSessions: current.maxSessions, - // Preserve lastActiveProjectPath - it will be updated separately when needed - lastActiveProjectPath: current.lastActiveProjectPath, - }, - }); + swapTerminals: (sessionId1, sessionId2) => { + const current = get().terminalState; + if (current.tabs.length === 0) return; + + const swapInLayout = (node: TerminalPanelContent): TerminalPanelContent => { + if (node.type === 'terminal') { + if (node.sessionId === sessionId1) return { ...node, sessionId: sessionId2 }; + if (node.sessionId === sessionId2) return { ...node, sessionId: sessionId1 }; + return node; + } + return { ...node, panels: node.panels.map(swapInLayout) }; + }; + + const newTabs = current.tabs.map((tab) => ({ + ...tab, + layout: tab.layout ? swapInLayout(tab.layout) : null, + })); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + clearTerminalState: () => { + const current = get().terminalState; + set({ + terminalState: { + // Preserve auth state - user shouldn't need to re-authenticate + isUnlocked: current.isUnlocked, + authToken: current.authToken, + // Clear session-specific state only + tabs: [], + activeTabId: null, + activeSessionId: null, + maximizedSessionId: null, + // Preserve user preferences - these should persist across projects + defaultFontSize: current.defaultFontSize, + defaultRunScript: current.defaultRunScript, + screenReaderMode: current.screenReaderMode, + fontFamily: current.fontFamily, + scrollbackLines: current.scrollbackLines, + lineHeight: current.lineHeight, + maxSessions: current.maxSessions, + // Preserve lastActiveProjectPath - it will be updated separately when needed + lastActiveProjectPath: current.lastActiveProjectPath, }, + }); + }, - setTerminalPanelFontSize: (sessionId, fontSize) => { - const current = get().terminalState; - const clampedSize = Math.max(8, Math.min(32, fontSize)); + setTerminalPanelFontSize: (sessionId, fontSize) => { + const current = get().terminalState; + const clampedSize = Math.max(8, Math.min(32, fontSize)); - const updateFontSize = (node: TerminalPanelContent): TerminalPanelContent => { - if (node.type === 'terminal') { - if (node.sessionId === sessionId) { - return { ...node, fontSize: clampedSize }; - } - return node; - } - return { ...node, panels: node.panels.map(updateFontSize) }; - }; - - const newTabs = current.tabs.map((tab) => { - if (!tab.layout) return tab; - return { ...tab, layout: updateFontSize(tab.layout) }; - }); - - set({ - terminalState: { ...current, tabs: newTabs }, - }); - }, - - setTerminalDefaultFontSize: (fontSize) => { - const current = get().terminalState; - const clampedSize = Math.max(8, Math.min(32, fontSize)); - set({ - terminalState: { ...current, defaultFontSize: clampedSize }, - }); - }, - - setTerminalDefaultRunScript: (script) => { - const current = get().terminalState; - set({ - terminalState: { ...current, defaultRunScript: script }, - }); - }, - - setTerminalScreenReaderMode: (enabled) => { - const current = get().terminalState; - set({ - terminalState: { ...current, screenReaderMode: enabled }, - }); - }, - - setTerminalFontFamily: (fontFamily) => { - const current = get().terminalState; - set({ - terminalState: { ...current, fontFamily }, - }); - }, - - setTerminalScrollbackLines: (lines) => { - const current = get().terminalState; - // Clamp to reasonable range: 1000 - 100000 lines - const clampedLines = Math.max(1000, Math.min(100000, lines)); - set({ - terminalState: { ...current, scrollbackLines: clampedLines }, - }); - }, - - setTerminalLineHeight: (lineHeight) => { - const current = get().terminalState; - // Clamp to reasonable range: 1.0 - 2.0 - const clampedHeight = Math.max(1.0, Math.min(2.0, lineHeight)); - set({ - terminalState: { ...current, lineHeight: clampedHeight }, - }); - }, - - setTerminalMaxSessions: (maxSessions) => { - const current = get().terminalState; - // Clamp to reasonable range: 1 - 500 - const clampedMax = Math.max(1, Math.min(500, maxSessions)); - set({ - terminalState: { ...current, maxSessions: clampedMax }, - }); - }, - - setTerminalLastActiveProjectPath: (projectPath) => { - const current = get().terminalState; - set({ - terminalState: { ...current, lastActiveProjectPath: projectPath }, - }); - }, - - addTerminalTab: (name) => { - const current = get().terminalState; - const newTabId = `tab-${Date.now()}`; - const tabNumber = current.tabs.length + 1; - const newTab: TerminalTab = { - id: newTabId, - name: name || `Terminal ${tabNumber}`, - layout: null, - }; - set({ - terminalState: { - ...current, - tabs: [...current.tabs, newTab], - activeTabId: newTabId, - }, - }); - return newTabId; - }, - - removeTerminalTab: (tabId) => { - const current = get().terminalState; - const newTabs = current.tabs.filter((t) => t.id !== tabId); - let newActiveTabId = current.activeTabId; - let newActiveSessionId = current.activeSessionId; - - if (current.activeTabId === tabId) { - newActiveTabId = newTabs.length > 0 ? newTabs[0].id : null; - if (newActiveTabId) { - const newActiveTab = newTabs.find((t) => t.id === newActiveTabId); - const findFirst = (node: TerminalPanelContent): string | null => { - if (node.type === 'terminal') return node.sessionId; - for (const p of node.panels) { - const f = findFirst(p); - if (f) return f; - } - return null; - }; - newActiveSessionId = newActiveTab?.layout ? findFirst(newActiveTab.layout) : null; - } else { - newActiveSessionId = null; - } + const updateFontSize = (node: TerminalPanelContent): TerminalPanelContent => { + if (node.type === 'terminal') { + if (node.sessionId === sessionId) { + return { ...node, fontSize: clampedSize }; } + return node; + } + return { ...node, panels: node.panels.map(updateFontSize) }; + }; - set({ - terminalState: { - ...current, - tabs: newTabs, - activeTabId: newActiveTabId, - activeSessionId: newActiveSessionId, - }, - }); + const newTabs = current.tabs.map((tab) => { + if (!tab.layout) return tab; + return { ...tab, layout: updateFontSize(tab.layout) }; + }); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + setTerminalDefaultFontSize: (fontSize) => { + const current = get().terminalState; + const clampedSize = Math.max(8, Math.min(32, fontSize)); + set({ + terminalState: { ...current, defaultFontSize: clampedSize }, + }); + }, + + setTerminalDefaultRunScript: (script) => { + const current = get().terminalState; + set({ + terminalState: { ...current, defaultRunScript: script }, + }); + }, + + setTerminalScreenReaderMode: (enabled) => { + const current = get().terminalState; + set({ + terminalState: { ...current, screenReaderMode: enabled }, + }); + }, + + setTerminalFontFamily: (fontFamily) => { + const current = get().terminalState; + set({ + terminalState: { ...current, fontFamily }, + }); + }, + + setTerminalScrollbackLines: (lines) => { + const current = get().terminalState; + // Clamp to reasonable range: 1000 - 100000 lines + const clampedLines = Math.max(1000, Math.min(100000, lines)); + set({ + terminalState: { ...current, scrollbackLines: clampedLines }, + }); + }, + + setTerminalLineHeight: (lineHeight) => { + const current = get().terminalState; + // Clamp to reasonable range: 1.0 - 2.0 + const clampedHeight = Math.max(1.0, Math.min(2.0, lineHeight)); + set({ + terminalState: { ...current, lineHeight: clampedHeight }, + }); + }, + + setTerminalMaxSessions: (maxSessions) => { + const current = get().terminalState; + // Clamp to reasonable range: 1 - 500 + const clampedMax = Math.max(1, Math.min(500, maxSessions)); + set({ + terminalState: { ...current, maxSessions: clampedMax }, + }); + }, + + setTerminalLastActiveProjectPath: (projectPath) => { + const current = get().terminalState; + set({ + terminalState: { ...current, lastActiveProjectPath: projectPath }, + }); + }, + + addTerminalTab: (name) => { + const current = get().terminalState; + const newTabId = `tab-${Date.now()}`; + const tabNumber = current.tabs.length + 1; + const newTab: TerminalTab = { + id: newTabId, + name: name || `Terminal ${tabNumber}`, + layout: null, + }; + set({ + terminalState: { + ...current, + tabs: [...current.tabs, newTab], + activeTabId: newTabId, }, + }); + return newTabId; + }, - setActiveTerminalTab: (tabId) => { - const current = get().terminalState; - const tab = current.tabs.find((t) => t.id === tabId); - if (!tab) return; + removeTerminalTab: (tabId) => { + const current = get().terminalState; + const newTabs = current.tabs.filter((t) => t.id !== tabId); + let newActiveTabId = current.activeTabId; + let newActiveSessionId = current.activeSessionId; - let newActiveSessionId = current.activeSessionId; - if (tab.layout) { - const findFirst = (node: TerminalPanelContent): string | null => { - if (node.type === 'terminal') return node.sessionId; - for (const p of node.panels) { - const f = findFirst(p); - if (f) return f; - } - return null; - }; - newActiveSessionId = findFirst(tab.layout); - } - - set({ - terminalState: { - ...current, - activeTabId: tabId, - activeSessionId: newActiveSessionId, - // Clear maximized state when switching tabs - the maximized terminal - // belongs to the previous tab and shouldn't persist across tab switches - maximizedSessionId: null, - }, - }); - }, - - renameTerminalTab: (tabId, name) => { - const current = get().terminalState; - const newTabs = current.tabs.map((t) => (t.id === tabId ? { ...t, name } : t)); - set({ - terminalState: { ...current, tabs: newTabs }, - }); - }, - - reorderTerminalTabs: (fromTabId, toTabId) => { - const current = get().terminalState; - const fromIndex = current.tabs.findIndex((t) => t.id === fromTabId); - const toIndex = current.tabs.findIndex((t) => t.id === toTabId); - - if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) { - return; - } - - // Reorder tabs by moving fromIndex to toIndex - const newTabs = [...current.tabs]; - const [movedTab] = newTabs.splice(fromIndex, 1); - newTabs.splice(toIndex, 0, movedTab); - - set({ - terminalState: { ...current, tabs: newTabs }, - }); - }, - - moveTerminalToTab: (sessionId, targetTabId) => { - const current = get().terminalState; - - let sourceTabId: string | null = null; - let originalTerminalNode: (TerminalPanelContent & { type: 'terminal' }) | null = null; - - const findTerminal = ( - node: TerminalPanelContent - ): (TerminalPanelContent & { type: 'terminal' }) | null => { - if (node.type === 'terminal') { - return node.sessionId === sessionId ? node : null; - } - for (const panel of node.panels) { - const found = findTerminal(panel); - if (found) return found; - } - return null; - }; - - for (const tab of current.tabs) { - if (tab.layout) { - const found = findTerminal(tab.layout); - if (found) { - sourceTabId = tab.id; - originalTerminalNode = found; - break; - } - } - } - if (!sourceTabId || !originalTerminalNode) return; - if (sourceTabId === targetTabId) return; - - const sourceTab = current.tabs.find((t) => t.id === sourceTabId); - if (!sourceTab?.layout) return; - - const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { - if (node.type === 'terminal') { - return node.sessionId === sessionId ? null : node; - } - const newPanels: TerminalPanelContent[] = []; - for (const panel of node.panels) { - const result = removeAndCollapse(panel); - if (result !== null) newPanels.push(result); - } - if (newPanels.length === 0) return null; - if (newPanels.length === 1) return newPanels[0]; - // Normalize sizes to sum to 100% - const totalSize = newPanels.reduce((sum, p) => sum + (p.size || 0), 0); - const normalizedPanels = - totalSize > 0 - ? newPanels.map((p) => ({ ...p, size: ((p.size || 0) / totalSize) * 100 })) - : newPanels.map((p) => ({ ...p, size: 100 / newPanels.length })); - return { ...node, panels: normalizedPanels }; - }; - - const newSourceLayout = removeAndCollapse(sourceTab.layout); - - let finalTargetTabId = targetTabId; - let newTabs = current.tabs; - - if (targetTabId === 'new') { - const newTabId = `tab-${Date.now()}`; - const sourceWillBeRemoved = !newSourceLayout; - const tabName = sourceWillBeRemoved - ? sourceTab.name - : `Terminal ${current.tabs.length + 1}`; - newTabs = [ - ...current.tabs, - { - id: newTabId, - name: tabName, - layout: { - type: 'terminal', - sessionId, - size: 100, - fontSize: originalTerminalNode.fontSize, - }, - }, - ]; - finalTargetTabId = newTabId; - } else { - const targetTab = current.tabs.find((t) => t.id === targetTabId); - if (!targetTab) return; - - const terminalNode: TerminalPanelContent = { - type: 'terminal', - sessionId, - size: 50, - fontSize: originalTerminalNode.fontSize, - }; - let newTargetLayout: TerminalPanelContent; - - if (!targetTab.layout) { - newTargetLayout = { - type: 'terminal', - sessionId, - size: 100, - fontSize: originalTerminalNode.fontSize, - }; - } else if (targetTab.layout.type === 'terminal') { - newTargetLayout = { - type: 'split', - id: generateSplitId(), - direction: 'horizontal', - panels: [{ ...targetTab.layout, size: 50 }, terminalNode], - }; - } else { - newTargetLayout = { - ...targetTab.layout, - panels: [...targetTab.layout.panels, terminalNode], - }; - } - - newTabs = current.tabs.map((t) => - t.id === targetTabId ? { ...t, layout: newTargetLayout } : t - ); - } - - if (!newSourceLayout) { - newTabs = newTabs.filter((t) => t.id !== sourceTabId); - } else { - newTabs = newTabs.map((t) => - t.id === sourceTabId ? { ...t, layout: newSourceLayout } : t - ); - } - - set({ - terminalState: { - ...current, - tabs: newTabs, - activeTabId: finalTargetTabId, - activeSessionId: sessionId, - }, - }); - }, - - addTerminalToTab: (sessionId, tabId, direction = 'horizontal') => { - const current = get().terminalState; - const tab = current.tabs.find((t) => t.id === tabId); - if (!tab) return; - - const terminalNode: TerminalPanelContent = { - type: 'terminal', - sessionId, - size: 50, - }; - let newLayout: TerminalPanelContent; - - if (!tab.layout) { - newLayout = { type: 'terminal', sessionId, size: 100 }; - } else if (tab.layout.type === 'terminal') { - newLayout = { - type: 'split', - id: generateSplitId(), - direction, - panels: [{ ...tab.layout, size: 50 }, terminalNode], - }; - } else { - if (tab.layout.direction === direction) { - const newSize = 100 / (tab.layout.panels.length + 1); - newLayout = { - ...tab.layout, - panels: [ - ...tab.layout.panels.map((p) => ({ ...p, size: newSize })), - { ...terminalNode, size: newSize }, - ], - }; - } else { - newLayout = { - type: 'split', - id: generateSplitId(), - direction, - panels: [{ ...tab.layout, size: 50 }, terminalNode], - }; - } - } - - const newTabs = current.tabs.map((t) => (t.id === tabId ? { ...t, layout: newLayout } : t)); - - set({ - terminalState: { - ...current, - tabs: newTabs, - activeTabId: tabId, - activeSessionId: sessionId, - }, - }); - }, - - setTerminalTabLayout: (tabId, layout, activeSessionId) => { - const current = get().terminalState; - const tab = current.tabs.find((t) => t.id === tabId); - if (!tab) return; - - const newTabs = current.tabs.map((t) => (t.id === tabId ? { ...t, layout } : t)); - - // Find first terminal in layout if no activeSessionId provided + if (current.activeTabId === tabId) { + newActiveTabId = newTabs.length > 0 ? newTabs[0].id : null; + if (newActiveTabId) { + const newActiveTab = newTabs.find((t) => t.id === newActiveTabId); const findFirst = (node: TerminalPanelContent): string | null => { if (node.type === 'terminal') return node.sessionId; for (const p of node.panels) { - const found = findFirst(p); - if (found) return found; + const f = findFirst(p); + if (f) return f; } return null; }; - - const newActiveSessionId = activeSessionId || findFirst(layout); - - set({ - terminalState: { - ...current, - tabs: newTabs, - activeTabId: tabId, - activeSessionId: newActiveSessionId, - }, - }); - }, - - updateTerminalPanelSizes: (tabId, panelKeys, sizes) => { - const current = get().terminalState; - const tab = current.tabs.find((t) => t.id === tabId); - if (!tab || !tab.layout) return; - - // Create a map of panel key to new size - const sizeMap = new Map(); - panelKeys.forEach((key, index) => { - sizeMap.set(key, sizes[index]); - }); - - // Helper to generate panel key (matches getPanelKey in terminal-view.tsx) - const getPanelKey = (panel: TerminalPanelContent): string => { - if (panel.type === 'terminal') return panel.sessionId; - const childKeys = panel.panels.map(getPanelKey).join('-'); - return `split-${panel.direction}-${childKeys}`; - }; - - // Recursively update sizes in the layout - const updateSizes = (panel: TerminalPanelContent): TerminalPanelContent => { - const key = getPanelKey(panel); - const newSize = sizeMap.get(key); - - if (panel.type === 'terminal') { - return newSize !== undefined ? { ...panel, size: newSize } : panel; - } - - return { - ...panel, - size: newSize !== undefined ? newSize : panel.size, - panels: panel.panels.map(updateSizes), - }; - }; - - const updatedLayout = updateSizes(tab.layout); - - const newTabs = current.tabs.map((t) => - t.id === tabId ? { ...t, layout: updatedLayout } : t - ); - - set({ - terminalState: { ...current, tabs: newTabs }, - }); - }, - - // Convert runtime layout to persisted format (preserves sessionIds for reconnection) - saveTerminalLayout: (projectPath) => { - const current = get().terminalState; - if (current.tabs.length === 0) { - // Nothing to save, clear any existing layout - const next = { ...get().terminalLayoutByProject }; - delete next[projectPath]; - set({ terminalLayoutByProject: next }); - return; - } - - // Convert TerminalPanelContent to PersistedTerminalPanel - // Now preserves sessionId so we can reconnect when switching back - const persistPanel = (panel: TerminalPanelContent): PersistedTerminalPanel => { - if (panel.type === 'terminal') { - return { - type: 'terminal', - size: panel.size, - fontSize: panel.fontSize, - sessionId: panel.sessionId, // Preserve for reconnection - }; - } - return { - type: 'split', - id: panel.id, // Preserve stable ID - direction: panel.direction, - panels: panel.panels.map(persistPanel), - size: panel.size, - }; - }; - - const persistedTabs: PersistedTerminalTab[] = current.tabs.map((tab) => ({ - id: tab.id, - name: tab.name, - layout: tab.layout ? persistPanel(tab.layout) : null, - })); - - const activeTabIndex = current.tabs.findIndex((t) => t.id === current.activeTabId); - - const persisted: PersistedTerminalState = { - tabs: persistedTabs, - activeTabIndex: activeTabIndex >= 0 ? activeTabIndex : 0, - defaultFontSize: current.defaultFontSize, - defaultRunScript: current.defaultRunScript, - screenReaderMode: current.screenReaderMode, - fontFamily: current.fontFamily, - scrollbackLines: current.scrollbackLines, - lineHeight: current.lineHeight, - }; - - set({ - terminalLayoutByProject: { - ...get().terminalLayoutByProject, - [projectPath]: persisted, - }, - }); - }, - - getPersistedTerminalLayout: (projectPath) => { - return get().terminalLayoutByProject[projectPath] || null; - }, - - clearPersistedTerminalLayout: (projectPath) => { - const next = { ...get().terminalLayoutByProject }; - delete next[projectPath]; - set({ terminalLayoutByProject: next }); - }, - - // Spec Creation actions - setSpecCreatingForProject: (projectPath) => { - set({ specCreatingForProject: projectPath }); - }, - - isSpecCreatingForProject: (projectPath) => { - return get().specCreatingForProject === projectPath; - }, - - setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }), - setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }), - setDefaultAIProfileId: (profileId) => set({ defaultAIProfileId: profileId }), - - // Plan Approval actions - setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }), - - // Claude Usage Tracking actions - setClaudeRefreshInterval: (interval: number) => set({ claudeRefreshInterval: interval }), - setClaudeUsageLastUpdated: (timestamp: number) => set({ claudeUsageLastUpdated: timestamp }), - setClaudeUsage: (usage: ClaudeUsage | null) => - set({ - claudeUsage: usage, - claudeUsageLastUpdated: usage ? Date.now() : null, - }), - - // Pipeline actions - setPipelineConfig: (projectPath, config) => { - set({ - pipelineConfigByProject: { - ...get().pipelineConfigByProject, - [projectPath]: config, - }, - }); - }, - - getPipelineConfig: (projectPath) => { - return get().pipelineConfigByProject[projectPath] || null; - }, - - addPipelineStep: (projectPath, step) => { - const config = get().pipelineConfigByProject[projectPath] || { version: 1, steps: [] }; - const now = new Date().toISOString(); - const newStep: PipelineStep = { - ...step, - id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`, - createdAt: now, - updatedAt: now, - }; - - const newSteps = [...config.steps, newStep].sort((a, b) => a.order - b.order); - newSteps.forEach((s, index) => { - s.order = index; - }); - - set({ - pipelineConfigByProject: { - ...get().pipelineConfigByProject, - [projectPath]: { ...config, steps: newSteps }, - }, - }); - - return newStep; - }, - - updatePipelineStep: (projectPath, stepId, updates) => { - const config = get().pipelineConfigByProject[projectPath]; - if (!config) return; - - const stepIndex = config.steps.findIndex((s) => s.id === stepId); - if (stepIndex === -1) return; - - const updatedSteps = [...config.steps]; - updatedSteps[stepIndex] = { - ...updatedSteps[stepIndex], - ...updates, - updatedAt: new Date().toISOString(), - }; - - set({ - pipelineConfigByProject: { - ...get().pipelineConfigByProject, - [projectPath]: { ...config, steps: updatedSteps }, - }, - }); - }, - - deletePipelineStep: (projectPath, stepId) => { - const config = get().pipelineConfigByProject[projectPath]; - if (!config) return; - - const newSteps = config.steps.filter((s) => s.id !== stepId); - newSteps.forEach((s, index) => { - s.order = index; - }); - - set({ - pipelineConfigByProject: { - ...get().pipelineConfigByProject, - [projectPath]: { ...config, steps: newSteps }, - }, - }); - }, - - reorderPipelineSteps: (projectPath, stepIds) => { - const config = get().pipelineConfigByProject[projectPath]; - if (!config) return; - - const stepMap = new Map(config.steps.map((s) => [s.id, s])); - const reorderedSteps = stepIds - .map((id, index) => { - const step = stepMap.get(id); - if (!step) return null; - return { ...step, order: index, updatedAt: new Date().toISOString() }; - }) - .filter((s): s is PipelineStep => s !== null); - - set({ - pipelineConfigByProject: { - ...get().pipelineConfigByProject, - [projectPath]: { ...config, steps: reorderedSteps }, - }, - }); - }, - - // Reset - reset: () => set(initialState), - }), - { - name: 'automaker-storage', - version: 2, // Increment when making breaking changes to persisted state - // Custom merge function to properly restore terminal settings on every load - // The default shallow merge doesn't work because we persist terminalSettings - // separately from terminalState (to avoid persisting session data like tabs) - merge: (persistedState, currentState) => { - const persisted = persistedState as Partial & { - terminalSettings?: PersistedTerminalSettings; - }; - const current = currentState as AppState & AppActions; - - // Start with default shallow merge - const merged = { ...current, ...persisted } as AppState & AppActions; - - // Restore terminal settings into terminalState - // terminalSettings is persisted separately from terminalState to avoid - // persisting session data (tabs, activeSessionId, etc.) - if (persisted.terminalSettings) { - merged.terminalState = { - // Start with current (initial) terminalState for session fields - ...current.terminalState, - // Override with persisted settings - defaultFontSize: - persisted.terminalSettings.defaultFontSize ?? current.terminalState.defaultFontSize, - defaultRunScript: - persisted.terminalSettings.defaultRunScript ?? current.terminalState.defaultRunScript, - screenReaderMode: - persisted.terminalSettings.screenReaderMode ?? current.terminalState.screenReaderMode, - fontFamily: persisted.terminalSettings.fontFamily ?? current.terminalState.fontFamily, - scrollbackLines: - persisted.terminalSettings.scrollbackLines ?? current.terminalState.scrollbackLines, - lineHeight: persisted.terminalSettings.lineHeight ?? current.terminalState.lineHeight, - maxSessions: - persisted.terminalSettings.maxSessions ?? current.terminalState.maxSessions, - }; - } - - return merged; - }, - migrate: (persistedState: unknown, version: number) => { - const state = persistedState as Partial; - - // Migration from version 0 (no version) to version 1: - // - Change addContextFile shortcut from "F" to "N" - if (version === 0) { - if (state.keyboardShortcuts?.addContextFile === 'F') { - state.keyboardShortcuts.addContextFile = 'N'; - } - } - - // Migration from version 1 to version 2: - // - Change terminal shortcut from "Cmd+`" to "T" - if (version <= 1) { - if ( - state.keyboardShortcuts?.terminal === 'Cmd+`' || - state.keyboardShortcuts?.terminal === undefined - ) { - state.keyboardShortcuts = { - ...DEFAULT_KEYBOARD_SHORTCUTS, - ...state.keyboardShortcuts, - terminal: 'T', - }; - } - } - - // Rehydrate terminal settings from persisted state - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const persistedSettings = (state as any).terminalSettings as - | PersistedTerminalSettings - | undefined; - if (persistedSettings) { - state.terminalState = { - ...state.terminalState, - // Preserve session state (tabs, activeTabId, etc.) but restore settings - isUnlocked: state.terminalState?.isUnlocked ?? false, - authToken: state.terminalState?.authToken ?? null, - tabs: state.terminalState?.tabs ?? [], - activeTabId: state.terminalState?.activeTabId ?? null, - activeSessionId: state.terminalState?.activeSessionId ?? null, - maximizedSessionId: state.terminalState?.maximizedSessionId ?? null, - lastActiveProjectPath: state.terminalState?.lastActiveProjectPath ?? null, - // Restore persisted settings - defaultFontSize: persistedSettings.defaultFontSize ?? 14, - defaultRunScript: persistedSettings.defaultRunScript ?? '', - screenReaderMode: persistedSettings.screenReaderMode ?? false, - fontFamily: persistedSettings.fontFamily ?? "Menlo, Monaco, 'Courier New', monospace", - scrollbackLines: persistedSettings.scrollbackLines ?? 5000, - lineHeight: persistedSettings.lineHeight ?? 1.0, - maxSessions: persistedSettings.maxSessions ?? 100, - }; - } - - return state as AppState; - }, - partialize: (state) => - ({ - // Project management - projects: state.projects, - currentProject: state.currentProject, - trashedProjects: state.trashedProjects, - projectHistory: state.projectHistory, - projectHistoryIndex: state.projectHistoryIndex, - // Features - cached locally for faster hydration (authoritative source is server) - features: state.features, - // UI state - currentView: state.currentView, - theme: state.theme, - sidebarOpen: state.sidebarOpen, - chatHistoryOpen: state.chatHistoryOpen, - kanbanCardDetailLevel: state.kanbanCardDetailLevel, - boardViewMode: state.boardViewMode, - // Settings - apiKeys: state.apiKeys, - maxConcurrency: state.maxConcurrency, - // Note: autoModeByProject is intentionally NOT persisted - // Auto-mode should always default to OFF on app refresh - defaultSkipTests: state.defaultSkipTests, - enableDependencyBlocking: state.enableDependencyBlocking, - skipVerificationInAutoMode: state.skipVerificationInAutoMode, - useWorktrees: state.useWorktrees, - currentWorktreeByProject: state.currentWorktreeByProject, - worktreesByProject: state.worktreesByProject, - showProfilesOnly: state.showProfilesOnly, - keyboardShortcuts: state.keyboardShortcuts, - muteDoneSound: state.muteDoneSound, - enhancementModel: state.enhancementModel, - validationModel: state.validationModel, - phaseModels: state.phaseModels, - enabledCursorModels: state.enabledCursorModels, - cursorDefaultModel: state.cursorDefaultModel, - autoLoadClaudeMd: state.autoLoadClaudeMd, - // MCP settings - mcpServers: state.mcpServers, - // Prompt customization - promptCustomization: state.promptCustomization, - // Profiles and sessions - aiProfiles: state.aiProfiles, - chatSessions: state.chatSessions, - lastSelectedSessionByProject: state.lastSelectedSessionByProject, - // Board background settings - boardBackgroundByProject: state.boardBackgroundByProject, - // Terminal layout persistence (per-project) - terminalLayoutByProject: state.terminalLayoutByProject, - // Terminal settings persistence (global) - terminalSettings: { - defaultFontSize: state.terminalState.defaultFontSize, - defaultRunScript: state.terminalState.defaultRunScript, - screenReaderMode: state.terminalState.screenReaderMode, - fontFamily: state.terminalState.fontFamily, - scrollbackLines: state.terminalState.scrollbackLines, - lineHeight: state.terminalState.lineHeight, - maxSessions: state.terminalState.maxSessions, - } as PersistedTerminalSettings, - defaultPlanningMode: state.defaultPlanningMode, - defaultRequirePlanApproval: state.defaultRequirePlanApproval, - defaultAIProfileId: state.defaultAIProfileId, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any, + newActiveSessionId = newActiveTab?.layout ? findFirst(newActiveTab.layout) : null; + } else { + newActiveSessionId = null; + } } - ) -); + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: newActiveTabId, + activeSessionId: newActiveSessionId, + }, + }); + }, + + setActiveTerminalTab: (tabId) => { + const current = get().terminalState; + const tab = current.tabs.find((t) => t.id === tabId); + if (!tab) return; + + let newActiveSessionId = current.activeSessionId; + if (tab.layout) { + const findFirst = (node: TerminalPanelContent): string | null => { + if (node.type === 'terminal') return node.sessionId; + for (const p of node.panels) { + const f = findFirst(p); + if (f) return f; + } + return null; + }; + newActiveSessionId = findFirst(tab.layout); + } + + set({ + terminalState: { + ...current, + activeTabId: tabId, + activeSessionId: newActiveSessionId, + // Clear maximized state when switching tabs - the maximized terminal + // belongs to the previous tab and shouldn't persist across tab switches + maximizedSessionId: null, + }, + }); + }, + + renameTerminalTab: (tabId, name) => { + const current = get().terminalState; + const newTabs = current.tabs.map((t) => (t.id === tabId ? { ...t, name } : t)); + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + reorderTerminalTabs: (fromTabId, toTabId) => { + const current = get().terminalState; + const fromIndex = current.tabs.findIndex((t) => t.id === fromTabId); + const toIndex = current.tabs.findIndex((t) => t.id === toTabId); + + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) { + return; + } + + // Reorder tabs by moving fromIndex to toIndex + const newTabs = [...current.tabs]; + const [movedTab] = newTabs.splice(fromIndex, 1); + newTabs.splice(toIndex, 0, movedTab); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + moveTerminalToTab: (sessionId, targetTabId) => { + const current = get().terminalState; + + let sourceTabId: string | null = null; + let originalTerminalNode: (TerminalPanelContent & { type: 'terminal' }) | null = null; + + const findTerminal = ( + node: TerminalPanelContent + ): (TerminalPanelContent & { type: 'terminal' }) | null => { + if (node.type === 'terminal') { + return node.sessionId === sessionId ? node : null; + } + for (const panel of node.panels) { + const found = findTerminal(panel); + if (found) return found; + } + return null; + }; + + for (const tab of current.tabs) { + if (tab.layout) { + const found = findTerminal(tab.layout); + if (found) { + sourceTabId = tab.id; + originalTerminalNode = found; + break; + } + } + } + if (!sourceTabId || !originalTerminalNode) return; + if (sourceTabId === targetTabId) return; + + const sourceTab = current.tabs.find((t) => t.id === sourceTabId); + if (!sourceTab?.layout) return; + + const removeAndCollapse = (node: TerminalPanelContent): TerminalPanelContent | null => { + if (node.type === 'terminal') { + return node.sessionId === sessionId ? null : node; + } + const newPanels: TerminalPanelContent[] = []; + for (const panel of node.panels) { + const result = removeAndCollapse(panel); + if (result !== null) newPanels.push(result); + } + if (newPanels.length === 0) return null; + if (newPanels.length === 1) return newPanels[0]; + // Normalize sizes to sum to 100% + const totalSize = newPanels.reduce((sum, p) => sum + (p.size || 0), 0); + const normalizedPanels = + totalSize > 0 + ? newPanels.map((p) => ({ ...p, size: ((p.size || 0) / totalSize) * 100 })) + : newPanels.map((p) => ({ ...p, size: 100 / newPanels.length })); + return { ...node, panels: normalizedPanels }; + }; + + const newSourceLayout = removeAndCollapse(sourceTab.layout); + + let finalTargetTabId = targetTabId; + let newTabs = current.tabs; + + if (targetTabId === 'new') { + const newTabId = `tab-${Date.now()}`; + const sourceWillBeRemoved = !newSourceLayout; + const tabName = sourceWillBeRemoved ? sourceTab.name : `Terminal ${current.tabs.length + 1}`; + newTabs = [ + ...current.tabs, + { + id: newTabId, + name: tabName, + layout: { + type: 'terminal', + sessionId, + size: 100, + fontSize: originalTerminalNode.fontSize, + }, + }, + ]; + finalTargetTabId = newTabId; + } else { + const targetTab = current.tabs.find((t) => t.id === targetTabId); + if (!targetTab) return; + + const terminalNode: TerminalPanelContent = { + type: 'terminal', + sessionId, + size: 50, + fontSize: originalTerminalNode.fontSize, + }; + let newTargetLayout: TerminalPanelContent; + + if (!targetTab.layout) { + newTargetLayout = { + type: 'terminal', + sessionId, + size: 100, + fontSize: originalTerminalNode.fontSize, + }; + } else if (targetTab.layout.type === 'terminal') { + newTargetLayout = { + type: 'split', + id: generateSplitId(), + direction: 'horizontal', + panels: [{ ...targetTab.layout, size: 50 }, terminalNode], + }; + } else { + newTargetLayout = { + ...targetTab.layout, + panels: [...targetTab.layout.panels, terminalNode], + }; + } + + newTabs = current.tabs.map((t) => + t.id === targetTabId ? { ...t, layout: newTargetLayout } : t + ); + } + + if (!newSourceLayout) { + newTabs = newTabs.filter((t) => t.id !== sourceTabId); + } else { + newTabs = newTabs.map((t) => (t.id === sourceTabId ? { ...t, layout: newSourceLayout } : t)); + } + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: finalTargetTabId, + activeSessionId: sessionId, + }, + }); + }, + + addTerminalToTab: (sessionId, tabId, direction = 'horizontal') => { + const current = get().terminalState; + const tab = current.tabs.find((t) => t.id === tabId); + if (!tab) return; + + const terminalNode: TerminalPanelContent = { + type: 'terminal', + sessionId, + size: 50, + }; + let newLayout: TerminalPanelContent; + + if (!tab.layout) { + newLayout = { type: 'terminal', sessionId, size: 100 }; + } else if (tab.layout.type === 'terminal') { + newLayout = { + type: 'split', + id: generateSplitId(), + direction, + panels: [{ ...tab.layout, size: 50 }, terminalNode], + }; + } else { + if (tab.layout.direction === direction) { + const newSize = 100 / (tab.layout.panels.length + 1); + newLayout = { + ...tab.layout, + panels: [ + ...tab.layout.panels.map((p) => ({ ...p, size: newSize })), + { ...terminalNode, size: newSize }, + ], + }; + } else { + newLayout = { + type: 'split', + id: generateSplitId(), + direction, + panels: [{ ...tab.layout, size: 50 }, terminalNode], + }; + } + } + + const newTabs = current.tabs.map((t) => (t.id === tabId ? { ...t, layout: newLayout } : t)); + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: tabId, + activeSessionId: sessionId, + }, + }); + }, + + setTerminalTabLayout: (tabId, layout, activeSessionId) => { + const current = get().terminalState; + const tab = current.tabs.find((t) => t.id === tabId); + if (!tab) return; + + const newTabs = current.tabs.map((t) => (t.id === tabId ? { ...t, layout } : t)); + + // Find first terminal in layout if no activeSessionId provided + const findFirst = (node: TerminalPanelContent): string | null => { + if (node.type === 'terminal') return node.sessionId; + for (const p of node.panels) { + const found = findFirst(p); + if (found) return found; + } + return null; + }; + + const newActiveSessionId = activeSessionId || findFirst(layout); + + set({ + terminalState: { + ...current, + tabs: newTabs, + activeTabId: tabId, + activeSessionId: newActiveSessionId, + }, + }); + }, + + updateTerminalPanelSizes: (tabId, panelKeys, sizes) => { + const current = get().terminalState; + const tab = current.tabs.find((t) => t.id === tabId); + if (!tab || !tab.layout) return; + + // Create a map of panel key to new size + const sizeMap = new Map(); + panelKeys.forEach((key, index) => { + sizeMap.set(key, sizes[index]); + }); + + // Helper to generate panel key (matches getPanelKey in terminal-view.tsx) + const getPanelKey = (panel: TerminalPanelContent): string => { + if (panel.type === 'terminal') return panel.sessionId; + const childKeys = panel.panels.map(getPanelKey).join('-'); + return `split-${panel.direction}-${childKeys}`; + }; + + // Recursively update sizes in the layout + const updateSizes = (panel: TerminalPanelContent): TerminalPanelContent => { + const key = getPanelKey(panel); + const newSize = sizeMap.get(key); + + if (panel.type === 'terminal') { + return newSize !== undefined ? { ...panel, size: newSize } : panel; + } + + return { + ...panel, + size: newSize !== undefined ? newSize : panel.size, + panels: panel.panels.map(updateSizes), + }; + }; + + const updatedLayout = updateSizes(tab.layout); + + const newTabs = current.tabs.map((t) => (t.id === tabId ? { ...t, layout: updatedLayout } : t)); + + set({ + terminalState: { ...current, tabs: newTabs }, + }); + }, + + // Convert runtime layout to persisted format (preserves sessionIds for reconnection) + saveTerminalLayout: (projectPath) => { + const current = get().terminalState; + if (current.tabs.length === 0) { + // Nothing to save, clear any existing layout + const next = { ...get().terminalLayoutByProject }; + delete next[projectPath]; + set({ terminalLayoutByProject: next }); + return; + } + + // Convert TerminalPanelContent to PersistedTerminalPanel + // Now preserves sessionId so we can reconnect when switching back + const persistPanel = (panel: TerminalPanelContent): PersistedTerminalPanel => { + if (panel.type === 'terminal') { + return { + type: 'terminal', + size: panel.size, + fontSize: panel.fontSize, + sessionId: panel.sessionId, // Preserve for reconnection + }; + } + return { + type: 'split', + id: panel.id, // Preserve stable ID + direction: panel.direction, + panels: panel.panels.map(persistPanel), + size: panel.size, + }; + }; + + const persistedTabs: PersistedTerminalTab[] = current.tabs.map((tab) => ({ + id: tab.id, + name: tab.name, + layout: tab.layout ? persistPanel(tab.layout) : null, + })); + + const activeTabIndex = current.tabs.findIndex((t) => t.id === current.activeTabId); + + const persisted: PersistedTerminalState = { + tabs: persistedTabs, + activeTabIndex: activeTabIndex >= 0 ? activeTabIndex : 0, + defaultFontSize: current.defaultFontSize, + defaultRunScript: current.defaultRunScript, + screenReaderMode: current.screenReaderMode, + fontFamily: current.fontFamily, + scrollbackLines: current.scrollbackLines, + lineHeight: current.lineHeight, + }; + + set({ + terminalLayoutByProject: { + ...get().terminalLayoutByProject, + [projectPath]: persisted, + }, + }); + }, + + getPersistedTerminalLayout: (projectPath) => { + return get().terminalLayoutByProject[projectPath] || null; + }, + + clearPersistedTerminalLayout: (projectPath) => { + const next = { ...get().terminalLayoutByProject }; + delete next[projectPath]; + set({ terminalLayoutByProject: next }); + }, + + // Spec Creation actions + setSpecCreatingForProject: (projectPath) => { + set({ specCreatingForProject: projectPath }); + }, + + isSpecCreatingForProject: (projectPath) => { + return get().specCreatingForProject === projectPath; + }, + + setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }), + setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }), + setDefaultAIProfileId: (profileId) => set({ defaultAIProfileId: profileId }), + + // Plan Approval actions + setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }), + + // Claude Usage Tracking actions + setClaudeRefreshInterval: (interval: number) => set({ claudeRefreshInterval: interval }), + setClaudeUsageLastUpdated: (timestamp: number) => set({ claudeUsageLastUpdated: timestamp }), + setClaudeUsage: (usage: ClaudeUsage | null) => + set({ + claudeUsage: usage, + claudeUsageLastUpdated: usage ? Date.now() : null, + }), + + // Pipeline actions + setPipelineConfig: (projectPath, config) => { + set({ + pipelineConfigByProject: { + ...get().pipelineConfigByProject, + [projectPath]: config, + }, + }); + }, + + getPipelineConfig: (projectPath) => { + return get().pipelineConfigByProject[projectPath] || null; + }, + + addPipelineStep: (projectPath, step) => { + const config = get().pipelineConfigByProject[projectPath] || { version: 1, steps: [] }; + const now = new Date().toISOString(); + const newStep: PipelineStep = { + ...step, + id: `step_${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 8)}`, + createdAt: now, + updatedAt: now, + }; + + const newSteps = [...config.steps, newStep].sort((a, b) => a.order - b.order); + newSteps.forEach((s, index) => { + s.order = index; + }); + + set({ + pipelineConfigByProject: { + ...get().pipelineConfigByProject, + [projectPath]: { ...config, steps: newSteps }, + }, + }); + + return newStep; + }, + + updatePipelineStep: (projectPath, stepId, updates) => { + const config = get().pipelineConfigByProject[projectPath]; + if (!config) return; + + const stepIndex = config.steps.findIndex((s) => s.id === stepId); + if (stepIndex === -1) return; + + const updatedSteps = [...config.steps]; + updatedSteps[stepIndex] = { + ...updatedSteps[stepIndex], + ...updates, + updatedAt: new Date().toISOString(), + }; + + set({ + pipelineConfigByProject: { + ...get().pipelineConfigByProject, + [projectPath]: { ...config, steps: updatedSteps }, + }, + }); + }, + + deletePipelineStep: (projectPath, stepId) => { + const config = get().pipelineConfigByProject[projectPath]; + if (!config) return; + + const newSteps = config.steps.filter((s) => s.id !== stepId); + newSteps.forEach((s, index) => { + s.order = index; + }); + + set({ + pipelineConfigByProject: { + ...get().pipelineConfigByProject, + [projectPath]: { ...config, steps: newSteps }, + }, + }); + }, + + reorderPipelineSteps: (projectPath, stepIds) => { + const config = get().pipelineConfigByProject[projectPath]; + if (!config) return; + + const stepMap = new Map(config.steps.map((s) => [s.id, s])); + const reorderedSteps = stepIds + .map((id, index) => { + const step = stepMap.get(id); + if (!step) return null; + return { ...step, order: index, updatedAt: new Date().toISOString() }; + }) + .filter((s): s is PipelineStep => s !== null); + + set({ + pipelineConfigByProject: { + ...get().pipelineConfigByProject, + [projectPath]: { ...config, steps: reorderedSteps }, + }, + }); + }, + + // UI State actions (previously in localStorage, now synced via API) + setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }), + setLastProjectDir: (dir) => set({ lastProjectDir: dir }), + setRecentFolders: (folders) => set({ recentFolders: folders }), + addRecentFolder: (folder) => { + const current = get().recentFolders; + // Remove if already exists, then add to front + const filtered = current.filter((f) => f !== folder); + // Keep max 10 recent folders + const updated = [folder, ...filtered].slice(0, 10); + set({ recentFolders: updated }); + }, + + // Reset + reset: () => set(initialState), +})); diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 7a271ed5..bf46b519 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -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()( - persist( - (set, get) => ({ - ...initialState, +export const useSetupStore = create()((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 }), +})); diff --git a/docs/settings-api-migration.md b/docs/settings-api-migration.md new file mode 100644 index 00000000..b59ea913 --- /dev/null +++ b/docs/settings-api-migration.md @@ -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 diff --git a/libs/types/src/feature.ts b/libs/types/src/feature.ts index eee6b3ea..598a16b9 100644 --- a/libs/types/src/feature.ts +++ b/libs/types/src/feature.ts @@ -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 } diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 57784b2a..259ea805 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -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 { diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index cad2cd6f..6cce2b9b 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -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,