mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
Add orchestrator types and error handling configurations
- Introduced new TypeScript types for orchestrator functionality, including `SessionStrategy`, `ErrorHandlingStrategy`, and `OrchestrationStep`. - Defined interfaces for `OrchestrationPlan` and `ManualOrchestrationParams` to facilitate orchestration management. - Added a new PNG image file for visual representation. - Created a placeholder file named 'nul' for future use.
This commit is contained in:
@@ -356,15 +356,21 @@ export const useCliStreamStore = create<CliStreamState>()(
|
||||
// Also update in executions
|
||||
const state = get();
|
||||
if (state.executions[executionId]) {
|
||||
set((state) => ({
|
||||
executions: {
|
||||
...state.executions,
|
||||
[executionId]: {
|
||||
...state.executions[executionId],
|
||||
output: [...state.executions[executionId].output, line],
|
||||
set((state) => {
|
||||
const currentOutput = state.executions[executionId].output;
|
||||
const updatedOutput = [...currentOutput, line];
|
||||
return {
|
||||
executions: {
|
||||
...state.executions,
|
||||
[executionId]: {
|
||||
...state.executions[executionId],
|
||||
output: updatedOutput.length > MAX_OUTPUT_LINES
|
||||
? updatedOutput.slice(-MAX_OUTPUT_LINES)
|
||||
: updatedOutput,
|
||||
},
|
||||
},
|
||||
},
|
||||
}), false, 'cliStream/updateExecutionOutput');
|
||||
};
|
||||
}, false, 'cliStream/updateExecutionOutput');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -529,11 +535,14 @@ export const useCliStreamStore = create<CliStreamState>()(
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/** Stable empty array to avoid new references */
|
||||
const EMPTY_OUTPUTS: CliOutputLine[] = [];
|
||||
|
||||
/**
|
||||
* Selector for getting outputs by execution ID
|
||||
*/
|
||||
export const selectOutputs = (state: CliStreamState, executionId: string) =>
|
||||
state.outputs[executionId] || [];
|
||||
state.outputs[executionId] || EMPTY_OUTPUTS;
|
||||
|
||||
/**
|
||||
* Selector for getting addOutput action
|
||||
|
||||
@@ -464,6 +464,7 @@ export const useExecutionStore = create<ExecutionStore>()(
|
||||
);
|
||||
|
||||
// Selectors for common access patterns
|
||||
const EMPTY_TOOL_CALLS: never[] = [];
|
||||
export const selectCurrentExecution = (state: ExecutionStore) => state.currentExecution;
|
||||
export const selectNodeStates = (state: ExecutionStore) => state.nodeStates;
|
||||
export const selectLogs = (state: ExecutionStore) => state.logs;
|
||||
@@ -474,7 +475,7 @@ export const selectAutoScrollLogs = (state: ExecutionStore) => state.autoScrollL
|
||||
export const selectNodeOutputs = (state: ExecutionStore, nodeId: string) =>
|
||||
state.nodeOutputs[nodeId];
|
||||
export const selectNodeToolCalls = (state: ExecutionStore, nodeId: string) =>
|
||||
state.nodeToolCalls[nodeId] || [];
|
||||
state.nodeToolCalls[nodeId] || EMPTY_TOOL_CALLS;
|
||||
export const selectSelectedNodeId = (state: ExecutionStore) => state.selectedNodeId;
|
||||
|
||||
// Helper to check if execution is active
|
||||
@@ -489,6 +490,6 @@ export const selectNodeStatus = (nodeId: string) => (state: ExecutionStore) => {
|
||||
|
||||
// Helper to get selected node's tool calls
|
||||
export const selectSelectedNodeToolCalls = (state: ExecutionStore) => {
|
||||
if (!state.selectedNodeId) return [];
|
||||
return state.nodeToolCalls[state.selectedNodeId] || [];
|
||||
if (!state.selectedNodeId) return EMPTY_TOOL_CALLS;
|
||||
return state.nodeToolCalls[state.selectedNodeId] || EMPTY_TOOL_CALLS;
|
||||
};
|
||||
|
||||
@@ -111,6 +111,18 @@ export {
|
||||
selectHasActiveExecution,
|
||||
} from './queueExecutionStore';
|
||||
|
||||
// Orchestrator Store
|
||||
export {
|
||||
useOrchestratorStore,
|
||||
selectActivePlans,
|
||||
selectPlan,
|
||||
selectStepStatuses,
|
||||
selectStepRunState,
|
||||
selectHasRunningPlan,
|
||||
selectActivePlanCount,
|
||||
selectPlanStepByExecutionId,
|
||||
} from './orchestratorStore';
|
||||
|
||||
// Terminal Panel Store Types
|
||||
export type {
|
||||
PanelView,
|
||||
@@ -131,6 +143,15 @@ export type {
|
||||
QueueExecutionStore,
|
||||
} from './queueExecutionStore';
|
||||
|
||||
// Orchestrator Store Types
|
||||
export type {
|
||||
StepRunState,
|
||||
OrchestrationRunState,
|
||||
OrchestratorState,
|
||||
OrchestratorActions,
|
||||
OrchestratorStore,
|
||||
} from './orchestratorStore';
|
||||
|
||||
// Re-export types for convenience
|
||||
export type {
|
||||
// App Store Types
|
||||
|
||||
533
ccw/frontend/src/stores/orchestratorStore.ts
Normal file
533
ccw/frontend/src/stores/orchestratorStore.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
// ========================================
|
||||
// Orchestrator Store
|
||||
// ========================================
|
||||
// Zustand store for orchestration plan execution state machine.
|
||||
// Manages multiple concurrent orchestration plans, step lifecycle,
|
||||
// and execution-to-step mapping for WebSocket callback chain matching.
|
||||
//
|
||||
// NOTE: This is SEPARATE from executionStore.ts (which handles Flow DAG
|
||||
// execution visualization). This store manages the plan-level state machine
|
||||
// for automated step advancement.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type {
|
||||
OrchestrationPlan,
|
||||
OrchestrationStatus,
|
||||
StepStatus,
|
||||
} from '../types/orchestrator';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/** Runtime state for a single orchestration step */
|
||||
export interface StepRunState {
|
||||
/** Current step status */
|
||||
status: StepStatus;
|
||||
/** CLI execution ID assigned when the step starts executing */
|
||||
executionId?: string;
|
||||
/** Step execution result (populated on completion) */
|
||||
result?: unknown;
|
||||
/** Error message (populated on failure) */
|
||||
error?: string;
|
||||
/** ISO timestamp when step started executing */
|
||||
startedAt?: string;
|
||||
/** ISO timestamp when step completed/failed */
|
||||
completedAt?: string;
|
||||
/** Number of retry attempts for this step */
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
/** Runtime state for an entire orchestration plan */
|
||||
export interface OrchestrationRunState {
|
||||
/** The orchestration plan definition */
|
||||
plan: OrchestrationPlan;
|
||||
/** Current overall status of the plan */
|
||||
status: OrchestrationStatus;
|
||||
/** Index of the current step being executed (for sequential tracking) */
|
||||
currentStepIndex: number;
|
||||
/** Per-step runtime state keyed by step ID */
|
||||
stepStatuses: Record<string, StepRunState>;
|
||||
/** Maps executionId -> stepId for callback chain matching */
|
||||
executionIdMap: Record<string, string>;
|
||||
/** Optional session key for session-scoped orchestration */
|
||||
sessionKey?: string;
|
||||
/** Error message if the plan itself failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface OrchestratorState {
|
||||
/** All active orchestration plans keyed by plan ID */
|
||||
activePlans: Record<string, OrchestrationRunState>;
|
||||
}
|
||||
|
||||
export interface OrchestratorActions {
|
||||
/** Initialize and start an orchestration plan */
|
||||
startOrchestration: (plan: OrchestrationPlan, sessionKey?: string) => void;
|
||||
/** Pause a running orchestration */
|
||||
pauseOrchestration: (planId: string) => void;
|
||||
/** Resume a paused orchestration */
|
||||
resumeOrchestration: (planId: string) => void;
|
||||
/** Stop an orchestration (marks as failed) */
|
||||
stopOrchestration: (planId: string, error?: string) => void;
|
||||
/** Update a step's status with optional result or error */
|
||||
updateStepStatus: (
|
||||
planId: string,
|
||||
stepId: string,
|
||||
status: StepStatus,
|
||||
result?: { data?: unknown; error?: string }
|
||||
) => void;
|
||||
/** Register an execution ID mapping for callback chain matching */
|
||||
registerExecution: (planId: string, stepId: string, executionId: string) => void;
|
||||
/** Retry a failed step (reset to pending, increment retryCount) */
|
||||
retryStep: (planId: string, stepId: string) => void;
|
||||
/** Skip a step (mark as skipped, treated as completed for dependency resolution) */
|
||||
skipStep: (planId: string, stepId: string) => void;
|
||||
/**
|
||||
* Internal: Find and return the next ready step ID.
|
||||
* Does NOT execute the step - only identifies it and updates currentStepIndex.
|
||||
* If no steps remain, marks plan as completed.
|
||||
* Returns the step ID if found, null otherwise.
|
||||
*/
|
||||
_advanceToNextStep: (planId: string) => string | null;
|
||||
/**
|
||||
* Pure getter: Find the next step whose dependsOn are all completed/skipped.
|
||||
* Returns the step ID or null if none are ready.
|
||||
*/
|
||||
getNextReadyStep: (planId: string) => string | null;
|
||||
/** Remove a completed/failed plan from active tracking */
|
||||
removePlan: (planId: string) => void;
|
||||
/** Clear all plans */
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
export type OrchestratorStore = OrchestratorState & OrchestratorActions;
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
/**
|
||||
* Check if a step's dependencies are all satisfied (completed or skipped).
|
||||
*/
|
||||
function areDependenciesSatisfied(
|
||||
step: { dependsOn: string[] },
|
||||
stepStatuses: Record<string, StepRunState>
|
||||
): boolean {
|
||||
return step.dependsOn.every((depId) => {
|
||||
const depState = stepStatuses[depId];
|
||||
return depState && (depState.status === 'completed' || depState.status === 'skipped');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the next ready step from a plan's steps that is pending and has all deps satisfied.
|
||||
*/
|
||||
function findNextReadyStep(
|
||||
plan: OrchestrationPlan,
|
||||
stepStatuses: Record<string, StepRunState>
|
||||
): string | null {
|
||||
for (const step of plan.steps) {
|
||||
const state = stepStatuses[step.id];
|
||||
if (state && state.status === 'pending' && areDependenciesSatisfied(step, stepStatuses)) {
|
||||
return step.id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all steps are in a terminal state (completed, skipped, or failed).
|
||||
*/
|
||||
function areAllStepsTerminal(stepStatuses: Record<string, StepRunState>): boolean {
|
||||
return Object.values(stepStatuses).every(
|
||||
(s) => s.status === 'completed' || s.status === 'skipped' || s.status === 'failed'
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Initial State ==========
|
||||
|
||||
const initialState: OrchestratorState = {
|
||||
activePlans: {},
|
||||
};
|
||||
|
||||
// ========== Store ==========
|
||||
|
||||
export const useOrchestratorStore = create<OrchestratorStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
// ========== Plan Lifecycle ==========
|
||||
|
||||
startOrchestration: (plan: OrchestrationPlan, sessionKey?: string) => {
|
||||
// Initialize all step statuses as pending
|
||||
const stepStatuses: Record<string, StepRunState> = {};
|
||||
for (const step of plan.steps) {
|
||||
stepStatuses[step.id] = {
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const runState: OrchestrationRunState = {
|
||||
plan,
|
||||
status: 'running',
|
||||
currentStepIndex: 0,
|
||||
stepStatuses,
|
||||
executionIdMap: {},
|
||||
sessionKey,
|
||||
};
|
||||
|
||||
set(
|
||||
(state) => ({
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[plan.id]: runState,
|
||||
},
|
||||
}),
|
||||
false,
|
||||
'startOrchestration'
|
||||
);
|
||||
},
|
||||
|
||||
pauseOrchestration: (planId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing || existing.status !== 'running') return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
status: 'paused',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'pauseOrchestration'
|
||||
);
|
||||
},
|
||||
|
||||
resumeOrchestration: (planId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing || existing.status !== 'paused') return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
status: 'running',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'resumeOrchestration'
|
||||
);
|
||||
},
|
||||
|
||||
stopOrchestration: (planId: string, error?: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing) return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
status: 'failed',
|
||||
error: error ?? 'Orchestration stopped by user',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'stopOrchestration'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Step State Updates ==========
|
||||
|
||||
updateStepStatus: (
|
||||
planId: string,
|
||||
stepId: string,
|
||||
status: StepStatus,
|
||||
result?: { data?: unknown; error?: string }
|
||||
) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing) return state;
|
||||
|
||||
const stepState = existing.stepStatuses[stepId];
|
||||
if (!stepState) return state;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const isStarting = status === 'running';
|
||||
const isTerminal =
|
||||
status === 'completed' || status === 'failed' || status === 'skipped';
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
stepStatuses: {
|
||||
...existing.stepStatuses,
|
||||
[stepId]: {
|
||||
...stepState,
|
||||
status,
|
||||
startedAt: isStarting ? now : stepState.startedAt,
|
||||
completedAt: isTerminal ? now : stepState.completedAt,
|
||||
result: result?.data ?? stepState.result,
|
||||
error: result?.error ?? stepState.error,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'updateStepStatus'
|
||||
);
|
||||
},
|
||||
|
||||
registerExecution: (planId: string, stepId: string, executionId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing) return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
executionIdMap: {
|
||||
...existing.executionIdMap,
|
||||
[executionId]: stepId,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'registerExecution'
|
||||
);
|
||||
},
|
||||
|
||||
retryStep: (planId: string, stepId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing) return state;
|
||||
|
||||
const stepState = existing.stepStatuses[stepId];
|
||||
if (!stepState) return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
status: 'running',
|
||||
stepStatuses: {
|
||||
...existing.stepStatuses,
|
||||
[stepId]: {
|
||||
...stepState,
|
||||
status: 'pending',
|
||||
error: undefined,
|
||||
result: undefined,
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
retryCount: stepState.retryCount + 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'retryStep'
|
||||
);
|
||||
},
|
||||
|
||||
skipStep: (planId: string, stepId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing) return state;
|
||||
|
||||
const stepState = existing.stepStatuses[stepId];
|
||||
if (!stepState) return state;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...state.activePlans,
|
||||
[planId]: {
|
||||
...existing,
|
||||
stepStatuses: {
|
||||
...existing.stepStatuses,
|
||||
[stepId]: {
|
||||
...stepState,
|
||||
status: 'skipped',
|
||||
completedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'skipStep'
|
||||
);
|
||||
},
|
||||
|
||||
// ========== Step Advancement ==========
|
||||
|
||||
_advanceToNextStep: (planId: string): string | null => {
|
||||
const state = get();
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing || existing.status !== 'running') return null;
|
||||
|
||||
// Find the next step that is pending with all dependencies satisfied
|
||||
const nextStepId = findNextReadyStep(existing.plan, existing.stepStatuses);
|
||||
|
||||
if (nextStepId) {
|
||||
// Update currentStepIndex to match the found step
|
||||
const stepIndex = existing.plan.steps.findIndex((s) => s.id === nextStepId);
|
||||
|
||||
set(
|
||||
(prevState) => {
|
||||
const plan = prevState.activePlans[planId];
|
||||
if (!plan) return prevState;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...prevState.activePlans,
|
||||
[planId]: {
|
||||
...plan,
|
||||
currentStepIndex: stepIndex >= 0 ? stepIndex : plan.currentStepIndex,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'_advanceToNextStep'
|
||||
);
|
||||
|
||||
return nextStepId;
|
||||
}
|
||||
|
||||
// No pending steps found - check if all are terminal
|
||||
if (areAllStepsTerminal(existing.stepStatuses)) {
|
||||
// Check if any step failed (and was not skipped)
|
||||
const hasFailed = Object.values(existing.stepStatuses).some(
|
||||
(s) => s.status === 'failed'
|
||||
);
|
||||
|
||||
set(
|
||||
(prevState) => {
|
||||
const plan = prevState.activePlans[planId];
|
||||
if (!plan) return prevState;
|
||||
|
||||
return {
|
||||
activePlans: {
|
||||
...prevState.activePlans,
|
||||
[planId]: {
|
||||
...plan,
|
||||
status: hasFailed ? 'failed' : 'completed',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
false,
|
||||
'_advanceToNextStep/complete'
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
getNextReadyStep: (planId: string): string | null => {
|
||||
const state = get();
|
||||
const existing = state.activePlans[planId];
|
||||
if (!existing || existing.status !== 'running') return null;
|
||||
|
||||
return findNextReadyStep(existing.plan, existing.stepStatuses);
|
||||
},
|
||||
|
||||
// ========== Cleanup ==========
|
||||
|
||||
removePlan: (planId: string) => {
|
||||
set(
|
||||
(state) => {
|
||||
const { [planId]: _removed, ...remaining } = state.activePlans;
|
||||
return { activePlans: remaining };
|
||||
},
|
||||
false,
|
||||
'removePlan'
|
||||
);
|
||||
},
|
||||
|
||||
clearAll: () => {
|
||||
set(initialState, false, 'clearAll');
|
||||
},
|
||||
}),
|
||||
{ name: 'OrchestratorStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/** Select all active plans */
|
||||
export const selectActivePlans = (state: OrchestratorStore) => state.activePlans;
|
||||
|
||||
/** Select a specific plan by ID */
|
||||
export const selectPlan =
|
||||
(planId: string) =>
|
||||
(state: OrchestratorStore): OrchestrationRunState | undefined =>
|
||||
state.activePlans[planId];
|
||||
|
||||
/** Select the step statuses for a plan */
|
||||
export const selectStepStatuses =
|
||||
(planId: string) =>
|
||||
(state: OrchestratorStore): Record<string, StepRunState> | undefined =>
|
||||
state.activePlans[planId]?.stepStatuses;
|
||||
|
||||
/** Select a specific step's run state */
|
||||
export const selectStepRunState =
|
||||
(planId: string, stepId: string) =>
|
||||
(state: OrchestratorStore): StepRunState | undefined =>
|
||||
state.activePlans[planId]?.stepStatuses[stepId];
|
||||
|
||||
/** Check if any plan is currently running */
|
||||
export const selectHasRunningPlan = (state: OrchestratorStore): boolean =>
|
||||
Object.values(state.activePlans).some((p) => p.status === 'running');
|
||||
|
||||
/** Get the count of active (non-terminal) plans */
|
||||
export const selectActivePlanCount = (state: OrchestratorStore): number =>
|
||||
Object.values(state.activePlans).filter(
|
||||
(p) => p.status === 'running' || p.status === 'paused'
|
||||
).length;
|
||||
|
||||
/** Look up which plan and step an executionId belongs to */
|
||||
export const selectPlanStepByExecutionId =
|
||||
(executionId: string) =>
|
||||
(
|
||||
state: OrchestratorStore
|
||||
): { planId: string; stepId: string } | undefined => {
|
||||
for (const [planId, runState] of Object.entries(state.activePlans)) {
|
||||
const stepId = runState.executionIdMap[executionId];
|
||||
if (stepId) {
|
||||
return { planId, stepId };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -157,21 +157,33 @@ export const useQueueExecutionStore = create<QueueExecutionStore>()(
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/** Stable empty array to avoid new references */
|
||||
const EMPTY_EXECUTIONS: QueueExecution[] = [];
|
||||
|
||||
/** Select all executions as a record */
|
||||
export const selectQueueExecutions = (state: QueueExecutionStore) => state.executions;
|
||||
|
||||
/** Select only currently running executions */
|
||||
/**
|
||||
* Select only currently running executions.
|
||||
* WARNING: Returns new array each call — use with useMemo in components.
|
||||
*/
|
||||
export const selectActiveExecutions = (state: QueueExecutionStore): QueueExecution[] => {
|
||||
return Object.values(state.executions).filter((exec) => exec.status === 'running');
|
||||
const all = Object.values(state.executions);
|
||||
const running = all.filter((exec) => exec.status === 'running');
|
||||
return running.length === 0 ? EMPTY_EXECUTIONS : running;
|
||||
};
|
||||
|
||||
/** Select executions for a specific queue item */
|
||||
/**
|
||||
* Select executions for a specific queue item.
|
||||
* WARNING: Returns new array each call — use with useMemo in components.
|
||||
*/
|
||||
export const selectByQueueItem =
|
||||
(queueItemId: string) =>
|
||||
(state: QueueExecutionStore): QueueExecution[] => {
|
||||
return Object.values(state.executions).filter(
|
||||
const matched = Object.values(state.executions).filter(
|
||||
(exec) => exec.queueItemId === queueItemId
|
||||
);
|
||||
return matched.length === 0 ? EMPTY_EXECUTIONS : matched;
|
||||
};
|
||||
|
||||
/** Compute execution statistics by status */
|
||||
|
||||
@@ -7,16 +7,28 @@ import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import type { TeamMessageFilter } from '@/types/team';
|
||||
|
||||
export type TeamDetailTab = 'artifacts' | 'messages';
|
||||
|
||||
interface TeamStore {
|
||||
selectedTeam: string | null;
|
||||
autoRefresh: boolean;
|
||||
messageFilter: TeamMessageFilter;
|
||||
timelineExpanded: boolean;
|
||||
viewMode: 'list' | 'detail';
|
||||
locationFilter: 'active' | 'archived' | 'all';
|
||||
searchQuery: string;
|
||||
detailTab: TeamDetailTab;
|
||||
setSelectedTeam: (name: string | null) => void;
|
||||
toggleAutoRefresh: () => void;
|
||||
setMessageFilter: (filter: Partial<TeamMessageFilter>) => void;
|
||||
clearMessageFilter: () => void;
|
||||
setTimelineExpanded: (expanded: boolean) => void;
|
||||
setViewMode: (mode: 'list' | 'detail') => void;
|
||||
setLocationFilter: (filter: 'active' | 'archived' | 'all') => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setDetailTab: (tab: TeamDetailTab) => void;
|
||||
selectTeamAndShowDetail: (name: string) => void;
|
||||
backToList: () => void;
|
||||
}
|
||||
|
||||
export const useTeamStore = create<TeamStore>()(
|
||||
@@ -27,12 +39,22 @@ export const useTeamStore = create<TeamStore>()(
|
||||
autoRefresh: true,
|
||||
messageFilter: {},
|
||||
timelineExpanded: true,
|
||||
viewMode: 'list',
|
||||
locationFilter: 'active',
|
||||
searchQuery: '',
|
||||
detailTab: 'artifacts',
|
||||
setSelectedTeam: (name) => set({ selectedTeam: name }),
|
||||
toggleAutoRefresh: () => set((s) => ({ autoRefresh: !s.autoRefresh })),
|
||||
setMessageFilter: (filter) =>
|
||||
set((s) => ({ messageFilter: { ...s.messageFilter, ...filter } })),
|
||||
clearMessageFilter: () => set({ messageFilter: {} }),
|
||||
setTimelineExpanded: (expanded) => set({ timelineExpanded: expanded }),
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setLocationFilter: (filter) => set({ locationFilter: filter }),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setDetailTab: (tab) => set({ detailTab: tab }),
|
||||
selectTeamAndShowDetail: (name) => set({ selectedTeam: name, viewMode: 'detail', detailTab: 'artifacts' }),
|
||||
backToList: () => set({ viewMode: 'list', detailTab: 'artifacts' }),
|
||||
}),
|
||||
{ name: 'ccw-team-store' }
|
||||
),
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface TerminalPanelActions {
|
||||
openTerminal: (sessionKey: string) => void;
|
||||
/** Close the terminal panel (keeps terminal order intact) */
|
||||
closePanel: () => void;
|
||||
/** Toggle panel open/closed; when opening, restores last active or shows queue */
|
||||
togglePanel: () => void;
|
||||
/** Switch active terminal without opening/closing */
|
||||
setActiveTerminal: (sessionKey: string) => void;
|
||||
/** Switch panel view between 'terminal' and 'queue' */
|
||||
@@ -80,6 +82,24 @@ export const useTerminalPanelStore = create<TerminalPanelStore>()(
|
||||
set({ isPanelOpen: false }, false, 'closePanel');
|
||||
},
|
||||
|
||||
togglePanel: () => {
|
||||
const { isPanelOpen, activeTerminalId, terminalOrder } = get();
|
||||
if (isPanelOpen) {
|
||||
set({ isPanelOpen: false }, false, 'togglePanel/close');
|
||||
} else {
|
||||
const nextActive = activeTerminalId ?? terminalOrder[0] ?? null;
|
||||
set(
|
||||
{
|
||||
isPanelOpen: true,
|
||||
activeTerminalId: nextActive,
|
||||
panelView: nextActive ? 'terminal' : 'queue',
|
||||
},
|
||||
false,
|
||||
'togglePanel/open'
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
// ========== Terminal Selection ==========
|
||||
|
||||
setActiveTerminal: (sessionKey: string) => {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Zustand store for managing CLI Viewer layout and tab state
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -909,6 +910,9 @@ export const useViewerStore = create<ViewerState>()(
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
/** Stable empty array to avoid new references */
|
||||
const EMPTY_TABS: TabState[] = [];
|
||||
|
||||
/**
|
||||
* Select the current layout
|
||||
*/
|
||||
@@ -940,11 +944,12 @@ export const selectPane = (state: ViewerState, paneId: PaneId) => state.panes[pa
|
||||
export const selectTab = (state: ViewerState, tabId: TabId) => state.tabs[tabId];
|
||||
|
||||
/**
|
||||
* Select tabs for a specific pane, sorted by order
|
||||
* Select tabs for a specific pane, sorted by order.
|
||||
* WARNING: Returns new array each call — use with useMemo or useShallow in components.
|
||||
*/
|
||||
export const selectPaneTabs = (state: ViewerState, paneId: PaneId): TabState[] => {
|
||||
const pane = state.panes[paneId];
|
||||
if (!pane) return [];
|
||||
if (!pane) return EMPTY_TABS;
|
||||
return [...pane.tabs].sort((a, b) => a.order - b.order);
|
||||
};
|
||||
|
||||
@@ -964,7 +969,7 @@ export const selectActiveTab = (state: ViewerState, paneId: PaneId): TabState |
|
||||
* Useful for components that only need actions, not the full state
|
||||
*/
|
||||
export const useViewerActions = () => {
|
||||
return useViewerStore((state) => ({
|
||||
return useViewerStore(useShallow((state) => ({
|
||||
setLayout: state.setLayout,
|
||||
addPane: state.addPane,
|
||||
removePane: state.removePane,
|
||||
@@ -976,5 +981,5 @@ export const useViewerActions = () => {
|
||||
setFocusedPane: state.setFocusedPane,
|
||||
initializeDefaultLayout: state.initializeDefaultLayout,
|
||||
reset: state.reset,
|
||||
}));
|
||||
})));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user