feat: add Accordion component for UI and Zustand store for coordinator management

- Implemented Accordion component using Radix UI for collapsible sections.
- Created Zustand store to manage coordinator execution state, command chains, logs, and interactive questions.
- Added validation tests for CLI settings type definitions, ensuring type safety and correct behavior of helper functions.
This commit is contained in:
catlog22
2026-02-03 10:02:40 +08:00
parent bcb4af3ba0
commit 5483a72e9f
82 changed files with 6156 additions and 7605 deletions

View File

@@ -36,6 +36,7 @@ const initialState = {
// Sidebar
sidebarOpen: true,
sidebarCollapsed: false,
expandedNavGroups: ['overview', 'workflow', 'knowledge', 'issues', 'tools', 'configuration'] as string[],
// View state
currentView: 'sessions' as ViewMode,
@@ -110,6 +111,10 @@ export const useAppStore = create<AppStore>()(
set({ sidebarCollapsed: collapsed }, false, 'setSidebarCollapsed');
},
setExpandedNavGroups: (groups: string[]) => {
set({ expandedNavGroups: groups }, false, 'setExpandedNavGroups');
},
// ========== View Actions ==========
setCurrentView: (view: ViewMode) => {
@@ -150,6 +155,7 @@ export const useAppStore = create<AppStore>()(
colorScheme: state.colorScheme,
locale: state.locale,
sidebarCollapsed: state.sidebarCollapsed,
expandedNavGroups: state.expandedNavGroups,
}),
onRehydrateStorage: () => (state) => {
// Apply theme on rehydration

View File

@@ -0,0 +1,772 @@
// ========================================
// Coordinator Store
// ========================================
// Zustand store for managing coordinator execution state and command chains
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
// ========== Types ==========
/**
* Execution status of a coordinator
*/
export type CoordinatorStatus = 'idle' | 'initializing' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
/**
* Node execution status within a command chain
*/
export type NodeExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
/**
* Log level for coordinator logs
*/
export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success';
/**
* Command node representing a step in the coordinator pipeline
*/
export interface CommandNode {
id: string;
name: string;
description?: string;
command: string;
status: NodeExecutionStatus;
startedAt?: string;
completedAt?: string;
result?: unknown;
error?: string;
output?: string;
parentId?: string; // For hierarchical structure
children?: CommandNode[];
}
/**
* Log entry for coordinator execution
*/
export interface CoordinatorLog {
id: string;
timestamp: string;
level: LogLevel;
message: string;
nodeId?: string;
source?: 'system' | 'node' | 'user';
}
/**
* Question to be answered during coordinator execution
*/
export interface CoordinatorQuestion {
id: string;
nodeId: string;
title: string;
description?: string;
type: 'text' | 'single' | 'multi' | 'yes_no';
options?: string[];
required: boolean;
answer?: string | string[];
}
/**
* Pipeline details fetched from backend
*/
export interface PipelineDetails {
id: string;
name: string;
description?: string;
nodes: CommandNode[];
totalSteps: number;
estimatedDuration?: number;
}
/**
* Coordinator state
*/
export interface CoordinatorState {
// Current execution
currentExecutionId: string | null;
status: CoordinatorStatus;
startedAt?: string;
completedAt?: string;
totalElapsedMs: number;
// Command chain
commandChain: CommandNode[];
currentNodeIndex: number;
currentNode: CommandNode | null;
// Pipeline details
pipelineDetails: PipelineDetails | null;
isPipelineLoaded: boolean;
// Logs
logs: CoordinatorLog[];
maxLogs: number;
// Interactive questions
activeQuestion: CoordinatorQuestion | null;
pendingQuestions: CoordinatorQuestion[];
// Execution metadata
metadata: Record<string, unknown>;
// Error tracking
lastError?: string;
errorDetails?: unknown;
// UI state
isLogPanelExpanded: boolean;
autoScrollLogs: boolean;
// Actions
startCoordinator: (executionId: string, taskDescription: string, parameters?: Record<string, unknown>) => Promise<void>;
pauseCoordinator: () => Promise<void>;
resumeCoordinator: () => Promise<void>;
cancelCoordinator: (reason?: string) => Promise<void>;
updateNodeStatus: (nodeId: string, status: NodeExecutionStatus, result?: unknown, error?: string) => void;
submitAnswer: (questionId: string, answer: string | string[]) => Promise<void>;
retryNode: (nodeId: string) => Promise<void>;
skipNode: (nodeId: string) => Promise<void>;
fetchPipelineDetails: (executionId: string) => Promise<void>;
syncStateFromServer: () => Promise<void>;
addLog: (message: string, level?: LogLevel, nodeId?: string, source?: 'system' | 'node' | 'user') => void;
clearLogs: () => void;
setActiveQuestion: (question: CoordinatorQuestion | null) => void;
markExecutionComplete: (success: boolean, finalResult?: unknown) => void;
setLogPanelExpanded: (expanded: boolean) => void;
setAutoScrollLogs: (autoScroll: boolean) => void;
reset: () => void;
}
// ========== Constants ==========
const MAX_LOGS = 1000;
const LOG_STORAGE_KEY = 'coordinator-storage';
const COORDINATOR_STORAGE_VERSION = 1;
// ========== Helper Functions ==========
/**
* Generate unique ID for logs and questions
*/
const generateId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
/**
* Find node by ID in command chain (handles hierarchical structure)
*/
const findNodeById = (nodes: CommandNode[], nodeId: string): CommandNode | null => {
for (const node of nodes) {
if (node.id === nodeId) {
return node;
}
if (node.children) {
const found = findNodeById(node.children, nodeId);
if (found) return found;
}
}
return null;
};
// ========== Initial State ==========
const initialState: CoordinatorState = {
currentExecutionId: null,
status: 'idle',
totalElapsedMs: 0,
commandChain: [],
currentNodeIndex: -1,
currentNode: null,
pipelineDetails: null,
isPipelineLoaded: false,
logs: [],
maxLogs: MAX_LOGS,
activeQuestion: null,
pendingQuestions: [],
metadata: {},
isLogPanelExpanded: true,
autoScrollLogs: true,
// Actions are added in the create callback
startCoordinator: async () => {},
pauseCoordinator: async () => {},
resumeCoordinator: async () => {},
cancelCoordinator: async () => {},
updateNodeStatus: () => {},
submitAnswer: async () => {},
retryNode: async () => {},
skipNode: async () => {},
fetchPipelineDetails: async () => {},
syncStateFromServer: async () => {},
addLog: () => {},
clearLogs: () => {},
setActiveQuestion: () => {},
markExecutionComplete: () => {},
setLogPanelExpanded: () => {},
setAutoScrollLogs: () => {},
reset: () => {},
};
// ========== Store ==========
/**
* Coordinator store for managing orchestrator execution state
*
* @remarks
* Uses Zustand with persist middleware to save execution metadata to localStorage.
* The store manages command chains, logs, interactive questions, and execution status.
*
* @example
* ```tsx
* const { startCoordinator, status, logs } = useCoordinatorStore();
* await startCoordinator('exec-123', 'Build and deploy application');
* ```
*/
export const useCoordinatorStore = create<CoordinatorState>()(
persist(
devtools(
(set, get) => ({
...initialState,
// ========== Coordinator Lifecycle Actions ==========
startCoordinator: async (
executionId: string,
taskDescription: string,
parameters?: Record<string, unknown>
) => {
set({
currentExecutionId: executionId,
status: 'initializing',
startedAt: new Date().toISOString(),
totalElapsedMs: 0,
lastError: undefined,
errorDetails: undefined,
metadata: parameters || {},
}, false, 'coordinator/startCoordinator');
get().addLog(`Starting coordinator execution: ${taskDescription}`, 'info', undefined, 'system');
try {
// Fetch pipeline details from backend
await get().fetchPipelineDetails(executionId);
const state = get();
set({
status: 'running',
currentNodeIndex: 0,
currentNode: state.commandChain.length > 0 ? state.commandChain[0] : null,
}, false, 'coordinator/startCoordinator-running');
get().addLog('Coordinator running', 'success', undefined, 'system');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
set({
status: 'failed',
lastError: errorMessage,
errorDetails: error,
}, false, 'coordinator/startCoordinator-error');
get().addLog(`Failed to start coordinator: ${errorMessage}`, 'error', undefined, 'system');
}
},
pauseCoordinator: async () => {
const state = get();
if (state.status !== 'running') {
get().addLog('Cannot pause - coordinator is not running', 'warn', undefined, 'system');
return;
}
set({ status: 'paused' }, false, 'coordinator/pauseCoordinator');
get().addLog('Coordinator paused', 'info', undefined, 'system');
},
resumeCoordinator: async () => {
const state = get();
if (state.status !== 'paused') {
get().addLog('Cannot resume - coordinator is not paused', 'warn', undefined, 'system');
return;
}
set({ status: 'running' }, false, 'coordinator/resumeCoordinator');
get().addLog('Coordinator resumed', 'info', undefined, 'system');
},
cancelCoordinator: async (reason?: string) => {
set({
status: 'cancelled',
completedAt: new Date().toISOString(),
}, false, 'coordinator/cancelCoordinator');
const message = reason ? `Coordinator cancelled: ${reason}` : 'Coordinator cancelled';
get().addLog(message, 'warn', undefined, 'system');
},
// ========== Node Status Management ==========
updateNodeStatus: (nodeId: string, status: NodeExecutionStatus, result?: unknown, error?: string) => {
const state = get();
const node = findNodeById(state.commandChain, nodeId);
if (!node) {
console.warn(`[CoordinatorStore] Node not found: ${nodeId}`);
return;
}
// Create a deep copy of the command chain with updated node
const updateNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
return nodes.map((n) => {
if (n.id === nodeId) {
const updated: CommandNode = { ...n, status };
if (status === 'running') {
updated.startedAt = new Date().toISOString();
} else if (status === 'completed') {
updated.completedAt = new Date().toISOString();
updated.result = result;
} else if (status === 'failed') {
updated.completedAt = new Date().toISOString();
updated.error = error;
} else if (status === 'skipped') {
updated.completedAt = new Date().toISOString();
}
return updated;
}
if (n.children && n.children.length > 0) {
return { ...n, children: updateNodeInTree(n.children) };
}
return n;
});
};
const updatedCommandChain = updateNodeInTree(state.commandChain);
set({ commandChain: updatedCommandChain }, false, 'coordinator/updateNodeStatus');
// Add logs after state update
if (status === 'running') {
get().addLog(`Node started: ${node.name}`, 'debug', nodeId, 'system');
} else if (status === 'completed') {
get().addLog(`Node completed: ${node.name}`, 'success', nodeId, 'system');
} else if (status === 'failed') {
get().addLog(`Node failed: ${node.name} - ${error || 'Unknown error'}`, 'error', nodeId, 'system');
} else if (status === 'skipped') {
get().addLog(`Node skipped: ${node.name}`, 'info', nodeId, 'system');
}
},
// ========== Interactive Question Handling ==========
submitAnswer: async (questionId: string, answer: string | string[]) => {
const state = get();
const question = state.activeQuestion || state.pendingQuestions.find((q) => q.id === questionId);
if (!question) {
get().addLog(`Question not found: ${questionId}`, 'warn', undefined, 'system');
return;
}
// Update question with answer
const updatedActiveQuestion =
state.activeQuestion && state.activeQuestion.id === questionId
? { ...state.activeQuestion, answer }
: state.activeQuestion;
const updatedPendingQuestions = state.pendingQuestions.map((q) =>
q.id === questionId ? { ...q, answer } : q
);
set(
{
activeQuestion: updatedActiveQuestion,
pendingQuestions: updatedPendingQuestions,
},
false,
'coordinator/submitAnswer'
);
get().addLog(
`Answer submitted for question: ${question.title}`,
'info',
question.nodeId,
'user'
);
// Clear active question
set({ activeQuestion: null }, false, 'coordinator/submitAnswer-clear');
},
// ========== Node Control Actions ==========
retryNode: async (nodeId: string) => {
const state = get();
const node = findNodeById(state.commandChain, nodeId);
if (!node) {
get().addLog(`Cannot retry - node not found: ${nodeId}`, 'warn', undefined, 'system');
return;
}
get().addLog(`Retrying node: ${node.name}`, 'info', nodeId, 'system');
// Recursively update node status to pending
const resetNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
return nodes.map((n) => {
if (n.id === nodeId) {
return { ...n, status: 'pending', result: undefined, error: undefined };
}
if (n.children && n.children.length > 0) {
return { ...n, children: resetNodeInTree(n.children) };
}
return n;
});
};
const updatedCommandChain = resetNodeInTree(state.commandChain);
set({ commandChain: updatedCommandChain }, false, 'coordinator/retryNode');
},
skipNode: async (nodeId: string) => {
const state = get();
const node = findNodeById(state.commandChain, nodeId);
if (!node) {
get().addLog(`Cannot skip - node not found: ${nodeId}`, 'warn', undefined, 'system');
return;
}
get().addLog(`Skipping node: ${node.name}`, 'info', nodeId, 'system');
// Recursively update node status to skipped
const skipNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
return nodes.map((n) => {
if (n.id === nodeId) {
return { ...n, status: 'skipped', completedAt: new Date().toISOString() };
}
if (n.children && n.children.length > 0) {
return { ...n, children: skipNodeInTree(n.children) };
}
return n;
});
};
const updatedCommandChain = skipNodeInTree(state.commandChain);
set({ commandChain: updatedCommandChain }, false, 'coordinator/skipNode');
},
// ========== Pipeline Details ==========
fetchPipelineDetails: async (executionId: string) => {
try {
get().addLog('Fetching pipeline details', 'info', undefined, 'system');
// Import API function dynamically to avoid circular deps
const { fetchCoordinatorPipeline } = await import('../lib/api');
const response = await fetchCoordinatorPipeline(executionId);
if (!response.success || !response.data) {
throw new Error('Failed to fetch pipeline details');
}
const apiData = response.data;
// Transform API response to PipelineDetails
const pipelineDetails: PipelineDetails = {
id: apiData.id,
name: apiData.name,
description: apiData.description,
nodes: apiData.nodes,
totalSteps: apiData.totalSteps,
estimatedDuration: apiData.estimatedDuration,
};
set({
pipelineDetails,
isPipelineLoaded: true,
commandChain: apiData.nodes,
status: apiData.status || get().status,
}, false, 'coordinator/fetchPipelineDetails');
// Load logs if available
if (apiData.logs && apiData.logs.length > 0) {
set({ logs: apiData.logs }, false, 'coordinator/fetchPipelineDetails-logs');
}
get().addLog('Pipeline details loaded', 'success', undefined, 'system');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
set({
isPipelineLoaded: false,
lastError: errorMessage,
}, false, 'coordinator/fetchPipelineDetails-error');
get().addLog(`Failed to fetch pipeline details: ${errorMessage}`, 'error', undefined, 'system');
throw error;
}
},
// ========== State Synchronization (for WebSocket reconnection) ==========
syncStateFromServer: async () => {
const state = get();
// Only sync if we have an active execution
if (!state.currentExecutionId) {
get().addLog('No active execution to sync', 'debug', undefined, 'system');
return;
}
try {
get().addLog('Syncing state from server', 'info', undefined, 'system');
// Fetch current execution state from server
const { fetchExecutionState } = await import('../lib/api');
const response = await fetchExecutionState(state.currentExecutionId);
if (!response.success || !response.data) {
throw new Error('Failed to sync execution state');
}
const serverState = response.data;
// Update local state with server state
set({
status: serverState.status as CoordinatorStatus,
totalElapsedMs: serverState.elapsedMs,
}, false, 'coordinator/syncStateFromServer');
// Fetch full pipeline details if status indicates running/paused
if (serverState.status === 'running' || serverState.status === 'paused') {
await get().fetchPipelineDetails(state.currentExecutionId);
}
get().addLog('State synchronized with server', 'success', undefined, 'system');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[CoordinatorStore] Failed to sync state:', error);
get().addLog(`Failed to sync state from server: ${errorMessage}`, 'warn', undefined, 'system');
}
},
addLog: (
message: string,
level: LogLevel = 'info',
nodeId?: string,
source: 'system' | 'node' | 'user' = 'system'
) => {
const state = get();
const log: CoordinatorLog = {
id: generateId(),
timestamp: new Date().toISOString(),
level,
message,
nodeId,
source,
};
let updatedLogs = [...state.logs, log];
// Keep only the last maxLogs entries
if (updatedLogs.length > state.maxLogs) {
updatedLogs = updatedLogs.slice(-state.maxLogs);
}
set({ logs: updatedLogs }, false, 'coordinator/addLog');
},
clearLogs: () => {
set({ logs: [] }, false, 'coordinator/clearLogs');
},
// ========== Question Management ==========
setActiveQuestion: (question: CoordinatorQuestion | null) => {
const state = get();
const updatedPendingQuestions =
question && !state.pendingQuestions.find((q) => q.id === question.id)
? [...state.pendingQuestions, question]
: state.pendingQuestions;
set({
activeQuestion: question,
pendingQuestions: updatedPendingQuestions,
}, false, 'coordinator/setActiveQuestion');
},
// ========== Execution Completion ==========
markExecutionComplete: (success: boolean, finalResult?: unknown) => {
const state = get();
set({
status: success ? 'completed' : 'failed',
completedAt: new Date().toISOString(),
metadata: { ...state.metadata, finalResult },
}, false, 'coordinator/markExecutionComplete');
const message = success
? 'Coordinator execution completed successfully'
: 'Coordinator execution failed';
get().addLog(message, success ? 'success' : 'error', undefined, 'system');
},
// ========== UI State ==========
setLogPanelExpanded: (expanded: boolean) => {
set({ isLogPanelExpanded: expanded }, false, 'coordinator/setLogPanelExpanded');
},
setAutoScrollLogs: (autoScroll: boolean) => {
set({ autoScrollLogs: autoScroll }, false, 'coordinator/setAutoScrollLogs');
},
// ========== Reset ==========
reset: () => {
set({
currentExecutionId: null,
status: 'idle',
startedAt: undefined,
completedAt: undefined,
totalElapsedMs: 0,
commandChain: [],
currentNodeIndex: -1,
currentNode: null,
pipelineDetails: null,
isPipelineLoaded: false,
logs: [],
activeQuestion: null,
pendingQuestions: [],
metadata: {},
lastError: undefined,
errorDetails: undefined,
}, false, 'coordinator/reset');
get().addLog('Coordinator state reset', 'info', undefined, 'system');
},
}),
{ name: 'CoordinatorStore' }
),
{
name: LOG_STORAGE_KEY,
version: COORDINATOR_STORAGE_VERSION,
// Only persist metadata and basic pipeline info (not full nodes/logs)
partialize: (state) => ({
currentExecutionId: state.currentExecutionId,
status: state.status,
startedAt: state.startedAt,
completedAt: state.completedAt,
totalElapsedMs: state.totalElapsedMs,
metadata: state.metadata,
isLogPanelExpanded: state.isLogPanelExpanded,
autoScrollLogs: state.autoScrollLogs,
// Only persist basic pipeline info, not full nodes
pipelineDetails: state.pipelineDetails ? {
id: state.pipelineDetails.id,
name: state.pipelineDetails.name,
description: state.pipelineDetails.description,
nodes: [], // Don't persist nodes - will be fetched from API
totalSteps: state.pipelineDetails.totalSteps,
estimatedDuration: state.pipelineDetails.estimatedDuration,
} : null,
}),
// Rehydration callback to restore state on page load
onRehydrateStorage: () => (state) => {
if (!state) return;
// Check if we have an active execution that needs hydration
const needsHydration =
state.currentExecutionId &&
(state.status === 'running' || state.status === 'paused' || state.status === 'initializing') &&
(!state.pipelineDetails || state.pipelineDetails.nodes.length === 0);
if (needsHydration && state.currentExecutionId) {
// Log restoration
state.addLog('Restoring coordinator state from localStorage', 'info', undefined, 'system');
// Fetch full pipeline details from API
state.fetchPipelineDetails(state.currentExecutionId).catch((error) => {
console.error('[CoordinatorStore] Failed to hydrate pipeline details:', error);
state.addLog('Failed to restore pipeline data - session may be incomplete', 'warn', undefined, 'system');
});
} else if (state.currentExecutionId) {
// Just log that we restored the session
state.addLog('Session state restored', 'info', undefined, 'system');
}
},
}
)
);
// ========== Helper Hooks ==========
/**
* Hook to get coordinator actions
* Useful for components that only need actions, not the full state
*/
export const useCoordinatorActions = () => {
return useCoordinatorStore((state) => ({
startCoordinator: state.startCoordinator,
pauseCoordinator: state.pauseCoordinator,
resumeCoordinator: state.resumeCoordinator,
cancelCoordinator: state.cancelCoordinator,
updateNodeStatus: state.updateNodeStatus,
submitAnswer: state.submitAnswer,
retryNode: state.retryNode,
skipNode: state.skipNode,
fetchPipelineDetails: state.fetchPipelineDetails,
syncStateFromServer: state.syncStateFromServer,
addLog: state.addLog,
clearLogs: state.clearLogs,
setActiveQuestion: state.setActiveQuestion,
markExecutionComplete: state.markExecutionComplete,
setLogPanelExpanded: state.setLogPanelExpanded,
setAutoScrollLogs: state.setAutoScrollLogs,
reset: state.reset,
}));
};
// ========== Selectors ==========
/**
* Select current execution status
*/
export const selectCoordinatorStatus = (state: CoordinatorState) => state.status;
/**
* Select current execution ID
*/
export const selectCurrentExecutionId = (state: CoordinatorState) => state.currentExecutionId;
/**
* Select all logs
*/
export const selectCoordinatorLogs = (state: CoordinatorState) => state.logs;
/**
* Select active question
*/
export const selectActiveQuestion = (state: CoordinatorState) => state.activeQuestion;
/**
* Select command chain
*/
export const selectCommandChain = (state: CoordinatorState) => state.commandChain;
/**
* Select current node
*/
export const selectCurrentNode = (state: CoordinatorState) => state.currentNode;
/**
* Select pipeline details
*/
export const selectPipelineDetails = (state: CoordinatorState) => state.pipelineDetails;
/**
* Select is pipeline loaded
*/
export const selectIsPipelineLoaded = (state: CoordinatorState) => state.isPipelineLoaded;

View File

@@ -76,6 +76,20 @@ export {
selectNodeStatus,
} from './executionStore';
// Coordinator Store
export {
useCoordinatorStore,
useCoordinatorActions,
selectCoordinatorStatus,
selectCurrentExecutionId,
selectCoordinatorLogs,
selectActiveQuestion,
selectCommandChain,
selectCurrentNode,
selectPipelineDetails,
selectIsPipelineLoaded,
} from './coordinatorStore';
// Re-export types for convenience
export type {
// App Store Types
@@ -119,6 +133,16 @@ export type {
AskQuestionPayload,
} from '../types/store';
// Coordinator Store Types
export type {
CoordinatorState,
CoordinatorStatus,
CommandNode,
CoordinatorLog,
CoordinatorQuestion,
PipelineDetails,
} from './coordinatorStore';
// Execution Types
export type {
ExecutionStatus,