Files
automaker/apps/ui/src/store/app-store.ts
Shirone 38e8a4c4ea refactor(store): Extract types from app-store.ts into modular type files
- Create store/types/ directory with 8 modular type files:
  - usage-types.ts: ClaudeUsage, CodexUsage, isClaudeUsageAtLimit
  - ui-types.ts: ViewMode, ThemeMode, KeyboardShortcuts, etc.
  - settings-types.ts: ApiKeys
  - chat-types.ts: ChatMessage, ChatSession, FeatureImage
  - terminal-types.ts: TerminalState, TerminalTab, etc.
  - project-types.ts: Feature, FileTreeNode, ProjectAnalysis
  - state-types.ts: AppState, AppActions interfaces
  - index.ts: Re-exports all types

- Update electron.ts to import from store/types/usage-types
  (breaks circular dependency between electron.ts and app-store.ts)

- Update app-store.ts to import and re-export types for backward
  compatibility - existing imports from @/store/app-store continue
  to work

This is PR 1 of the app-store refactoring plan.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:20:27 +01:00

2764 lines
87 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 { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { createLogger } from '@automaker/utils/logger';
import { setItem, getItem } from '@/lib/storage';
import {
UI_SANS_FONT_OPTIONS,
UI_MONO_FONT_OPTIONS,
DEFAULT_FONT_VALUE,
} from '@/config/ui-font-options';
import type {
Feature as BaseFeature,
FeatureImagePath,
FeatureTextFilePath,
ModelAlias,
PlanningMode,
ThinkingLevel,
ReasoningEffort,
ModelProvider,
CursorModelId,
CodexModelId,
OpencodeModelId,
GeminiModelId,
CopilotModelId,
PhaseModelConfig,
PhaseModelKey,
PhaseModelEntry,
MCPServerConfig,
FeatureStatusWithPipeline,
PipelineConfig,
PipelineStep,
PromptCustomization,
ModelDefinition,
ServerLogLevel,
EventHook,
ClaudeApiProfile,
ClaudeCompatibleProvider,
SidebarStyle,
ParsedTask,
PlanSpec,
} from '@automaker/types';
import {
getAllCursorModelIds,
getAllCodexModelIds,
getAllOpencodeModelIds,
getAllGeminiModelIds,
getAllCopilotModelIds,
DEFAULT_PHASE_MODELS,
DEFAULT_OPENCODE_MODEL,
DEFAULT_GEMINI_MODEL,
DEFAULT_COPILOT_MODEL,
DEFAULT_MAX_CONCURRENCY,
DEFAULT_GLOBAL_SETTINGS,
} from '@automaker/types';
// Import types from modular type files
import {
// UI types
type ViewMode,
type ThemeMode,
type BoardViewMode,
type ShortcutKey,
type KeyboardShortcuts,
DEFAULT_KEYBOARD_SHORTCUTS,
parseShortcut,
formatShortcut,
// Settings types
type ApiKeys,
// Chat types
type ImageAttachment,
type TextFileAttachment,
type ChatMessage,
type ChatSession,
type FeatureImage,
// Terminal types
type TerminalPanelContent,
type TerminalTab,
type TerminalState,
type PersistedTerminalPanel,
type PersistedTerminalTab,
type PersistedTerminalState,
type PersistedTerminalSettings,
generateSplitId,
// Project types
type ClaudeModel,
type Feature,
type FileTreeNode,
type ProjectAnalysis,
// State types
type InitScriptState,
type AutoModeActivity,
type AppState,
type AppActions,
// Usage types
type ClaudeUsage,
type ClaudeUsageResponse,
type CodexPlanType,
type CodexRateLimitWindow,
type CodexUsage,
type CodexUsageResponse,
isClaudeUsageAtLimit,
} from './types';
const logger = createLogger('AppStore');
const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock';
const OPENCODE_BEDROCK_MODEL_PREFIX = `${OPENCODE_BEDROCK_PROVIDER_ID}/`;
// Re-export types from @automaker/types for convenience
export type {
ModelAlias,
PlanningMode,
ThinkingLevel,
ReasoningEffort,
ModelProvider,
ServerLogLevel,
FeatureTextFilePath,
FeatureImagePath,
ParsedTask,
PlanSpec,
};
// Re-export all types from ./types for backward compatibility
export type {
ViewMode,
ThemeMode,
BoardViewMode,
ShortcutKey,
KeyboardShortcuts,
ApiKeys,
ImageAttachment,
TextFileAttachment,
ChatMessage,
ChatSession,
FeatureImage,
TerminalPanelContent,
TerminalTab,
TerminalState,
PersistedTerminalPanel,
PersistedTerminalTab,
PersistedTerminalState,
PersistedTerminalSettings,
ClaudeModel,
Feature,
FileTreeNode,
ProjectAnalysis,
InitScriptState,
AutoModeActivity,
AppState,
AppActions,
ClaudeUsage,
ClaudeUsageResponse,
CodexPlanType,
CodexRateLimitWindow,
CodexUsage,
CodexUsageResponse,
};
// Re-export values from ./types for backward compatibility
export {
DEFAULT_KEYBOARD_SHORTCUTS,
parseShortcut,
formatShortcut,
isClaudeUsageAtLimit,
generateSplitId,
};
// NOTE: Type definitions moved to ./types/ directory
// The following inline types have been replaced with imports above:
// - ViewMode, ThemeMode, BoardViewMode (./types/ui-types.ts)
// - ShortcutKey, KeyboardShortcuts, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut (./types/ui-types.ts)
// - ApiKeys (./types/settings-types.ts)
// - ImageAttachment, TextFileAttachment, ChatMessage, ChatSession, FeatureImage (./types/chat-types.ts)
// - Terminal types (./types/terminal-types.ts)
// - ClaudeModel, Feature, FileTreeNode, ProjectAnalysis (./types/project-types.ts)
// - InitScriptState, AutoModeActivity, AppState, AppActions (./types/state-types.ts)
// - Claude/Codex usage types (./types/usage-types.ts)
// LocalStorage keys for persistence (fallback when server settings aren't available)
export const THEME_STORAGE_KEY = 'automaker:theme';
export const FONT_SANS_STORAGE_KEY = 'automaker:font-sans';
export const FONT_MONO_STORAGE_KEY = 'automaker:font-mono';
// Maximum number of output lines to keep in init script state (prevents unbounded memory growth)
export const MAX_INIT_OUTPUT_LINES = 500;
/**
* Get the theme from localStorage as a fallback
* Used before server settings are loaded (e.g., on login/setup pages)
*/
export function getStoredTheme(): ThemeMode | null {
const stored = getItem(THEME_STORAGE_KEY);
if (stored) return stored as ThemeMode;
// Backwards compatibility: older versions stored theme inside the Zustand persist blob.
// We intentionally keep reading it as a fallback so users don't get a "default theme flash"
// on login/logged-out pages if THEME_STORAGE_KEY hasn't been written yet.
try {
const legacy = getItem('automaker-storage');
if (!legacy) return null;
interface LegacyStorageFormat {
state?: { theme?: string };
theme?: string;
}
const parsed = JSON.parse(legacy) as LegacyStorageFormat;
const theme = parsed.state?.theme ?? parsed.theme;
if (typeof theme === 'string' && theme.length > 0) {
return theme as ThemeMode;
}
} catch {
// Ignore legacy parse errors
}
return null;
}
/**
* Helper to get effective font value with validation
* Returns the font to use (project override -> global -> null for default)
* @param projectFont - The project-specific font override
* @param globalFont - The global font setting
* @param fontOptions - The list of valid font options for validation
*/
function getEffectiveFont(
projectFont: string | undefined,
globalFont: string | null,
fontOptions: readonly { value: string; label: string }[]
): string | null {
const isValidFont = (font: string | null | undefined): boolean => {
if (!font || font === DEFAULT_FONT_VALUE) return true;
return fontOptions.some((opt) => opt.value === font);
};
if (projectFont) {
if (!isValidFont(projectFont)) return null; // Fallback to default if font not in list
return projectFont === DEFAULT_FONT_VALUE ? null : projectFont;
}
if (!isValidFont(globalFont)) return null; // Fallback to default if font not in list
return globalFont === DEFAULT_FONT_VALUE ? null : globalFont;
}
/**
* Save theme to localStorage for immediate persistence
* This is used as a fallback when server settings can't be loaded
*/
function saveThemeToStorage(theme: ThemeMode): void {
setItem(THEME_STORAGE_KEY, theme);
}
/**
* Get fonts from localStorage as a fallback
* Used before server settings are loaded (e.g., on login/setup pages)
*/
export function getStoredFontSans(): string | null {
return getItem(FONT_SANS_STORAGE_KEY);
}
export function getStoredFontMono(): string | null {
return getItem(FONT_MONO_STORAGE_KEY);
}
/**
* Save fonts to localStorage for immediate persistence
* This is used as a fallback when server settings can't be loaded
*/
function saveFontSansToStorage(fontFamily: string | null): void {
if (fontFamily) {
setItem(FONT_SANS_STORAGE_KEY, fontFamily);
} else {
// Remove from storage if null (using default)
localStorage.removeItem(FONT_SANS_STORAGE_KEY);
}
}
function saveFontMonoToStorage(fontFamily: string | null): void {
if (fontFamily) {
setItem(FONT_MONO_STORAGE_KEY, fontFamily);
} else {
// Remove from storage if null (using default)
localStorage.removeItem(FONT_MONO_STORAGE_KEY);
}
}
function persistEffectiveThemeForProject(project: Project | null, fallbackTheme: ThemeMode): void {
const projectTheme = project?.theme as ThemeMode | undefined;
const themeToStore = projectTheme ?? fallbackTheme;
saveThemeToStorage(themeToStore);
}
// NOTE: Type definitions have been moved to ./types/ directory
// Types are imported at the top of this file and re-exported for backward compatibility
// 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,
};
const initialState: AppState = {
projects: [],
currentProject: null,
trashedProjects: [],
projectHistory: [],
projectHistoryIndex: -1,
currentView: 'welcome',
sidebarOpen: true,
sidebarStyle: 'unified',
collapsedNavSections: {},
mobileSidebarHidden: false,
lastSelectedSessionByProject: {},
theme: getStoredTheme() || 'dark',
fontFamilySans: getStoredFontSans(),
fontFamilyMono: getStoredFontMono(),
features: [],
appSpec: '',
ipcConnected: false,
apiKeys: {
anthropic: '',
google: '',
openai: '',
},
chatSessions: [],
currentChatSession: null,
chatHistoryOpen: false,
autoModeByWorktree: {},
autoModeActivityLog: [],
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
boardViewMode: 'kanban',
defaultSkipTests: true,
enableDependencyBlocking: true,
skipVerificationInAutoMode: false,
enableAiCommitMessages: true,
planUseSelectedWorktreeBranch: true,
addFeatureUseSelectedWorktreeBranch: false,
useWorktrees: true,
currentWorktreeByProject: {},
worktreesByProject: {},
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
muteDoneSound: false,
disableSplashScreen: false,
serverLogLevel: 'info',
enableRequestLogging: true,
showQueryDevtools: true,
enhancementModel: 'claude-sonnet',
validationModel: 'claude-opus',
phaseModels: DEFAULT_PHASE_MODELS,
favoriteModels: [],
enabledCursorModels: getAllCursorModelIds(),
cursorDefaultModel: 'cursor-auto',
enabledCodexModels: getAllCodexModelIds(),
codexDefaultModel: 'codex-gpt-5.2-codex',
codexAutoLoadAgents: false,
codexSandboxMode: 'workspace-write',
codexApprovalPolicy: 'on-request',
codexEnableWebSearch: false,
codexEnableImages: false,
enabledOpencodeModels: getAllOpencodeModelIds(),
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
dynamicOpencodeModels: [],
enabledDynamicModelIds: [],
cachedOpencodeProviders: [],
opencodeModelsLoading: false,
opencodeModelsError: null,
opencodeModelsLastFetched: null,
opencodeModelsLastFailedAt: null,
enabledGeminiModels: getAllGeminiModelIds(),
geminiDefaultModel: DEFAULT_GEMINI_MODEL,
enabledCopilotModels: getAllCopilotModelIds(),
copilotDefaultModel: DEFAULT_COPILOT_MODEL,
disabledProviders: [],
autoLoadClaudeMd: false,
skipSandboxWarning: false,
mcpServers: [],
defaultEditorCommand: null,
defaultTerminalId: null,
enableSkills: true,
skillsSources: ['user', 'project'] as Array<'user' | 'project'>,
enableSubagents: true,
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>,
promptCustomization: {},
eventHooks: [],
claudeCompatibleProviders: [],
claudeApiProfiles: [],
activeClaudeApiProfileId: null,
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: DEFAULT_FONT_VALUE,
scrollbackLines: 5000,
lineHeight: 1.0,
maxSessions: 100,
lastActiveProjectPath: null,
openTerminalMode: 'newTab',
},
terminalLayoutByProject: {},
specCreatingForProject: null,
defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false,
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
pendingPlanApproval: null,
claudeRefreshInterval: 60,
claudeUsage: null,
claudeUsageLastUpdated: null,
codexUsage: null,
codexUsageLastUpdated: null,
codexModels: [],
codexModelsLoading: false,
codexModelsError: null,
codexModelsLastFetched: null,
codexModelsLastFailedAt: null,
pipelineConfigByProject: {},
worktreePanelVisibleByProject: {},
showInitScriptIndicatorByProject: {},
defaultDeleteBranchByProject: {},
autoDismissInitScriptIndicatorByProject: {},
useWorktreesByProject: {},
worktreePanelCollapsed: false,
lastProjectDir: '',
recentFolders: [],
initScriptState: {},
};
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;
set({ projects: updated });
} else {
set({ projects: [...projects, project] });
}
},
removeProject: (projectId) =>
set((state) => ({
projects: state.projects.filter((p) => p.id !== projectId),
})),
moveProjectToTrash: (projectId: string) => {
const project = get().projects.find((p) => p.id === projectId);
if (!project) return;
const trashedProject: TrashedProject = {
...project,
trashedAt: Date.now(),
};
set((state) => ({
projects: state.projects.filter((p) => p.id !== projectId),
trashedProjects: [...state.trashedProjects, trashedProject],
currentProject: state.currentProject?.id === projectId ? null : state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
},
restoreTrashedProject: (projectId: string) => {
const trashedProject = get().trashedProjects.find((p) => p.id === projectId);
if (!trashedProject) return;
// Remove trashedAt from the project
const { trashedAt, ...restoredProject } = trashedProject;
void trashedAt; // Explicitly ignore trashedAt to satisfy linter
set((state) => ({
projects: [...state.projects, restoredProject as Project],
trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId),
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
},
deleteTrashedProject: (projectId: string) => {
set((state) => ({
trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId),
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setTrashedProjects(get().trashedProjects);
}
},
emptyTrash: () => {
set({ trashedProjects: [] });
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setTrashedProjects([]);
}
},
setCurrentProject: (project) => {
const currentId = get().currentProject?.id;
const newId = project?.id;
// If we're switching to a different project, add the new one to history
if (newId && newId !== currentId) {
set((state) => {
// Remove the new project from history if it exists
const filteredHistory = state.projectHistory.filter((id) => id !== newId);
// Add new project at the front (most recent)
const newHistory = [newId, ...filteredHistory];
// Limit history size to prevent unbounded growth
const MAX_HISTORY = 50;
// Persist effective theme for the new project to localStorage
persistEffectiveThemeForProject(project, state.theme);
return {
currentProject: project,
projectHistory: newHistory.slice(0, MAX_HISTORY),
projectHistoryIndex: 0, // Reset index to start of history
};
});
} else {
// Same project or null - just update without affecting history
set({ currentProject: project });
// Still persist theme for project changes
if (project) {
persistEffectiveThemeForProject(project, get().theme);
}
}
},
upsertAndSetCurrentProject: (path: string, name: string, theme?: ThemeMode) => {
const existingProject = get().projects.find((p) => p.path === path);
if (existingProject) {
get().setCurrentProject(existingProject);
return existingProject;
}
// Create new project
const newProject: Project = {
id: crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2),
name,
path,
isFavorite: false, // New projects start as non-favorites
...(theme ? { theme } : {}),
};
// Add and set as current
get().addProject(newProject);
get().setCurrentProject(newProject);
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
// Small delay to ensure state is updated before persisting
setTimeout(() => {
electronAPI.projects.setProjects(get().projects);
}, 0);
}
return newProject;
},
reorderProjects: (oldIndex: number, newIndex: number) => {
set((state) => {
const projects = [...state.projects];
const [removed] = projects.splice(oldIndex, 1);
projects.splice(newIndex, 0, removed);
return { projects };
});
},
cyclePrevProject: () => {
set((state) => {
const { projectHistory, projectHistoryIndex, projects } = state;
if (projectHistory.length === 0) return state;
// Move back in history (to older project)
const newIndex = Math.min(projectHistoryIndex + 1, projectHistory.length - 1);
if (newIndex === projectHistoryIndex) return state; // Already at oldest
const projectId = projectHistory[newIndex];
const project = projects.find((p) => p.id === projectId);
if (!project) {
// Project no longer exists, remove from history and try again
const filteredHistory = projectHistory.filter((id) => id !== projectId);
return { projectHistory: filteredHistory, projectHistoryIndex: state.projectHistoryIndex };
}
// Persist effective theme for the cycled-to project
persistEffectiveThemeForProject(project, state.theme);
return {
currentProject: project,
projectHistoryIndex: newIndex,
};
});
},
cycleNextProject: () => {
set((state) => {
const { projectHistory, projectHistoryIndex, projects } = state;
if (projectHistory.length === 0 || projectHistoryIndex === 0) return state; // Already at most recent
// Move forward in history (to newer project)
const newIndex = Math.max(projectHistoryIndex - 1, 0);
const projectId = projectHistory[newIndex];
const project = projects.find((p) => p.id === projectId);
if (!project) {
// Project no longer exists, remove from history and try again
const filteredHistory = projectHistory.filter((id) => id !== projectId);
return { projectHistory: filteredHistory, projectHistoryIndex: state.projectHistoryIndex };
}
// Persist effective theme for the cycled-to project
persistEffectiveThemeForProject(project, state.theme);
return {
currentProject: project,
projectHistoryIndex: newIndex,
};
});
},
clearProjectHistory: () => {
const currentId = get().currentProject?.id;
set({
projectHistory: currentId ? [currentId] : [],
projectHistoryIndex: 0,
});
},
toggleProjectFavorite: (projectId: string) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, isFavorite: !p.isFavorite } : p
),
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
setProjectIcon: (projectId: string, icon: string | null) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, icon: icon ?? undefined } : p
),
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
setProjectCustomIcon: (projectId: string, customIconPath: string | null) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, customIcon: customIconPath ?? undefined } : p
),
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
setProjectName: (projectId: string, name: string) => {
set((state) => ({
projects: state.projects.map((p) => (p.id === projectId ? { ...p, name } : p)),
// Also update currentProject if it's the one being renamed
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, name }
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
// View actions
setCurrentView: (view) => set({ currentView: view }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
setSidebarStyle: (style) => set({ sidebarStyle: style }),
setCollapsedNavSections: (sections) => set({ collapsedNavSections: sections }),
toggleNavSection: (sectionLabel) =>
set((state) => ({
collapsedNavSections: {
...state.collapsedNavSections,
[sectionLabel]: !state.collapsedNavSections[sectionLabel],
},
})),
toggleMobileSidebarHidden: () =>
set((state) => ({ mobileSidebarHidden: !state.mobileSidebarHidden })),
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),
// Theme actions
setTheme: (theme) => {
set({ theme });
saveThemeToStorage(theme);
},
setProjectTheme: (projectId: string, theme: ThemeMode | null) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, theme: theme ?? undefined } : p
),
// Also update currentProject if it's the one being changed
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, theme: theme ?? undefined }
: state.currentProject,
}));
// Update localStorage with new effective theme if this is the current project
const currentProject = get().currentProject;
if (currentProject?.id === projectId) {
persistEffectiveThemeForProject(
{ ...currentProject, theme: theme ?? undefined },
get().theme
);
}
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
getEffectiveTheme: () => {
const state = get();
// If there's a preview theme, use it (for hover preview)
if (state.previewTheme) return state.previewTheme;
// Otherwise, use project theme if set, or fall back to global theme
const projectTheme = state.currentProject?.theme as ThemeMode | undefined;
return projectTheme ?? state.theme;
},
setPreviewTheme: (theme) => set({ previewTheme: theme }),
// Font actions
setFontSans: (fontFamily) => {
set({ fontFamilySans: fontFamily });
saveFontSansToStorage(fontFamily);
},
setFontMono: (fontFamily) => {
set({ fontFamilyMono: fontFamily });
saveFontMonoToStorage(fontFamily);
},
setProjectFontSans: (projectId: string, fontFamily: string | null) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, fontSans: fontFamily ?? undefined } : p
),
// Also update currentProject if it's the one being changed
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, fontSans: fontFamily ?? undefined }
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
setProjectFontMono: (projectId: string, fontFamily: string | null) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, fontMono: fontFamily ?? undefined } : p
),
// Also update currentProject if it's the one being changed
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, fontMono: fontFamily ?? undefined }
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
getEffectiveFontSans: () => {
const state = get();
const projectFont = state.currentProject?.fontSans;
return getEffectiveFont(projectFont, state.fontFamilySans, UI_SANS_FONT_OPTIONS);
},
getEffectiveFontMono: () => {
const state = get();
const projectFont = state.currentProject?.fontMono;
return getEffectiveFont(projectFont, state.fontFamilyMono, UI_MONO_FONT_OPTIONS);
},
// Claude API Profile actions (per-project override)
setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, claudeApiProfileId: profileId } : p
),
// Also update currentProject if it's the one being changed
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, claudeApiProfileId: profileId }
: state.currentProject,
}));
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
// Project Phase Model Overrides
setProjectPhaseModelOverride: (
projectId: string,
phase: PhaseModelKey,
entry: PhaseModelEntry | null
) => {
set((state) => {
const updatePhaseModels = (project: Project): Project => {
const currentOverrides = project.phaseModelOverrides || {};
const newOverrides = { ...currentOverrides };
if (entry === null) {
delete newOverrides[phase];
} else {
newOverrides[phase] = entry;
}
return {
...project,
phaseModelOverrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined,
};
};
return {
projects: state.projects.map((p) => (p.id === projectId ? updatePhaseModels(p) : p)),
currentProject:
state.currentProject?.id === projectId
? updatePhaseModels(state.currentProject)
: state.currentProject,
};
});
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
clearAllProjectPhaseModelOverrides: (projectId: string) => {
set((state) => {
const clearOverrides = (project: Project): Project => ({
...project,
phaseModelOverrides: undefined,
});
return {
projects: state.projects.map((p) => (p.id === projectId ? clearOverrides(p) : p)),
currentProject:
state.currentProject?.id === projectId
? clearOverrides(state.currentProject)
: state.currentProject,
};
});
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
// Project Default Feature Model Override
setProjectDefaultFeatureModel: (projectId: string, entry: PhaseModelEntry | null) => {
set((state) => {
const updateDefaultFeatureModel = (project: Project): Project => ({
...project,
defaultFeatureModel: entry ?? undefined,
});
return {
projects: state.projects.map((p) =>
p.id === projectId ? updateDefaultFeatureModel(p) : p
),
currentProject:
state.currentProject?.id === projectId
? updateDefaultFeatureModel(state.currentProject)
: state.currentProject,
};
});
// Persist to Electron store if available
const electronAPI = getElectronAPI();
if (electronAPI) {
electronAPI.projects.setProjects(get().projects);
}
},
// Feature actions
setFeatures: (features) => set({ features }),
updateFeature: (id, updates) =>
set((state) => ({
features: state.features.map((f) => (f.id === id ? { ...f, ...updates } : f)),
})),
addFeature: (feature) => {
const id = feature.id ?? `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const newFeature: Feature = { ...feature, id };
set((state) => ({ features: [...state.features, newFeature] }));
return newFeature;
},
removeFeature: (id) => set((state) => ({ features: state.features.filter((f) => f.id !== id) })),
moveFeature: (id, newStatus) =>
set((state) => ({
features: state.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((state) => ({ apiKeys: { ...state.apiKeys, ...keys } })),
// Chat Session actions
createChatSession: (title) => {
const currentProject = get().currentProject;
const newSession: ChatSession = {
id: `session-${Date.now()}-${Math.random().toString(36).slice(2)}`,
title: title || 'New Chat',
projectId: currentProject?.id || '',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
archived: false,
};
set((state) => ({
chatSessions: [...state.chatSessions, newSession],
currentChatSession: newSession,
}));
return newSession;
},
updateChatSession: (sessionId, updates) =>
set((state) => ({
chatSessions: state.chatSessions.map((s) =>
s.id === sessionId ? { ...s, ...updates, updatedAt: new Date() } : s
),
currentChatSession:
state.currentChatSession?.id === sessionId
? { ...state.currentChatSession, ...updates, updatedAt: new Date() }
: state.currentChatSession,
})),
addMessageToSession: (sessionId, message) =>
set((state) => ({
chatSessions: state.chatSessions.map((s) =>
s.id === sessionId ? { ...s, messages: [...s.messages, message], updatedAt: new Date() } : s
),
currentChatSession:
state.currentChatSession?.id === sessionId
? {
...state.currentChatSession,
messages: [...state.currentChatSession.messages, message],
updatedAt: new Date(),
}
: state.currentChatSession,
})),
setCurrentChatSession: (session) => set({ currentChatSession: session }),
archiveChatSession: (sessionId) =>
set((state) => ({
chatSessions: state.chatSessions.map((s) =>
s.id === sessionId ? { ...s, archived: true } : s
),
})),
unarchiveChatSession: (sessionId) =>
set((state) => ({
chatSessions: state.chatSessions.map((s) =>
s.id === sessionId ? { ...s, archived: false } : s
),
})),
deleteChatSession: (sessionId) =>
set((state) => ({
chatSessions: state.chatSessions.filter((s) => s.id !== sessionId),
currentChatSession:
state.currentChatSession?.id === sessionId ? null : state.currentChatSession,
})),
setChatHistoryOpen: (open) => set({ chatHistoryOpen: open }),
toggleChatHistory: () => set((state) => ({ chatHistoryOpen: !state.chatHistoryOpen })),
// Auto Mode actions (per-worktree)
getWorktreeKey: (projectId: string, branchName: string | null) =>
`${projectId}::${branchName ?? '__main__'}`,
setAutoModeRunning: (
projectId: string,
branchName: string | null,
running: boolean,
maxConcurrency?: number,
runningTasks?: string[]
) => {
const key = get().getWorktreeKey(projectId, branchName);
set((state) => ({
autoModeByWorktree: {
...state.autoModeByWorktree,
[key]: {
isRunning: running,
runningTasks: runningTasks ?? state.autoModeByWorktree[key]?.runningTasks ?? [],
branchName,
maxConcurrency: maxConcurrency ?? state.autoModeByWorktree[key]?.maxConcurrency,
},
},
}));
},
addRunningTask: (projectId: string, branchName: string | null, taskId: string) => {
const key = get().getWorktreeKey(projectId, branchName);
set((state) => {
const current = state.autoModeByWorktree[key] || {
isRunning: true,
runningTasks: [],
branchName,
};
return {
autoModeByWorktree: {
...state.autoModeByWorktree,
[key]: {
...current,
runningTasks: [...current.runningTasks, taskId],
},
},
};
});
},
removeRunningTask: (projectId: string, branchName: string | null, taskId: string) => {
const key = get().getWorktreeKey(projectId, branchName);
set((state) => {
const current = state.autoModeByWorktree[key];
if (!current) return state;
return {
autoModeByWorktree: {
...state.autoModeByWorktree,
[key]: {
...current,
runningTasks: current.runningTasks.filter((id) => id !== taskId),
},
},
};
});
},
clearRunningTasks: (projectId: string, branchName: string | null) => {
const key = get().getWorktreeKey(projectId, branchName);
set((state) => {
const current = state.autoModeByWorktree[key];
if (!current) return state;
return {
autoModeByWorktree: {
...state.autoModeByWorktree,
[key]: {
...current,
runningTasks: [],
},
},
};
});
},
getAutoModeState: (projectId: string, branchName: string | null) => {
const key = get().getWorktreeKey(projectId, branchName);
const worktreeState = get().autoModeByWorktree[key];
return (
worktreeState || {
isRunning: false,
runningTasks: [],
branchName,
}
);
},
addAutoModeActivity: (activity) =>
set((state) => ({
autoModeActivityLog: [
{ ...activity, id: Math.random().toString(36).slice(2), timestamp: new Date() },
...state.autoModeActivityLog.slice(0, 99), // Keep last 100 activities
],
})),
clearAutoModeActivity: () => set({ autoModeActivityLog: [] }),
setMaxConcurrency: (max) => set({ maxConcurrency: max }),
getMaxConcurrencyForWorktree: (projectId: string, branchName: string | null) => {
const key = get().getWorktreeKey(projectId, branchName);
const worktreeState = get().autoModeByWorktree[key];
return worktreeState?.maxConcurrency ?? get().maxConcurrency;
},
setMaxConcurrencyForWorktree: (
projectId: string,
branchName: string | null,
maxConcurrency: number
) => {
const key = get().getWorktreeKey(projectId, branchName);
set((state) => ({
autoModeByWorktree: {
...state.autoModeByWorktree,
[key]: {
...state.autoModeByWorktree[key],
isRunning: state.autoModeByWorktree[key]?.isRunning ?? false,
runningTasks: state.autoModeByWorktree[key]?.runningTasks ?? [],
branchName,
maxConcurrency,
},
},
}));
},
// Kanban Card Settings actions
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
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { skipVerificationInAutoMode: enabled });
} catch (error) {
logger.error('Failed to sync skipVerificationInAutoMode:', error);
}
},
setEnableAiCommitMessages: async (enabled) => {
set({ enableAiCommitMessages: enabled });
// Sync to server
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { enableAiCommitMessages: enabled });
} catch (error) {
logger.error('Failed to sync enableAiCommitMessages:', error);
}
},
setPlanUseSelectedWorktreeBranch: async (enabled) => {
set({ planUseSelectedWorktreeBranch: enabled });
// Sync to server
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { planUseSelectedWorktreeBranch: enabled });
} catch (error) {
logger.error('Failed to sync planUseSelectedWorktreeBranch:', error);
}
},
setAddFeatureUseSelectedWorktreeBranch: async (enabled) => {
set({ addFeatureUseSelectedWorktreeBranch: enabled });
// Sync to server
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { addFeatureUseSelectedWorktreeBranch: enabled });
} catch (error) {
logger.error('Failed to sync addFeatureUseSelectedWorktreeBranch:', error);
}
},
// Worktree Settings actions
setUseWorktrees: (enabled) => set({ useWorktrees: enabled }),
setCurrentWorktree: (projectPath, worktreePath, branch) =>
set((state) => ({
currentWorktreeByProject: {
...state.currentWorktreeByProject,
[projectPath]: { path: worktreePath, branch },
},
})),
setWorktrees: (projectPath, worktrees) =>
set((state) => ({
worktreesByProject: {
...state.worktreesByProject,
[projectPath]: worktrees,
},
})),
getCurrentWorktree: (projectPath) => get().currentWorktreeByProject[projectPath] ?? null,
getWorktrees: (projectPath) => get().worktreesByProject[projectPath] ?? [],
isPrimaryWorktreeBranch: (projectPath: string, branchName: string) => {
const worktrees = get().worktreesByProject[projectPath] ?? [];
const mainWorktree = worktrees.find((w) => w.isMain);
return mainWorktree?.branch === branchName;
},
getPrimaryWorktreeBranch: (projectPath: string) => {
const worktrees = get().worktreesByProject[projectPath] ?? [];
const mainWorktree = worktrees.find((w) => w.isMain);
return mainWorktree?.branch ?? null;
},
// Keyboard Shortcuts actions
setKeyboardShortcut: (key, value) =>
set((state) => ({
keyboardShortcuts: { ...state.keyboardShortcuts, [key]: value },
})),
setKeyboardShortcuts: (shortcuts) =>
set((state) => ({
keyboardShortcuts: { ...state.keyboardShortcuts, ...shortcuts },
})),
resetKeyboardShortcuts: () => set({ keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS }),
// Audio Settings actions
setMuteDoneSound: (muted) => set({ muteDoneSound: muted }),
// Splash Screen actions
setDisableSplashScreen: (disabled) => set({ disableSplashScreen: disabled }),
// Server Log Level actions
setServerLogLevel: (level) => set({ serverLogLevel: level }),
setEnableRequestLogging: (enabled) => set({ enableRequestLogging: enabled }),
// Developer Tools actions
setShowQueryDevtools: (show) => set({ showQueryDevtools: show }),
// 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
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { phaseModels: get().phaseModels });
} catch (error) {
logger.error('Failed to sync phase model:', error);
}
},
setPhaseModels: async (models) => {
set((state) => ({
phaseModels: { ...state.phaseModels, ...models },
}));
// Sync to server
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { phaseModels: get().phaseModels });
} catch (error) {
logger.error('Failed to sync phase models:', error);
}
},
resetPhaseModels: async () => {
set({ phaseModels: DEFAULT_PHASE_MODELS });
// Sync to server
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { phaseModels: DEFAULT_PHASE_MODELS });
} catch (error) {
logger.error('Failed to sync phase models reset:', error);
}
},
toggleFavoriteModel: (modelId) =>
set((state) => ({
favoriteModels: state.favoriteModels.includes(modelId)
? state.favoriteModels.filter((id) => id !== modelId)
: [...state.favoriteModels, 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),
})),
// Codex CLI Settings actions
setEnabledCodexModels: (models) => set({ enabledCodexModels: models }),
setCodexDefaultModel: (model) => set({ codexDefaultModel: model }),
toggleCodexModel: (model, enabled) =>
set((state) => ({
enabledCodexModels: enabled
? [...state.enabledCodexModels, model]
: state.enabledCodexModels.filter((m) => m !== model),
})),
setCodexAutoLoadAgents: async (enabled) => {
set({ codexAutoLoadAgents: enabled });
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { codexAutoLoadAgents: enabled });
} catch (error) {
logger.error('Failed to sync codexAutoLoadAgents:', error);
}
},
setCodexSandboxMode: async (mode) => {
set({ codexSandboxMode: mode });
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { codexSandboxMode: mode });
} catch (error) {
logger.error('Failed to sync codexSandboxMode:', error);
}
},
setCodexApprovalPolicy: async (policy) => {
set({ codexApprovalPolicy: policy });
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { codexApprovalPolicy: policy });
} catch (error) {
logger.error('Failed to sync codexApprovalPolicy:', error);
}
},
setCodexEnableWebSearch: async (enabled) => {
set({ codexEnableWebSearch: enabled });
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { codexEnableWebSearch: enabled });
} catch (error) {
logger.error('Failed to sync codexEnableWebSearch:', error);
}
},
setCodexEnableImages: async (enabled) => {
set({ codexEnableImages: enabled });
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { codexEnableImages: enabled });
} catch (error) {
logger.error('Failed to sync codexEnableImages:', error);
}
},
// OpenCode CLI Settings actions
setEnabledOpencodeModels: (models) => set({ enabledOpencodeModels: models }),
setOpencodeDefaultModel: (model) => set({ opencodeDefaultModel: model }),
toggleOpencodeModel: (model, enabled) =>
set((state) => ({
enabledOpencodeModels: enabled
? [...state.enabledOpencodeModels, model]
: state.enabledOpencodeModels.filter((m) => m !== model),
})),
setDynamicOpencodeModels: (models) => set({ dynamicOpencodeModels: models }),
setEnabledDynamicModelIds: (ids) => set({ enabledDynamicModelIds: ids }),
toggleDynamicModel: (modelId, enabled) =>
set((state) => ({
enabledDynamicModelIds: enabled
? [...state.enabledDynamicModelIds, modelId]
: state.enabledDynamicModelIds.filter((id) => id !== modelId),
})),
setCachedOpencodeProviders: (providers) => set({ cachedOpencodeProviders: providers }),
// Gemini CLI Settings actions
setEnabledGeminiModels: (models) => set({ enabledGeminiModels: models }),
setGeminiDefaultModel: (model) => set({ geminiDefaultModel: model }),
toggleGeminiModel: (model, enabled) =>
set((state) => ({
enabledGeminiModels: enabled
? [...state.enabledGeminiModels, model]
: state.enabledGeminiModels.filter((m) => m !== model),
})),
// Copilot SDK Settings actions
setEnabledCopilotModels: (models) => set({ enabledCopilotModels: models }),
setCopilotDefaultModel: (model) => set({ copilotDefaultModel: model }),
toggleCopilotModel: (model, enabled) =>
set((state) => ({
enabledCopilotModels: enabled
? [...state.enabledCopilotModels, model]
: state.enabledCopilotModels.filter((m) => m !== model),
})),
// Provider Visibility Settings actions
setDisabledProviders: (providers) => set({ disabledProviders: providers }),
toggleProviderDisabled: (provider, disabled) =>
set((state) => ({
disabledProviders: disabled
? [...state.disabledProviders, provider]
: state.disabledProviders.filter((p) => p !== provider),
})),
isProviderDisabled: (provider) => get().disabledProviders.includes(provider),
// Claude Agent SDK Settings actions
setAutoLoadClaudeMd: async (enabled) => {
set({ autoLoadClaudeMd: enabled });
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { autoLoadClaudeMd: enabled });
} catch (error) {
logger.error('Failed to sync autoLoadClaudeMd:', error);
}
},
setSkipSandboxWarning: async (skip) => {
set({ skipSandboxWarning: skip });
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { skipSandboxWarning: skip });
} catch (error) {
logger.error('Failed to sync skipSandboxWarning:', error);
}
},
// Editor Configuration actions
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
// Terminal Configuration actions
setDefaultTerminalId: (terminalId) => set({ defaultTerminalId: terminalId }),
// Prompt Customization actions
setPromptCustomization: async (customization) => {
set({ promptCustomization: customization });
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { promptCustomization: customization });
} catch (error) {
logger.error('Failed to sync prompt customization:', error);
}
},
// Event Hook actions
setEventHooks: (hooks) => set({ eventHooks: hooks }),
// Claude-Compatible Provider actions (new system)
addClaudeCompatibleProvider: async (provider) => {
set((state) => ({
claudeCompatibleProviders: [...state.claudeCompatibleProviders, provider],
}));
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', {
claudeCompatibleProviders: get().claudeCompatibleProviders,
});
} catch (error) {
logger.error('Failed to sync Claude-compatible providers:', error);
}
},
updateClaudeCompatibleProvider: async (id, updates) => {
set((state) => ({
claudeCompatibleProviders: state.claudeCompatibleProviders.map((p) =>
p.id === id ? { ...p, ...updates } : p
),
}));
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', {
claudeCompatibleProviders: get().claudeCompatibleProviders,
});
} catch (error) {
logger.error('Failed to sync Claude-compatible providers:', error);
}
},
deleteClaudeCompatibleProvider: async (id) => {
set((state) => ({
claudeCompatibleProviders: state.claudeCompatibleProviders.filter((p) => p.id !== id),
}));
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', {
claudeCompatibleProviders: get().claudeCompatibleProviders,
});
} catch (error) {
logger.error('Failed to sync Claude-compatible providers:', error);
}
},
setClaudeCompatibleProviders: async (providers) => {
set({ claudeCompatibleProviders: providers });
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { claudeCompatibleProviders: providers });
} catch (error) {
logger.error('Failed to sync Claude-compatible providers:', error);
}
},
toggleClaudeCompatibleProviderEnabled: async (id) => {
set((state) => ({
claudeCompatibleProviders: state.claudeCompatibleProviders.map((p) =>
p.id === id ? { ...p, enabled: !p.enabled } : p
),
}));
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', {
claudeCompatibleProviders: get().claudeCompatibleProviders,
});
} catch (error) {
logger.error('Failed to sync Claude-compatible providers:', error);
}
},
// Claude API Profile actions (deprecated)
addClaudeApiProfile: async (profile) => {
set((state) => ({
claudeApiProfiles: [...state.claudeApiProfiles, profile],
}));
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { claudeApiProfiles: get().claudeApiProfiles });
} catch (error) {
logger.error('Failed to sync Claude API profiles:', error);
}
},
updateClaudeApiProfile: async (id, updates) => {
set((state) => ({
claudeApiProfiles: state.claudeApiProfiles.map((p) =>
p.id === id ? { ...p, ...updates } : p
),
}));
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { claudeApiProfiles: get().claudeApiProfiles });
} catch (error) {
logger.error('Failed to sync Claude API profiles:', error);
}
},
deleteClaudeApiProfile: async (id) => {
set((state) => ({
claudeApiProfiles: state.claudeApiProfiles.filter((p) => p.id !== id),
activeClaudeApiProfileId:
state.activeClaudeApiProfileId === id ? null : state.activeClaudeApiProfileId,
}));
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', {
claudeApiProfiles: get().claudeApiProfiles,
activeClaudeApiProfileId: get().activeClaudeApiProfileId,
});
} catch (error) {
logger.error('Failed to sync Claude API profiles:', error);
}
},
setActiveClaudeApiProfile: async (id) => {
set({ activeClaudeApiProfileId: id });
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { activeClaudeApiProfileId: id });
} catch (error) {
logger.error('Failed to sync active Claude API profile:', error);
}
},
setClaudeApiProfiles: async (profiles) => {
set({ claudeApiProfiles: profiles });
try {
const httpApi = getHttpApiClient();
await httpApi.put('/api/settings', { claudeApiProfiles: profiles });
} catch (error) {
logger.error('Failed to sync Claude API profiles:', error);
}
},
// MCP Server actions
addMCPServer: (server) =>
set((state) => ({
mcpServers: [
...state.mcpServers,
{ ...server, id: `mcp-${Date.now()}-${Math.random().toString(36).slice(2)}` },
],
})),
updateMCPServer: (id, updates) =>
set((state) => ({
mcpServers: state.mcpServers.map((s) => (s.id === id ? { ...s, ...updates } : s)),
})),
removeMCPServer: (id) =>
set((state) => ({
mcpServers: state.mcpServers.filter((s) => s.id !== id),
})),
reorderMCPServers: (oldIndex, newIndex) =>
set((state) => {
const servers = [...state.mcpServers];
const [removed] = servers.splice(oldIndex, 1);
servers.splice(newIndex, 0, removed);
return { mcpServers: servers };
}),
// Project Analysis actions
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }),
clearAnalysis: () => set({ projectAnalysis: null, isAnalyzing: false }),
// Agent Session actions
setLastSelectedSession: (projectPath, sessionId) =>
set((state) => ({
lastSelectedSessionByProject: {
...state.lastSelectedSessionByProject,
[projectPath]: sessionId ?? undefined,
} as Record<string, string>,
})),
getLastSelectedSession: (projectPath) => get().lastSelectedSessionByProject[projectPath] ?? null,
// Board Background actions
setBoardBackground: (projectPath, imagePath) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
imagePath,
imageVersion: Date.now(), // Bust cache on image change
},
},
})),
setCardOpacity: (projectPath, opacity) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardOpacity: opacity,
},
},
})),
setColumnOpacity: (projectPath, opacity) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
columnOpacity: opacity,
},
},
})),
setColumnBorderEnabled: (projectPath, enabled) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
columnBorderEnabled: enabled,
},
},
})),
getBoardBackground: (projectPath) =>
get().boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings,
setCardGlassmorphism: (projectPath, enabled) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardGlassmorphism: enabled,
},
},
})),
setCardBorderEnabled: (projectPath, enabled) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardBorderEnabled: enabled,
},
},
})),
setCardBorderOpacity: (projectPath, opacity) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
cardBorderOpacity: opacity,
},
},
})),
setHideScrollbar: (projectPath, hide) =>
set((state) => ({
boardBackgroundByProject: {
...state.boardBackgroundByProject,
[projectPath]: {
...(state.boardBackgroundByProject[projectPath] ?? defaultBackgroundSettings),
hideScrollbar: hide,
},
},
})),
clearBoardBackground: (projectPath) =>
set((state) => {
const newBackgrounds = { ...state.boardBackgroundByProject };
delete newBackgrounds[projectPath];
return { boardBackgroundByProject: newBackgrounds };
}),
// Terminal actions
setTerminalUnlocked: (unlocked, token) =>
set((state) => ({
terminalState: {
...state.terminalState,
isUnlocked: unlocked,
authToken: token ?? state.terminalState.authToken,
},
})),
setActiveTerminalSession: (sessionId) =>
set((state) => ({
terminalState: {
...state.terminalState,
activeSessionId: sessionId,
},
})),
toggleTerminalMaximized: (sessionId) =>
set((state) => ({
terminalState: {
...state.terminalState,
maximizedSessionId: state.terminalState.maximizedSessionId === sessionId ? null : sessionId,
},
})),
addTerminalToLayout: (sessionId, direction = 'horizontal', _targetSessionId, branchName) => {
set((state) => {
const { tabs, activeTabId } = state.terminalState;
// If no tabs exist, create a new one
if (tabs.length === 0) {
const newTabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`;
return {
terminalState: {
...state.terminalState,
tabs: [
{
id: newTabId,
name: 'Terminal 1',
layout: { type: 'terminal' as const, sessionId, branchName },
},
],
activeTabId: newTabId,
activeSessionId: sessionId,
},
};
}
// Find active tab
const activeTab = tabs.find((t) => t.id === activeTabId);
if (!activeTab) return state;
// If tab has no layout, add terminal directly
if (!activeTab.layout) {
return {
terminalState: {
...state.terminalState,
tabs: tabs.map((t) =>
t.id === activeTabId
? { ...t, layout: { type: 'terminal' as const, sessionId, branchName } }
: t
),
activeSessionId: sessionId,
},
};
}
// Add new terminal to split
const newLayout: TerminalPanelContent = {
type: 'split',
id: generateSplitId(),
direction,
panels: [activeTab.layout, { type: 'terminal' as const, sessionId, branchName }],
};
return {
terminalState: {
...state.terminalState,
tabs: tabs.map((t) => (t.id === activeTabId ? { ...t, layout: newLayout } : t)),
activeSessionId: sessionId,
},
};
});
},
removeTerminalFromLayout: (sessionId) => {
set((state) => {
const { tabs } = state.terminalState;
const removeFromLayout = (
layout: TerminalPanelContent | null
): TerminalPanelContent | null => {
if (!layout) return null;
if (layout.type === 'terminal' && layout.sessionId === sessionId) return null;
if (layout.type === 'testRunner' && layout.sessionId === sessionId) return null;
if (layout.type === 'split') {
const remainingPanels = layout.panels
.map(removeFromLayout)
.filter((p): p is TerminalPanelContent => p !== null);
if (remainingPanels.length === 0) return null;
if (remainingPanels.length === 1) return remainingPanels[0];
return { ...layout, panels: remainingPanels };
}
return layout;
};
const updatedTabs = tabs.map((t) => ({
...t,
layout: removeFromLayout(t.layout),
}));
// Find a new active session if the removed one was active
let newActiveSessionId = state.terminalState.activeSessionId;
if (newActiveSessionId === sessionId) {
// Find the first available session in any tab
for (const tab of updatedTabs) {
const findFirstSession = (layout: TerminalPanelContent | null): string | null => {
if (!layout) return null;
if (layout.type === 'terminal' || layout.type === 'testRunner') return layout.sessionId;
if (layout.type === 'split') {
for (const panel of layout.panels) {
const found = findFirstSession(panel);
if (found) return found;
}
}
return null;
};
const found = findFirstSession(tab.layout);
if (found) {
newActiveSessionId = found;
break;
}
}
if (newActiveSessionId === sessionId) newActiveSessionId = null;
}
return {
terminalState: {
...state.terminalState,
tabs: updatedTabs,
activeSessionId: newActiveSessionId,
maximizedSessionId:
state.terminalState.maximizedSessionId === sessionId
? null
: state.terminalState.maximizedSessionId,
},
};
});
},
swapTerminals: (sessionId1, sessionId2) => {
set((state) => {
const { tabs } = state.terminalState;
const swapInLayout = (layout: TerminalPanelContent | null): TerminalPanelContent | null => {
if (!layout) return null;
if (
(layout.type === 'terminal' || layout.type === 'testRunner') &&
layout.sessionId === sessionId1
) {
return { ...layout, sessionId: sessionId2 };
}
if (
(layout.type === 'terminal' || layout.type === 'testRunner') &&
layout.sessionId === sessionId2
) {
return { ...layout, sessionId: sessionId1 };
}
if (layout.type === 'split') {
return {
...layout,
panels: layout.panels
.map(swapInLayout)
.filter((p): p is TerminalPanelContent => p !== null),
};
}
return layout;
};
return {
terminalState: {
...state.terminalState,
tabs: tabs.map((t) => ({ ...t, layout: swapInLayout(t.layout) })),
},
};
});
},
clearTerminalState: () =>
set((state) => ({
terminalState: {
...state.terminalState,
tabs: [],
activeTabId: null,
activeSessionId: null,
maximizedSessionId: null,
},
})),
setTerminalPanelFontSize: (sessionId, fontSize) => {
set((state) => {
const { tabs } = state.terminalState;
const updateFontSize = (layout: TerminalPanelContent | null): TerminalPanelContent | null => {
if (!layout) return null;
if (layout.type === 'terminal' && layout.sessionId === sessionId) {
return { ...layout, fontSize };
}
if (layout.type === 'split') {
return {
...layout,
panels: layout.panels
.map(updateFontSize)
.filter((p): p is TerminalPanelContent => p !== null),
};
}
return layout;
};
return {
terminalState: {
...state.terminalState,
tabs: tabs.map((t) => ({ ...t, layout: updateFontSize(t.layout) })),
},
};
});
},
setTerminalDefaultFontSize: (fontSize) =>
set((state) => ({
terminalState: { ...state.terminalState, defaultFontSize: fontSize },
})),
setTerminalDefaultRunScript: (script) =>
set((state) => ({
terminalState: { ...state.terminalState, defaultRunScript: script },
})),
setTerminalScreenReaderMode: (enabled) =>
set((state) => ({
terminalState: { ...state.terminalState, screenReaderMode: enabled },
})),
setTerminalFontFamily: (fontFamily) =>
set((state) => ({
terminalState: { ...state.terminalState, fontFamily },
})),
setTerminalScrollbackLines: (lines) =>
set((state) => ({
terminalState: { ...state.terminalState, scrollbackLines: lines },
})),
setTerminalLineHeight: (lineHeight) =>
set((state) => ({
terminalState: { ...state.terminalState, lineHeight },
})),
setTerminalMaxSessions: (maxSessions) =>
set((state) => ({
terminalState: { ...state.terminalState, maxSessions },
})),
setTerminalLastActiveProjectPath: (projectPath) =>
set((state) => ({
terminalState: { ...state.terminalState, lastActiveProjectPath: projectPath },
})),
setOpenTerminalMode: (mode) =>
set((state) => ({
terminalState: { ...state.terminalState, openTerminalMode: mode },
})),
addTerminalTab: (name) => {
const newTabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const tabNumber = get().terminalState.tabs.length + 1;
set((state) => ({
terminalState: {
...state.terminalState,
tabs: [
...state.terminalState.tabs,
{ id: newTabId, name: name || `Terminal ${tabNumber}`, layout: null },
],
activeTabId: newTabId,
},
}));
return newTabId;
},
removeTerminalTab: (tabId) => {
set((state) => {
const tabIndex = state.terminalState.tabs.findIndex((t) => t.id === tabId);
const newTabs = state.terminalState.tabs.filter((t) => t.id !== tabId);
let newActiveTabId = state.terminalState.activeTabId;
if (newActiveTabId === tabId && newTabs.length > 0) {
// Select adjacent tab
const newIndex = Math.min(tabIndex, newTabs.length - 1);
newActiveTabId = newTabs[newIndex].id;
} else if (newTabs.length === 0) {
newActiveTabId = null;
}
// Find new active session from new active tab
let newActiveSessionId = state.terminalState.activeSessionId;
if (newActiveTabId) {
const newActiveTab = newTabs.find((t) => t.id === newActiveTabId);
if (newActiveTab?.layout) {
const findFirstSession = (layout: TerminalPanelContent): string | null => {
if (layout.type === 'terminal' || layout.type === 'testRunner') return layout.sessionId;
if (layout.type === 'split') {
for (const panel of layout.panels) {
const found = findFirstSession(panel);
if (found) return found;
}
}
return null;
};
newActiveSessionId = findFirstSession(newActiveTab.layout);
} else {
newActiveSessionId = null;
}
} else {
newActiveSessionId = null;
}
return {
terminalState: {
...state.terminalState,
tabs: newTabs,
activeTabId: newActiveTabId,
activeSessionId: newActiveSessionId,
},
};
});
},
setActiveTerminalTab: (tabId) => {
set((state) => {
const tab = state.terminalState.tabs.find((t) => t.id === tabId);
if (!tab) return state;
// Find first session in the tab's layout
let newActiveSessionId = state.terminalState.activeSessionId;
if (tab.layout) {
const findFirstSession = (layout: TerminalPanelContent): string | null => {
if (layout.type === 'terminal' || layout.type === 'testRunner') return layout.sessionId;
if (layout.type === 'split') {
for (const panel of layout.panels) {
const found = findFirstSession(panel);
if (found) return found;
}
}
return null;
};
newActiveSessionId = findFirstSession(tab.layout);
} else {
newActiveSessionId = null;
}
return {
terminalState: {
...state.terminalState,
activeTabId: tabId,
activeSessionId: newActiveSessionId,
},
};
});
},
renameTerminalTab: (tabId, name) =>
set((state) => ({
terminalState: {
...state.terminalState,
tabs: state.terminalState.tabs.map((t) => (t.id === tabId ? { ...t, name } : t)),
},
})),
reorderTerminalTabs: (fromTabId, toTabId) =>
set((state) => {
const tabs = [...state.terminalState.tabs];
const fromIndex = tabs.findIndex((t) => t.id === fromTabId);
const toIndex = tabs.findIndex((t) => t.id === toTabId);
if (fromIndex === -1 || toIndex === -1) return state;
const [removed] = tabs.splice(fromIndex, 1);
tabs.splice(toIndex, 0, removed);
return {
terminalState: { ...state.terminalState, tabs },
};
}),
moveTerminalToTab: (sessionId, targetTabId) => {
set((state) => {
const { tabs } = state.terminalState;
// Find the terminal panel to move
let panelToMove: TerminalPanelContent | null = null;
let sourceTabId: string | null = null;
for (const tab of tabs) {
const findPanel = (layout: TerminalPanelContent | null): TerminalPanelContent | null => {
if (!layout) return null;
if (
(layout.type === 'terminal' || layout.type === 'testRunner') &&
layout.sessionId === sessionId
) {
return layout;
}
if (layout.type === 'split') {
for (const panel of layout.panels) {
const found = findPanel(panel);
if (found) return found;
}
}
return null;
};
const found = findPanel(tab.layout);
if (found) {
panelToMove = found;
sourceTabId = tab.id;
break;
}
}
if (!panelToMove || !sourceTabId) return state;
// Remove from source tab
const removeFromLayout = (
layout: TerminalPanelContent | null
): TerminalPanelContent | null => {
if (!layout) return null;
if (
(layout.type === 'terminal' || layout.type === 'testRunner') &&
layout.sessionId === sessionId
) {
return null;
}
if (layout.type === 'split') {
const remainingPanels = layout.panels
.map(removeFromLayout)
.filter((p): p is TerminalPanelContent => p !== null);
if (remainingPanels.length === 0) return null;
if (remainingPanels.length === 1) return remainingPanels[0];
return { ...layout, panels: remainingPanels };
}
return layout;
};
let newTabs = tabs.map((t) =>
t.id === sourceTabId ? { ...t, layout: removeFromLayout(t.layout) } : t
);
// Add to target tab (or create new tab)
if (targetTabId === 'new') {
const newTabId = `tab-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const tabNumber = newTabs.length + 1;
newTabs = [
...newTabs,
{ id: newTabId, name: `Terminal ${tabNumber}`, layout: panelToMove },
];
return {
terminalState: {
...state.terminalState,
tabs: newTabs,
activeTabId: newTabId,
activeSessionId: sessionId,
},
};
} else {
newTabs = newTabs.map((t) => {
if (t.id !== targetTabId) return t;
if (!t.layout) {
return { ...t, layout: panelToMove };
}
return {
...t,
layout: {
type: 'split' as const,
id: generateSplitId(),
direction: 'horizontal' as const,
panels: [t.layout, panelToMove!],
},
};
});
return {
terminalState: {
...state.terminalState,
tabs: newTabs,
activeTabId: targetTabId,
activeSessionId: sessionId,
},
};
}
});
},
addTerminalToTab: (sessionId, tabId, direction = 'horizontal', branchName) => {
set((state) => {
const { tabs } = state.terminalState;
const targetTab = tabs.find((t) => t.id === tabId);
if (!targetTab) return state;
const newPanel: TerminalPanelContent = { type: 'terminal', sessionId, branchName };
if (!targetTab.layout) {
return {
terminalState: {
...state.terminalState,
tabs: tabs.map((t) => (t.id === tabId ? { ...t, layout: newPanel } : t)),
activeTabId: tabId,
activeSessionId: sessionId,
},
};
}
const newLayout: TerminalPanelContent = {
type: 'split',
id: generateSplitId(),
direction,
panels: [targetTab.layout, newPanel],
};
return {
terminalState: {
...state.terminalState,
tabs: tabs.map((t) => (t.id === tabId ? { ...t, layout: newLayout } : t)),
activeTabId: tabId,
activeSessionId: sessionId,
},
};
});
},
setTerminalTabLayout: (tabId, layout, activeSessionId) =>
set((state) => ({
terminalState: {
...state.terminalState,
tabs: state.terminalState.tabs.map((t) => (t.id === tabId ? { ...t, layout } : t)),
activeSessionId: activeSessionId ?? state.terminalState.activeSessionId,
},
})),
updateTerminalPanelSizes: (tabId, panelKeys, sizes) => {
set((state) => {
const { tabs } = state.terminalState;
const tab = tabs.find((t) => t.id === tabId);
if (!tab?.layout) return state;
const updateSizes = (layout: TerminalPanelContent): TerminalPanelContent => {
if (layout.type === 'split') {
// Find matching panels and update sizes
const updatedPanels = layout.panels.map((panel, index) => {
// Generate key for this panel
const panelKey =
panel.type === 'split'
? panel.id
: panel.type === 'terminal' || panel.type === 'testRunner'
? panel.sessionId
: '';
const keyIndex = panelKeys.indexOf(panelKey);
if (keyIndex !== -1 && sizes[keyIndex] !== undefined) {
return { ...panel, size: sizes[keyIndex] };
}
// Recursively update nested splits
if (panel.type === 'split') {
return updateSizes(panel);
}
return panel;
});
return { ...layout, panels: updatedPanels };
}
return layout;
};
return {
terminalState: {
...state.terminalState,
tabs: tabs.map((t) => (t.id === tabId ? { ...t, layout: updateSizes(t.layout!) } : t)),
},
};
});
},
saveTerminalLayout: (projectPath) => {
const state = get();
const { terminalState } = state;
const persistLayout = (layout: TerminalPanelContent | null): PersistedTerminalPanel | null => {
if (!layout) return null;
if (layout.type === 'terminal') {
return {
type: 'terminal',
size: layout.size,
fontSize: layout.fontSize,
sessionId: layout.sessionId,
branchName: layout.branchName,
};
}
if (layout.type === 'testRunner') {
return {
type: 'testRunner',
size: layout.size,
sessionId: layout.sessionId,
worktreePath: layout.worktreePath,
};
}
if (layout.type === 'split') {
return {
type: 'split',
id: layout.id,
direction: layout.direction,
panels: layout.panels
.map(persistLayout)
.filter((p): p is PersistedTerminalPanel => p !== null),
size: layout.size,
};
}
return null;
};
const persistedState: PersistedTerminalState = {
tabs: terminalState.tabs.map((t) => ({
id: t.id,
name: t.name,
layout: persistLayout(t.layout),
})),
activeTabIndex: terminalState.tabs.findIndex((t) => t.id === terminalState.activeTabId),
defaultFontSize: terminalState.defaultFontSize,
defaultRunScript: terminalState.defaultRunScript,
screenReaderMode: terminalState.screenReaderMode,
fontFamily: terminalState.fontFamily,
scrollbackLines: terminalState.scrollbackLines,
lineHeight: terminalState.lineHeight,
};
set((state) => ({
terminalLayoutByProject: {
...state.terminalLayoutByProject,
[projectPath]: persistedState,
},
}));
},
getPersistedTerminalLayout: (projectPath) => get().terminalLayoutByProject[projectPath] ?? null,
clearPersistedTerminalLayout: (projectPath) =>
set((state) => {
const newLayouts = { ...state.terminalLayoutByProject };
delete newLayouts[projectPath];
return { terminalLayoutByProject: newLayouts };
}),
// Spec Creation actions
setSpecCreatingForProject: (projectPath) => set({ specCreatingForProject: projectPath }),
isSpecCreatingForProject: (projectPath) => get().specCreatingForProject === projectPath,
setDefaultPlanningMode: (mode) => set({ defaultPlanningMode: mode }),
setDefaultRequirePlanApproval: (require) => set({ defaultRequirePlanApproval: require }),
setDefaultFeatureModel: (entry) => set({ defaultFeatureModel: entry }),
// Plan Approval actions
setPendingPlanApproval: (approval) => set({ pendingPlanApproval: approval }),
// Pipeline actions
setPipelineConfig: (projectPath, config) =>
set((state) => ({
pipelineConfigByProject: {
...state.pipelineConfigByProject,
[projectPath]: config,
},
})),
getPipelineConfig: (projectPath) => get().pipelineConfigByProject[projectPath] ?? null,
addPipelineStep: (projectPath, step) => {
const newStep: PipelineStep = {
...step,
id: `step-${Date.now()}-${Math.random().toString(36).slice(2)}`,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
set((state) => {
const config = state.pipelineConfigByProject[projectPath] ?? {
steps: [],
version: 1,
};
return {
pipelineConfigByProject: {
...state.pipelineConfigByProject,
[projectPath]: {
...config,
steps: [...config.steps, newStep],
},
},
};
});
return newStep;
},
updatePipelineStep: (projectPath, stepId, updates) =>
set((state) => {
const config = state.pipelineConfigByProject[projectPath];
if (!config) return state;
return {
pipelineConfigByProject: {
...state.pipelineConfigByProject,
[projectPath]: {
...config,
steps: config.steps.map((s) =>
s.id === stepId ? { ...s, ...updates, updatedAt: new Date().toISOString() } : s
),
},
},
};
}),
deletePipelineStep: (projectPath, stepId) =>
set((state) => {
const config = state.pipelineConfigByProject[projectPath];
if (!config) return state;
return {
pipelineConfigByProject: {
...state.pipelineConfigByProject,
[projectPath]: {
...config,
steps: config.steps.filter((s) => s.id !== stepId),
},
},
};
}),
reorderPipelineSteps: (projectPath, stepIds) =>
set((state) => {
const config = state.pipelineConfigByProject[projectPath];
if (!config) return state;
const stepMap = new Map(config.steps.map((s) => [s.id, s]));
const reorderedSteps = stepIds
.map((id) => stepMap.get(id))
.filter((s): s is PipelineStep => !!s);
return {
pipelineConfigByProject: {
...state.pipelineConfigByProject,
[projectPath]: {
...config,
steps: reorderedSteps,
},
},
};
}),
// Worktree Panel Visibility actions
setWorktreePanelVisible: (projectPath, visible) =>
set((state) => ({
worktreePanelVisibleByProject: {
...state.worktreePanelVisibleByProject,
[projectPath]: visible,
},
})),
getWorktreePanelVisible: (projectPath) =>
get().worktreePanelVisibleByProject[projectPath] ?? true,
// Init Script Indicator Visibility actions
setShowInitScriptIndicator: (projectPath, visible) =>
set((state) => ({
showInitScriptIndicatorByProject: {
...state.showInitScriptIndicatorByProject,
[projectPath]: visible,
},
})),
getShowInitScriptIndicator: (projectPath) =>
get().showInitScriptIndicatorByProject[projectPath] ?? true,
// Default Delete Branch actions
setDefaultDeleteBranch: (projectPath, deleteBranch) =>
set((state) => ({
defaultDeleteBranchByProject: {
...state.defaultDeleteBranchByProject,
[projectPath]: deleteBranch,
},
})),
getDefaultDeleteBranch: (projectPath) => get().defaultDeleteBranchByProject[projectPath] ?? false,
// Auto-dismiss Init Script Indicator actions
setAutoDismissInitScriptIndicator: (projectPath, autoDismiss) =>
set((state) => ({
autoDismissInitScriptIndicatorByProject: {
...state.autoDismissInitScriptIndicatorByProject,
[projectPath]: autoDismiss,
},
})),
getAutoDismissInitScriptIndicator: (projectPath) =>
get().autoDismissInitScriptIndicatorByProject[projectPath] ?? true,
// Use Worktrees Override actions
setProjectUseWorktrees: (projectPath, useWorktrees) =>
set((state) => ({
useWorktreesByProject: {
...state.useWorktreesByProject,
[projectPath]: useWorktrees ?? undefined,
},
})),
getProjectUseWorktrees: (projectPath) => get().useWorktreesByProject[projectPath],
getEffectiveUseWorktrees: (projectPath) => {
const projectOverride = get().useWorktreesByProject[projectPath];
return projectOverride !== undefined ? projectOverride : get().useWorktrees;
},
// UI State actions
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
setRecentFolders: (folders) => set({ recentFolders: folders }),
addRecentFolder: (folder) =>
set((state) => {
const filtered = state.recentFolders.filter((f) => f !== folder);
return { recentFolders: [folder, ...filtered].slice(0, 10) };
}),
// Claude Usage Tracking actions
setClaudeRefreshInterval: (interval) => set({ claudeRefreshInterval: interval }),
setClaudeUsageLastUpdated: (timestamp) => set({ claudeUsageLastUpdated: timestamp }),
setClaudeUsage: (usage) => set({ claudeUsage: usage, claudeUsageLastUpdated: Date.now() }),
// Codex Usage Tracking actions
setCodexUsage: (usage) => set({ codexUsage: usage, codexUsageLastUpdated: Date.now() }),
// Codex Models actions
fetchCodexModels: async (forceRefresh = false) => {
const state = get();
const now = Date.now();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const RETRY_DELAY = 30 * 1000; // 30 seconds after failure
// Skip if already loading
if (state.codexModelsLoading) return;
// Skip if recently fetched (unless force refresh)
if (
!forceRefresh &&
state.codexModelsLastFetched &&
now - state.codexModelsLastFetched < CACHE_DURATION
) {
return;
}
// Skip if recently failed (unless force refresh)
if (
!forceRefresh &&
state.codexModelsLastFailedAt &&
now - state.codexModelsLastFailedAt < RETRY_DELAY
) {
return;
}
set({ codexModelsLoading: true, codexModelsError: null });
try {
const httpApi = getHttpApiClient();
const response = await httpApi.get('/api/codex/models');
const data = response.data as {
success: boolean;
models?: Array<{
id: string;
label: string;
description: string;
hasThinking: boolean;
supportsVision: boolean;
tier: 'premium' | 'standard' | 'basic';
isDefault: boolean;
}>;
error?: string;
};
if (data.success && data.models) {
set({
codexModels: data.models,
codexModelsLoading: false,
codexModelsLastFetched: now,
codexModelsError: null,
});
} else {
set({
codexModelsLoading: false,
codexModelsError: data.error || 'Failed to fetch Codex models',
codexModelsLastFailedAt: now,
});
}
} catch (error) {
set({
codexModelsLoading: false,
codexModelsError: error instanceof Error ? error.message : 'Unknown error',
codexModelsLastFailedAt: now,
});
}
},
setCodexModels: (models) => set({ codexModels: models }),
// OpenCode Models actions
fetchOpencodeModels: async (forceRefresh = false) => {
const state = get();
const now = Date.now();
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const RETRY_DELAY = 30 * 1000; // 30 seconds after failure
// Skip if already loading
if (state.opencodeModelsLoading) return;
// Skip if recently fetched (unless force refresh)
if (
!forceRefresh &&
state.opencodeModelsLastFetched &&
now - state.opencodeModelsLastFetched < CACHE_DURATION
) {
return;
}
// Skip if recently failed (unless force refresh)
if (
!forceRefresh &&
state.opencodeModelsLastFailedAt &&
now - state.opencodeModelsLastFailedAt < RETRY_DELAY
) {
return;
}
set({ opencodeModelsLoading: true, opencodeModelsError: null });
try {
const httpApi = getHttpApiClient();
const response = await httpApi.get('/api/opencode/models');
const data = response.data as {
success: boolean;
models?: ModelDefinition[];
providers?: Array<{
id: string;
name: string;
authenticated: boolean;
authMethod?: string;
}>;
error?: string;
};
if (data.success && data.models) {
// Filter out Bedrock models
const filteredModels = data.models.filter(
(m) => !m.id.startsWith(OPENCODE_BEDROCK_MODEL_PREFIX)
);
set({
dynamicOpencodeModels: filteredModels,
cachedOpencodeProviders: data.providers ?? [],
opencodeModelsLoading: false,
opencodeModelsLastFetched: now,
opencodeModelsError: null,
});
} else {
set({
opencodeModelsLoading: false,
opencodeModelsError: data.error || 'Failed to fetch OpenCode models',
opencodeModelsLastFailedAt: now,
});
}
} catch (error) {
set({
opencodeModelsLoading: false,
opencodeModelsError: error instanceof Error ? error.message : 'Unknown error',
opencodeModelsLastFailedAt: now,
});
}
},
// Init Script State actions
setInitScriptState: (projectPath, branch, state) => {
const key = `${projectPath}::${branch}`;
set((s) => ({
initScriptState: {
...s.initScriptState,
[key]: {
...s.initScriptState[key],
branch,
output: s.initScriptState[key]?.output ?? [],
status: s.initScriptState[key]?.status ?? 'idle',
...state,
},
},
}));
},
appendInitScriptOutput: (projectPath, branch, content) => {
const key = `${projectPath}::${branch}`;
set((s) => {
const current = s.initScriptState[key];
if (!current) return s;
// Split content by newlines and add each line
const newLines = content.split('\n').filter((line) => line.length > 0);
const combinedOutput = [...current.output, ...newLines];
// Limit to MAX_INIT_OUTPUT_LINES
const limitedOutput = combinedOutput.slice(-MAX_INIT_OUTPUT_LINES);
return {
initScriptState: {
...s.initScriptState,
[key]: {
...current,
output: limitedOutput,
},
},
};
});
},
clearInitScriptState: (projectPath, branch) => {
const key = `${projectPath}::${branch}`;
set((s) => {
const newState = { ...s.initScriptState };
delete newState[key];
return { initScriptState: newState };
});
},
getInitScriptState: (projectPath, branch) => {
const key = `${projectPath}::${branch}`;
return get().initScriptState[key] ?? null;
},
getInitScriptStatesForProject: (projectPath) => {
const prefix = `${projectPath}::`;
const states = get().initScriptState;
return Object.entries(states)
.filter(([key]) => key.startsWith(prefix))
.map(([key, state]) => ({ key, state }));
},
// Reset
reset: () => set(initialState),
}));