Files
automaker/apps/ui/src/store/app-store.ts
2026-01-25 23:32:29 +01:00

2647 lines
84 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';
// Note: setItem/getItem moved to ./utils/theme-utils.ts
import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS } 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,
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,
} 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,
};
// 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 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)
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: defaultTerminalState,
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),
}));