Files
automaker/apps/ui/src/store/app-store.ts
2026-03-03 19:59:55 -08:00

3093 lines
100 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 { saveProjects, saveTrashedProjects } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { createLogger } from '@automaker/utils/logger';
// Note: setItem/getItem moved to ./utils/theme-utils.ts
import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS } from '@/config/ui-font-options';
import { loadFont } from '@/styles/font-imports';
import type {
FeatureImagePath,
FeatureTextFilePath,
ModelAlias,
PlanningMode,
ThinkingLevel,
ReasoningEffort,
ModelProvider,
PhaseModelKey,
PhaseModelEntry,
PipelineStep,
ModelDefinition,
ServerLogLevel,
ParsedTask,
PlanSpec,
FeatureTemplate,
} 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,
getThinkingLevelsForModel,
} from '@automaker/types';
// Import types from modular type files
import {
// UI types
type ViewMode,
type ThemeMode,
type BoardViewMode,
type ShortcutKey,
type KeyboardShortcuts,
type BackgroundSettings,
// 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,
type ZaiPlanType,
type ZaiQuotaLimit,
type ZaiUsage,
type ZaiUsageResponse,
type GeminiQuotaBucket,
type GeminiTierQuota,
type GeminiUsage,
type GeminiUsageResponse,
} from './types';
// Import utility functions from modular utils files
import {
THEME_STORAGE_KEY,
getStoredTheme,
getStoredFontSans,
getStoredFontMono,
parseShortcut,
formatShortcut,
DEFAULT_KEYBOARD_SHORTCUTS,
isClaudeUsageAtLimit,
} from './utils';
// Import default values from modular defaults files
import { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES } from './defaults';
// Import internal theme utils (not re-exported publicly)
import {
getEffectiveFont,
saveThemeToStorage,
saveFontSansToStorage,
saveFontMonoToStorage,
persistEffectiveThemeForProject,
} from './utils/theme-utils';
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,
BackgroundSettings,
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,
ZaiPlanType,
ZaiQuotaLimit,
ZaiUsage,
ZaiUsageResponse,
GeminiQuotaBucket,
GeminiTierQuota,
GeminiUsage,
GeminiUsageResponse,
};
// Re-export values from ./types for backward compatibility
export { generateSplitId };
// Re-export utilities from ./utils for backward compatibility
export {
THEME_STORAGE_KEY,
getStoredTheme,
getStoredFontSans,
getStoredFontMono,
parseShortcut,
formatShortcut,
DEFAULT_KEYBOARD_SHORTCUTS,
isClaudeUsageAtLimit,
};
// Re-export defaults from ./defaults for backward compatibility
export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES } from './defaults';
// NOTE: Type definitions moved to ./types/ directory, utilities moved to ./utils/ directory
// The following inline types have been replaced with imports above:
// - ViewMode, ThemeMode, BoardViewMode (./types/ui-types.ts)
// - ShortcutKey, KeyboardShortcuts (./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/Zai/Gemini usage types (./types/usage-types.ts)
// The following utility functions have been moved to ./utils/:
// - Theme utilities: THEME_STORAGE_KEY, getStoredTheme, getStoredFontSans, getStoredFontMono, etc. (./utils/theme-utils.ts)
// - Shortcut utilities: parseShortcut, formatShortcut, DEFAULT_KEYBOARD_SHORTCUTS (./utils/shortcut-utils.ts)
// - Usage utilities: isClaudeUsageAtLimit (./utils/usage-utils.ts)
// The following default values have been moved to ./defaults/:
// - MAX_INIT_OUTPUT_LINES (./defaults/constants.ts)
// - defaultBackgroundSettings (./defaults/background-settings.ts)
// - defaultTerminalState (./defaults/terminal-defaults.ts)
// Type definitions are imported from ./types/state-types.ts
// AppActions interface is defined in ./types/state-types.ts
/**
* Pre-populate sidebar/UI state from the UI cache at module load time.
* This runs synchronously before createRoot().render(), so the very first
* React render uses the correct sidebar width — eliminating the layout shift
* (wide sidebar → collapsed) that was visible when auth was pre-populated
* but sidebar state wasn't.
*/
function getInitialUIState(): {
sidebarOpen: boolean;
sidebarStyle: 'unified' | 'discord';
collapsedNavSections: Record<string, boolean>;
} {
try {
const raw = localStorage.getItem('automaker-ui-cache');
if (raw) {
const wrapper = JSON.parse(raw);
// zustand/persist wraps state under a "state" key
const cache = wrapper?.state;
if (cache) {
return {
sidebarOpen:
typeof cache.cachedSidebarOpen === 'boolean' ? cache.cachedSidebarOpen : true,
sidebarStyle: cache.cachedSidebarStyle === 'discord' ? 'discord' : 'unified',
collapsedNavSections: (() => {
const raw = cache.cachedCollapsedNavSections;
if (
raw &&
typeof raw === 'object' &&
!Array.isArray(raw) &&
Object.getOwnPropertyNames(raw).every((k) => typeof raw[k] === 'boolean')
) {
return raw as Record<string, boolean>;
}
return {};
})(),
};
}
}
} catch {
// fall through to defaults
}
return { sidebarOpen: true, sidebarStyle: 'unified', collapsedNavSections: {} };
}
const cachedUI = getInitialUIState();
const initialState: AppState = {
projects: [],
currentProject: null,
trashedProjects: [],
projectHistory: [],
projectHistoryIndex: -1,
currentView: 'welcome',
sidebarOpen: cachedUI.sidebarOpen,
sidebarStyle: cachedUI.sidebarStyle,
collapsedNavSections: cachedUI.collapsedNavSections,
mobileSidebarHidden: false,
lastSelectedSessionByProject: {},
agentModelBySession: {},
theme: getStoredTheme() || 'dark',
fontFamilySans: getStoredFontSans(),
fontFamilyMono: getStoredFontMono(),
features: [],
appSpec: '',
ipcConnected: false,
apiKeys: {
anthropic: '',
google: '',
openai: '',
zai: '',
},
chatSessions: [],
currentChatSession: null,
chatHistoryOpen: false,
autoModeByWorktree: {},
autoModeActivityLog: [],
recentlyCompletedFeatures: new Set<string>(),
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
boardViewMode: 'kanban',
defaultSkipTests: true,
enableDependencyBlocking: true,
skipVerificationInAutoMode: false,
enableAiCommitMessages: true,
mergePostAction: null,
planUseSelectedWorktreeBranch: true,
addFeatureUseSelectedWorktreeBranch: false,
useWorktrees: true,
currentWorktreeByProject: {},
worktreesByProject: {},
keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS,
muteDoneSound: false,
disableSplashScreen: false,
defaultSortNewestCardOnTop: 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,
codexAdditionalDirs: [],
codexThreadId: undefined,
enabledOpencodeModels: getAllOpencodeModelIds(),
opencodeDefaultModel: DEFAULT_OPENCODE_MODEL,
dynamicOpencodeModels: [],
enabledDynamicModelIds: [],
knownDynamicModelIds: [],
cachedOpencodeProviders: [],
opencodeModelsLoading: false,
opencodeModelsError: null,
opencodeModelsLastFetched: null,
opencodeModelsLastFailedAt: null,
enabledGeminiModels: getAllGeminiModelIds(),
geminiDefaultModel: DEFAULT_GEMINI_MODEL,
enabledCopilotModels: getAllCopilotModelIds(),
copilotDefaultModel: DEFAULT_COPILOT_MODEL,
disabledProviders: [],
autoLoadClaudeMd: false,
useClaudeCodeSystemPrompt: true,
skipSandboxWarning: false,
mcpServers: [],
defaultEditorCommand: null,
editorFontSize: 13,
editorFontFamily: 'default',
editorAutoSave: false,
editorAutoSaveDelay: 1000,
defaultTerminalId: null,
enableSkills: true,
skillsSources: ['user', 'project'] as Array<'user' | 'project'>,
enableSubagents: true,
subagentsSources: ['user', 'project'] as Array<'user' | 'project'>,
promptCustomization: {},
eventHooks: [],
ntfyEndpoints: [],
featureTemplates: DEFAULT_GLOBAL_SETTINGS.featureTemplates ?? [],
claudeCompatibleProviders: [],
claudeApiProfiles: [],
activeClaudeApiProfileId: null,
projectAnalysis: null,
isAnalyzing: false,
boardBackgroundByProject: {},
previewTheme: null,
terminalState: defaultTerminalState,
terminalLayoutByProject: {},
specCreatingForProject: null,
defaultPlanningMode: 'skip' as PlanningMode,
defaultRequirePlanApproval: false,
defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel,
defaultThinkingLevel: DEFAULT_GLOBAL_SETTINGS.defaultThinkingLevel ?? 'adaptive',
defaultReasoningEffort: DEFAULT_GLOBAL_SETTINGS.defaultReasoningEffort ?? 'none',
defaultMaxTurns: DEFAULT_GLOBAL_SETTINGS.defaultMaxTurns ?? 10000,
pendingPlanApproval: null,
claudeRefreshInterval: 60,
claudeUsage: null,
claudeUsageLastUpdated: null,
codexUsage: null,
codexUsageLastUpdated: null,
zaiUsage: null,
zaiUsageLastUpdated: null,
geminiUsage: null,
geminiUsageLastUpdated: null,
codexModels: [],
codexModelsLoading: false,
codexModelsError: null,
codexModelsLastFetched: null,
codexModelsLastFailedAt: null,
pipelineConfigByProject: {},
worktreePanelVisibleByProject: {},
showInitScriptIndicatorByProject: {},
defaultDeleteBranchByProject: {},
autoDismissInitScriptIndicatorByProject: {},
useWorktreesByProject: {},
worktreeCopyFilesByProject: {},
pinnedWorktreesCountByProject: {},
pinnedWorktreeBranchesByProject: {},
worktreeDropdownThresholdByProject: {},
alwaysUseWorktreeDropdownByProject: {},
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: string) => {
set((state) => ({
projects: state.projects.filter((p) => p.id !== projectId),
currentProject: state.currentProject?.id === projectId ? null : state.currentProject,
}));
// Persist to storage
saveProjects(get().projects);
},
moveProjectToTrash: (projectId: string) => {
const project = get().projects.find((p) => p.id === projectId);
if (!project) return;
const trashedProject: TrashedProject = {
...project,
trashedAt: new Date().toISOString(),
};
set((state) => ({
projects: state.projects.filter((p) => p.id !== projectId),
trashedProjects: [...state.trashedProjects, trashedProject],
currentProject: state.currentProject?.id === projectId ? null : state.currentProject,
}));
// Persist to storage
saveProjects(get().projects);
saveTrashedProjects(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 storage
saveProjects(get().projects);
saveTrashedProjects(get().trashedProjects);
},
deleteTrashedProject: (projectId: string) => {
set((state) => ({
trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId),
}));
// Persist to storage
saveTrashedProjects(get().trashedProjects);
},
emptyTrash: () => {
set({ trashedProjects: [] });
// Persist to storage
saveTrashedProjects([]);
},
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 storage (small delay to ensure state is updated)
setTimeout(() => {
saveProjects(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 storage
saveProjects(get().projects);
},
setProjectIcon: (projectId: string, icon: string | null) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, icon: icon ?? undefined } : p
),
// Also update currentProject if it's the one being modified
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, icon: icon ?? undefined }
: state.currentProject,
}));
// Persist to storage
saveProjects(get().projects);
},
setProjectCustomIcon: (projectId: string, customIconPath: string | null) => {
set((state) => ({
projects: state.projects.map((p) =>
p.id === projectId ? { ...p, customIconPath: customIconPath ?? undefined } : p
),
// Also update currentProject if it's the one being modified
currentProject:
state.currentProject?.id === projectId
? { ...state.currentProject, customIconPath: customIconPath ?? undefined }
: state.currentProject,
}));
// Persist to storage
saveProjects(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 storage
saveProjects(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 storage
saveProjects(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 - triggers lazy font loading for on-demand fonts
setFontSans: (fontFamily) => {
if (fontFamily) loadFont(fontFamily);
set({ fontFamilySans: fontFamily });
saveFontSansToStorage(fontFamily);
},
setFontMono: (fontFamily) => {
if (fontFamily) loadFont(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 storage
saveProjects(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 storage
saveProjects(get().projects);
},
getEffectiveFontSans: () => {
const state = get();
const projectFont = state.currentProject?.fontFamilySans;
return getEffectiveFont(projectFont, state.fontFamilySans, UI_SANS_FONT_OPTIONS);
},
getEffectiveFontMono: () => {
const state = get();
const projectFont = state.currentProject?.fontFamilyMono;
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 storage
saveProjects(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 storage
saveProjects(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 storage
saveProjects(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 storage
saveProjects(get().projects);
},
// Feature actions
setFeatures: (features) => set({ features }),
updateFeature: (id, updates) =>
set((state) => ({
features: state.features.map((f) => (f.id === id ? { ...f, ...updates } : f)),
})),
batchUpdateFeatures: (ids, updates) => {
if (ids.length === 0) return;
const idSet = new Set(ids);
set((state) => ({
features: state.features.map((f) => (idSet.has(f.id) ? { ...f, ...updates } : f)),
}));
},
addFeature: (feature) => {
const id = feature.id ?? `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const newFeature = { ...feature, id } as Feature;
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) => {
const { [sessionId]: _removed, ...remainingAgentModels } = state.agentModelBySession;
return {
chatSessions: state.chatSessions.filter((s) => s.id !== sessionId),
currentChatSession:
state.currentChatSession?.id === sessionId ? null : state.currentChatSession,
agentModelBySession: remainingAgentModels,
};
}),
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: false,
runningTasks: [],
branchName,
};
// Prevent duplicate entries - the same feature can trigger multiple
// auto_mode_feature_start events (e.g., from execution-service and
// pipeline-orchestrator), so we must guard against adding the same
// taskId more than once.
if (current.runningTasks.includes(taskId)) {
return state;
}
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;
// Idempotent: skip if task is not in the list to avoid creating new
// object references that trigger unnecessary re-renders.
if (!current.runningTasks.includes(taskId)) 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: [] }),
addRecentlyCompletedFeature: (featureId: string) => {
set((state) => {
// Idempotent: skip if already tracked to avoid creating a new Set reference
// that triggers unnecessary re-renders in useBoardColumnFeatures.
if (state.recentlyCompletedFeatures.has(featureId)) return state;
const newSet = new Set(state.recentlyCompletedFeatures);
newSet.add(featureId);
return { recentlyCompletedFeatures: newSet };
});
},
clearRecentlyCompletedFeatures: () => {
// Idempotent: skip if already empty to avoid creating a new Set reference.
if (get().recentlyCompletedFeatures.size === 0) return;
set({ recentlyCompletedFeatures: new Set() });
},
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.settings.updateGlobal({ 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.settings.updateGlobal({ enableAiCommitMessages: enabled });
} catch (error) {
logger.error('Failed to sync enableAiCommitMessages:', error);
}
},
setMergePostAction: async (action) => {
set({ mergePostAction: action });
// Sync to server
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ mergePostAction: action });
} catch (error) {
logger.error('Failed to sync mergePostAction:', error);
}
},
setPlanUseSelectedWorktreeBranch: async (enabled) => {
set({ planUseSelectedWorktreeBranch: enabled });
// Sync to server
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ 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.settings.updateGlobal({ 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 }),
// Board Card Sorting (global default) actions
setDefaultSortNewestCardOnTop: (enabled) => set({ defaultSortNewestCardOnTop: enabled }),
// 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.settings.updateGlobal({ 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.settings.updateGlobal({ 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.settings.updateGlobal({ 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.settings.updateGlobal({ codexAutoLoadAgents: enabled });
} catch (error) {
logger.error('Failed to sync codexAutoLoadAgents:', error);
}
},
setCodexSandboxMode: async (mode) => {
set({ codexSandboxMode: mode });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ codexSandboxMode: mode });
} catch (error) {
logger.error('Failed to sync codexSandboxMode:', error);
}
},
setCodexApprovalPolicy: async (policy) => {
set({ codexApprovalPolicy: policy });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ codexApprovalPolicy: policy });
} catch (error) {
logger.error('Failed to sync codexApprovalPolicy:', error);
}
},
setCodexEnableWebSearch: async (enabled) => {
set({ codexEnableWebSearch: enabled });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ codexEnableWebSearch: enabled });
} catch (error) {
logger.error('Failed to sync codexEnableWebSearch:', error);
}
},
setCodexEnableImages: async (enabled) => {
set({ codexEnableImages: enabled });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ codexEnableImages: enabled });
} catch (error) {
logger.error('Failed to sync codexEnableImages:', error);
}
},
// OpenCode CLI Settings actions
setEnabledOpencodeModels: (models) => set({ enabledOpencodeModels: models }),
setOpencodeDefaultModel: async (model) => {
set({ opencodeDefaultModel: model });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ opencodeDefaultModel: model });
} catch (error) {
logger.error('Failed to sync opencodeDefaultModel:', error);
}
},
toggleOpencodeModel: async (model, enabled) => {
set((state) => ({
enabledOpencodeModels: enabled
? [...new Set([...state.enabledOpencodeModels, model])]
: state.enabledOpencodeModels.filter((m) => m !== model),
}));
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ enabledOpencodeModels: get().enabledOpencodeModels });
} catch (error) {
logger.error('Failed to sync enabledOpencodeModels:', error);
}
},
setDynamicOpencodeModels: (models) => set({ dynamicOpencodeModels: models }),
setEnabledDynamicModelIds: async (ids) => {
const deduped = Array.from(new Set(ids));
set({ enabledDynamicModelIds: deduped });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ enabledDynamicModelIds: deduped });
} catch (error) {
logger.error('Failed to sync enabledDynamicModelIds:', error);
}
},
toggleDynamicModel: async (modelId, enabled) => {
set((state) => ({
enabledDynamicModelIds: enabled
? [...new Set([...state.enabledDynamicModelIds, modelId])]
: state.enabledDynamicModelIds.filter((id) => id !== modelId),
}));
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ enabledDynamicModelIds: get().enabledDynamicModelIds });
} catch (error) {
logger.error('Failed to sync enabledDynamicModelIds:', error);
}
},
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.settings.updateGlobal({ autoLoadClaudeMd: enabled });
} catch (error) {
logger.error('Failed to sync autoLoadClaudeMd:', error);
}
},
setUseClaudeCodeSystemPrompt: async (enabled) => {
set({ useClaudeCodeSystemPrompt: enabled });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ useClaudeCodeSystemPrompt: enabled });
} catch (error) {
logger.error('Failed to sync useClaudeCodeSystemPrompt:', error);
}
},
setSkipSandboxWarning: async (skip) => {
set({ skipSandboxWarning: skip });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ skipSandboxWarning: skip });
} catch (error) {
logger.error('Failed to sync skipSandboxWarning:', error);
}
},
// Editor Configuration actions
setDefaultEditorCommand: (command) => set({ defaultEditorCommand: command }),
// File Editor Settings actions
setEditorFontSize: (size) => set({ editorFontSize: size }),
setEditorFontFamily: (fontFamily) => set({ editorFontFamily: fontFamily }),
setEditorAutoSave: (enabled) => set({ editorAutoSave: enabled }),
setEditorAutoSaveDelay: (delay) => set({ editorAutoSaveDelay: delay }),
// Terminal Configuration actions
setDefaultTerminalId: (terminalId) => set({ defaultTerminalId: terminalId }),
// Prompt Customization actions
setPromptCustomization: async (customization) => {
set({ promptCustomization: customization });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ promptCustomization: customization });
} catch (error) {
logger.error('Failed to sync prompt customization:', error);
}
},
// Event Hook actions
setEventHooks: async (hooks) => {
set({ eventHooks: hooks });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({
eventHooks: hooks,
// Signal the server that an empty array is intentional (not a wipe from stale state)
...(hooks.length === 0 ? { __allowEmptyEventHooks: true } : {}),
});
} catch (error) {
logger.error('Failed to sync event hooks:', error);
}
},
// Ntfy Endpoint actions
setNtfyEndpoints: async (endpoints) => {
set({ ntfyEndpoints: endpoints });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({
ntfyEndpoints: endpoints,
// Signal the server that an empty array is intentional (not a wipe from stale state)
...(endpoints.length === 0 ? { __allowEmptyNtfyEndpoints: true } : {}),
});
} catch (error) {
logger.error('Failed to sync ntfy endpoints:', error);
}
},
// Feature Template actions
setFeatureTemplates: async (templates) => {
set({ featureTemplates: templates });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ featureTemplates: templates });
} catch (error) {
logger.error('Failed to sync feature templates:', error);
}
},
addFeatureTemplate: async (template) => {
set((state) => ({
featureTemplates: [...state.featureTemplates, template],
}));
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
} catch (error) {
logger.error('Failed to sync feature templates:', error);
}
},
updateFeatureTemplate: async (id, updates) => {
set((state) => ({
featureTemplates: state.featureTemplates.map((t) => (t.id === id ? { ...t, ...updates } : t)),
}));
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
} catch (error) {
logger.error('Failed to sync feature templates:', error);
}
},
deleteFeatureTemplate: async (id) => {
set((state) => ({
featureTemplates: state.featureTemplates.filter((t) => t.id !== id),
}));
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
} catch (error) {
logger.error('Failed to sync feature templates:', error);
}
},
reorderFeatureTemplates: async (templateIds) => {
set((state) => {
const templateMap = new Map(state.featureTemplates.map((t) => [t.id, t]));
const reordered: FeatureTemplate[] = [];
templateIds.forEach((id, index) => {
const template = templateMap.get(id);
if (template) {
reordered.push({ ...template, order: index });
}
});
return { featureTemplates: reordered };
});
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ featureTemplates: get().featureTemplates });
} catch (error) {
logger.error('Failed to sync feature templates:', error);
}
},
// Claude-Compatible Provider actions (new system)
addClaudeCompatibleProvider: async (provider) => {
set((state) => ({
claudeCompatibleProviders: [...state.claudeCompatibleProviders, provider],
}));
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({
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.settings.updateGlobal({
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.settings.updateGlobal({
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.settings.updateGlobal({ 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.settings.updateGlobal({
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.settings.updateGlobal({ 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.settings.updateGlobal({ 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.settings.updateGlobal({
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.settings.updateGlobal({ 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.settings.updateGlobal({ 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,
// Agent model selection actions
setAgentModelForSession: (sessionId, model) =>
set((state) => ({
agentModelBySession: {
...state.agentModelBySession,
[sessionId]: model,
},
})),
getAgentModelForSession: (sessionId) => get().agentModelBySession[sessionId] ?? 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 },
})),
setTerminalBackgroundColor: (color) =>
set((state) => ({
terminalState: { ...state.terminalState, customBackgroundColor: color },
})),
setTerminalForegroundColor: (color) =>
set((state) => ({
terminalState: { ...state.terminalState, customForegroundColor: color },
})),
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: async (mode) => {
set({ defaultPlanningMode: mode });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ defaultPlanningMode: mode });
} catch (error) {
logger.error('Failed to sync defaultPlanningMode:', error);
}
},
setDefaultRequirePlanApproval: async (require) => {
set({ defaultRequirePlanApproval: require });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ defaultRequirePlanApproval: require });
} catch (error) {
logger.error('Failed to sync defaultRequirePlanApproval:', error);
}
},
setDefaultFeatureModel: async (entry) => {
set({ defaultFeatureModel: entry });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ defaultFeatureModel: entry });
} catch (error) {
logger.error('Failed to sync defaultFeatureModel:', error);
}
},
setDefaultThinkingLevel: async (level) => {
const currentModel = get().defaultFeatureModel;
const modelId = currentModel.model;
const availableLevels = getThinkingLevelsForModel(modelId);
// Also update defaultFeatureModel's thinkingLevel if compatible
if (availableLevels.includes(level)) {
const updatedFeatureModel = { ...currentModel, thinkingLevel: level };
set({
defaultThinkingLevel: level,
defaultFeatureModel: updatedFeatureModel,
});
// Sync to server - include defaultFeatureModel since thinkingLevel is embedded there too
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({
defaultThinkingLevel: level,
defaultFeatureModel: updatedFeatureModel,
});
} catch (error) {
logger.error('Failed to sync defaultThinkingLevel:', error);
}
} else {
set({ defaultThinkingLevel: level });
// Sync to server
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ defaultThinkingLevel: level });
} catch (error) {
logger.error('Failed to sync defaultThinkingLevel:', error);
}
}
},
setDefaultReasoningEffort: async (effort) => {
set({ defaultReasoningEffort: effort });
// Sync to server
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ defaultReasoningEffort: effort });
} catch (error) {
logger.error('Failed to sync defaultReasoningEffort:', error);
}
},
setDefaultMaxTurns: async (maxTurns: number) => {
// Guard against NaN/Infinity before flooring and clamping
const safeValue = Number.isFinite(maxTurns) ? maxTurns : 1;
// Clamp to valid range
const clamped = Math.max(1, Math.min(10000, Math.floor(safeValue)));
set({ defaultMaxTurns: clamped });
// Sync to server
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ defaultMaxTurns: clamped });
} catch (error) {
logger.error('Failed to sync defaultMaxTurns:', error);
}
},
// 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;
},
// Worktree Copy Files actions
setWorktreeCopyFiles: (projectPath, files) =>
set((state) => ({
worktreeCopyFilesByProject: {
...state.worktreeCopyFilesByProject,
[projectPath]: files,
},
})),
getWorktreeCopyFiles: (projectPath) => get().worktreeCopyFilesByProject[projectPath] ?? [],
// Worktree Display Settings actions
setPinnedWorktreesCount: (projectPath, count) =>
set((state) => ({
pinnedWorktreesCountByProject: {
...state.pinnedWorktreesCountByProject,
[projectPath]: count,
},
})),
getPinnedWorktreesCount: (projectPath) => get().pinnedWorktreesCountByProject[projectPath] ?? 0,
setPinnedWorktreeBranches: (projectPath, branches) =>
set((state) => ({
pinnedWorktreeBranchesByProject: {
...state.pinnedWorktreeBranchesByProject,
[projectPath]: branches,
},
})),
getPinnedWorktreeBranches: (projectPath) =>
get().pinnedWorktreeBranchesByProject[projectPath] ?? [],
swapPinnedWorktreeBranch: (projectPath, slotIndex, newBranch) =>
set((state) => {
const src = state.pinnedWorktreeBranchesByProject[projectPath] ?? [];
// Pre-fill up to slotIndex to prevent sparse holes
const current: string[] = Array.from(
{ length: Math.max(src.length, slotIndex + 1) },
(_, i) => src[i] ?? ''
);
// If the new branch is already in another slot, swap them (only when newBranch is non-empty)
const existingIndex = newBranch !== '' ? current.indexOf(newBranch) : -1;
if (existingIndex !== -1 && existingIndex !== slotIndex) {
// Swap: put the old branch from this slot into the other slot
current[existingIndex] = current[slotIndex];
}
current[slotIndex] = newBranch;
return {
pinnedWorktreeBranchesByProject: {
...state.pinnedWorktreeBranchesByProject,
[projectPath]: current,
},
};
}),
setWorktreeDropdownThreshold: (projectPath, threshold) =>
set((state) => ({
worktreeDropdownThresholdByProject: {
...state.worktreeDropdownThresholdByProject,
[projectPath]: threshold,
},
})),
getWorktreeDropdownThreshold: (projectPath) =>
get().worktreeDropdownThresholdByProject[projectPath] ?? 3,
setAlwaysUseWorktreeDropdown: (projectPath, always) =>
set((state) => ({
alwaysUseWorktreeDropdownByProject: {
...state.alwaysUseWorktreeDropdownByProject,
[projectPath]: always,
},
})),
getAlwaysUseWorktreeDropdown: (projectPath) =>
get().alwaysUseWorktreeDropdownByProject[projectPath] ?? true,
// 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() }),
// z.ai Usage Tracking actions
setZaiUsage: (usage) => set({ zaiUsage: usage, zaiUsageLastUpdated: usage ? Date.now() : null }),
// Gemini Usage Tracking actions
setGeminiUsage: (usage, lastUpdated) =>
set({
geminiUsage: usage,
geminiUsageLastUpdated: lastUpdated ?? (usage ? Date.now() : null),
}),
// 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 data = await httpApi.get<{
success: boolean;
models?: Array<{
id: string;
label: string;
description: string;
hasThinking: boolean;
supportsVision: boolean;
tier: 'premium' | 'standard' | 'basic';
isDefault: boolean;
}>;
error?: string;
}>('/api/codex/models');
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 data = await httpApi.get<{
success: boolean;
models?: ModelDefinition[];
providers?: Array<{
id: string;
name: string;
authenticated: boolean;
authMethod?: string;
}>;
error?: string;
}>('/api/setup/opencode/models');
if (data.success && data.models) {
// Filter out Bedrock models
const filteredModels = data.models.filter(
(m) => !m.id.startsWith(OPENCODE_BEDROCK_MODEL_PREFIX)
);
// Auto-enable only models that are genuinely new (never seen before).
// Models that existed previously and were explicitly deselected by the user
// should NOT be re-enabled on subsequent fetches.
const currentEnabledIds = get().enabledDynamicModelIds;
const currentKnownIds = get().knownDynamicModelIds;
const allFetchedIds = filteredModels.map((m) => m.id);
// Only auto-enable models that have NEVER been seen before (not in knownDynamicModelIds)
const trulyNewModelIds = allFetchedIds.filter((id) => !currentKnownIds.includes(id));
const updatedEnabledIds =
trulyNewModelIds.length > 0
? [...new Set([...currentEnabledIds, ...trulyNewModelIds])]
: currentEnabledIds;
// Track all discovered model IDs (union of known + newly fetched)
const updatedKnownIds = [...new Set([...currentKnownIds, ...allFetchedIds])];
set({
dynamicOpencodeModels: filteredModels,
enabledDynamicModelIds: updatedEnabledIds,
knownDynamicModelIds: updatedKnownIds,
cachedOpencodeProviders: data.providers ?? [],
opencodeModelsLoading: false,
opencodeModelsLastFetched: now,
opencodeModelsError: null,
});
// Persist newly enabled model IDs and known model IDs to server settings
if (trulyNewModelIds.length > 0) {
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({
enabledDynamicModelIds: updatedEnabledIds,
knownDynamicModelIds: updatedKnownIds,
});
} catch (syncError) {
logger.error('Failed to sync enabledDynamicModelIds after auto-enable:', syncError);
}
}
} 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),
}));