mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 14:22:02 +00:00
- Added new RadioGroup and Switch components for better UI interaction. - Introduced BranchSelector for improved branch selection in feature dialogs. - Updated Autocomplete and BranchAutocomplete components to handle error states. - Refactored feature management to archive verified features instead of deleting them. - Enhanced worktree handling by removing worktreePath from features, relying on branchName instead. - Improved auto mode functionality by integrating branch management and worktree updates. - Cleaned up unused code and optimized existing logic for better performance.
2284 lines
70 KiB
TypeScript
2284 lines
70 KiB
TypeScript
import { create } from "zustand";
|
|
import { persist } from "zustand/middleware";
|
|
import type { Project, TrashedProject } from "@/lib/electron";
|
|
|
|
export type ViewMode =
|
|
| "welcome"
|
|
| "setup"
|
|
| "spec"
|
|
| "board"
|
|
| "agent"
|
|
| "settings"
|
|
| "interview"
|
|
| "context"
|
|
| "profiles"
|
|
| "running-agents"
|
|
| "terminal"
|
|
| "wiki";
|
|
|
|
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 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;
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Default keyboard shortcuts
|
|
export const DEFAULT_KEYBOARD_SHORTCUTS: KeyboardShortcuts = {
|
|
// Navigation
|
|
board: "K",
|
|
agent: "A",
|
|
spec: "D",
|
|
context: "C",
|
|
settings: "S",
|
|
profiles: "M",
|
|
terminal: "T",
|
|
|
|
// 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",
|
|
};
|
|
|
|
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 ChatMessage {
|
|
id: string;
|
|
role: "user" | "assistant";
|
|
content: string;
|
|
timestamp: Date;
|
|
images?: ImageAttachment[];
|
|
}
|
|
|
|
export interface ChatSession {
|
|
id: string;
|
|
title: string;
|
|
projectId: string;
|
|
messages: ChatMessage[];
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
archived: boolean;
|
|
}
|
|
|
|
export interface FeatureImage {
|
|
id: string;
|
|
data: string; // base64 encoded
|
|
mimeType: string;
|
|
filename: string;
|
|
size: number;
|
|
}
|
|
|
|
export interface FeatureImagePath {
|
|
id: string;
|
|
path: string; // Path to the temp file
|
|
filename: string;
|
|
mimeType: string;
|
|
}
|
|
|
|
// Available models for feature execution
|
|
export type ClaudeModel = "opus" | "sonnet" | "haiku";
|
|
export type AgentModel = ClaudeModel;
|
|
|
|
// Model provider type
|
|
export type ModelProvider = "claude";
|
|
|
|
// Thinking level (budget_tokens) options
|
|
export type ThinkingLevel = "none" | "low" | "medium" | "high" | "ultrathink";
|
|
|
|
// AI Provider Profile - user-defined presets for model configurations
|
|
export interface AIProfile {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
model: AgentModel;
|
|
thinkingLevel: ThinkingLevel;
|
|
provider: ModelProvider;
|
|
isBuiltIn: boolean; // Built-in profiles cannot be deleted
|
|
icon?: string; // Optional icon name from lucide
|
|
}
|
|
|
|
export interface Feature {
|
|
id: string;
|
|
category: string;
|
|
description: string;
|
|
steps: string[];
|
|
status:
|
|
| "backlog"
|
|
| "in_progress"
|
|
| "waiting_approval"
|
|
| "verified"
|
|
| "completed";
|
|
images?: FeatureImage[];
|
|
imagePaths?: FeatureImagePath[]; // Paths to temp files for agent context
|
|
startedAt?: string; // ISO timestamp for when the card moved to in_progress
|
|
skipTests?: boolean; // When true, skip TDD approach and require manual verification
|
|
summary?: string; // Summary of what was done/modified by the agent
|
|
model?: AgentModel; // Model to use for this feature (defaults to opus)
|
|
thinkingLevel?: ThinkingLevel; // Thinking level for extended thinking (defaults to none)
|
|
error?: string; // Error message if the agent errored during processing
|
|
priority?: number; // Priority: 1 = high, 2 = medium, 3 = low
|
|
dependencies?: string[]; // Array of feature IDs this feature depends on
|
|
// Branch info - worktree path is derived at runtime from branchName
|
|
branchName?: string; // Name of the feature branch (undefined = use current worktree)
|
|
justFinishedAt?: string; // ISO timestamp when agent just finished and moved to waiting_approval (shows badge for 2 minutes)
|
|
}
|
|
|
|
// 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";
|
|
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;
|
|
defaultFontSize: number; // Default font size for new terminals
|
|
}
|
|
|
|
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
|
|
|
|
// 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)
|
|
|
|
// 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: AgentModel; // Model used for feature enhancement (default: sonnet)
|
|
|
|
// 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;
|
|
|
|
// Spec Creation State (per-project, keyed by project path)
|
|
// Tracks which project is currently having its spec generated
|
|
specCreatingForProject: string | null;
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Feature Default Settings actions
|
|
setDefaultSkipTests: (skip: boolean) => void;
|
|
setEnableDependencyBlocking: (enabled: boolean) => 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: AgentModel) => 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;
|
|
|
|
// 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;
|
|
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;
|
|
addTerminalTab: (name?: string) => string;
|
|
removeTerminalTab: (tabId: string) => void;
|
|
setActiveTerminalTab: (tabId: string) => void;
|
|
renameTerminalTab: (tabId: string, name: string) => void;
|
|
moveTerminalToTab: (sessionId: string, targetTabId: string | "new") => void;
|
|
addTerminalToTab: (
|
|
sessionId: string,
|
|
tabId: string,
|
|
direction?: "horizontal" | "vertical"
|
|
) => void;
|
|
|
|
// Spec Creation actions
|
|
setSpecCreatingForProject: (projectPath: string | null) => void;
|
|
isSpecCreatingForProject: (projectPath: string) => boolean;
|
|
|
|
// Reset
|
|
reset: () => void;
|
|
}
|
|
|
|
// Default built-in AI profiles
|
|
const DEFAULT_AI_PROFILES: AIProfile[] = [
|
|
{
|
|
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",
|
|
},
|
|
];
|
|
|
|
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
|
|
defaultSkipTests: true, // Default to manual verification (tests disabled)
|
|
enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI)
|
|
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
|
|
aiProfiles: DEFAULT_AI_PROFILES,
|
|
projectAnalysis: null,
|
|
isAnalyzing: false,
|
|
boardBackgroundByProject: {},
|
|
previewTheme: null,
|
|
terminalState: {
|
|
isUnlocked: false,
|
|
authToken: null,
|
|
tabs: [],
|
|
activeTabId: null,
|
|
activeSessionId: null,
|
|
defaultFontSize: 14,
|
|
},
|
|
specCreatingForProject: null,
|
|
};
|
|
|
|
export const useAppStore = create<AppState & AppActions>()(
|
|
persist(
|
|
(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 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 }),
|
|
|
|
// Feature Default Settings actions
|
|
setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }),
|
|
setEnableDependencyBlocking: (enabled) =>
|
|
set({ enableDependencyBlocking: enabled }),
|
|
|
|
// 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 }),
|
|
|
|
// 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) {
|
|
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] });
|
|
},
|
|
|
|
// 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,
|
|
},
|
|
});
|
|
},
|
|
|
|
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",
|
|
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",
|
|
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",
|
|
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];
|
|
return { ...node, panels: newPanels };
|
|
};
|
|
|
|
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: () => {
|
|
set({
|
|
terminalState: {
|
|
isUnlocked: false,
|
|
authToken: null,
|
|
tabs: [],
|
|
activeTabId: null,
|
|
activeSessionId: null,
|
|
defaultFontSize: 14,
|
|
},
|
|
});
|
|
},
|
|
|
|
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 },
|
|
});
|
|
},
|
|
|
|
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,
|
|
},
|
|
});
|
|
},
|
|
|
|
renameTerminalTab: (tabId, name) => {
|
|
const current = get().terminalState;
|
|
const newTabs = current.tabs.map((t) =>
|
|
t.id === tabId ? { ...t, name } : t
|
|
);
|
|
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];
|
|
return { ...node, panels: newPanels };
|
|
};
|
|
|
|
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",
|
|
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",
|
|
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",
|
|
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,
|
|
},
|
|
});
|
|
},
|
|
|
|
// Spec Creation actions
|
|
setSpecCreatingForProject: (projectPath) => {
|
|
set({ specCreatingForProject: projectPath });
|
|
},
|
|
|
|
isSpecCreatingForProject: (projectPath) => {
|
|
return get().specCreatingForProject === projectPath;
|
|
},
|
|
|
|
// Reset
|
|
reset: () => set(initialState),
|
|
}),
|
|
{
|
|
name: "automaker-storage",
|
|
version: 2, // Increment when making breaking changes to persisted state
|
|
migrate: (persistedState: unknown, version: number) => {
|
|
const state = persistedState as Partial<AppState>;
|
|
|
|
// 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",
|
|
};
|
|
}
|
|
}
|
|
|
|
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,
|
|
// 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,
|
|
useWorktrees: state.useWorktrees,
|
|
currentWorktreeByProject: state.currentWorktreeByProject,
|
|
worktreesByProject: state.worktreesByProject,
|
|
showProfilesOnly: state.showProfilesOnly,
|
|
keyboardShortcuts: state.keyboardShortcuts,
|
|
muteDoneSound: state.muteDoneSound,
|
|
enhancementModel: state.enhancementModel,
|
|
// Profiles and sessions
|
|
aiProfiles: state.aiProfiles,
|
|
chatSessions: state.chatSessions,
|
|
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
|
|
// Board background settings
|
|
boardBackgroundByProject: state.boardBackgroundByProject,
|
|
}),
|
|
}
|
|
)
|
|
);
|