mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-01 08:13:37 +00:00
The fetchOpencodeModels function was calling '/api/opencode/models' which returns 404. Changed to '/api/setup/opencode/models' which correctly returns the dynamic models. This fixes an issue where enabled OpenCode dynamic models (e.g., local Ollama models) were not appearing in the Model Defaults dropdown selectors despite being visible and enabled in the OpenCode Settings page.
2606 lines
82 KiB
TypeScript
2606 lines
82 KiB
TypeScript
import { create } from 'zustand';
|
|
// Note: persist middleware removed - settings now sync via API (use-settings-sync.ts)
|
|
import type { Project, TrashedProject } from '@/lib/electron';
|
|
import { saveProjects, saveTrashedProjects } from '@/lib/electron';
|
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
|
import { createLogger } from '@automaker/utils/logger';
|
|
// Note: setItem/getItem moved to ./utils/theme-utils.ts
|
|
import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS } from '@/config/ui-font-options';
|
|
import 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: new Date().toISOString(),
|
|
};
|
|
|
|
set((state) => ({
|
|
projects: state.projects.filter((p) => p.id !== projectId),
|
|
trashedProjects: [...state.trashedProjects, trashedProject],
|
|
currentProject: state.currentProject?.id === projectId ? null : state.currentProject,
|
|
}));
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
saveTrashedProjects(get().trashedProjects);
|
|
},
|
|
|
|
restoreTrashedProject: (projectId: string) => {
|
|
const trashedProject = get().trashedProjects.find((p) => p.id === projectId);
|
|
if (!trashedProject) return;
|
|
|
|
// Remove trashedAt from the project
|
|
const { trashedAt, ...restoredProject } = trashedProject;
|
|
void trashedAt; // Explicitly ignore trashedAt to satisfy linter
|
|
|
|
set((state) => ({
|
|
projects: [...state.projects, restoredProject as Project],
|
|
trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId),
|
|
}));
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
saveTrashedProjects(get().trashedProjects);
|
|
},
|
|
|
|
deleteTrashedProject: (projectId: string) => {
|
|
set((state) => ({
|
|
trashedProjects: state.trashedProjects.filter((p) => p.id !== projectId),
|
|
}));
|
|
|
|
// Persist to storage
|
|
saveTrashedProjects(get().trashedProjects);
|
|
},
|
|
|
|
emptyTrash: () => {
|
|
set({ trashedProjects: [] });
|
|
|
|
// Persist to storage
|
|
saveTrashedProjects([]);
|
|
},
|
|
|
|
setCurrentProject: (project) => {
|
|
const currentId = get().currentProject?.id;
|
|
const newId = project?.id;
|
|
|
|
// If we're switching to a different project, add the new one to history
|
|
if (newId && newId !== currentId) {
|
|
set((state) => {
|
|
// Remove the new project from history if it exists
|
|
const filteredHistory = state.projectHistory.filter((id) => id !== newId);
|
|
// Add new project at the front (most recent)
|
|
const newHistory = [newId, ...filteredHistory];
|
|
// Limit history size to prevent unbounded growth
|
|
const MAX_HISTORY = 50;
|
|
|
|
// Persist effective theme for the new project to localStorage
|
|
persistEffectiveThemeForProject(project, state.theme);
|
|
|
|
return {
|
|
currentProject: project,
|
|
projectHistory: newHistory.slice(0, MAX_HISTORY),
|
|
projectHistoryIndex: 0, // Reset index to start of history
|
|
};
|
|
});
|
|
} else {
|
|
// Same project or null - just update without affecting history
|
|
set({ currentProject: project });
|
|
|
|
// Still persist theme for project changes
|
|
if (project) {
|
|
persistEffectiveThemeForProject(project, get().theme);
|
|
}
|
|
}
|
|
},
|
|
|
|
upsertAndSetCurrentProject: (path: string, name: string, theme?: ThemeMode) => {
|
|
const existingProject = get().projects.find((p) => p.path === path);
|
|
if (existingProject) {
|
|
get().setCurrentProject(existingProject);
|
|
return existingProject;
|
|
}
|
|
|
|
// Create new project
|
|
const newProject: Project = {
|
|
id: crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2),
|
|
name,
|
|
path,
|
|
isFavorite: false, // New projects start as non-favorites
|
|
...(theme ? { theme } : {}),
|
|
};
|
|
|
|
// Add and set as current
|
|
get().addProject(newProject);
|
|
get().setCurrentProject(newProject);
|
|
|
|
// Persist to storage (small delay to ensure state is updated)
|
|
setTimeout(() => {
|
|
saveProjects(get().projects);
|
|
}, 0);
|
|
|
|
return newProject;
|
|
},
|
|
|
|
reorderProjects: (oldIndex: number, newIndex: number) => {
|
|
set((state) => {
|
|
const projects = [...state.projects];
|
|
const [removed] = projects.splice(oldIndex, 1);
|
|
projects.splice(newIndex, 0, removed);
|
|
return { projects };
|
|
});
|
|
},
|
|
|
|
cyclePrevProject: () => {
|
|
set((state) => {
|
|
const { projectHistory, projectHistoryIndex, projects } = state;
|
|
if (projectHistory.length === 0) return state;
|
|
|
|
// Move back in history (to older project)
|
|
const newIndex = Math.min(projectHistoryIndex + 1, projectHistory.length - 1);
|
|
if (newIndex === projectHistoryIndex) return state; // Already at oldest
|
|
|
|
const projectId = projectHistory[newIndex];
|
|
const project = projects.find((p) => p.id === projectId);
|
|
|
|
if (!project) {
|
|
// Project no longer exists, remove from history and try again
|
|
const filteredHistory = projectHistory.filter((id) => id !== projectId);
|
|
return { projectHistory: filteredHistory, projectHistoryIndex: state.projectHistoryIndex };
|
|
}
|
|
|
|
// Persist effective theme for the cycled-to project
|
|
persistEffectiveThemeForProject(project, state.theme);
|
|
|
|
return {
|
|
currentProject: project,
|
|
projectHistoryIndex: newIndex,
|
|
};
|
|
});
|
|
},
|
|
|
|
cycleNextProject: () => {
|
|
set((state) => {
|
|
const { projectHistory, projectHistoryIndex, projects } = state;
|
|
if (projectHistory.length === 0 || projectHistoryIndex === 0) return state; // Already at most recent
|
|
|
|
// Move forward in history (to newer project)
|
|
const newIndex = Math.max(projectHistoryIndex - 1, 0);
|
|
const projectId = projectHistory[newIndex];
|
|
const project = projects.find((p) => p.id === projectId);
|
|
|
|
if (!project) {
|
|
// Project no longer exists, remove from history and try again
|
|
const filteredHistory = projectHistory.filter((id) => id !== projectId);
|
|
return { projectHistory: filteredHistory, projectHistoryIndex: state.projectHistoryIndex };
|
|
}
|
|
|
|
// Persist effective theme for the cycled-to project
|
|
persistEffectiveThemeForProject(project, state.theme);
|
|
|
|
return {
|
|
currentProject: project,
|
|
projectHistoryIndex: newIndex,
|
|
};
|
|
});
|
|
},
|
|
|
|
clearProjectHistory: () => {
|
|
const currentId = get().currentProject?.id;
|
|
set({
|
|
projectHistory: currentId ? [currentId] : [],
|
|
projectHistoryIndex: 0,
|
|
});
|
|
},
|
|
|
|
toggleProjectFavorite: (projectId: string) => {
|
|
set((state) => ({
|
|
projects: state.projects.map((p) =>
|
|
p.id === projectId ? { ...p, isFavorite: !p.isFavorite } : p
|
|
),
|
|
}));
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
},
|
|
|
|
setProjectIcon: (projectId: string, icon: string | null) => {
|
|
set((state) => ({
|
|
projects: state.projects.map((p) =>
|
|
p.id === projectId ? { ...p, icon: icon ?? undefined } : p
|
|
),
|
|
// Also update currentProject if it's the one being modified
|
|
currentProject:
|
|
state.currentProject?.id === projectId
|
|
? { ...state.currentProject, icon: icon ?? undefined }
|
|
: state.currentProject,
|
|
}));
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
},
|
|
|
|
setProjectCustomIcon: (projectId: string, customIconPath: string | null) => {
|
|
set((state) => ({
|
|
projects: state.projects.map((p) =>
|
|
p.id === projectId ? { ...p, customIconPath: customIconPath ?? undefined } : p
|
|
),
|
|
// Also update currentProject if it's the one being modified
|
|
currentProject:
|
|
state.currentProject?.id === projectId
|
|
? { ...state.currentProject, customIconPath: customIconPath ?? undefined }
|
|
: state.currentProject,
|
|
}));
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
},
|
|
|
|
setProjectName: (projectId: string, name: string) => {
|
|
set((state) => ({
|
|
projects: state.projects.map((p) => (p.id === projectId ? { ...p, name } : p)),
|
|
// Also update currentProject if it's the one being renamed
|
|
currentProject:
|
|
state.currentProject?.id === projectId
|
|
? { ...state.currentProject, name }
|
|
: state.currentProject,
|
|
}));
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
},
|
|
|
|
// View actions
|
|
setCurrentView: (view) => set({ currentView: view }),
|
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
|
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
|
setSidebarStyle: (style) => set({ sidebarStyle: style }),
|
|
setCollapsedNavSections: (sections) => set({ collapsedNavSections: sections }),
|
|
toggleNavSection: (sectionLabel) =>
|
|
set((state) => ({
|
|
collapsedNavSections: {
|
|
...state.collapsedNavSections,
|
|
[sectionLabel]: !state.collapsedNavSections[sectionLabel],
|
|
},
|
|
})),
|
|
toggleMobileSidebarHidden: () =>
|
|
set((state) => ({ mobileSidebarHidden: !state.mobileSidebarHidden })),
|
|
setMobileSidebarHidden: (hidden) => set({ mobileSidebarHidden: hidden }),
|
|
|
|
// Theme actions
|
|
setTheme: (theme) => {
|
|
set({ theme });
|
|
saveThemeToStorage(theme);
|
|
},
|
|
setProjectTheme: (projectId: string, theme: ThemeMode | null) => {
|
|
set((state) => ({
|
|
projects: state.projects.map((p) =>
|
|
p.id === projectId ? { ...p, theme: theme ?? undefined } : p
|
|
),
|
|
// Also update currentProject if it's the one being changed
|
|
currentProject:
|
|
state.currentProject?.id === projectId
|
|
? { ...state.currentProject, theme: theme ?? undefined }
|
|
: state.currentProject,
|
|
}));
|
|
|
|
// Update localStorage with new effective theme if this is the current project
|
|
const currentProject = get().currentProject;
|
|
if (currentProject?.id === projectId) {
|
|
persistEffectiveThemeForProject(
|
|
{ ...currentProject, theme: theme ?? undefined },
|
|
get().theme
|
|
);
|
|
}
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
},
|
|
getEffectiveTheme: () => {
|
|
const state = get();
|
|
// If there's a preview theme, use it (for hover preview)
|
|
if (state.previewTheme) return state.previewTheme;
|
|
// Otherwise, use project theme if set, or fall back to global theme
|
|
const projectTheme = state.currentProject?.theme as ThemeMode | undefined;
|
|
return projectTheme ?? state.theme;
|
|
},
|
|
setPreviewTheme: (theme) => set({ previewTheme: theme }),
|
|
|
|
// Font actions
|
|
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 storage
|
|
saveProjects(get().projects);
|
|
},
|
|
setProjectFontMono: (projectId: string, fontFamily: string | null) => {
|
|
set((state) => ({
|
|
projects: state.projects.map((p) =>
|
|
p.id === projectId ? { ...p, fontMono: fontFamily ?? undefined } : p
|
|
),
|
|
// Also update currentProject if it's the one being changed
|
|
currentProject:
|
|
state.currentProject?.id === projectId
|
|
? { ...state.currentProject, fontMono: fontFamily ?? undefined }
|
|
: state.currentProject,
|
|
}));
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
},
|
|
getEffectiveFontSans: () => {
|
|
const state = get();
|
|
const projectFont = state.currentProject?.fontFamilySans;
|
|
return getEffectiveFont(projectFont, state.fontFamilySans, UI_SANS_FONT_OPTIONS);
|
|
},
|
|
getEffectiveFontMono: () => {
|
|
const state = get();
|
|
const projectFont = state.currentProject?.fontFamilyMono;
|
|
return getEffectiveFont(projectFont, state.fontFamilyMono, UI_MONO_FONT_OPTIONS);
|
|
},
|
|
|
|
// Claude API Profile actions (per-project override)
|
|
setProjectClaudeApiProfile: (projectId: string, profileId: string | null | undefined) => {
|
|
set((state) => ({
|
|
projects: state.projects.map((p) =>
|
|
p.id === projectId ? { ...p, claudeApiProfileId: profileId } : p
|
|
),
|
|
// Also update currentProject if it's the one being changed
|
|
currentProject:
|
|
state.currentProject?.id === projectId
|
|
? { ...state.currentProject, claudeApiProfileId: profileId }
|
|
: state.currentProject,
|
|
}));
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
},
|
|
|
|
// Project Phase Model Overrides
|
|
setProjectPhaseModelOverride: (
|
|
projectId: string,
|
|
phase: PhaseModelKey,
|
|
entry: PhaseModelEntry | null
|
|
) => {
|
|
set((state) => {
|
|
const updatePhaseModels = (project: Project): Project => {
|
|
const currentOverrides = project.phaseModelOverrides || {};
|
|
const newOverrides = { ...currentOverrides };
|
|
if (entry === null) {
|
|
delete newOverrides[phase];
|
|
} else {
|
|
newOverrides[phase] = entry;
|
|
}
|
|
return {
|
|
...project,
|
|
phaseModelOverrides: Object.keys(newOverrides).length > 0 ? newOverrides : undefined,
|
|
};
|
|
};
|
|
|
|
return {
|
|
projects: state.projects.map((p) => (p.id === projectId ? updatePhaseModels(p) : p)),
|
|
currentProject:
|
|
state.currentProject?.id === projectId
|
|
? updatePhaseModels(state.currentProject)
|
|
: state.currentProject,
|
|
};
|
|
});
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
},
|
|
|
|
clearAllProjectPhaseModelOverrides: (projectId: string) => {
|
|
set((state) => {
|
|
const clearOverrides = (project: Project): Project => ({
|
|
...project,
|
|
phaseModelOverrides: undefined,
|
|
});
|
|
|
|
return {
|
|
projects: state.projects.map((p) => (p.id === projectId ? clearOverrides(p) : p)),
|
|
currentProject:
|
|
state.currentProject?.id === projectId
|
|
? clearOverrides(state.currentProject)
|
|
: state.currentProject,
|
|
};
|
|
});
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
},
|
|
|
|
// Project Default Feature Model Override
|
|
setProjectDefaultFeatureModel: (projectId: string, entry: PhaseModelEntry | null) => {
|
|
set((state) => {
|
|
const updateDefaultFeatureModel = (project: Project): Project => ({
|
|
...project,
|
|
defaultFeatureModel: entry ?? undefined,
|
|
});
|
|
|
|
return {
|
|
projects: state.projects.map((p) =>
|
|
p.id === projectId ? updateDefaultFeatureModel(p) : p
|
|
),
|
|
currentProject:
|
|
state.currentProject?.id === projectId
|
|
? updateDefaultFeatureModel(state.currentProject)
|
|
: state.currentProject,
|
|
};
|
|
});
|
|
|
|
// Persist to storage
|
|
saveProjects(get().projects);
|
|
},
|
|
|
|
// Feature actions
|
|
setFeatures: (features) => set({ features }),
|
|
updateFeature: (id, updates) =>
|
|
set((state) => ({
|
|
features: state.features.map((f) => (f.id === id ? { ...f, ...updates } : f)),
|
|
})),
|
|
addFeature: (feature) => {
|
|
const id = feature.id ?? `feature-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const newFeature = { ...feature, id } as Feature;
|
|
set((state) => ({ features: [...state.features, newFeature] }));
|
|
return newFeature;
|
|
},
|
|
removeFeature: (id) => set((state) => ({ features: state.features.filter((f) => f.id !== id) })),
|
|
moveFeature: (id, newStatus) =>
|
|
set((state) => ({
|
|
features: state.features.map((f) => (f.id === id ? { ...f, status: newStatus } : f)),
|
|
})),
|
|
|
|
// App spec actions
|
|
setAppSpec: (spec) => set({ appSpec: spec }),
|
|
|
|
// IPC actions
|
|
setIpcConnected: (connected) => set({ ipcConnected: connected }),
|
|
|
|
// API Keys actions
|
|
setApiKeys: (keys) => set((state) => ({ apiKeys: { ...state.apiKeys, ...keys } })),
|
|
|
|
// Chat Session actions
|
|
createChatSession: (title) => {
|
|
const currentProject = get().currentProject;
|
|
const newSession: ChatSession = {
|
|
id: `session-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
title: title || 'New Chat',
|
|
projectId: currentProject?.id || '',
|
|
messages: [],
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
archived: false,
|
|
};
|
|
set((state) => ({
|
|
chatSessions: [...state.chatSessions, newSession],
|
|
currentChatSession: newSession,
|
|
}));
|
|
return newSession;
|
|
},
|
|
updateChatSession: (sessionId, updates) =>
|
|
set((state) => ({
|
|
chatSessions: state.chatSessions.map((s) =>
|
|
s.id === sessionId ? { ...s, ...updates, updatedAt: new Date() } : s
|
|
),
|
|
currentChatSession:
|
|
state.currentChatSession?.id === sessionId
|
|
? { ...state.currentChatSession, ...updates, updatedAt: new Date() }
|
|
: state.currentChatSession,
|
|
})),
|
|
addMessageToSession: (sessionId, message) =>
|
|
set((state) => ({
|
|
chatSessions: state.chatSessions.map((s) =>
|
|
s.id === sessionId ? { ...s, messages: [...s.messages, message], updatedAt: new Date() } : s
|
|
),
|
|
currentChatSession:
|
|
state.currentChatSession?.id === sessionId
|
|
? {
|
|
...state.currentChatSession,
|
|
messages: [...state.currentChatSession.messages, message],
|
|
updatedAt: new Date(),
|
|
}
|
|
: state.currentChatSession,
|
|
})),
|
|
setCurrentChatSession: (session) => set({ currentChatSession: session }),
|
|
archiveChatSession: (sessionId) =>
|
|
set((state) => ({
|
|
chatSessions: state.chatSessions.map((s) =>
|
|
s.id === sessionId ? { ...s, archived: true } : s
|
|
),
|
|
})),
|
|
unarchiveChatSession: (sessionId) =>
|
|
set((state) => ({
|
|
chatSessions: state.chatSessions.map((s) =>
|
|
s.id === sessionId ? { ...s, archived: false } : s
|
|
),
|
|
})),
|
|
deleteChatSession: (sessionId) =>
|
|
set((state) => ({
|
|
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 data = await httpApi.get<{
|
|
success: boolean;
|
|
models?: Array<{
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
hasThinking: boolean;
|
|
supportsVision: boolean;
|
|
tier: 'premium' | 'standard' | 'basic';
|
|
isDefault: boolean;
|
|
}>;
|
|
error?: string;
|
|
}>('/api/codex/models');
|
|
|
|
if (data.success && data.models) {
|
|
set({
|
|
codexModels: data.models,
|
|
codexModelsLoading: false,
|
|
codexModelsLastFetched: now,
|
|
codexModelsError: null,
|
|
});
|
|
} else {
|
|
set({
|
|
codexModelsLoading: false,
|
|
codexModelsError: data.error || 'Failed to fetch Codex models',
|
|
codexModelsLastFailedAt: now,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
set({
|
|
codexModelsLoading: false,
|
|
codexModelsError: error instanceof Error ? error.message : 'Unknown error',
|
|
codexModelsLastFailedAt: now,
|
|
});
|
|
}
|
|
},
|
|
setCodexModels: (models) => set({ codexModels: models }),
|
|
|
|
// OpenCode Models actions
|
|
fetchOpencodeModels: async (forceRefresh = false) => {
|
|
const state = get();
|
|
const now = Date.now();
|
|
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
|
|
const RETRY_DELAY = 30 * 1000; // 30 seconds after failure
|
|
|
|
// Skip if already loading
|
|
if (state.opencodeModelsLoading) return;
|
|
|
|
// Skip if recently fetched (unless force refresh)
|
|
if (
|
|
!forceRefresh &&
|
|
state.opencodeModelsLastFetched &&
|
|
now - state.opencodeModelsLastFetched < CACHE_DURATION
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Skip if recently failed (unless force refresh)
|
|
if (
|
|
!forceRefresh &&
|
|
state.opencodeModelsLastFailedAt &&
|
|
now - state.opencodeModelsLastFailedAt < RETRY_DELAY
|
|
) {
|
|
return;
|
|
}
|
|
|
|
set({ opencodeModelsLoading: true, opencodeModelsError: null });
|
|
|
|
try {
|
|
const httpApi = getHttpApiClient();
|
|
const data = await httpApi.get<{
|
|
success: boolean;
|
|
models?: ModelDefinition[];
|
|
providers?: Array<{
|
|
id: string;
|
|
name: string;
|
|
authenticated: boolean;
|
|
authMethod?: string;
|
|
}>;
|
|
error?: string;
|
|
}>('/api/setup/opencode/models');
|
|
|
|
if (data.success && data.models) {
|
|
// Filter out Bedrock models
|
|
const filteredModels = data.models.filter(
|
|
(m) => !m.id.startsWith(OPENCODE_BEDROCK_MODEL_PREFIX)
|
|
);
|
|
|
|
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),
|
|
}));
|