import { create } from 'zustand'; // Note: persist middleware removed - settings now sync via API (use-settings-sync.ts) import type { Project, TrashedProject } from '@/lib/electron'; import { getElectronAPI } from '@/lib/electron'; import { getHttpApiClient } from '@/lib/http-api-client'; import { createLogger } from '@automaker/utils/logger'; import { setItem, getItem } from '@/lib/storage'; import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS, DEFAULT_FONT_VALUE, } from '@/config/ui-font-options'; import type { Feature as BaseFeature, FeatureImagePath, FeatureTextFilePath, ModelAlias, PlanningMode, ThinkingLevel, ReasoningEffort, ModelProvider, CursorModelId, CodexModelId, OpencodeModelId, GeminiModelId, CopilotModelId, PhaseModelConfig, PhaseModelKey, PhaseModelEntry, MCPServerConfig, FeatureStatusWithPipeline, PipelineConfig, PipelineStep, PromptCustomization, ModelDefinition, ServerLogLevel, EventHook, ClaudeApiProfile, ClaudeCompatibleProvider, SidebarStyle, ParsedTask, PlanSpec, } from '@automaker/types'; import { getAllCursorModelIds, getAllCodexModelIds, getAllOpencodeModelIds, getAllGeminiModelIds, getAllCopilotModelIds, DEFAULT_PHASE_MODELS, DEFAULT_OPENCODE_MODEL, DEFAULT_GEMINI_MODEL, DEFAULT_COPILOT_MODEL, DEFAULT_MAX_CONCURRENCY, DEFAULT_GLOBAL_SETTINGS, } from '@automaker/types'; // Import types from modular type files import { // UI types type ViewMode, type ThemeMode, type BoardViewMode, type ShortcutKey, type KeyboardShortcuts, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut, // Settings types type ApiKeys, // Chat types type ImageAttachment, type TextFileAttachment, type ChatMessage, type ChatSession, type FeatureImage, // Terminal types type TerminalPanelContent, type TerminalTab, type TerminalState, type PersistedTerminalPanel, type PersistedTerminalTab, type PersistedTerminalState, type PersistedTerminalSettings, generateSplitId, // Project types type ClaudeModel, type Feature, type FileTreeNode, type ProjectAnalysis, // State types type InitScriptState, type AutoModeActivity, type AppState, type AppActions, // Usage types type ClaudeUsage, type ClaudeUsageResponse, type CodexPlanType, type CodexRateLimitWindow, type CodexUsage, type CodexUsageResponse, isClaudeUsageAtLimit, } from './types'; const logger = createLogger('AppStore'); const OPENCODE_BEDROCK_PROVIDER_ID = 'amazon-bedrock'; const OPENCODE_BEDROCK_MODEL_PREFIX = `${OPENCODE_BEDROCK_PROVIDER_ID}/`; // Re-export types from @automaker/types for convenience export type { ModelAlias, PlanningMode, ThinkingLevel, ReasoningEffort, ModelProvider, ServerLogLevel, FeatureTextFilePath, FeatureImagePath, ParsedTask, PlanSpec, }; // Re-export all types from ./types for backward compatibility export type { ViewMode, ThemeMode, BoardViewMode, ShortcutKey, KeyboardShortcuts, ApiKeys, ImageAttachment, TextFileAttachment, ChatMessage, ChatSession, FeatureImage, TerminalPanelContent, TerminalTab, TerminalState, PersistedTerminalPanel, PersistedTerminalTab, PersistedTerminalState, PersistedTerminalSettings, ClaudeModel, Feature, FileTreeNode, ProjectAnalysis, InitScriptState, AutoModeActivity, AppState, AppActions, ClaudeUsage, ClaudeUsageResponse, CodexPlanType, CodexRateLimitWindow, CodexUsage, CodexUsageResponse, }; // Re-export values from ./types for backward compatibility export { DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut, isClaudeUsageAtLimit, generateSplitId, }; // NOTE: Type definitions moved to ./types/ directory // The following inline types have been replaced with imports above: // - ViewMode, ThemeMode, BoardViewMode (./types/ui-types.ts) // - ShortcutKey, KeyboardShortcuts, DEFAULT_KEYBOARD_SHORTCUTS, parseShortcut, formatShortcut (./types/ui-types.ts) // - ApiKeys (./types/settings-types.ts) // - ImageAttachment, TextFileAttachment, ChatMessage, ChatSession, FeatureImage (./types/chat-types.ts) // - Terminal types (./types/terminal-types.ts) // - ClaudeModel, Feature, FileTreeNode, ProjectAnalysis (./types/project-types.ts) // - InitScriptState, AutoModeActivity, AppState, AppActions (./types/state-types.ts) // - Claude/Codex usage types (./types/usage-types.ts) // LocalStorage keys for persistence (fallback when server settings aren't available) export const THEME_STORAGE_KEY = 'automaker:theme'; export const FONT_SANS_STORAGE_KEY = 'automaker:font-sans'; export const FONT_MONO_STORAGE_KEY = 'automaker:font-mono'; // Maximum number of output lines to keep in init script state (prevents unbounded memory growth) export const MAX_INIT_OUTPUT_LINES = 500; /** * Get the theme from localStorage as a fallback * Used before server settings are loaded (e.g., on login/setup pages) */ export function getStoredTheme(): ThemeMode | null { const stored = getItem(THEME_STORAGE_KEY); if (stored) return stored as ThemeMode; // Backwards compatibility: older versions stored theme inside the Zustand persist blob. // We intentionally keep reading it as a fallback so users don't get a "default theme flash" // on login/logged-out pages if THEME_STORAGE_KEY hasn't been written yet. try { const legacy = getItem('automaker-storage'); if (!legacy) return null; interface LegacyStorageFormat { state?: { theme?: string }; theme?: string; } const parsed = JSON.parse(legacy) as LegacyStorageFormat; const theme = parsed.state?.theme ?? parsed.theme; if (typeof theme === 'string' && theme.length > 0) { return theme as ThemeMode; } } catch { // Ignore legacy parse errors } return null; } /** * Helper to get effective font value with validation * Returns the font to use (project override -> global -> null for default) * @param projectFont - The project-specific font override * @param globalFont - The global font setting * @param fontOptions - The list of valid font options for validation */ function getEffectiveFont( projectFont: string | undefined, globalFont: string | null, fontOptions: readonly { value: string; label: string }[] ): string | null { const isValidFont = (font: string | null | undefined): boolean => { if (!font || font === DEFAULT_FONT_VALUE) return true; return fontOptions.some((opt) => opt.value === font); }; if (projectFont) { if (!isValidFont(projectFont)) return null; // Fallback to default if font not in list return projectFont === DEFAULT_FONT_VALUE ? null : projectFont; } if (!isValidFont(globalFont)) return null; // Fallback to default if font not in list return globalFont === DEFAULT_FONT_VALUE ? null : globalFont; } /** * Save theme to localStorage for immediate persistence * This is used as a fallback when server settings can't be loaded */ function saveThemeToStorage(theme: ThemeMode): void { setItem(THEME_STORAGE_KEY, theme); } /** * Get fonts from localStorage as a fallback * Used before server settings are loaded (e.g., on login/setup pages) */ export function getStoredFontSans(): string | null { return getItem(FONT_SANS_STORAGE_KEY); } export function getStoredFontMono(): string | null { return getItem(FONT_MONO_STORAGE_KEY); } /** * Save fonts to localStorage for immediate persistence * This is used as a fallback when server settings can't be loaded */ function saveFontSansToStorage(fontFamily: string | null): void { if (fontFamily) { setItem(FONT_SANS_STORAGE_KEY, fontFamily); } else { // Remove from storage if null (using default) localStorage.removeItem(FONT_SANS_STORAGE_KEY); } } function saveFontMonoToStorage(fontFamily: string | null): void { if (fontFamily) { setItem(FONT_MONO_STORAGE_KEY, fontFamily); } else { // Remove from storage if null (using default) localStorage.removeItem(FONT_MONO_STORAGE_KEY); } } function persistEffectiveThemeForProject(project: Project | null, fallbackTheme: ThemeMode): void { const projectTheme = project?.theme as ThemeMode | undefined; const themeToStore = projectTheme ?? fallbackTheme; saveThemeToStorage(themeToStore); } // NOTE: Type definitions have been moved to ./types/ directory // Types are imported at the top of this file and re-exported for backward compatibility // Default background settings for board backgrounds export const defaultBackgroundSettings: { imagePath: string | null; imageVersion?: number; cardOpacity: number; columnOpacity: number; columnBorderEnabled: boolean; cardGlassmorphism: boolean; cardBorderEnabled: boolean; cardBorderOpacity: number; hideScrollbar: boolean; } = { imagePath: null, cardOpacity: 100, columnOpacity: 100, columnBorderEnabled: true, cardGlassmorphism: true, cardBorderEnabled: true, cardBorderOpacity: 100, hideScrollbar: false, }; const initialState: AppState = { projects: [], currentProject: null, trashedProjects: [], projectHistory: [], projectHistoryIndex: -1, currentView: 'welcome', sidebarOpen: true, sidebarStyle: 'unified', collapsedNavSections: {}, mobileSidebarHidden: false, lastSelectedSessionByProject: {}, theme: getStoredTheme() || 'dark', fontFamilySans: getStoredFontSans(), fontFamilyMono: getStoredFontMono(), features: [], appSpec: '', ipcConnected: false, apiKeys: { anthropic: '', google: '', openai: '', }, chatSessions: [], currentChatSession: null, chatHistoryOpen: false, autoModeByWorktree: {}, autoModeActivityLog: [], maxConcurrency: DEFAULT_MAX_CONCURRENCY, boardViewMode: 'kanban', defaultSkipTests: true, enableDependencyBlocking: true, skipVerificationInAutoMode: false, enableAiCommitMessages: true, planUseSelectedWorktreeBranch: true, addFeatureUseSelectedWorktreeBranch: false, useWorktrees: true, currentWorktreeByProject: {}, worktreesByProject: {}, keyboardShortcuts: DEFAULT_KEYBOARD_SHORTCUTS, muteDoneSound: false, disableSplashScreen: false, serverLogLevel: 'info', enableRequestLogging: true, showQueryDevtools: true, enhancementModel: 'claude-sonnet', validationModel: 'claude-opus', phaseModels: DEFAULT_PHASE_MODELS, favoriteModels: [], enabledCursorModels: getAllCursorModelIds(), cursorDefaultModel: 'cursor-auto', enabledCodexModels: getAllCodexModelIds(), codexDefaultModel: 'codex-gpt-5.2-codex', codexAutoLoadAgents: false, codexSandboxMode: 'workspace-write', codexApprovalPolicy: 'on-request', codexEnableWebSearch: false, codexEnableImages: false, enabledOpencodeModels: getAllOpencodeModelIds(), opencodeDefaultModel: DEFAULT_OPENCODE_MODEL, dynamicOpencodeModels: [], enabledDynamicModelIds: [], cachedOpencodeProviders: [], opencodeModelsLoading: false, opencodeModelsError: null, opencodeModelsLastFetched: null, opencodeModelsLastFailedAt: null, enabledGeminiModels: getAllGeminiModelIds(), geminiDefaultModel: DEFAULT_GEMINI_MODEL, enabledCopilotModels: getAllCopilotModelIds(), copilotDefaultModel: DEFAULT_COPILOT_MODEL, disabledProviders: [], autoLoadClaudeMd: false, skipSandboxWarning: false, mcpServers: [], defaultEditorCommand: null, defaultTerminalId: null, enableSkills: true, skillsSources: ['user', 'project'] as Array<'user' | 'project'>, enableSubagents: true, subagentsSources: ['user', 'project'] as Array<'user' | 'project'>, promptCustomization: {}, eventHooks: [], claudeCompatibleProviders: [], claudeApiProfiles: [], activeClaudeApiProfileId: null, projectAnalysis: null, isAnalyzing: false, boardBackgroundByProject: {}, previewTheme: null, terminalState: { isUnlocked: false, authToken: null, tabs: [], activeTabId: null, activeSessionId: null, maximizedSessionId: null, defaultFontSize: 14, defaultRunScript: '', screenReaderMode: false, fontFamily: DEFAULT_FONT_VALUE, scrollbackLines: 5000, lineHeight: 1.0, maxSessions: 100, lastActiveProjectPath: null, openTerminalMode: 'newTab', }, terminalLayoutByProject: {}, specCreatingForProject: null, defaultPlanningMode: 'skip' as PlanningMode, defaultRequirePlanApproval: false, defaultFeatureModel: DEFAULT_GLOBAL_SETTINGS.defaultFeatureModel, pendingPlanApproval: null, claudeRefreshInterval: 60, claudeUsage: null, claudeUsageLastUpdated: null, codexUsage: null, codexUsageLastUpdated: null, codexModels: [], codexModelsLoading: false, codexModelsError: null, codexModelsLastFetched: null, codexModelsLastFailedAt: null, pipelineConfigByProject: {}, worktreePanelVisibleByProject: {}, showInitScriptIndicatorByProject: {}, defaultDeleteBranchByProject: {}, autoDismissInitScriptIndicatorByProject: {}, useWorktreesByProject: {}, worktreePanelCollapsed: false, lastProjectDir: '', recentFolders: [], initScriptState: {}, }; export const useAppStore = create()((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, })), 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), }));