Files
automaker/apps/ui/src/store/app-store.ts
webdevcody 11accac5ae feat: implement API-first settings management and description history tracking
- Migrated settings persistence from localStorage to an API-first approach, ensuring consistency between Electron and web modes.
- Introduced `useSettingsSync` hook for automatic synchronization of settings to the server with debouncing.
- Enhanced feature update logic to track description changes with a history, allowing for better management of feature descriptions.
- Updated various components and services to utilize the new settings structure and description history functionality.
- Removed persist middleware from Zustand store, streamlining state management and improving performance.
2026-01-07 10:05:54 -05:00

2902 lines
89 KiB
TypeScript

import { create } from 'zustand';
// 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 {
Feature as BaseFeature,
FeatureImagePath,
FeatureTextFilePath,
ModelAlias,
PlanningMode,
AIProfile,
CursorModelId,
PhaseModelConfig,
PhaseModelKey,
PhaseModelEntry,
MCPServerConfig,
FeatureStatusWithPipeline,
PipelineConfig,
PipelineStep,
PromptCustomization,
} from '@automaker/types';
import { getAllCursorModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types';
const logger = createLogger('AppStore');
// Re-export types for convenience
export type { ModelAlias };
export type ViewMode =
| 'welcome'
| 'setup'
| 'spec'
| 'board'
| 'agent'
| 'settings'
| 'interview'
| 'context'
| 'profiles'
| 'running-agents'
| 'terminal'
| 'wiki'
| 'ideation';
export type ThemeMode =
| 'light'
| 'dark'
| 'system'
| 'retro'
| 'dracula'
| 'nord'
| 'monokai'
| 'tokyonight'
| 'solarized'
| 'gruvbox'
| 'catppuccin'
| 'onedark'
| 'synthwave'
| 'red'
| 'cream'
| 'sunset'
| 'gray';
export type KanbanCardDetailLevel = 'minimal' | 'standard' | 'detailed';
export type BoardViewMode = 'kanban' | 'graph';
export interface ApiKeys {
anthropic: string;
google: string;
openai: string;
}
// Keyboard Shortcut with optional modifiers
export interface ShortcutKey {
key: string; // The main key (e.g., "K", "N", "1")
shift?: boolean; // Shift key modifier
cmdCtrl?: boolean; // Cmd on Mac, Ctrl on Windows/Linux
alt?: boolean; // Alt/Option key modifier
}
// Helper to parse shortcut string to ShortcutKey object
export function parseShortcut(shortcut: string | undefined | null): ShortcutKey {
if (!shortcut) return { key: '' };
const parts = shortcut.split('+').map((p) => p.trim());
const result: ShortcutKey = { key: parts[parts.length - 1] };
// Normalize common OS-specific modifiers (Cmd/Ctrl/Win/Super symbols) into cmdCtrl
for (let i = 0; i < parts.length - 1; i++) {
const modifier = parts[i].toLowerCase();
if (modifier === 'shift') result.shift = true;
else if (
modifier === 'cmd' ||
modifier === 'ctrl' ||
modifier === 'win' ||
modifier === 'super' ||
modifier === '⌘' ||
modifier === '^' ||
modifier === '⊞' ||
modifier === '◆'
)
result.cmdCtrl = true;
else if (modifier === 'alt' || modifier === 'opt' || modifier === 'option' || modifier === '⌥')
result.alt = true;
}
return result;
}
// Helper to format ShortcutKey to display string
export function formatShortcut(shortcut: string | undefined | null, forDisplay = false): string {
if (!shortcut) return '';
const parsed = parseShortcut(shortcut);
const parts: string[] = [];
// Prefer User-Agent Client Hints when available; fall back to legacy
const platform: 'darwin' | 'win32' | 'linux' = (() => {
if (typeof navigator === 'undefined') return 'linux';
const uaPlatform = (
navigator as Navigator & { userAgentData?: { platform?: string } }
).userAgentData?.platform?.toLowerCase?.();
const legacyPlatform = navigator.platform?.toLowerCase?.();
const platformString = uaPlatform || legacyPlatform || '';
if (platformString.includes('mac')) return 'darwin';
if (platformString.includes('win')) return 'win32';
return 'linux';
})();
// Primary modifier - OS-specific
if (parsed.cmdCtrl) {
if (forDisplay) {
parts.push(platform === 'darwin' ? '⌘' : platform === 'win32' ? '⊞' : '◆');
} else {
parts.push(platform === 'darwin' ? 'Cmd' : platform === 'win32' ? 'Win' : 'Super');
}
}
// Alt/Option
if (parsed.alt) {
parts.push(
forDisplay ? (platform === 'darwin' ? '⌥' : 'Alt') : platform === 'darwin' ? 'Opt' : 'Alt'
);
}
// Shift
if (parsed.shift) {
parts.push(forDisplay ? '⇧' : 'Shift');
}
parts.push(parsed.key.toUpperCase());
// Add spacing when displaying symbols
return parts.join(forDisplay ? ' ' : '+');
}
// Keyboard Shortcuts - stored as strings like "K", "Shift+N", "Cmd+K"
export interface KeyboardShortcuts {
// Navigation shortcuts
board: string;
agent: string;
spec: string;
context: string;
settings: string;
profiles: string;
terminal: string;
ideation: string;
githubIssues: string;
githubPrs: string;
// UI shortcuts
toggleSidebar: string;
// Action shortcuts
addFeature: string;
addContextFile: string;
startNext: string;
newSession: string;
openProject: string;
projectPicker: string;
cyclePrevProject: string;
cycleNextProject: string;
addProfile: string;
// Terminal shortcuts
splitTerminalRight: string;
splitTerminalDown: string;
closeTerminal: string;
newTerminalTab: string;
}
// Default keyboard shortcuts
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
// Navigation
board: 'K',
agent: 'A',
spec: 'D',
context: 'C',
settings: 'S',
profiles: 'M',
terminal: 'T',
ideation: 'I',
githubIssues: 'G',
githubPrs: 'R',
// UI
toggleSidebar: '`',
// Actions
// Note: Some shortcuts share the same key (e.g., "N" for addFeature, newSession, addProfile)
// This is intentional as they are context-specific and only active in their respective views
addFeature: 'N', // Only active in board view
addContextFile: 'N', // Only active in context view
startNext: 'G', // Only active in board view
newSession: 'N', // Only active in agent view
openProject: 'O', // Global shortcut
projectPicker: 'P', // Global shortcut
cyclePrevProject: 'Q', // Global shortcut
cycleNextProject: 'E', // Global shortcut
addProfile: 'N', // Only active in profiles view
// Terminal shortcuts (only active in terminal view)
// Using Alt modifier to avoid conflicts with both terminal signals AND browser shortcuts
splitTerminalRight: 'Alt+D',
splitTerminalDown: 'Alt+S',
closeTerminal: 'Alt+W',
newTerminalTab: 'Alt+T',
};
export interface ImageAttachment {
id?: string; // Optional - may not be present in messages loaded from server
data: string; // base64 encoded image data
mimeType: string; // e.g., "image/png", "image/jpeg"
filename: string;
size?: number; // file size in bytes - optional for messages from server
}
export interface TextFileAttachment {
id: string;
content: string; // text content of the file
mimeType: string; // e.g., "text/plain", "text/markdown"
filename: string;
size: number; // file size in bytes
}
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
images?: ImageAttachment[];
textFiles?: TextFileAttachment[];
}
export interface ChatSession {
id: string;
title: string;
projectId: string;
messages: ChatMessage[];
createdAt: Date;
updatedAt: Date;
archived: boolean;
}
// UI-specific: base64-encoded images (not in shared types)
export interface FeatureImage {
id: string;
data: string; // base64 encoded
mimeType: string;
filename: string;
size: number;
}
// Available models for feature execution
export type ClaudeModel = 'opus' | 'sonnet' | 'haiku';
export interface Feature extends Omit<
BaseFeature,
'steps' | 'imagePaths' | 'textFilePaths' | 'status'
> {
id: string;
title?: string;
titleGenerating?: boolean;
category: string;
description: string;
steps: string[]; // Required in UI (not optional)
status: FeatureStatusWithPipeline;
images?: FeatureImage[]; // UI-specific base64 images
imagePaths?: FeatureImagePath[]; // Stricter type than base (no string | union)
textFilePaths?: FeatureTextFilePath[]; // Text file attachments for context
justFinishedAt?: string; // UI-specific: ISO timestamp when agent just finished
prUrl?: string; // UI-specific: Pull request URL
}
// Parsed task from spec (for spec and full planning modes)
export interface ParsedTask {
id: string; // e.g., "T001"
description: string; // e.g., "Create user model"
filePath?: string; // e.g., "src/models/user.ts"
phase?: string; // e.g., "Phase 1: Foundation" (for full mode)
status: 'pending' | 'in_progress' | 'completed' | 'failed';
}
// PlanSpec status for feature planning/specification
export interface PlanSpec {
status: 'pending' | 'generating' | 'generated' | 'approved' | 'rejected';
content?: string; // The actual spec/plan markdown content
version: number;
generatedAt?: string; // ISO timestamp
approvedAt?: string; // ISO timestamp
reviewedByUser: boolean; // True if user has seen the spec
tasksCompleted?: number;
tasksTotal?: number;
currentTaskId?: string; // ID of the task currently being worked on
tasks?: ParsedTask[]; // Parsed tasks from the spec
}
// File tree node for project analysis
export interface FileTreeNode {
name: string;
path: string;
isDirectory: boolean;
extension?: string;
children?: FileTreeNode[];
}
// Project analysis result
export interface ProjectAnalysis {
fileTree: FileTreeNode[];
totalFiles: number;
totalDirectories: number;
filesByExtension: Record<string, number>;
analyzedAt: string;
}
// Terminal panel layout types (recursive for splits)
export type TerminalPanelContent =
| { type: 'terminal'; sessionId: string; size?: number; fontSize?: number }
| {
type: 'split';
id: string; // Stable ID for React key stability
direction: 'horizontal' | 'vertical';
panels: TerminalPanelContent[];
size?: number;
};
// Terminal tab - each tab has its own layout
export interface TerminalTab {
id: string;
name: string;
layout: TerminalPanelContent | null;
}
export interface TerminalState {
isUnlocked: boolean;
authToken: string | null;
tabs: TerminalTab[];
activeTabId: string | null;
activeSessionId: string | null;
maximizedSessionId: string | null; // Session ID of the maximized terminal pane (null if none)
defaultFontSize: number; // Default font size for new terminals
defaultRunScript: string; // Script to run when a new terminal is created (e.g., "claude" to start Claude Code)
screenReaderMode: boolean; // Enable screen reader accessibility mode
fontFamily: string; // Font family for terminal text
scrollbackLines: number; // Number of lines to keep in scrollback buffer
lineHeight: number; // Line height multiplier for terminal text
maxSessions: number; // Maximum concurrent terminal sessions (server setting)
lastActiveProjectPath: string | null; // Last project path to detect route changes vs project switches
}
// Persisted terminal layout - now includes sessionIds for reconnection
// Used to restore terminal layout structure when switching projects
export type PersistedTerminalPanel =
| { type: 'terminal'; size?: number; fontSize?: number; sessionId?: string }
| {
type: 'split';
id?: string; // Optional for backwards compatibility with older persisted layouts
direction: 'horizontal' | 'vertical';
panels: PersistedTerminalPanel[];
size?: number;
};
// Helper to generate unique split IDs
const generateSplitId = () => `split-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
export interface PersistedTerminalTab {
id: string;
name: string;
layout: PersistedTerminalPanel | null;
}
export interface PersistedTerminalState {
tabs: PersistedTerminalTab[];
activeTabIndex: number; // Use index instead of ID since IDs are regenerated
defaultFontSize: number;
defaultRunScript?: string; // Optional to support existing persisted data
screenReaderMode?: boolean; // Optional to support existing persisted data
fontFamily?: string; // Optional to support existing persisted data
scrollbackLines?: number; // Optional to support existing persisted data
lineHeight?: number; // Optional to support existing persisted data
}
// Persisted terminal settings - stored globally (not per-project)
export interface PersistedTerminalSettings {
defaultFontSize: number;
defaultRunScript: string;
screenReaderMode: boolean;
fontFamily: string;
scrollbackLines: number;
lineHeight: number;
maxSessions: number;
}
export interface AppState {
// Project state
projects: Project[];
currentProject: Project | null;
trashedProjects: TrashedProject[];
projectHistory: string[]; // Array of project IDs in MRU order (most recent first)
projectHistoryIndex: number; // Current position in project history for cycling
// View state
currentView: ViewMode;
sidebarOpen: boolean;
// Agent Session state (per-project, keyed by project path)
lastSelectedSessionByProject: Record<string, string>; // projectPath -> sessionId
// Theme
theme: ThemeMode;
// Features/Kanban
features: Feature[];
// App spec
appSpec: string;
// IPC status
ipcConnected: boolean;
// API Keys
apiKeys: ApiKeys;
// Chat Sessions
chatSessions: ChatSession[];
currentChatSession: ChatSession | null;
chatHistoryOpen: boolean;
// Auto Mode (per-project state, keyed by project ID)
autoModeByProject: Record<
string,
{
isRunning: boolean;
runningTasks: string[]; // Feature IDs being worked on
}
>;
autoModeActivityLog: AutoModeActivity[];
maxConcurrency: number; // Maximum number of concurrent agent tasks
// Kanban Card Display Settings
kanbanCardDetailLevel: KanbanCardDetailLevel; // Level of detail shown on kanban cards
boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view
// Feature Default Settings
defaultSkipTests: boolean; // Default value for skip tests when creating new features
enableDependencyBlocking: boolean; // When true, show blocked badges and warnings for features with incomplete dependencies (default: true)
skipVerificationInAutoMode: boolean; // When true, auto-mode grabs features even if dependencies are not verified (only checks they're not running)
// Worktree Settings
useWorktrees: boolean; // Whether to use git worktree isolation for features (default: false)
// User-managed Worktrees (per-project)
// projectPath -> { path: worktreePath or null for main, branch: branch name }
currentWorktreeByProject: Record<string, { path: string | null; branch: string }>;
worktreesByProject: Record<
string,
Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>
>;
// AI Profiles
aiProfiles: AIProfile[];
// Profile Display Settings
showProfilesOnly: boolean; // When true, hide model tweaking options and show only profile selection
// Keyboard Shortcuts
keyboardShortcuts: KeyboardShortcuts; // User-defined keyboard shortcuts
// Audio Settings
muteDoneSound: boolean; // When true, mute the notification sound when agents complete (default: false)
// Enhancement Model Settings
enhancementModel: ModelAlias; // Model used for feature enhancement (default: sonnet)
// Validation Model Settings
validationModel: ModelAlias; // Model used for GitHub issue validation (default: opus)
// Phase Model Settings - per-phase AI model configuration
phaseModels: PhaseModelConfig;
favoriteModels: string[];
// Cursor CLI Settings (global)
enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal
cursorDefaultModel: CursorModelId; // Default Cursor model selection
// Claude Agent SDK Settings
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
// MCP Servers
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
// Prompt Customization
promptCustomization: PromptCustomization; // Custom prompts for Auto Mode, Agent, Backlog Plan, Enhancement
// Project Analysis
projectAnalysis: ProjectAnalysis | null;
isAnalyzing: boolean;
// Board Background Settings (per-project, keyed by project path)
boardBackgroundByProject: Record<
string,
{
imagePath: string | null; // Path to background image in .automaker directory
imageVersion?: number; // Timestamp to bust browser cache when image is updated
cardOpacity: number; // Opacity of cards (0-100)
columnOpacity: number; // Opacity of columns (0-100)
columnBorderEnabled: boolean; // Whether to show column borders
cardGlassmorphism: boolean; // Whether to use glassmorphism (backdrop-blur) on cards
cardBorderEnabled: boolean; // Whether to show card borders
cardBorderOpacity: number; // Opacity of card borders (0-100)
hideScrollbar: boolean; // Whether to hide the board scrollbar
}
>;
// Theme Preview (for hover preview in theme selectors)
previewTheme: ThemeMode | null;
// Terminal state
terminalState: TerminalState;
// Terminal layout persistence (per-project, keyed by project path)
// Stores the tab/split structure so it can be restored when switching projects
terminalLayoutByProject: Record<string, PersistedTerminalState>;
// Spec Creation State (per-project, keyed by project path)
// Tracks which project is currently having its spec generated
specCreatingForProject: string | null;
defaultPlanningMode: PlanningMode;
defaultRequirePlanApproval: boolean;
defaultAIProfileId: string | null;
// Plan Approval State
// When a plan requires user approval, this holds the pending approval details
pendingPlanApproval: {
featureId: string;
projectPath: string;
planContent: string;
planningMode: 'lite' | 'spec' | 'full';
} | null;
// Claude Usage Tracking
claudeRefreshInterval: number; // Refresh interval in seconds (default: 60)
claudeUsage: ClaudeUsage | null;
claudeUsageLastUpdated: number | null;
// Pipeline Configuration (per-project, keyed by project path)
pipelineConfigByProject: Record<string, PipelineConfig>;
// 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
export type ClaudeUsage = {
sessionTokensUsed: number;
sessionLimit: number;
sessionPercentage: number;
sessionResetTime: string;
sessionResetText: string;
weeklyTokensUsed: number;
weeklyLimit: number;
weeklyPercentage: number;
weeklyResetTime: string;
weeklyResetText: string;
sonnetWeeklyTokensUsed: number;
sonnetWeeklyPercentage: number;
sonnetResetText: string;
costUsed: number | null;
costLimit: number | null;
costCurrency: string | null;
lastUpdated: string;
userTimezone: string;
};
// Response type for Claude usage API (can be success or error)
export type ClaudeUsageResponse = ClaudeUsage | { error: string; message?: string };
/**
* Check if Claude usage is at its limit (any of: session >= 100%, weekly >= 100%, OR cost >= limit)
* Returns true if any limit is reached, meaning auto mode should pause feature pickup.
*/
export function isClaudeUsageAtLimit(claudeUsage: ClaudeUsage | null): boolean {
if (!claudeUsage) {
// No usage data available - don't block
return false;
}
// Check session limit (5-hour window)
if (claudeUsage.sessionPercentage >= 100) {
return true;
}
// Check weekly limit
if (claudeUsage.weeklyPercentage >= 100) {
return true;
}
// Check cost limit (if configured)
if (
claudeUsage.costLimit !== null &&
claudeUsage.costLimit > 0 &&
claudeUsage.costUsed !== null &&
claudeUsage.costUsed >= claudeUsage.costLimit
) {
return true;
}
return false;
}
// Default background settings for board backgrounds
export const defaultBackgroundSettings: {
imagePath: string | null;
imageVersion?: number;
cardOpacity: number;
columnOpacity: number;
columnBorderEnabled: boolean;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
hideScrollbar: boolean;
} = {
imagePath: null,
cardOpacity: 100,
columnOpacity: 100,
columnBorderEnabled: true,
cardGlassmorphism: true,
cardBorderEnabled: true,
cardBorderOpacity: 100,
hideScrollbar: false,
};
export interface AutoModeActivity {
id: string;
featureId: string;
timestamp: Date;
type:
| 'start'
| 'progress'
| 'tool'
| 'complete'
| 'error'
| 'planning'
| 'action'
| 'verification';
message: string;
tool?: string;
passes?: boolean;
phase?: 'planning' | 'action' | 'verification';
errorType?: 'authentication' | 'execution';
}
export interface AppActions {
// Project actions
setProjects: (projects: Project[]) => void;
addProject: (project: Project) => void;
removeProject: (projectId: string) => void;
moveProjectToTrash: (projectId: string) => void;
restoreTrashedProject: (projectId: string) => void;
deleteTrashedProject: (projectId: string) => void;
emptyTrash: () => void;
setCurrentProject: (project: Project | null) => void;
upsertAndSetCurrentProject: (path: string, name: string, theme?: ThemeMode) => Project; // Upsert project by path and set as current
reorderProjects: (oldIndex: number, newIndex: number) => void;
cyclePrevProject: () => void; // Cycle back through project history (Q)
cycleNextProject: () => void; // Cycle forward through project history (E)
clearProjectHistory: () => void; // Clear history, keeping only current project
// View actions
setCurrentView: (view: ViewMode) => void;
toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void;
// Theme actions
setTheme: (theme: ThemeMode) => void;
setProjectTheme: (projectId: string, theme: ThemeMode | null) => void; // Set per-project theme (null to clear)
getEffectiveTheme: () => ThemeMode; // Get the effective theme (project, global, or preview if set)
setPreviewTheme: (theme: ThemeMode | null) => void; // Set preview theme for hover preview (null to clear)
// Feature actions
setFeatures: (features: Feature[]) => void;
updateFeature: (id: string, updates: Partial<Feature>) => void;
addFeature: (feature: Omit<Feature, 'id'> & Partial<Pick<Feature, 'id'>>) => Feature;
removeFeature: (id: string) => void;
moveFeature: (id: string, newStatus: Feature['status']) => void;
// App spec actions
setAppSpec: (spec: string) => void;
// IPC actions
setIpcConnected: (connected: boolean) => void;
// API Keys actions
setApiKeys: (keys: Partial<ApiKeys>) => void;
// Chat Session actions
createChatSession: (title?: string) => ChatSession;
updateChatSession: (sessionId: string, updates: Partial<ChatSession>) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
setCurrentChatSession: (session: ChatSession | null) => void;
archiveChatSession: (sessionId: string) => void;
unarchiveChatSession: (sessionId: string) => void;
deleteChatSession: (sessionId: string) => void;
setChatHistoryOpen: (open: boolean) => void;
toggleChatHistory: () => void;
// Auto Mode actions (per-project)
setAutoModeRunning: (projectId: string, running: boolean) => void;
addRunningTask: (projectId: string, taskId: string) => void;
removeRunningTask: (projectId: string, taskId: string) => void;
clearRunningTasks: (projectId: string) => void;
getAutoModeState: (projectId: string) => {
isRunning: boolean;
runningTasks: string[];
};
addAutoModeActivity: (activity: Omit<AutoModeActivity, 'id' | 'timestamp'>) => void;
clearAutoModeActivity: () => void;
setMaxConcurrency: (max: number) => void;
// Kanban Card Settings actions
setKanbanCardDetailLevel: (level: KanbanCardDetailLevel) => void;
setBoardViewMode: (mode: BoardViewMode) => void;
// Feature Default Settings actions
setDefaultSkipTests: (skip: boolean) => void;
setEnableDependencyBlocking: (enabled: boolean) => void;
setSkipVerificationInAutoMode: (enabled: boolean) => Promise<void>;
// Worktree Settings actions
setUseWorktrees: (enabled: boolean) => void;
setCurrentWorktree: (projectPath: string, worktreePath: string | null, branch: string) => void;
setWorktrees: (
projectPath: string,
worktrees: Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>
) => void;
getCurrentWorktree: (projectPath: string) => { path: string | null; branch: string } | null;
getWorktrees: (projectPath: string) => Array<{
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}>;
isPrimaryWorktreeBranch: (projectPath: string, branchName: string) => boolean;
getPrimaryWorktreeBranch: (projectPath: string) => string | null;
// Profile Display Settings actions
setShowProfilesOnly: (enabled: boolean) => void;
// Keyboard Shortcuts actions
setKeyboardShortcut: (key: keyof KeyboardShortcuts, value: string) => void;
setKeyboardShortcuts: (shortcuts: Partial<KeyboardShortcuts>) => void;
resetKeyboardShortcuts: () => void;
// Audio Settings actions
setMuteDoneSound: (muted: boolean) => void;
// Enhancement Model actions
setEnhancementModel: (model: ModelAlias) => void;
// Validation Model actions
setValidationModel: (model: ModelAlias) => void;
// Phase Model actions
setPhaseModel: (phase: PhaseModelKey, entry: PhaseModelEntry) => Promise<void>;
setPhaseModels: (models: Partial<PhaseModelConfig>) => Promise<void>;
resetPhaseModels: () => Promise<void>;
toggleFavoriteModel: (modelId: string) => void;
// Cursor CLI Settings actions
setEnabledCursorModels: (models: CursorModelId[]) => void;
setCursorDefaultModel: (model: CursorModelId) => void;
toggleCursorModel: (model: CursorModelId, enabled: boolean) => void;
// Claude Agent SDK Settings actions
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
// Prompt Customization actions
setPromptCustomization: (customization: PromptCustomization) => Promise<void>;
// AI Profile actions
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
updateAIProfile: (id: string, updates: Partial<AIProfile>) => void;
removeAIProfile: (id: string) => void;
reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
resetAIProfiles: () => void;
// MCP Server actions
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => void;
removeMCPServer: (id: string) => void;
reorderMCPServers: (oldIndex: number, newIndex: number) => void;
// Project Analysis actions
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
setIsAnalyzing: (analyzing: boolean) => void;
clearAnalysis: () => void;
// Agent Session actions
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
getLastSelectedSession: (projectPath: string) => string | null;
// Board Background actions
setBoardBackground: (projectPath: string, imagePath: string | null) => void;
setCardOpacity: (projectPath: string, opacity: number) => void;
setColumnOpacity: (projectPath: string, opacity: number) => void;
setColumnBorderEnabled: (projectPath: string, enabled: boolean) => void;
getBoardBackground: (projectPath: string) => {
imagePath: string | null;
cardOpacity: number;
columnOpacity: number;
columnBorderEnabled: boolean;
cardGlassmorphism: boolean;
cardBorderEnabled: boolean;
cardBorderOpacity: number;
hideScrollbar: boolean;
};
setCardGlassmorphism: (projectPath: string, enabled: boolean) => void;
setCardBorderEnabled: (projectPath: string, enabled: boolean) => void;
setCardBorderOpacity: (projectPath: string, opacity: number) => void;
setHideScrollbar: (projectPath: string, hide: boolean) => void;
clearBoardBackground: (projectPath: string) => void;
// Terminal actions
setTerminalUnlocked: (unlocked: boolean, token?: string) => void;
setActiveTerminalSession: (sessionId: string | null) => void;
toggleTerminalMaximized: (sessionId: string) => void;
addTerminalToLayout: (
sessionId: string,
direction?: 'horizontal' | 'vertical',
targetSessionId?: string
) => void;
removeTerminalFromLayout: (sessionId: string) => void;
swapTerminals: (sessionId1: string, sessionId2: string) => void;
clearTerminalState: () => void;
setTerminalPanelFontSize: (sessionId: string, fontSize: number) => void;
setTerminalDefaultFontSize: (fontSize: number) => void;
setTerminalDefaultRunScript: (script: string) => void;
setTerminalScreenReaderMode: (enabled: boolean) => void;
setTerminalFontFamily: (fontFamily: string) => void;
setTerminalScrollbackLines: (lines: number) => void;
setTerminalLineHeight: (lineHeight: number) => void;
setTerminalMaxSessions: (maxSessions: number) => void;
setTerminalLastActiveProjectPath: (projectPath: string | null) => void;
addTerminalTab: (name?: string) => string;
removeTerminalTab: (tabId: string) => void;
setActiveTerminalTab: (tabId: string) => void;
renameTerminalTab: (tabId: string, name: string) => void;
reorderTerminalTabs: (fromTabId: string, toTabId: string) => void;
moveTerminalToTab: (sessionId: string, targetTabId: string | 'new') => void;
addTerminalToTab: (
sessionId: string,
tabId: string,
direction?: 'horizontal' | 'vertical'
) => void;
setTerminalTabLayout: (
tabId: string,
layout: TerminalPanelContent,
activeSessionId?: string
) => void;
updateTerminalPanelSizes: (tabId: string, panelKeys: string[], sizes: number[]) => void;
saveTerminalLayout: (projectPath: string) => void;
getPersistedTerminalLayout: (projectPath: string) => PersistedTerminalState | null;
clearPersistedTerminalLayout: (projectPath: string) => void;
// Spec Creation actions
setSpecCreatingForProject: (projectPath: string | null) => void;
isSpecCreatingForProject: (projectPath: string) => boolean;
setDefaultPlanningMode: (mode: PlanningMode) => void;
setDefaultRequirePlanApproval: (require: boolean) => void;
setDefaultAIProfileId: (profileId: string | null) => void;
// Plan Approval actions
setPendingPlanApproval: (
approval: {
featureId: string;
projectPath: string;
planContent: string;
planningMode: 'lite' | 'spec' | 'full';
} | null
) => void;
// Pipeline actions
setPipelineConfig: (projectPath: string, config: PipelineConfig) => void;
getPipelineConfig: (projectPath: string) => PipelineConfig | null;
addPipelineStep: (
projectPath: string,
step: Omit<PipelineStep, 'id' | 'createdAt' | 'updatedAt'>
) => PipelineStep;
updatePipelineStep: (
projectPath: string,
stepId: string,
updates: Partial<Omit<PipelineStep, 'id' | 'createdAt'>>
) => void;
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;
}
// Default built-in AI profiles
const DEFAULT_AI_PROFILES: AIProfile[] = [
// Claude profiles
{
id: 'profile-heavy-task',
name: 'Heavy Task',
description:
'Claude Opus with Ultrathink for complex architecture, migrations, or deep debugging.',
model: 'opus',
thinkingLevel: 'ultrathink',
provider: 'claude',
isBuiltIn: true,
icon: 'Brain',
},
{
id: 'profile-balanced',
name: 'Balanced',
description: 'Claude Sonnet with medium thinking for typical development tasks.',
model: 'sonnet',
thinkingLevel: 'medium',
provider: 'claude',
isBuiltIn: true,
icon: 'Scale',
},
{
id: 'profile-quick-edit',
name: 'Quick Edit',
description: 'Claude Haiku for fast, simple edits and minor fixes.',
model: 'haiku',
thinkingLevel: 'none',
provider: 'claude',
isBuiltIn: true,
icon: 'Zap',
},
// Cursor profiles
{
id: 'profile-cursor-refactoring',
name: 'Cursor Refactoring',
description: 'Cursor Composer 1 for refactoring tasks.',
provider: 'cursor',
cursorModel: 'composer-1',
isBuiltIn: true,
icon: 'Sparkles',
},
];
const initialState: AppState = {
projects: [],
currentProject: null,
trashedProjects: [],
projectHistory: [],
projectHistoryIndex: -1,
currentView: 'welcome',
sidebarOpen: true,
lastSelectedSessionByProject: {},
theme: 'dark',
features: [],
appSpec: '',
ipcConnected: false,
apiKeys: {
anthropic: '',
google: '',
openai: '',
},
chatSessions: [],
currentChatSession: null,
chatHistoryOpen: false,
autoModeByProject: {},
autoModeActivityLog: [],
maxConcurrency: 3, // Default to 3 concurrent agents
kanbanCardDetailLevel: 'standard', // Default to standard detail level
boardViewMode: 'kanban', // Default to kanban view
defaultSkipTests: true, // Default to manual verification (tests disabled)
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
skipVerificationInAutoMode: false, // Default to disabled (require dependencies to be verified)
useWorktrees: false, // Default to disabled (worktree feature is experimental)
currentWorktreeByProject: {},
worktreesByProject: {},
showProfilesOnly: false, // Default to showing all options (not profiles only)
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, // Default keyboard shortcuts
muteDoneSound: false, // Default to sound enabled (not muted)
enhancementModel: 'sonnet', // Default to sonnet for feature enhancement
validationModel: 'opus', // Default to opus for GitHub issue validation
phaseModels: DEFAULT_PHASE_MODELS, // Phase-specific model configuration
favoriteModels: [],
enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default
cursorDefaultModel: 'auto', // Default to auto selection
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
mcpServers: [], // No MCP servers configured by default
promptCustomization: {}, // Empty by default - all prompts use built-in defaults
aiProfiles: DEFAULT_AI_PROFILES,
projectAnalysis: null,
isAnalyzing: false,
boardBackgroundByProject: {},
previewTheme: null,
terminalState: {
isUnlocked: false,
authToken: null,
tabs: [],
activeTabId: null,
activeSessionId: null,
maximizedSessionId: null,
defaultFontSize: 14,
defaultRunScript: '',
screenReaderMode: false,
fontFamily: "Menlo, Monaco, 'Courier New', monospace",
scrollbackLines: 5000,
lineHeight: 1.0,
maxSessions: 100,
lastActiveProjectPath: null,
},
terminalLayoutByProject: {},
specCreatingForProject: null,
defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false,
defaultAIProfileId: null,
pendingPlanApproval: null,
claudeRefreshInterval: 60,
claudeUsage: null,
claudeUsageLastUpdated: null,
pipelineConfigByProject: {},
// UI State (previously in localStorage, now synced via API)
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
};
export const useAppStore = create<AppState & AppActions>()((set, get) => ({
...initialState,
// 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() }],
});
}
},
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;
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;
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({
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
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);
}
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;
};
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 };
};
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,
},
});
},
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));
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;
}
}
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<string, number>();
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),
}));