mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: implement FlowExecutor for executing flow definitions with DAG traversal and node execution
This commit is contained in:
164
ccw/frontend/src/stores/appStore.ts
Normal file
164
ccw/frontend/src/stores/appStore.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// ========================================
|
||||
// App Store
|
||||
// ========================================
|
||||
// Manages UI state: theme, sidebar, view, loading, error
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist, devtools } from 'zustand/middleware';
|
||||
import type { AppStore, Theme, ViewMode, SessionFilter, LiteTaskType } from '../types/store';
|
||||
|
||||
// Helper to resolve system theme
|
||||
const getSystemTheme = (): 'light' | 'dark' => {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
// Helper to resolve theme based on preference
|
||||
const resolveTheme = (theme: Theme): 'light' | 'dark' => {
|
||||
if (theme === 'system') {
|
||||
return getSystemTheme();
|
||||
}
|
||||
return theme;
|
||||
};
|
||||
|
||||
// Initial state
|
||||
const initialState = {
|
||||
// Theme
|
||||
theme: 'system' as Theme,
|
||||
resolvedTheme: 'light' as 'light' | 'dark',
|
||||
|
||||
// Sidebar
|
||||
sidebarOpen: true,
|
||||
sidebarCollapsed: false,
|
||||
|
||||
// View state
|
||||
currentView: 'sessions' as ViewMode,
|
||||
currentFilter: 'all' as SessionFilter,
|
||||
currentLiteType: null as LiteTaskType,
|
||||
currentSessionDetailKey: null as string | null,
|
||||
|
||||
// Loading and error states
|
||||
isLoading: false,
|
||||
loadingMessage: null as string | null,
|
||||
error: null as string | null,
|
||||
};
|
||||
|
||||
export const useAppStore = create<AppStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Theme Actions ==========
|
||||
|
||||
setTheme: (theme: Theme) => {
|
||||
const resolved = resolveTheme(theme);
|
||||
set({ theme, resolvedTheme: resolved }, false, 'setTheme');
|
||||
|
||||
// Apply theme to document
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(resolved);
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
}
|
||||
},
|
||||
|
||||
toggleTheme: () => {
|
||||
const { theme } = get();
|
||||
const newTheme: Theme = theme === 'dark' ? 'light' : theme === 'light' ? 'dark' : 'dark';
|
||||
get().setTheme(newTheme);
|
||||
},
|
||||
|
||||
// ========== Sidebar Actions ==========
|
||||
|
||||
setSidebarOpen: (open: boolean) => {
|
||||
set({ sidebarOpen: open }, false, 'setSidebarOpen');
|
||||
},
|
||||
|
||||
toggleSidebar: () => {
|
||||
set((state) => ({ sidebarOpen: !state.sidebarOpen }), false, 'toggleSidebar');
|
||||
},
|
||||
|
||||
setSidebarCollapsed: (collapsed: boolean) => {
|
||||
set({ sidebarCollapsed: collapsed }, false, 'setSidebarCollapsed');
|
||||
},
|
||||
|
||||
// ========== View Actions ==========
|
||||
|
||||
setCurrentView: (view: ViewMode) => {
|
||||
set({ currentView: view }, false, 'setCurrentView');
|
||||
},
|
||||
|
||||
setCurrentFilter: (filter: SessionFilter) => {
|
||||
set({ currentFilter: filter }, false, 'setCurrentFilter');
|
||||
},
|
||||
|
||||
setCurrentLiteType: (type: LiteTaskType) => {
|
||||
set({ currentLiteType: type }, false, 'setCurrentLiteType');
|
||||
},
|
||||
|
||||
setCurrentSessionDetailKey: (key: string | null) => {
|
||||
set({ currentSessionDetailKey: key }, false, 'setCurrentSessionDetailKey');
|
||||
},
|
||||
|
||||
// ========== Loading/Error Actions ==========
|
||||
|
||||
setLoading: (loading: boolean, message: string | null = null) => {
|
||||
set({ isLoading: loading, loadingMessage: message }, false, 'setLoading');
|
||||
},
|
||||
|
||||
setError: (error: string | null) => {
|
||||
set({ error }, false, 'setError');
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null }, false, 'clearError');
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'ccw-app-store',
|
||||
// Only persist theme preference
|
||||
partialize: (state) => ({
|
||||
theme: state.theme,
|
||||
sidebarCollapsed: state.sidebarCollapsed,
|
||||
}),
|
||||
onRehydrateStorage: () => (state) => {
|
||||
// Apply theme on rehydration
|
||||
if (state) {
|
||||
const resolved = resolveTheme(state.theme);
|
||||
state.resolvedTheme = resolved;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(resolved);
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
{ name: 'AppStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// Setup system theme listener
|
||||
if (typeof window !== 'undefined') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', () => {
|
||||
const state = useAppStore.getState();
|
||||
if (state.theme === 'system') {
|
||||
const resolved = getSystemTheme();
|
||||
useAppStore.setState({ resolvedTheme: resolved });
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(resolved);
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Selectors for common access patterns
|
||||
export const selectTheme = (state: AppStore) => state.theme;
|
||||
export const selectResolvedTheme = (state: AppStore) => state.resolvedTheme;
|
||||
export const selectSidebarOpen = (state: AppStore) => state.sidebarOpen;
|
||||
export const selectCurrentView = (state: AppStore) => state.currentView;
|
||||
export const selectIsLoading = (state: AppStore) => state.isLoading;
|
||||
export const selectError = (state: AppStore) => state.error;
|
||||
223
ccw/frontend/src/stores/configStore.ts
Normal file
223
ccw/frontend/src/stores/configStore.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
// ========================================
|
||||
// Config Store
|
||||
// ========================================
|
||||
// Manages CLI tools, API endpoints, and user preferences with persistence
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { persist, devtools } from 'zustand/middleware';
|
||||
import type {
|
||||
ConfigStore,
|
||||
ConfigState,
|
||||
CliToolConfig,
|
||||
ApiEndpoints,
|
||||
UserPreferences,
|
||||
} from '../types/store';
|
||||
|
||||
// Default CLI tools configuration
|
||||
const defaultCliTools: Record<string, CliToolConfig> = {
|
||||
gemini: {
|
||||
enabled: true,
|
||||
primaryModel: 'gemini-2.5-pro',
|
||||
secondaryModel: 'gemini-2.5-flash',
|
||||
tags: ['analysis', 'debug'],
|
||||
type: 'builtin',
|
||||
},
|
||||
qwen: {
|
||||
enabled: true,
|
||||
primaryModel: 'coder-model',
|
||||
secondaryModel: 'coder-model',
|
||||
tags: [],
|
||||
type: 'builtin',
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
primaryModel: 'gpt-5.2',
|
||||
secondaryModel: 'gpt-5.2',
|
||||
tags: [],
|
||||
type: 'builtin',
|
||||
},
|
||||
claude: {
|
||||
enabled: true,
|
||||
primaryModel: 'sonnet',
|
||||
secondaryModel: 'haiku',
|
||||
tags: [],
|
||||
type: 'builtin',
|
||||
},
|
||||
};
|
||||
|
||||
// Default API endpoints
|
||||
const defaultApiEndpoints: ApiEndpoints = {
|
||||
base: '/api',
|
||||
sessions: '/api/sessions',
|
||||
tasks: '/api/tasks',
|
||||
loops: '/api/loops',
|
||||
issues: '/api/issues',
|
||||
orchestrator: '/api/orchestrator',
|
||||
};
|
||||
|
||||
// Default user preferences
|
||||
const defaultUserPreferences: UserPreferences = {
|
||||
autoRefresh: true,
|
||||
refreshInterval: 30000, // 30 seconds
|
||||
notificationsEnabled: true,
|
||||
soundEnabled: false,
|
||||
compactView: false,
|
||||
showCompletedTasks: true,
|
||||
defaultSessionFilter: 'all',
|
||||
defaultSortField: 'created_at',
|
||||
defaultSortDirection: 'desc',
|
||||
};
|
||||
|
||||
// Initial state
|
||||
const initialState: ConfigState = {
|
||||
cliTools: defaultCliTools,
|
||||
defaultCliTool: 'gemini',
|
||||
apiEndpoints: defaultApiEndpoints,
|
||||
userPreferences: defaultUserPreferences,
|
||||
featureFlags: {
|
||||
orchestratorEnabled: true,
|
||||
darkModeEnabled: true,
|
||||
notificationsEnabled: true,
|
||||
experimentalFeatures: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const useConfigStore = create<ConfigStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== CLI Tools Actions ==========
|
||||
|
||||
setCliTools: (tools: Record<string, CliToolConfig>) => {
|
||||
set({ cliTools: tools }, false, 'setCliTools');
|
||||
},
|
||||
|
||||
updateCliTool: (toolId: string, updates: Partial<CliToolConfig>) => {
|
||||
set(
|
||||
(state) => ({
|
||||
cliTools: {
|
||||
...state.cliTools,
|
||||
[toolId]: {
|
||||
...state.cliTools[toolId],
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'updateCliTool'
|
||||
);
|
||||
},
|
||||
|
||||
setDefaultCliTool: (toolId: string) => {
|
||||
const { cliTools } = get();
|
||||
if (cliTools[toolId]?.enabled) {
|
||||
set({ defaultCliTool: toolId }, false, 'setDefaultCliTool');
|
||||
}
|
||||
},
|
||||
|
||||
// ========== API Endpoints Actions ==========
|
||||
|
||||
setApiEndpoints: (endpoints: Partial<ApiEndpoints>) => {
|
||||
set(
|
||||
(state) => ({
|
||||
apiEndpoints: {
|
||||
...state.apiEndpoints,
|
||||
...endpoints,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setApiEndpoints'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== User Preferences Actions ==========
|
||||
|
||||
setUserPreferences: (prefs: Partial<UserPreferences>) => {
|
||||
set(
|
||||
(state) => ({
|
||||
userPreferences: {
|
||||
...state.userPreferences,
|
||||
...prefs,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setUserPreferences'
|
||||
);
|
||||
},
|
||||
|
||||
resetUserPreferences: () => {
|
||||
set({ userPreferences: defaultUserPreferences }, false, 'resetUserPreferences');
|
||||
},
|
||||
|
||||
// ========== Feature Flags Actions ==========
|
||||
|
||||
setFeatureFlag: (flag: string, enabled: boolean) => {
|
||||
set(
|
||||
(state) => ({
|
||||
featureFlags: {
|
||||
...state.featureFlags,
|
||||
[flag]: enabled,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setFeatureFlag'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Bulk Config Actions ==========
|
||||
|
||||
loadConfig: (config: Partial<ConfigState>) => {
|
||||
set(
|
||||
(state) => ({
|
||||
...state,
|
||||
...config,
|
||||
// Deep merge nested objects
|
||||
cliTools: config.cliTools || state.cliTools,
|
||||
apiEndpoints: {
|
||||
...state.apiEndpoints,
|
||||
...(config.apiEndpoints || {}),
|
||||
},
|
||||
userPreferences: {
|
||||
...state.userPreferences,
|
||||
...(config.userPreferences || {}),
|
||||
},
|
||||
featureFlags: {
|
||||
...state.featureFlags,
|
||||
...(config.featureFlags || {}),
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'loadConfig'
|
||||
);
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'ccw-config-store',
|
||||
// Persist all config state
|
||||
partialize: (state) => ({
|
||||
cliTools: state.cliTools,
|
||||
defaultCliTool: state.defaultCliTool,
|
||||
userPreferences: state.userPreferences,
|
||||
featureFlags: state.featureFlags,
|
||||
}),
|
||||
}
|
||||
),
|
||||
{ name: 'ConfigStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// Selectors for common access patterns
|
||||
export const selectCliTools = (state: ConfigStore) => state.cliTools;
|
||||
export const selectDefaultCliTool = (state: ConfigStore) => state.defaultCliTool;
|
||||
export const selectApiEndpoints = (state: ConfigStore) => state.apiEndpoints;
|
||||
export const selectUserPreferences = (state: ConfigStore) => state.userPreferences;
|
||||
export const selectFeatureFlags = (state: ConfigStore) => state.featureFlags;
|
||||
|
||||
// Helper to get first enabled CLI tool
|
||||
export const getFirstEnabledCliTool = (cliTools: Record<string, CliToolConfig>): string => {
|
||||
const entries = Object.entries(cliTools);
|
||||
const enabled = entries.find(([, config]) => config.enabled);
|
||||
return enabled ? enabled[0] : 'gemini';
|
||||
};
|
||||
227
ccw/frontend/src/stores/executionStore.ts
Normal file
227
ccw/frontend/src/stores/executionStore.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
// ========================================
|
||||
// Execution Store
|
||||
// ========================================
|
||||
// Zustand store for Orchestrator execution state management
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type {
|
||||
ExecutionStore,
|
||||
ExecutionState,
|
||||
ExecutionStatus,
|
||||
NodeExecutionState,
|
||||
ExecutionLog,
|
||||
} from '../types/execution';
|
||||
|
||||
// Constants
|
||||
const MAX_LOGS = 500;
|
||||
|
||||
// Initial state
|
||||
const initialState = {
|
||||
// Current execution
|
||||
currentExecution: null as ExecutionState | null,
|
||||
|
||||
// Node execution states
|
||||
nodeStates: {} as Record<string, NodeExecutionState>,
|
||||
|
||||
// Execution logs
|
||||
logs: [] as ExecutionLog[],
|
||||
maxLogs: MAX_LOGS,
|
||||
|
||||
// UI state
|
||||
isMonitorExpanded: true,
|
||||
autoScrollLogs: true,
|
||||
};
|
||||
|
||||
export const useExecutionStore = create<ExecutionStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Execution Lifecycle ==========
|
||||
|
||||
startExecution: (execId: string, flowId: string) => {
|
||||
const now = new Date().toISOString();
|
||||
set(
|
||||
{
|
||||
currentExecution: {
|
||||
execId,
|
||||
flowId,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
elapsedMs: 0,
|
||||
},
|
||||
nodeStates: {},
|
||||
logs: [],
|
||||
},
|
||||
false,
|
||||
'startExecution'
|
||||
);
|
||||
},
|
||||
|
||||
setExecutionStatus: (status: ExecutionStatus, currentNodeId?: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
if (!state.currentExecution) return state;
|
||||
return {
|
||||
currentExecution: {
|
||||
...state.currentExecution,
|
||||
status,
|
||||
currentNodeId: currentNodeId ?? state.currentExecution.currentNodeId,
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'setExecutionStatus'
|
||||
);
|
||||
},
|
||||
|
||||
completeExecution: (status: 'completed' | 'failed') => {
|
||||
const now = new Date().toISOString();
|
||||
set(
|
||||
(state) => {
|
||||
if (!state.currentExecution) return state;
|
||||
const startTime = new Date(state.currentExecution.startedAt).getTime();
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
return {
|
||||
currentExecution: {
|
||||
...state.currentExecution,
|
||||
status,
|
||||
completedAt: now,
|
||||
elapsedMs,
|
||||
currentNodeId: undefined,
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'completeExecution'
|
||||
);
|
||||
},
|
||||
|
||||
clearExecution: () => {
|
||||
set(
|
||||
{
|
||||
currentExecution: null,
|
||||
nodeStates: {},
|
||||
logs: [],
|
||||
},
|
||||
false,
|
||||
'clearExecution'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Node State Updates ==========
|
||||
|
||||
setNodeStarted: (nodeId: string) => {
|
||||
const now = new Date().toISOString();
|
||||
set(
|
||||
(state) => ({
|
||||
nodeStates: {
|
||||
...state.nodeStates,
|
||||
[nodeId]: {
|
||||
nodeId,
|
||||
status: 'running',
|
||||
startedAt: now,
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setNodeStarted'
|
||||
);
|
||||
},
|
||||
|
||||
setNodeCompleted: (nodeId: string, result?: unknown) => {
|
||||
const now = new Date().toISOString();
|
||||
set(
|
||||
(state) => ({
|
||||
nodeStates: {
|
||||
...state.nodeStates,
|
||||
[nodeId]: {
|
||||
...state.nodeStates[nodeId],
|
||||
nodeId,
|
||||
status: 'completed',
|
||||
completedAt: now,
|
||||
result,
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setNodeCompleted'
|
||||
);
|
||||
},
|
||||
|
||||
setNodeFailed: (nodeId: string, error: string) => {
|
||||
const now = new Date().toISOString();
|
||||
set(
|
||||
(state) => ({
|
||||
nodeStates: {
|
||||
...state.nodeStates,
|
||||
[nodeId]: {
|
||||
...state.nodeStates[nodeId],
|
||||
nodeId,
|
||||
status: 'failed',
|
||||
completedAt: now,
|
||||
error,
|
||||
},
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setNodeFailed'
|
||||
);
|
||||
},
|
||||
|
||||
clearNodeStates: () => {
|
||||
set({ nodeStates: {} }, false, 'clearNodeStates');
|
||||
},
|
||||
|
||||
// ========== Logs ==========
|
||||
|
||||
addLog: (log: ExecutionLog) => {
|
||||
set(
|
||||
(state) => {
|
||||
const newLogs = [...state.logs, log];
|
||||
// Trim logs if exceeding max
|
||||
if (newLogs.length > state.maxLogs) {
|
||||
return { logs: newLogs.slice(-state.maxLogs) };
|
||||
}
|
||||
return { logs: newLogs };
|
||||
},
|
||||
false,
|
||||
'addLog'
|
||||
);
|
||||
},
|
||||
|
||||
clearLogs: () => {
|
||||
set({ logs: [] }, false, 'clearLogs');
|
||||
},
|
||||
|
||||
// ========== UI State ==========
|
||||
|
||||
setMonitorExpanded: (expanded: boolean) => {
|
||||
set({ isMonitorExpanded: expanded }, false, 'setMonitorExpanded');
|
||||
},
|
||||
|
||||
setAutoScrollLogs: (autoScroll: boolean) => {
|
||||
set({ autoScrollLogs: autoScroll }, false, 'setAutoScrollLogs');
|
||||
},
|
||||
}),
|
||||
{ name: 'ExecutionStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// Selectors for common access patterns
|
||||
export const selectCurrentExecution = (state: ExecutionStore) => state.currentExecution;
|
||||
export const selectNodeStates = (state: ExecutionStore) => state.nodeStates;
|
||||
export const selectLogs = (state: ExecutionStore) => state.logs;
|
||||
export const selectIsMonitorExpanded = (state: ExecutionStore) => state.isMonitorExpanded;
|
||||
export const selectAutoScrollLogs = (state: ExecutionStore) => state.autoScrollLogs;
|
||||
|
||||
// Helper to check if execution is active
|
||||
export const selectIsExecuting = (state: ExecutionStore) => {
|
||||
return state.currentExecution?.status === 'running';
|
||||
};
|
||||
|
||||
// Helper to get node status
|
||||
export const selectNodeStatus = (nodeId: string) => (state: ExecutionStore) => {
|
||||
return state.nodeStates[nodeId]?.status ?? 'pending';
|
||||
};
|
||||
435
ccw/frontend/src/stores/flowStore.ts
Normal file
435
ccw/frontend/src/stores/flowStore.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
// ========================================
|
||||
// Flow Store
|
||||
// ========================================
|
||||
// Zustand store for Orchestrator flow editor state management
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type {
|
||||
FlowStore,
|
||||
Flow,
|
||||
FlowNode,
|
||||
FlowEdge,
|
||||
FlowNodeType,
|
||||
NodeData,
|
||||
FlowEdgeData,
|
||||
} from '../types/flow';
|
||||
import { NODE_TYPE_CONFIGS as nodeConfigs } from '../types/flow';
|
||||
|
||||
// Helper to generate unique IDs
|
||||
const generateId = (prefix: string): string => {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
};
|
||||
|
||||
// API base URL
|
||||
const API_BASE = '/api/orchestrator';
|
||||
|
||||
// Initial state
|
||||
const initialState = {
|
||||
// Current flow
|
||||
currentFlow: null as Flow | null,
|
||||
isModified: false,
|
||||
|
||||
// Nodes and edges
|
||||
nodes: [] as FlowNode[],
|
||||
edges: [] as FlowEdge[],
|
||||
|
||||
// Selection state
|
||||
selectedNodeId: null as string | null,
|
||||
selectedEdgeId: null as string | null,
|
||||
|
||||
// Flow list
|
||||
flows: [] as Flow[],
|
||||
isLoadingFlows: false,
|
||||
|
||||
// UI state
|
||||
isPaletteOpen: true,
|
||||
isPropertyPanelOpen: true,
|
||||
};
|
||||
|
||||
export const useFlowStore = create<FlowStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Flow CRUD ==========
|
||||
|
||||
setCurrentFlow: (flow: Flow | null) => {
|
||||
set(
|
||||
{
|
||||
currentFlow: flow,
|
||||
nodes: flow?.nodes ?? [],
|
||||
edges: flow?.edges ?? [],
|
||||
isModified: false,
|
||||
selectedNodeId: null,
|
||||
selectedEdgeId: null,
|
||||
},
|
||||
false,
|
||||
'setCurrentFlow'
|
||||
);
|
||||
},
|
||||
|
||||
createFlow: (name: string, description?: string): Flow => {
|
||||
const flow: Flow = {
|
||||
id: generateId('flow'),
|
||||
name,
|
||||
description,
|
||||
version: 1,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
nodes: [],
|
||||
edges: [],
|
||||
variables: {},
|
||||
metadata: { source: 'custom' },
|
||||
};
|
||||
|
||||
set(
|
||||
{
|
||||
currentFlow: flow,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
isModified: true,
|
||||
selectedNodeId: null,
|
||||
selectedEdgeId: null,
|
||||
},
|
||||
false,
|
||||
'createFlow'
|
||||
);
|
||||
|
||||
return flow;
|
||||
},
|
||||
|
||||
saveFlow: async (): Promise<boolean> => {
|
||||
const { currentFlow, nodes, edges } = get();
|
||||
if (!currentFlow) return false;
|
||||
|
||||
try {
|
||||
const flowToSave: Flow = {
|
||||
...currentFlow,
|
||||
nodes,
|
||||
edges,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const isNew = !get().flows.some((f) => f.id === currentFlow.id);
|
||||
const method = isNew ? 'POST' : 'PUT';
|
||||
const url = isNew
|
||||
? `${API_BASE}/flows`
|
||||
: `${API_BASE}/flows/${currentFlow.id}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(flowToSave),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save flow: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const savedFlow = await response.json();
|
||||
|
||||
set(
|
||||
(state) => ({
|
||||
currentFlow: savedFlow,
|
||||
isModified: false,
|
||||
flows: isNew
|
||||
? [...state.flows, savedFlow]
|
||||
: state.flows.map((f) => (f.id === savedFlow.id ? savedFlow : f)),
|
||||
}),
|
||||
false,
|
||||
'saveFlow'
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving flow:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
loadFlow: async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/flows/${id}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load flow: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const flow: Flow = await response.json();
|
||||
|
||||
set(
|
||||
{
|
||||
currentFlow: flow,
|
||||
nodes: flow.nodes,
|
||||
edges: flow.edges,
|
||||
isModified: false,
|
||||
selectedNodeId: null,
|
||||
selectedEdgeId: null,
|
||||
},
|
||||
false,
|
||||
'loadFlow'
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error loading flow:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
deleteFlow: async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/flows/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete flow: ${response.statusText}`);
|
||||
}
|
||||
|
||||
set(
|
||||
(state) => ({
|
||||
flows: state.flows.filter((f) => f.id !== id),
|
||||
currentFlow: state.currentFlow?.id === id ? null : state.currentFlow,
|
||||
nodes: state.currentFlow?.id === id ? [] : state.nodes,
|
||||
edges: state.currentFlow?.id === id ? [] : state.edges,
|
||||
}),
|
||||
false,
|
||||
'deleteFlow'
|
||||
);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error deleting flow:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
duplicateFlow: async (id: string): Promise<Flow | null> => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/flows/${id}/duplicate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to duplicate flow: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const duplicatedFlow: Flow = await response.json();
|
||||
|
||||
set(
|
||||
(state) => ({
|
||||
flows: [...state.flows, duplicatedFlow],
|
||||
}),
|
||||
false,
|
||||
'duplicateFlow'
|
||||
);
|
||||
|
||||
return duplicatedFlow;
|
||||
} catch (error) {
|
||||
console.error('Error duplicating flow:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Node Operations ==========
|
||||
|
||||
addNode: (type: FlowNodeType, position: { x: number; y: number }): string => {
|
||||
const config = nodeConfigs[type];
|
||||
const id = generateId('node');
|
||||
|
||||
const newNode: FlowNode = {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
data: { ...config.defaultData },
|
||||
};
|
||||
|
||||
set(
|
||||
(state) => ({
|
||||
nodes: [...state.nodes, newNode],
|
||||
isModified: true,
|
||||
selectedNodeId: id,
|
||||
}),
|
||||
false,
|
||||
'addNode'
|
||||
);
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
updateNode: (id: string, data: Partial<NodeData>) => {
|
||||
set(
|
||||
(state) => ({
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, ...data } as NodeData }
|
||||
: node
|
||||
),
|
||||
isModified: true,
|
||||
}),
|
||||
false,
|
||||
'updateNode'
|
||||
);
|
||||
},
|
||||
|
||||
removeNode: (id: string) => {
|
||||
set(
|
||||
(state) => ({
|
||||
nodes: state.nodes.filter((node) => node.id !== id),
|
||||
edges: state.edges.filter(
|
||||
(edge) => edge.source !== id && edge.target !== id
|
||||
),
|
||||
isModified: true,
|
||||
selectedNodeId: state.selectedNodeId === id ? null : state.selectedNodeId,
|
||||
}),
|
||||
false,
|
||||
'removeNode'
|
||||
);
|
||||
},
|
||||
|
||||
setNodes: (nodes: FlowNode[]) => {
|
||||
set({ nodes, isModified: true }, false, 'setNodes');
|
||||
},
|
||||
|
||||
// ========== Edge Operations ==========
|
||||
|
||||
addEdge: (
|
||||
source: string,
|
||||
target: string,
|
||||
sourceHandle?: string,
|
||||
targetHandle?: string
|
||||
): string => {
|
||||
const id = generateId('edge');
|
||||
|
||||
const newEdge: FlowEdge = {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
};
|
||||
|
||||
set(
|
||||
(state) => ({
|
||||
edges: [...state.edges, newEdge],
|
||||
isModified: true,
|
||||
}),
|
||||
false,
|
||||
'addEdge'
|
||||
);
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
updateEdge: (id: string, data: Partial<FlowEdgeData>) => {
|
||||
set(
|
||||
(state) => ({
|
||||
edges: state.edges.map((edge) =>
|
||||
edge.id === id ? { ...edge, data: { ...edge.data, ...data } } : edge
|
||||
),
|
||||
isModified: true,
|
||||
}),
|
||||
false,
|
||||
'updateEdge'
|
||||
);
|
||||
},
|
||||
|
||||
removeEdge: (id: string) => {
|
||||
set(
|
||||
(state) => ({
|
||||
edges: state.edges.filter((edge) => edge.id !== id),
|
||||
isModified: true,
|
||||
selectedEdgeId: state.selectedEdgeId === id ? null : state.selectedEdgeId,
|
||||
}),
|
||||
false,
|
||||
'removeEdge'
|
||||
);
|
||||
},
|
||||
|
||||
setEdges: (edges: FlowEdge[]) => {
|
||||
set({ edges, isModified: true }, false, 'setEdges');
|
||||
},
|
||||
|
||||
// ========== Selection ==========
|
||||
|
||||
setSelectedNodeId: (id: string | null) => {
|
||||
set({ selectedNodeId: id, selectedEdgeId: null }, false, 'setSelectedNodeId');
|
||||
},
|
||||
|
||||
setSelectedEdgeId: (id: string | null) => {
|
||||
set({ selectedEdgeId: id, selectedNodeId: null }, false, 'setSelectedEdgeId');
|
||||
},
|
||||
|
||||
// ========== Flow List ==========
|
||||
|
||||
fetchFlows: async (): Promise<void> => {
|
||||
set({ isLoadingFlows: true }, false, 'fetchFlows/start');
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/flows`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch flows: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const flows: Flow[] = data.flows || [];
|
||||
|
||||
set({ flows, isLoadingFlows: false }, false, 'fetchFlows/success');
|
||||
} catch (error) {
|
||||
console.error('Error fetching flows:', error);
|
||||
set({ isLoadingFlows: false }, false, 'fetchFlows/error');
|
||||
}
|
||||
},
|
||||
|
||||
// ========== UI State ==========
|
||||
|
||||
setIsPaletteOpen: (open: boolean) => {
|
||||
set({ isPaletteOpen: open }, false, 'setIsPaletteOpen');
|
||||
},
|
||||
|
||||
setIsPropertyPanelOpen: (open: boolean) => {
|
||||
set({ isPropertyPanelOpen: open }, false, 'setIsPropertyPanelOpen');
|
||||
},
|
||||
|
||||
// ========== Utility ==========
|
||||
|
||||
resetFlow: () => {
|
||||
set(
|
||||
{
|
||||
currentFlow: null,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
isModified: false,
|
||||
selectedNodeId: null,
|
||||
selectedEdgeId: null,
|
||||
},
|
||||
false,
|
||||
'resetFlow'
|
||||
);
|
||||
},
|
||||
|
||||
getSelectedNode: (): FlowNode | undefined => {
|
||||
const { nodes, selectedNodeId } = get();
|
||||
return nodes.find((node) => node.id === selectedNodeId);
|
||||
},
|
||||
|
||||
markModified: () => {
|
||||
set({ isModified: true }, false, 'markModified');
|
||||
},
|
||||
}),
|
||||
{ name: 'FlowStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// Selectors for common access patterns
|
||||
export const selectCurrentFlow = (state: FlowStore) => state.currentFlow;
|
||||
export const selectNodes = (state: FlowStore) => state.nodes;
|
||||
export const selectEdges = (state: FlowStore) => state.edges;
|
||||
export const selectSelectedNodeId = (state: FlowStore) => state.selectedNodeId;
|
||||
export const selectSelectedEdgeId = (state: FlowStore) => state.selectedEdgeId;
|
||||
export const selectFlows = (state: FlowStore) => state.flows;
|
||||
export const selectIsModified = (state: FlowStore) => state.isModified;
|
||||
export const selectIsLoadingFlows = (state: FlowStore) => state.isLoadingFlows;
|
||||
export const selectIsPaletteOpen = (state: FlowStore) => state.isPaletteOpen;
|
||||
export const selectIsPropertyPanelOpen = (state: FlowStore) => state.isPropertyPanelOpen;
|
||||
154
ccw/frontend/src/stores/index.ts
Normal file
154
ccw/frontend/src/stores/index.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// ========================================
|
||||
// Stores Barrel Export
|
||||
// ========================================
|
||||
// Re-export all stores for convenient imports
|
||||
|
||||
// App Store
|
||||
export {
|
||||
useAppStore,
|
||||
selectTheme,
|
||||
selectResolvedTheme,
|
||||
selectSidebarOpen,
|
||||
selectCurrentView,
|
||||
selectIsLoading,
|
||||
selectError,
|
||||
} from './appStore';
|
||||
|
||||
// Workflow Store
|
||||
export {
|
||||
useWorkflowStore,
|
||||
selectWorkflowData,
|
||||
selectActiveSessions,
|
||||
selectArchivedSessions,
|
||||
selectActiveSessionId,
|
||||
selectProjectPath,
|
||||
selectFilters,
|
||||
selectSorting,
|
||||
} from './workflowStore';
|
||||
|
||||
// Config Store
|
||||
export {
|
||||
useConfigStore,
|
||||
selectCliTools,
|
||||
selectDefaultCliTool,
|
||||
selectApiEndpoints,
|
||||
selectUserPreferences,
|
||||
selectFeatureFlags,
|
||||
getFirstEnabledCliTool,
|
||||
} from './configStore';
|
||||
|
||||
// Notification Store
|
||||
export {
|
||||
useNotificationStore,
|
||||
selectToasts,
|
||||
selectWsStatus,
|
||||
selectWsLastMessage,
|
||||
selectIsPanelVisible,
|
||||
selectPersistentNotifications,
|
||||
toast,
|
||||
} from './notificationStore';
|
||||
|
||||
// Flow Store
|
||||
export {
|
||||
useFlowStore,
|
||||
selectCurrentFlow,
|
||||
selectNodes,
|
||||
selectEdges,
|
||||
selectSelectedNodeId,
|
||||
selectSelectedEdgeId,
|
||||
selectFlows,
|
||||
selectIsModified,
|
||||
selectIsLoadingFlows,
|
||||
selectIsPaletteOpen,
|
||||
selectIsPropertyPanelOpen,
|
||||
} from './flowStore';
|
||||
|
||||
// Execution Store
|
||||
export {
|
||||
useExecutionStore,
|
||||
selectCurrentExecution,
|
||||
selectNodeStates,
|
||||
selectLogs,
|
||||
selectIsMonitorExpanded,
|
||||
selectAutoScrollLogs,
|
||||
selectIsExecuting,
|
||||
selectNodeStatus,
|
||||
} from './executionStore';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
// App Store Types
|
||||
AppStore,
|
||||
AppState,
|
||||
AppActions,
|
||||
Theme,
|
||||
ViewMode,
|
||||
SessionFilter,
|
||||
LiteTaskType,
|
||||
|
||||
// Workflow Store Types
|
||||
WorkflowStore,
|
||||
WorkflowState,
|
||||
WorkflowActions,
|
||||
WorkflowData,
|
||||
WorkflowFilters,
|
||||
WorkflowSorting,
|
||||
SessionMetadata,
|
||||
TaskData,
|
||||
LiteTaskSession,
|
||||
|
||||
// Config Store Types
|
||||
ConfigStore,
|
||||
ConfigState,
|
||||
ConfigActions,
|
||||
CliToolConfig,
|
||||
ApiEndpoints,
|
||||
UserPreferences,
|
||||
|
||||
// Notification Store Types
|
||||
NotificationStore,
|
||||
NotificationState,
|
||||
NotificationActions,
|
||||
Toast,
|
||||
ToastType,
|
||||
WebSocketStatus,
|
||||
WebSocketMessage,
|
||||
} from '../types/store';
|
||||
|
||||
// Execution Types
|
||||
export type {
|
||||
ExecutionStatus,
|
||||
NodeExecutionStatus,
|
||||
LogLevel,
|
||||
ExecutionLog,
|
||||
NodeExecutionState,
|
||||
ExecutionState,
|
||||
OrchestratorWebSocketMessage,
|
||||
ExecutionStore,
|
||||
ExecutionStoreState,
|
||||
ExecutionStoreActions,
|
||||
FlowTemplate,
|
||||
TemplateInstallRequest,
|
||||
TemplateExportRequest,
|
||||
} from '../types/execution';
|
||||
|
||||
// Flow Types
|
||||
export type {
|
||||
FlowNodeType,
|
||||
SlashCommandNodeData,
|
||||
FileOperationNodeData,
|
||||
ConditionalNodeData,
|
||||
ParallelNodeData,
|
||||
NodeData,
|
||||
FlowNode,
|
||||
FlowEdge,
|
||||
FlowEdgeData,
|
||||
Flow,
|
||||
FlowMetadata,
|
||||
FlowState,
|
||||
FlowActions,
|
||||
FlowStore,
|
||||
NodeTypeConfig,
|
||||
} from '../types/flow';
|
||||
|
||||
export { NODE_TYPE_CONFIGS } from '../types/flow';
|
||||
257
ccw/frontend/src/stores/notificationStore.ts
Normal file
257
ccw/frontend/src/stores/notificationStore.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
// ========================================
|
||||
// Notification Store
|
||||
// ========================================
|
||||
// Manages toasts, WebSocket connection status, and persistent notifications
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type {
|
||||
NotificationStore,
|
||||
NotificationState,
|
||||
Toast,
|
||||
WebSocketStatus,
|
||||
WebSocketMessage,
|
||||
} from '../types/store';
|
||||
|
||||
// Constants
|
||||
const NOTIFICATION_STORAGE_KEY = 'ccw_notifications';
|
||||
const NOTIFICATION_MAX_STORED = 100;
|
||||
const NOTIFICATION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
// Helper to generate unique ID
|
||||
const generateId = (): string => {
|
||||
return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
// Helper to load notifications from localStorage
|
||||
const loadFromStorage = (): Toast[] => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(NOTIFICATION_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed: Toast[] = JSON.parse(stored);
|
||||
// Filter out notifications older than max age
|
||||
const cutoffTime = Date.now() - NOTIFICATION_MAX_AGE_MS;
|
||||
return parsed.filter((n) => new Date(n.timestamp).getTime() > cutoffTime);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[NotificationStore] Failed to load from storage:', e);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
// Helper to save notifications to localStorage
|
||||
const saveToStorage = (notifications: Toast[]): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
try {
|
||||
// Keep only the last N notifications
|
||||
const toSave = notifications.slice(0, NOTIFICATION_MAX_STORED);
|
||||
localStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(toSave));
|
||||
} catch (e) {
|
||||
console.error('[NotificationStore] Failed to save to storage:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial state
|
||||
const initialState: NotificationState = {
|
||||
// Toast queue (ephemeral, UI-only)
|
||||
toasts: [],
|
||||
maxToasts: 5,
|
||||
|
||||
// WebSocket status
|
||||
wsStatus: 'disconnected',
|
||||
wsLastMessage: null,
|
||||
wsReconnectAttempts: 0,
|
||||
|
||||
// Notification panel
|
||||
isPanelVisible: false,
|
||||
|
||||
// Persistent notifications (stored in localStorage)
|
||||
persistentNotifications: [],
|
||||
};
|
||||
|
||||
export const useNotificationStore = create<NotificationStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Toast Actions ==========
|
||||
|
||||
addToast: (toast: Omit<Toast, 'id' | 'timestamp'>): string => {
|
||||
const id = generateId();
|
||||
const newToast: Toast = {
|
||||
...toast,
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
dismissible: toast.dismissible ?? true,
|
||||
duration: toast.duration ?? 5000, // Default 5 seconds
|
||||
};
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
const { maxToasts } = state;
|
||||
// Add new toast at the end, remove oldest if over limit
|
||||
let newToasts = [...state.toasts, newToast];
|
||||
if (newToasts.length > maxToasts) {
|
||||
newToasts = newToasts.slice(-maxToasts);
|
||||
}
|
||||
return { toasts: newToasts };
|
||||
},
|
||||
false,
|
||||
'addToast'
|
||||
);
|
||||
|
||||
// Auto-remove after duration (if not persistent)
|
||||
if (newToast.duration && newToast.duration > 0) {
|
||||
setTimeout(() => {
|
||||
get().removeToast(id);
|
||||
}, newToast.duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
removeToast: (id: string) => {
|
||||
set(
|
||||
(state) => ({
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}),
|
||||
false,
|
||||
'removeToast'
|
||||
);
|
||||
},
|
||||
|
||||
clearAllToasts: () => {
|
||||
set({ toasts: [] }, false, 'clearAllToasts');
|
||||
},
|
||||
|
||||
// ========== WebSocket Status Actions ==========
|
||||
|
||||
setWsStatus: (status: WebSocketStatus) => {
|
||||
set({ wsStatus: status }, false, 'setWsStatus');
|
||||
},
|
||||
|
||||
setWsLastMessage: (message: WebSocketMessage | null) => {
|
||||
set({ wsLastMessage: message }, false, 'setWsLastMessage');
|
||||
},
|
||||
|
||||
incrementReconnectAttempts: () => {
|
||||
set(
|
||||
(state) => ({
|
||||
wsReconnectAttempts: state.wsReconnectAttempts + 1,
|
||||
}),
|
||||
false,
|
||||
'incrementReconnectAttempts'
|
||||
);
|
||||
},
|
||||
|
||||
resetReconnectAttempts: () => {
|
||||
set({ wsReconnectAttempts: 0 }, false, 'resetReconnectAttempts');
|
||||
},
|
||||
|
||||
// ========== Notification Panel Actions ==========
|
||||
|
||||
togglePanel: () => {
|
||||
set(
|
||||
(state) => ({
|
||||
isPanelVisible: !state.isPanelVisible,
|
||||
}),
|
||||
false,
|
||||
'togglePanel'
|
||||
);
|
||||
},
|
||||
|
||||
setPanelVisible: (visible: boolean) => {
|
||||
set({ isPanelVisible: visible }, false, 'setPanelVisible');
|
||||
},
|
||||
|
||||
// ========== Persistent Notification Actions ==========
|
||||
|
||||
addPersistentNotification: (notification: Omit<Toast, 'id' | 'timestamp'>) => {
|
||||
const id = generateId();
|
||||
const newNotification: Toast = {
|
||||
...notification,
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
dismissible: notification.dismissible ?? true,
|
||||
};
|
||||
|
||||
set(
|
||||
(state) => ({
|
||||
persistentNotifications: [newNotification, ...state.persistentNotifications],
|
||||
}),
|
||||
false,
|
||||
'addPersistentNotification'
|
||||
);
|
||||
|
||||
// Also save to localStorage
|
||||
const state = get();
|
||||
saveToStorage(state.persistentNotifications);
|
||||
},
|
||||
|
||||
removePersistentNotification: (id: string) => {
|
||||
set(
|
||||
(state) => ({
|
||||
persistentNotifications: state.persistentNotifications.filter((n) => n.id !== id),
|
||||
}),
|
||||
false,
|
||||
'removePersistentNotification'
|
||||
);
|
||||
|
||||
// Also save to localStorage
|
||||
const state = get();
|
||||
saveToStorage(state.persistentNotifications);
|
||||
},
|
||||
|
||||
clearPersistentNotifications: () => {
|
||||
set({ persistentNotifications: [] }, false, 'clearPersistentNotifications');
|
||||
|
||||
// Also clear localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(NOTIFICATION_STORAGE_KEY);
|
||||
}
|
||||
},
|
||||
|
||||
loadPersistentNotifications: () => {
|
||||
const loaded = loadFromStorage();
|
||||
set({ persistentNotifications: loaded }, false, 'loadPersistentNotifications');
|
||||
},
|
||||
|
||||
savePersistentNotifications: () => {
|
||||
const state = get();
|
||||
saveToStorage(state.persistentNotifications);
|
||||
},
|
||||
}),
|
||||
{ name: 'NotificationStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// Initialize persistent notifications on store creation
|
||||
if (typeof window !== 'undefined') {
|
||||
const loaded = loadFromStorage();
|
||||
if (loaded.length > 0) {
|
||||
useNotificationStore.setState({ persistentNotifications: loaded });
|
||||
}
|
||||
}
|
||||
|
||||
// Selectors for common access patterns
|
||||
export const selectToasts = (state: NotificationStore) => state.toasts;
|
||||
export const selectWsStatus = (state: NotificationStore) => state.wsStatus;
|
||||
export const selectWsLastMessage = (state: NotificationStore) => state.wsLastMessage;
|
||||
export const selectIsPanelVisible = (state: NotificationStore) => state.isPanelVisible;
|
||||
export const selectPersistentNotifications = (state: NotificationStore) =>
|
||||
state.persistentNotifications;
|
||||
|
||||
// Helper to create toast shortcuts
|
||||
export const toast = {
|
||||
info: (title: string, message?: string) =>
|
||||
useNotificationStore.getState().addToast({ type: 'info', title, message }),
|
||||
success: (title: string, message?: string) =>
|
||||
useNotificationStore.getState().addToast({ type: 'success', title, message }),
|
||||
warning: (title: string, message?: string) =>
|
||||
useNotificationStore.getState().addToast({ type: 'warning', title, message }),
|
||||
error: (title: string, message?: string) =>
|
||||
useNotificationStore.getState().addToast({ type: 'error', title, message, duration: 0 }),
|
||||
};
|
||||
477
ccw/frontend/src/stores/workflowStore.ts
Normal file
477
ccw/frontend/src/stores/workflowStore.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
// ========================================
|
||||
// Workflow Store
|
||||
// ========================================
|
||||
// Manages workflow sessions, tasks, and related data
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type {
|
||||
WorkflowStore,
|
||||
WorkflowState,
|
||||
SessionMetadata,
|
||||
TaskData,
|
||||
LiteTaskSession,
|
||||
WorkflowFilters,
|
||||
WorkflowSorting,
|
||||
} from '../types/store';
|
||||
|
||||
// Helper to generate session key from ID
|
||||
const sessionKey = (sessionId: string): string => {
|
||||
return `session-${sessionId}`.replace(/[^a-zA-Z0-9-]/g, '-');
|
||||
};
|
||||
|
||||
// Default filters
|
||||
const defaultFilters: WorkflowFilters = {
|
||||
status: null,
|
||||
search: '',
|
||||
dateRange: { start: null, end: null },
|
||||
};
|
||||
|
||||
// Default sorting
|
||||
const defaultSorting: WorkflowSorting = {
|
||||
field: 'created_at',
|
||||
direction: 'desc',
|
||||
};
|
||||
|
||||
// Initial state
|
||||
const initialState: WorkflowState = {
|
||||
// Core data
|
||||
workflowData: {
|
||||
activeSessions: [],
|
||||
archivedSessions: [],
|
||||
},
|
||||
projectPath: '',
|
||||
recentPaths: [],
|
||||
serverPlatform: 'win32',
|
||||
|
||||
// Data stores
|
||||
sessionDataStore: {},
|
||||
liteTaskDataStore: {},
|
||||
taskJsonStore: {},
|
||||
|
||||
// Active session
|
||||
activeSessionId: null,
|
||||
|
||||
// Filters and sorting
|
||||
filters: defaultFilters,
|
||||
sorting: defaultSorting,
|
||||
};
|
||||
|
||||
export const useWorkflowStore = create<WorkflowStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Session Actions ==========
|
||||
|
||||
setSessions: (active: SessionMetadata[], archived: SessionMetadata[]) => {
|
||||
const sessionDataStore: Record<string, SessionMetadata> = {};
|
||||
|
||||
// Build sessionDataStore from both arrays
|
||||
[...active, ...archived].forEach((session) => {
|
||||
const key = sessionKey(session.session_id);
|
||||
sessionDataStore[key] = session;
|
||||
});
|
||||
|
||||
set(
|
||||
{
|
||||
workflowData: {
|
||||
activeSessions: active,
|
||||
archivedSessions: archived,
|
||||
},
|
||||
sessionDataStore,
|
||||
},
|
||||
false,
|
||||
'setSessions'
|
||||
);
|
||||
},
|
||||
|
||||
addSession: (session: SessionMetadata) => {
|
||||
const key = sessionKey(session.session_id);
|
||||
|
||||
set(
|
||||
(state) => ({
|
||||
workflowData: {
|
||||
...state.workflowData,
|
||||
activeSessions: [...state.workflowData.activeSessions, session],
|
||||
},
|
||||
sessionDataStore: {
|
||||
...state.sessionDataStore,
|
||||
[key]: session,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'addSession'
|
||||
);
|
||||
},
|
||||
|
||||
updateSession: (sessionId: string, updates: Partial<SessionMetadata>) => {
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
const session = state.sessionDataStore[key];
|
||||
if (!session) return state;
|
||||
|
||||
const updatedSession = { ...session, ...updates, updated_at: new Date().toISOString() };
|
||||
|
||||
// Update in the appropriate array
|
||||
const isActive = session.location === 'active';
|
||||
const targetArray = isActive ? 'activeSessions' : 'archivedSessions';
|
||||
|
||||
return {
|
||||
sessionDataStore: {
|
||||
...state.sessionDataStore,
|
||||
[key]: updatedSession,
|
||||
},
|
||||
workflowData: {
|
||||
...state.workflowData,
|
||||
[targetArray]: state.workflowData[targetArray].map((s) =>
|
||||
s.session_id === sessionId ? updatedSession : s
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'updateSession'
|
||||
);
|
||||
},
|
||||
|
||||
removeSession: (sessionId: string) => {
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
const { [key]: removed, ...remainingStore } = state.sessionDataStore;
|
||||
|
||||
return {
|
||||
sessionDataStore: remainingStore,
|
||||
workflowData: {
|
||||
activeSessions: state.workflowData.activeSessions.filter(
|
||||
(s) => s.session_id !== sessionId
|
||||
),
|
||||
archivedSessions: state.workflowData.archivedSessions.filter(
|
||||
(s) => s.session_id !== sessionId
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'removeSession'
|
||||
);
|
||||
},
|
||||
|
||||
archiveSession: (sessionId: string) => {
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
const session = state.sessionDataStore[key];
|
||||
if (!session || session.location === 'archived') return state;
|
||||
|
||||
const archivedSession: SessionMetadata = {
|
||||
...session,
|
||||
location: 'archived',
|
||||
status: 'archived',
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
sessionDataStore: {
|
||||
...state.sessionDataStore,
|
||||
[key]: archivedSession,
|
||||
},
|
||||
workflowData: {
|
||||
activeSessions: state.workflowData.activeSessions.filter(
|
||||
(s) => s.session_id !== sessionId
|
||||
),
|
||||
archivedSessions: [...state.workflowData.archivedSessions, archivedSession],
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'archiveSession'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Task Actions ==========
|
||||
|
||||
addTask: (sessionId: string, task: TaskData) => {
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
const session = state.sessionDataStore[key];
|
||||
if (!session) return state;
|
||||
|
||||
// Check for duplicate
|
||||
const existingTask = session.tasks?.find((t) => t.task_id === task.task_id);
|
||||
if (existingTask) return state;
|
||||
|
||||
const updatedSession: SessionMetadata = {
|
||||
...session,
|
||||
tasks: [...(session.tasks || []), task],
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
sessionDataStore: {
|
||||
...state.sessionDataStore,
|
||||
[key]: updatedSession,
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'addTask'
|
||||
);
|
||||
},
|
||||
|
||||
updateTask: (sessionId: string, taskId: string, updates: Partial<TaskData>) => {
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
const session = state.sessionDataStore[key];
|
||||
if (!session?.tasks) return state;
|
||||
|
||||
const updatedTasks = session.tasks.map((task) =>
|
||||
task.task_id === taskId
|
||||
? { ...task, ...updates, updated_at: new Date().toISOString() }
|
||||
: task
|
||||
);
|
||||
|
||||
const updatedSession: SessionMetadata = {
|
||||
...session,
|
||||
tasks: updatedTasks,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
sessionDataStore: {
|
||||
...state.sessionDataStore,
|
||||
[key]: updatedSession,
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'updateTask'
|
||||
);
|
||||
},
|
||||
|
||||
removeTask: (sessionId: string, taskId: string) => {
|
||||
const key = sessionKey(sessionId);
|
||||
|
||||
set(
|
||||
(state) => {
|
||||
const session = state.sessionDataStore[key];
|
||||
if (!session?.tasks) return state;
|
||||
|
||||
const updatedSession: SessionMetadata = {
|
||||
...session,
|
||||
tasks: session.tasks.filter((t) => t.task_id !== taskId),
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return {
|
||||
sessionDataStore: {
|
||||
...state.sessionDataStore,
|
||||
[key]: updatedSession,
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'removeTask'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Lite Task Actions ==========
|
||||
|
||||
setLiteTaskSession: (key: string, session: LiteTaskSession) => {
|
||||
set(
|
||||
(state) => ({
|
||||
liteTaskDataStore: {
|
||||
...state.liteTaskDataStore,
|
||||
[key]: session,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setLiteTaskSession'
|
||||
);
|
||||
},
|
||||
|
||||
removeLiteTaskSession: (key: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const { [key]: removed, ...remaining } = state.liteTaskDataStore;
|
||||
return { liteTaskDataStore: remaining };
|
||||
},
|
||||
false,
|
||||
'removeLiteTaskSession'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Task JSON Store ==========
|
||||
|
||||
setTaskJson: (key: string, data: unknown) => {
|
||||
set(
|
||||
(state) => ({
|
||||
taskJsonStore: {
|
||||
...state.taskJsonStore,
|
||||
[key]: data,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'setTaskJson'
|
||||
);
|
||||
},
|
||||
|
||||
removeTaskJson: (key: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const { [key]: removed, ...remaining } = state.taskJsonStore;
|
||||
return { taskJsonStore: remaining };
|
||||
},
|
||||
false,
|
||||
'removeTaskJson'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Active Session ==========
|
||||
|
||||
setActiveSessionId: (sessionId: string | null) => {
|
||||
set({ activeSessionId: sessionId }, false, 'setActiveSessionId');
|
||||
},
|
||||
|
||||
// ========== Project Path ==========
|
||||
|
||||
setProjectPath: (path: string) => {
|
||||
set({ projectPath: path }, false, 'setProjectPath');
|
||||
},
|
||||
|
||||
addRecentPath: (path: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
// Remove if exists, add to front
|
||||
const filtered = state.recentPaths.filter((p) => p !== path);
|
||||
const updated = [path, ...filtered].slice(0, 10); // Keep max 10
|
||||
return { recentPaths: updated };
|
||||
},
|
||||
false,
|
||||
'addRecentPath'
|
||||
);
|
||||
},
|
||||
|
||||
setServerPlatform: (platform: 'win32' | 'darwin' | 'linux') => {
|
||||
set({ serverPlatform: platform }, false, 'setServerPlatform');
|
||||
},
|
||||
|
||||
// ========== Filters and Sorting ==========
|
||||
|
||||
setFilters: (filters: Partial<WorkflowFilters>) => {
|
||||
set(
|
||||
(state) => ({
|
||||
filters: { ...state.filters, ...filters },
|
||||
}),
|
||||
false,
|
||||
'setFilters'
|
||||
);
|
||||
},
|
||||
|
||||
setSorting: (sorting: Partial<WorkflowSorting>) => {
|
||||
set(
|
||||
(state) => ({
|
||||
sorting: { ...state.sorting, ...sorting },
|
||||
}),
|
||||
false,
|
||||
'setSorting'
|
||||
);
|
||||
},
|
||||
|
||||
resetFilters: () => {
|
||||
set({ filters: defaultFilters, sorting: defaultSorting }, false, 'resetFilters');
|
||||
},
|
||||
|
||||
// ========== Computed Selectors ==========
|
||||
|
||||
getActiveSession: () => {
|
||||
const { activeSessionId, sessionDataStore } = get();
|
||||
if (!activeSessionId) return null;
|
||||
const key = sessionKey(activeSessionId);
|
||||
return sessionDataStore[key] || null;
|
||||
},
|
||||
|
||||
getFilteredSessions: () => {
|
||||
const { workflowData, filters, sorting } = get();
|
||||
|
||||
// Combine active and archived based on filter
|
||||
let sessions = [...workflowData.activeSessions, ...workflowData.archivedSessions];
|
||||
|
||||
// Apply status filter
|
||||
if (filters.status && filters.status.length > 0) {
|
||||
sessions = sessions.filter((s) => filters.status!.includes(s.status));
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
if (filters.search) {
|
||||
const searchLower = filters.search.toLowerCase();
|
||||
sessions = sessions.filter(
|
||||
(s) =>
|
||||
s.session_id.toLowerCase().includes(searchLower) ||
|
||||
s.title?.toLowerCase().includes(searchLower) ||
|
||||
s.description?.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply date range filter
|
||||
if (filters.dateRange.start || filters.dateRange.end) {
|
||||
sessions = sessions.filter((s) => {
|
||||
const createdAt = new Date(s.created_at);
|
||||
if (filters.dateRange.start && createdAt < filters.dateRange.start) return false;
|
||||
if (filters.dateRange.end && createdAt > filters.dateRange.end) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
sessions.sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sorting.field) {
|
||||
case 'created_at':
|
||||
comparison = new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
|
||||
break;
|
||||
case 'updated_at':
|
||||
comparison =
|
||||
new Date(a.updated_at || a.created_at).getTime() -
|
||||
new Date(b.updated_at || b.created_at).getTime();
|
||||
break;
|
||||
case 'title':
|
||||
comparison = (a.title || a.session_id).localeCompare(b.title || b.session_id);
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
}
|
||||
|
||||
return sorting.direction === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
|
||||
return sessions;
|
||||
},
|
||||
|
||||
getSessionByKey: (key: string) => {
|
||||
return get().sessionDataStore[key];
|
||||
},
|
||||
}),
|
||||
{ name: 'WorkflowStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// Selectors for common access patterns
|
||||
export const selectWorkflowData = (state: WorkflowStore) => state.workflowData;
|
||||
export const selectActiveSessions = (state: WorkflowStore) => state.workflowData.activeSessions;
|
||||
export const selectArchivedSessions = (state: WorkflowStore) => state.workflowData.archivedSessions;
|
||||
export const selectActiveSessionId = (state: WorkflowStore) => state.activeSessionId;
|
||||
export const selectProjectPath = (state: WorkflowStore) => state.projectPath;
|
||||
export const selectFilters = (state: WorkflowStore) => state.filters;
|
||||
export const selectSorting = (state: WorkflowStore) => state.sorting;
|
||||
Reference in New Issue
Block a user