mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat(orchestrator): redesign orchestrator page as template editor with terminal execution
Phase 1: Orchestrator Simplification - Remove ExecutionMonitor from OrchestratorPage - Replace "Run Workflow" button with "Send to Terminal" button - Update i18n texts for template editor context Phase 2: Session Lock Mechanism - Add 'locked' status to TerminalStatus type - Extend TerminalMeta with isLocked, lockReason, lockedByExecutionId, lockedAt - Implement lockSession/unlockSession in sessionManagerStore - Create SessionLockConfirmDialog component for input interception Phase 3: Execution Monitor Panel - Create executionMonitorStore for execution state management - Create ExecutionMonitorPanel component with step progress display - Add execution panel to DashboardToolbar and TerminalDashboardPage - Support WebSocket message handling for execution updates Phase 4: Execution Bridge - Add POST /api/orchestrator/flows/:id/execute-in-session endpoint - Create useExecuteFlowInSession hook for frontend API calls - Broadcast EXECUTION_STARTED and CLI_SESSION_LOCKED WebSocket messages - Lock session when execution starts, unlock on completion
This commit is contained in:
@@ -34,6 +34,7 @@ import { useTerminalGridStore, selectTerminalGridFocusedPaneId } from '@/stores/
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { toast } from '@/stores/notificationStore';
|
||||
import { useExecutionMonitorStore, selectActiveExecutionCount } from '@/stores/executionMonitorStore';
|
||||
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
|
||||
import { CliConfigModal, type CliSessionConfig } from './CliConfigModal';
|
||||
|
||||
// ========== Types ==========
|
||||
@@ -106,6 +107,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
||||
const createSessionAndAssign = useTerminalGridStore((s) => s.createSessionAndAssign);
|
||||
const updateTerminalMeta = useSessionManagerStore((s) => s.updateTerminalMeta);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
||||
|
||||
@@ -131,7 +133,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
||||
const targetPaneId = getOrCreateFocusedPane();
|
||||
if (!targetPaneId) throw new Error('Failed to create pane');
|
||||
|
||||
await createSessionAndAssign(
|
||||
const result = await createSessionAndAssign(
|
||||
targetPaneId,
|
||||
{
|
||||
workingDir: config.workingDir || projectPath,
|
||||
@@ -142,6 +144,14 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
||||
},
|
||||
projectPath
|
||||
);
|
||||
|
||||
// Store tag in terminalMetas for grouping
|
||||
if (result?.session?.sessionKey) {
|
||||
updateTerminalMeta(result.session.sessionKey, {
|
||||
tag: config.tag,
|
||||
title: config.tag,
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
@@ -153,7 +163,7 @@ export function DashboardToolbar({ activePanel, onTogglePanel, isFileSidebarOpen
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [projectPath, createSessionAndAssign, getOrCreateFocusedPane]);
|
||||
}, [projectPath, createSessionAndAssign, getOrCreateFocusedPane, updateTerminalMeta]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
// ========================================
|
||||
// Execution Monitor Panel
|
||||
// ========================================
|
||||
// Panel for monitoring workflow executions in Terminal Dashboard.
|
||||
// Displays execution progress, step list, and control buttons.
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Circle,
|
||||
Loader2,
|
||||
Clock,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
import {
|
||||
useExecutionMonitorStore,
|
||||
selectCurrentExecution,
|
||||
selectActiveExecutions,
|
||||
} from '@/stores/executionMonitorStore';
|
||||
import type { ExecutionStatus, StepInfo } from '@/stores/executionMonitorStore';
|
||||
|
||||
// ========== Status Config ==========
|
||||
|
||||
const statusConfig: Record<ExecutionStatus, { label: string; color: string; bgColor: string }> = {
|
||||
pending: { label: 'Pending', color: 'text-muted-foreground', bgColor: 'bg-muted' },
|
||||
running: { label: 'Running', color: 'text-primary', bgColor: 'bg-primary/10' },
|
||||
paused: { label: 'Paused', color: 'text-amber-500', bgColor: 'bg-amber-500/10' },
|
||||
completed: { label: 'Completed', color: 'text-green-500', bgColor: 'bg-green-500/10' },
|
||||
failed: { label: 'Failed', color: 'text-destructive', bgColor: 'bg-destructive/10' },
|
||||
cancelled: { label: 'Cancelled', color: 'text-muted-foreground', bgColor: 'bg-muted' },
|
||||
};
|
||||
|
||||
// ========== Step Status Icon ==========
|
||||
|
||||
function StepStatusIcon({ status }: { status: ExecutionStatus }) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Circle className="w-4 h-4 text-muted-foreground" />;
|
||||
case 'running':
|
||||
return <Loader2 className="w-4 h-4 text-primary animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-4 h-4 text-destructive" />;
|
||||
case 'paused':
|
||||
return <Pause className="w-4 h-4 text-amber-500" />;
|
||||
case 'cancelled':
|
||||
return <Square className="w-4 h-4 text-muted-foreground" />;
|
||||
default:
|
||||
return <Circle className="w-4 h-4 text-muted-foreground opacity-50" />;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Step List Item ==========
|
||||
|
||||
interface StepListItemProps {
|
||||
step: StepInfo;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
function StepListItem({ step, isCurrent }: StepListItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-start gap-3 px-3 py-2 rounded-md transition-colors',
|
||||
isCurrent && step.status === 'running' && 'bg-primary/5 border border-primary/20',
|
||||
step.status === 'failed' && 'bg-destructive/5'
|
||||
)}
|
||||
>
|
||||
<div className="pt-0.5 shrink-0">
|
||||
<StepStatusIcon status={step.status} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium truncate block',
|
||||
step.status === 'completed' && 'text-muted-foreground',
|
||||
step.status === 'running' && 'text-foreground',
|
||||
step.status === 'failed' && 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{step.name}
|
||||
</span>
|
||||
{step.error && (
|
||||
<p className="text-xs text-destructive mt-1 truncate">{step.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function ExecutionMonitorPanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const currentExecution = useExecutionMonitorStore(selectCurrentExecution);
|
||||
const activeExecutions = useExecutionMonitorStore(selectActiveExecutions);
|
||||
const selectExecution = useExecutionMonitorStore((s) => s.selectExecution);
|
||||
const pauseExecution = useExecutionMonitorStore((s) => s.pauseExecution);
|
||||
const resumeExecution = useExecutionMonitorStore((s) => s.resumeExecution);
|
||||
const stopExecution = useExecutionMonitorStore((s) => s.stopExecution);
|
||||
const clearExecution = useExecutionMonitorStore((s) => s.clearExecution);
|
||||
|
||||
const executions = Object.values(activeExecutions);
|
||||
const hasExecutions = executions.length > 0;
|
||||
|
||||
if (!hasExecutions) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground p-8">
|
||||
<Terminal className="w-10 h-10 mb-3 opacity-30" />
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'executionMonitor.noExecutions', defaultMessage: 'No active executions' })}
|
||||
</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{formatMessage({ id: 'executionMonitor.sendToTerminal', defaultMessage: 'Send a workflow to terminal to start' })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Execution selector (if multiple) */}
|
||||
{executions.length > 1 && (
|
||||
<div className="border-b border-border p-2 shrink-0">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{executions.map((exec) => (
|
||||
<button
|
||||
key={exec.executionId}
|
||||
onClick={() => selectExecution(exec.executionId)}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs rounded-md transition-colors truncate max-w-[120px]',
|
||||
currentExecution?.executionId === exec.executionId
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted hover:bg-muted/80 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{exec.flowName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentExecution && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border space-y-3 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground truncate flex-1">
|
||||
{currentExecution.flowName}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn('shrink-0', statusConfig[currentExecution.status].bgColor)}
|
||||
>
|
||||
{statusConfig[currentExecution.status].label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{formatMessage(
|
||||
{ id: 'executionMonitor.progress', defaultMessage: '{completed}/{total} steps' },
|
||||
{ completed: currentExecution.completedSteps, total: currentExecution.totalSteps || currentExecution.steps.length }
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{Math.round(
|
||||
(currentExecution.completedSteps / (currentExecution.totalSteps || currentExecution.steps.length || 1)) * 100
|
||||
)}%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={
|
||||
(currentExecution.completedSteps / (currentExecution.totalSteps || currentExecution.steps.length || 1)) * 100
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Meta info */}
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(currentExecution.startedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Terminal className="w-3 h-3" />
|
||||
<span className="truncate max-w-[100px]">{currentExecution.sessionKey.slice(0, 20)}...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step list */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{currentExecution.steps.map((step) => (
|
||||
<StepListItem
|
||||
key={step.id}
|
||||
step={step}
|
||||
isCurrent={step.id === currentExecution.currentStepId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Control bar */}
|
||||
<div className="p-3 border-t border-border flex items-center gap-2 shrink-0">
|
||||
{currentExecution.status === 'running' && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => pauseExecution(currentExecution.executionId)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Pause className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'executionMonitor.pause', defaultMessage: 'Pause' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => stopExecution(currentExecution.executionId)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'executionMonitor.stop', defaultMessage: 'Stop' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentExecution.status === 'paused' && (
|
||||
<>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => resumeExecution(currentExecution.executionId)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'executionMonitor.resume', defaultMessage: 'Resume' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => stopExecution(currentExecution.executionId)}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'executionMonitor.stop', defaultMessage: 'Stop' })}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(currentExecution.status === 'completed' ||
|
||||
currentExecution.status === 'failed' ||
|
||||
currentExecution.status === 'cancelled') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => clearExecution(currentExecution.executionId)}
|
||||
className="gap-1.5 ml-auto"
|
||||
>
|
||||
{formatMessage({ id: 'executionMonitor.clear', defaultMessage: 'Clear' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExecutionMonitorPanel;
|
||||
@@ -1,28 +1,18 @@
|
||||
// ========================================
|
||||
// SessionGroupTree Component
|
||||
// ========================================
|
||||
// Tree view for session groups with drag-and-drop support.
|
||||
// Sessions can be dragged between groups. Groups are expandable sections.
|
||||
// Uses @hello-pangea/dnd for drag-and-drop, sessionManagerStore for state.
|
||||
// Tree view for CLI sessions grouped by tag.
|
||||
// Sessions are automatically grouped by their tag (e.g., "gemini-143052").
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
DragDropContext,
|
||||
Droppable,
|
||||
Draggable,
|
||||
type DropResult,
|
||||
} from '@hello-pangea/dnd';
|
||||
import {
|
||||
ChevronRight,
|
||||
FolderOpen,
|
||||
Folder,
|
||||
Plus,
|
||||
Terminal,
|
||||
GripVertical,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSessionManagerStore, selectGroups, selectSessionManagerActiveTerminalId, selectTerminalMetas } from '@/stores';
|
||||
import { useSessionManagerStore, selectSessionManagerActiveTerminalId, selectTerminalMetas } from '@/stores';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { useTerminalGridStore, selectTerminalGridPanes } from '@/stores/terminalGridStore';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
@@ -36,17 +26,15 @@ const statusDotStyles: Record<TerminalStatus, string> = {
|
||||
error: 'bg-red-500',
|
||||
paused: 'bg-yellow-500',
|
||||
resuming: 'bg-blue-400 animate-pulse',
|
||||
locked: 'bg-purple-500',
|
||||
};
|
||||
|
||||
// ========== SessionGroupTree Component ==========
|
||||
|
||||
export function SessionGroupTree() {
|
||||
const { formatMessage } = useIntl();
|
||||
const groups = useSessionManagerStore(selectGroups);
|
||||
const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId);
|
||||
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
||||
const createGroup = useSessionManagerStore((s) => s.createGroup);
|
||||
const moveSessionToGroup = useSessionManagerStore((s) => s.moveSessionToGroup);
|
||||
const setActiveTerminal = useSessionManagerStore((s) => s.setActiveTerminal);
|
||||
const sessions = useCliSessionStore((s) => s.sessions);
|
||||
|
||||
@@ -55,25 +43,20 @@ export function SessionGroupTree() {
|
||||
const assignSession = useTerminalGridStore((s) => s.assignSession);
|
||||
const setFocused = useTerminalGridStore((s) => s.setFocused);
|
||||
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [expandedTags, setExpandedTags] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleGroup = useCallback((groupId: string) => {
|
||||
setExpandedGroups((prev) => {
|
||||
const toggleTag = useCallback((tag: string) => {
|
||||
setExpandedTags((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
if (next.has(tag)) {
|
||||
next.delete(tag);
|
||||
} else {
|
||||
next.add(groupId);
|
||||
next.add(tag);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCreateGroup = useCallback(() => {
|
||||
const name = formatMessage({ id: 'terminalDashboard.sessionTree.defaultGroupName' });
|
||||
createGroup(name);
|
||||
}, [createGroup, formatMessage]);
|
||||
|
||||
const handleSessionClick = useCallback(
|
||||
(sessionId: string) => {
|
||||
// Set active terminal in session manager
|
||||
@@ -100,44 +83,55 @@ export function SessionGroupTree() {
|
||||
[setActiveTerminal, panes, setFocused, assignSession]
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(result: DropResult) => {
|
||||
const { draggableId, destination } = result;
|
||||
if (!destination) return;
|
||||
// Group sessions by tag
|
||||
const sessionsByTag = useMemo(() => {
|
||||
const groups: Record<string, { tag: string; sessionIds: string[] }> = {};
|
||||
const untagged: string[] = [];
|
||||
|
||||
// destination.droppableId is the target group ID
|
||||
const targetGroupId = destination.droppableId;
|
||||
moveSessionToGroup(draggableId, targetGroupId);
|
||||
},
|
||||
[moveSessionToGroup]
|
||||
);
|
||||
for (const sessionKey of Object.keys(sessions)) {
|
||||
const meta = terminalMetas[sessionKey];
|
||||
const tag = meta?.tag;
|
||||
if (tag) {
|
||||
if (!groups[tag]) {
|
||||
groups[tag] = { tag, sessionIds: [] };
|
||||
}
|
||||
groups[tag].sessionIds.push(sessionKey);
|
||||
} else {
|
||||
untagged.push(sessionKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by tag name (newest first by time suffix)
|
||||
const result = Object.values(groups).sort((a, b) => b.tag.localeCompare(a.tag));
|
||||
|
||||
// Add untagged sessions at the end
|
||||
if (untagged.length > 0) {
|
||||
result.push({ tag: '__untagged__', sessionIds: untagged });
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [sessions, terminalMetas]);
|
||||
|
||||
// Build a lookup for session display names
|
||||
const sessionNames = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
for (const [key, meta] of Object.entries(sessions)) {
|
||||
map[key] = meta.tool ? `${meta.tool} - ${meta.shellKind}` : meta.shellKind;
|
||||
map[key] = meta.tool ?? meta.shellKind;
|
||||
}
|
||||
return map;
|
||||
}, [sessions]);
|
||||
|
||||
if (groups.length === 0) {
|
||||
if (Object.keys(sessions).length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<button
|
||||
onClick={handleCreateGroup}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'terminalDashboard.sessionTree.createGroup' })}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-1.5 text-muted-foreground p-4">
|
||||
<Folder className="w-6 h-6 opacity-30" />
|
||||
<Terminal className="w-6 h-6 opacity-30" />
|
||||
<p className="text-xs text-center">
|
||||
{formatMessage({ id: 'terminalDashboard.sessionTree.noGroups' })}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground/70">
|
||||
Click "New Session" to create one
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -145,117 +139,69 @@ export function SessionGroupTree() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Create group button */}
|
||||
<div className="px-3 py-2 border-b border-border shrink-0">
|
||||
<button
|
||||
onClick={handleCreateGroup}
|
||||
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'terminalDashboard.sessionTree.createGroup' })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Groups with drag-and-drop */}
|
||||
{/* Session list grouped by tag */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
{groups.map((group) => {
|
||||
const isExpanded = expandedGroups.has(group.id);
|
||||
return (
|
||||
<div key={group.id} className="border-b border-border/50 last:border-b-0">
|
||||
{/* Group header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 w-full px-3 py-2 text-left',
|
||||
'hover:bg-muted/50 transition-colors text-sm'
|
||||
)}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="w-4 h-4 text-blue-500 shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-blue-400 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate font-medium">{group.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{group.sessionIds.length}
|
||||
</Badge>
|
||||
</button>
|
||||
{sessionsByTag.map((group) => {
|
||||
const isExpanded = expandedTags.has(group.tag);
|
||||
const isUntagged = group.tag === '__untagged__';
|
||||
const displayName = isUntagged ? 'Other Sessions' : group.tag;
|
||||
|
||||
{/* Expanded: droppable session list */}
|
||||
{isExpanded && (
|
||||
<Droppable droppableId={group.id}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
className={cn(
|
||||
'min-h-[32px] pb-1',
|
||||
snapshot.isDraggingOver && 'bg-primary/5'
|
||||
)}
|
||||
>
|
||||
{group.sessionIds.length === 0 ? (
|
||||
<p className="px-8 py-2 text-xs text-muted-foreground italic">
|
||||
{formatMessage({ id: 'terminalDashboard.sessionTree.emptyGroup' })}
|
||||
</p>
|
||||
) : (
|
||||
group.sessionIds.map((sessionId, index) => {
|
||||
const meta = terminalMetas[sessionId];
|
||||
const sessionStatus: TerminalStatus = meta?.status ?? 'idle';
|
||||
return (
|
||||
<Draggable
|
||||
key={sessionId}
|
||||
draggableId={sessionId}
|
||||
index={index}
|
||||
>
|
||||
{(dragProvided, dragSnapshot) => (
|
||||
<div
|
||||
ref={dragProvided.innerRef}
|
||||
{...dragProvided.draggableProps}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 mx-1 px-2 py-1.5 rounded-sm cursor-pointer',
|
||||
'hover:bg-muted/50 transition-colors text-sm',
|
||||
activeTerminalId === sessionId && 'bg-primary/10 text-primary',
|
||||
dragSnapshot.isDragging && 'bg-muted shadow-md'
|
||||
)}
|
||||
onClick={() => handleSessionClick(sessionId)}
|
||||
>
|
||||
<span
|
||||
{...dragProvided.dragHandleProps}
|
||||
className="text-muted-foreground/50 hover:text-muted-foreground shrink-0"
|
||||
>
|
||||
<GripVertical className="w-3 h-3" />
|
||||
</span>
|
||||
{/* Status indicator dot */}
|
||||
<span
|
||||
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[sessionStatus])}
|
||||
title={sessionStatus}
|
||||
/>
|
||||
<Terminal className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate text-xs">
|
||||
{sessionNames[sessionId] ?? sessionId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
return (
|
||||
<div key={group.tag} className="border-b border-border/50 last:border-b-0">
|
||||
{/* Tag header */}
|
||||
<button
|
||||
onClick={() => toggleTag(group.tag)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 w-full px-3 py-2 text-left',
|
||||
'hover:bg-muted/50 transition-colors text-sm'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DragDropContext>
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 text-muted-foreground transition-transform shrink-0',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<Tag className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate font-medium text-xs">{displayName}</span>
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||
{group.sessionIds.length}
|
||||
</Badge>
|
||||
</button>
|
||||
|
||||
{/* Expanded: session list */}
|
||||
{isExpanded && (
|
||||
<div className="pb-1">
|
||||
{group.sessionIds.map((sessionId) => {
|
||||
const meta = terminalMetas[sessionId];
|
||||
const sessionStatus: TerminalStatus = meta?.status ?? 'idle';
|
||||
return (
|
||||
<div
|
||||
key={sessionId}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 mx-1 px-2 py-1.5 rounded-sm cursor-pointer',
|
||||
'hover:bg-muted/50 transition-colors text-sm',
|
||||
activeTerminalId === sessionId && 'bg-primary/10 text-primary'
|
||||
)}
|
||||
onClick={() => handleSessionClick(sessionId)}
|
||||
>
|
||||
{/* Status indicator dot */}
|
||||
<span
|
||||
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[sessionStatus])}
|
||||
title={sessionStatus}
|
||||
/>
|
||||
<Terminal className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="flex-1 truncate text-xs">
|
||||
{sessionNames[sessionId] ?? sessionId}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// ========================================
|
||||
// Session Lock Confirm Dialog
|
||||
// ========================================
|
||||
// Dialog shown when user tries to input in a locked session.
|
||||
// Displays execution info and offers options to wait or unlock.
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Lock, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
} from '@/components/ui/AlertDialog';
|
||||
import { Progress } from '@/components/ui/Progress';
|
||||
|
||||
interface SessionLockConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
lockInfo: {
|
||||
reason: string;
|
||||
executionName?: string;
|
||||
currentStep?: string;
|
||||
progress?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function SessionLockConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
lockInfo,
|
||||
}: SessionLockConfirmDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<Lock className="w-5 h-5 text-amber-500" />
|
||||
{formatMessage({ id: 'sessionLock.title', defaultMessage: '会话正在执行任务' })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{formatMessage({
|
||||
id: 'sessionLock.description',
|
||||
defaultMessage: '此会话当前正在执行工作流,手动输入可能会中断执行。'
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div className="space-y-3 py-4">
|
||||
{/* Execution info */}
|
||||
<div className="bg-muted/50 rounded-lg p-3 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionLock.workflow', defaultMessage: '工作流:' })}
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{lockInfo.executionName || lockInfo.reason}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{lockInfo.currentStep && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'sessionLock.currentStep', defaultMessage: '当前步骤:' })}
|
||||
</span>
|
||||
<span>{lockInfo.currentStep}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lockInfo.progress !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<Progress value={lockInfo.progress} className="h-2" />
|
||||
<p className="text-xs text-muted-foreground text-right">
|
||||
{lockInfo.progress}% {formatMessage({ id: 'sessionLock.completed', defaultMessage: '完成' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warning alert */}
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/30 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-amber-600 dark:text-amber-400">
|
||||
{formatMessage({ id: 'sessionLock.warning', defaultMessage: '注意' })}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{formatMessage({
|
||||
id: 'sessionLock.warningMessage',
|
||||
defaultMessage: '继续输入将解锁会话,可能会影响正在执行的工作流。'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialogFooter className="flex-col sm:flex-row gap-2">
|
||||
<AlertDialogCancel onClick={onClose} className="w-full sm:w-auto">
|
||||
{formatMessage({ id: 'sessionLock.cancel', defaultMessage: '取消,等待完成' })}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="w-full sm:w-auto bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{formatMessage({ id: 'sessionLock.confirm', defaultMessage: '解锁并继续输入' })}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionLockConfirmDialog;
|
||||
154
ccw/frontend/src/hooks/useOrchestratorExecution.ts
Normal file
154
ccw/frontend/src/hooks/useOrchestratorExecution.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
// ========================================
|
||||
// Orchestrator Execution Hooks
|
||||
// ========================================
|
||||
// React Query hooks for executing flows in terminal sessions.
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useExecutionMonitorStore } from '@/stores/executionMonitorStore';
|
||||
import { useSessionManagerStore } from '@/stores/sessionManagerStore';
|
||||
import { toast } from '@/stores/notificationStore';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface SessionConfig {
|
||||
tool?: 'claude' | 'gemini' | 'qwen' | 'codex' | 'opencode';
|
||||
model?: string;
|
||||
preferredShell?: 'bash' | 'pwsh' | 'cmd';
|
||||
}
|
||||
|
||||
export interface ExecuteInSessionRequest {
|
||||
sessionConfig?: SessionConfig;
|
||||
sessionKey?: string;
|
||||
variables?: Record<string, unknown>;
|
||||
stepTimeout?: number;
|
||||
errorStrategy?: 'pause' | 'skip' | 'stop';
|
||||
}
|
||||
|
||||
export interface ExecuteInSessionResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
executionId: string;
|
||||
flowId: string;
|
||||
sessionKey: string;
|
||||
status: 'pending' | 'running';
|
||||
totalSteps: number;
|
||||
startedAt: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ========== Helper ==========
|
||||
|
||||
function withPath(url: string, projectPath?: string | null): string {
|
||||
const p = typeof projectPath === 'string' ? projectPath.trim() : '';
|
||||
if (!p) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}path=${encodeURIComponent(p)}`;
|
||||
}
|
||||
|
||||
// ========== Hook ==========
|
||||
|
||||
export function useExecuteFlowInSession() {
|
||||
const queryClient = useQueryClient();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
const handleExecutionMessage = useExecutionMonitorStore((s) => s.handleExecutionMessage);
|
||||
const setPanelOpen = useExecutionMonitorStore((s) => s.setPanelOpen);
|
||||
const lockSession = useSessionManagerStore((s) => s.lockSession);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (params: {
|
||||
flowId: string;
|
||||
sessionConfig?: SessionConfig;
|
||||
sessionKey?: string;
|
||||
}): Promise<ExecuteInSessionResponse> => {
|
||||
const url = withPath(`/api/orchestrator/flows/${params.flowId}/execute-in-session`, projectPath);
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionConfig: params.sessionConfig,
|
||||
sessionKey: params.sessionKey,
|
||||
}),
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
const { executionId, flowId, sessionKey, startedAt } = data.data;
|
||||
|
||||
// Initialize execution in store
|
||||
handleExecutionMessage({
|
||||
type: 'EXECUTION_STARTED',
|
||||
payload: {
|
||||
executionId,
|
||||
flowId,
|
||||
sessionKey,
|
||||
stepName: flowId,
|
||||
timestamp: startedAt,
|
||||
},
|
||||
});
|
||||
|
||||
// Lock the session
|
||||
lockSession(sessionKey, `Executing workflow: ${flowId}`, executionId);
|
||||
|
||||
// Open the execution monitor panel
|
||||
setPanelOpen(true);
|
||||
|
||||
// Update query cache
|
||||
queryClient.setQueryData(['activeExecution'], data.data);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('[ExecuteFlowInSession] Error:', error);
|
||||
toast.error(
|
||||
'Execution Failed',
|
||||
'Could not start workflow execution in terminal session.'
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Session Lock Hooks ==========
|
||||
|
||||
export function useLockSession() {
|
||||
return useMutation({
|
||||
mutationFn: async (params: {
|
||||
sessionKey: string;
|
||||
reason: string;
|
||||
executionId?: string;
|
||||
}) => {
|
||||
const response = await fetch(`/api/sessions/${params.sessionKey}/lock`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: params.reason, executionId: params.executionId }),
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnlockSession() {
|
||||
return useMutation({
|
||||
mutationFn: async (sessionKey: string) => {
|
||||
const response = await fetch(`/api/sessions/${sessionKey}/unlock`, {
|
||||
method: 'POST',
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Flow to Session Conversion Hook ==========
|
||||
|
||||
export function usePrepareFlowForExecution() {
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (flowId: string) => {
|
||||
const url = withPath(`/api/orchestrator/flows/${flowId}`, projectPath);
|
||||
const response = await fetch(url);
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "Orchestrator",
|
||||
"description": "Manage and execute workflow flows",
|
||||
"title": "Workflow Template Editor",
|
||||
"description": "Create and edit workflow templates",
|
||||
"flow": {
|
||||
"title": "Flow",
|
||||
"flows": "Flows",
|
||||
@@ -98,6 +98,9 @@
|
||||
"couldNotDuplicate": "Could not duplicate the flow",
|
||||
"flowExported": "Flow exported as JSON file",
|
||||
"noFlowToExport": "Create or load a flow first",
|
||||
"saveBeforeExecute": "Please save the flow first",
|
||||
"flowSent": "Flow Sent",
|
||||
"sentToTerminal": "\"{name}\" sent to terminal for execution",
|
||||
"executionFailed": "Execution Failed",
|
||||
"couldNotExecute": "Could not start flow execution"
|
||||
},
|
||||
@@ -152,8 +155,7 @@
|
||||
"export": "Export Flow",
|
||||
"templates": "Templates",
|
||||
"importTemplate": "Import Template",
|
||||
"runWorkflow": "Run Workflow",
|
||||
"monitor": "Monitor",
|
||||
"sendToTerminal": "Send to Terminal",
|
||||
"savedFlows": "Saved Flows ({count})",
|
||||
"loading": "Loading...",
|
||||
"noSavedFlows": "No saved flows",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"title": "编排器",
|
||||
"description": "管理和执行工作流",
|
||||
"title": "工作流模板编辑器",
|
||||
"description": "创建和编辑工作流模板",
|
||||
"flow": {
|
||||
"title": "流程",
|
||||
"flows": "流程列表",
|
||||
@@ -98,6 +98,9 @@
|
||||
"couldNotDuplicate": "无法复制流程",
|
||||
"flowExported": "流程已导出为 JSON 文件",
|
||||
"noFlowToExport": "请先创建或加载流程",
|
||||
"saveBeforeExecute": "请先保存流程",
|
||||
"flowSent": "流程已发送",
|
||||
"sentToTerminal": "\"{name}\" 已发送到终端执行",
|
||||
"executionFailed": "执行失败",
|
||||
"couldNotExecute": "无法启动流程执行"
|
||||
},
|
||||
@@ -152,8 +155,7 @@
|
||||
"export": "导出流程",
|
||||
"templates": "模板",
|
||||
"importTemplate": "导入模板",
|
||||
"runWorkflow": "运行流程",
|
||||
"monitor": "监控",
|
||||
"sendToTerminal": "发送到终端执行",
|
||||
"savedFlows": "已保存的流程 ({count})",
|
||||
"loading": "加载中...",
|
||||
"noSavedFlows": "无已保存的流程",
|
||||
|
||||
@@ -22,6 +22,7 @@ import { AgentList } from '@/components/terminal-dashboard/AgentList';
|
||||
import { IssuePanel } from '@/components/terminal-dashboard/IssuePanel';
|
||||
import { QueuePanel } from '@/components/terminal-dashboard/QueuePanel';
|
||||
import { InspectorContent } from '@/components/terminal-dashboard/BottomInspector';
|
||||
import { ExecutionMonitorPanel } from '@/components/terminal-dashboard/ExecutionMonitorPanel';
|
||||
import { FileSidebarPanel } from '@/components/terminal-dashboard/FileSidebarPanel';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
|
||||
@@ -128,6 +129,16 @@ export function TerminalDashboardPage() {
|
||||
>
|
||||
<InspectorContent />
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
isOpen={activePanel === 'execution'}
|
||||
onClose={closePanel}
|
||||
title={formatMessage({ id: 'terminalDashboard.toolbar.executionMonitor', defaultMessage: 'Execution Monitor' })}
|
||||
side="right"
|
||||
width={380}
|
||||
>
|
||||
<ExecutionMonitorPanel />
|
||||
</FloatingPanel>
|
||||
</AssociationHighlightProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// ========================================
|
||||
// Flow Toolbar Component
|
||||
// ========================================
|
||||
// Toolbar for flow operations: Save, Load, Import Template, Export, Run, Monitor
|
||||
// Toolbar for flow operations: Save, Load, Import Template, Export, Send to Terminal
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Save,
|
||||
FolderOpen,
|
||||
@@ -15,8 +16,7 @@ import {
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Library,
|
||||
Play,
|
||||
Activity,
|
||||
Terminal,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
@@ -24,8 +24,6 @@ import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useFlowStore, toast } from '@/stores';
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
import { useExecuteFlow } from '@/hooks/useFlows';
|
||||
import { useAppStore, selectIsImmersiveMode } from '@/stores/appStore';
|
||||
import type { Flow } from '@/types/flow';
|
||||
|
||||
@@ -36,6 +34,7 @@ interface FlowToolbarProps {
|
||||
|
||||
export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
const [isFlowListOpen, setIsFlowListOpen] = useState(false);
|
||||
const [flowName, setFlowName] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -55,18 +54,6 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
const duplicateFlow = useFlowStore((state) => state.duplicateFlow);
|
||||
const fetchFlows = useFlowStore((state) => state.fetchFlows);
|
||||
|
||||
// Execution store
|
||||
const currentExecution = useExecutionStore((state) => state.currentExecution);
|
||||
const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen);
|
||||
const setMonitorPanelOpen = useExecutionStore((state) => state.setMonitorPanelOpen);
|
||||
const startExecution = useExecutionStore((state) => state.startExecution);
|
||||
|
||||
// Mutations
|
||||
const executeFlow = useExecuteFlow();
|
||||
|
||||
const isExecuting = currentExecution?.status === 'running';
|
||||
const isPaused = currentExecution?.status === 'paused';
|
||||
|
||||
// Load flows on mount
|
||||
useEffect(() => {
|
||||
fetchFlows();
|
||||
@@ -194,24 +181,26 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
toast.success(formatMessage({ id: 'orchestrator.notifications.flowExported' }), formatMessage({ id: 'orchestrator.notifications.flowExported' }));
|
||||
}, [currentFlow]);
|
||||
|
||||
// Handle run workflow
|
||||
const handleRun = useCallback(async () => {
|
||||
if (!currentFlow) return;
|
||||
try {
|
||||
// Open monitor panel automatically
|
||||
setMonitorPanelOpen(true);
|
||||
const result = await executeFlow.mutateAsync(currentFlow.id);
|
||||
startExecution(result.execId, currentFlow.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to execute flow:', error);
|
||||
toast.error(formatMessage({ id: 'orchestrator.notifications.executionFailed' }), formatMessage({ id: 'orchestrator.notifications.couldNotExecute' }));
|
||||
// Handle send to terminal execution
|
||||
const handleSendToTerminal = useCallback(async () => {
|
||||
if (!currentFlow) {
|
||||
toast.error(formatMessage({ id: 'orchestrator.notifications.noFlow' }), formatMessage({ id: 'orchestrator.notifications.saveBeforeExecute' }));
|
||||
return;
|
||||
}
|
||||
}, [currentFlow, executeFlow, startExecution, setMonitorPanelOpen]);
|
||||
|
||||
// Handle monitor toggle
|
||||
const handleToggleMonitor = useCallback(() => {
|
||||
setMonitorPanelOpen(!isMonitorPanelOpen);
|
||||
}, [isMonitorPanelOpen, setMonitorPanelOpen]);
|
||||
// Save flow first if modified
|
||||
if (isModified) {
|
||||
const saved = await saveFlow();
|
||||
if (!saved) {
|
||||
toast.error(formatMessage({ id: 'orchestrator.notifications.saveFailed' }), formatMessage({ id: 'orchestrator.notifications.couldNotSave' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to terminal dashboard with flow execution request
|
||||
navigate(`/terminal?executeFlow=${currentFlow.id}`);
|
||||
toast.success(formatMessage({ id: 'orchestrator.notifications.flowSent' }), formatMessage({ id: 'orchestrator.notifications.sentToTerminal' }, { name: currentFlow.name }));
|
||||
}, [currentFlow, isModified, saveFlow, navigate, formatMessage]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3 p-3 bg-card border-b border-border', className)}>
|
||||
@@ -346,29 +335,15 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
{/* Run & Monitor Group */}
|
||||
<Button
|
||||
variant={isMonitorPanelOpen ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={handleToggleMonitor}
|
||||
title={formatMessage({ id: 'orchestrator.monitor.toggleMonitor' })}
|
||||
>
|
||||
<Activity className={cn('w-4 h-4 mr-1', (isExecuting || isPaused) && 'text-primary animate-pulse')} />
|
||||
{formatMessage({ id: 'orchestrator.toolbar.monitor' })}
|
||||
</Button>
|
||||
|
||||
{/* Execute in Terminal */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={!currentFlow || isExecuting || isPaused || executeFlow.isPending}
|
||||
onClick={handleSendToTerminal}
|
||||
disabled={!currentFlow}
|
||||
>
|
||||
{executeFlow.isPending ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{formatMessage({ id: 'orchestrator.toolbar.runWorkflow' })}
|
||||
<Terminal className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'orchestrator.toolbar.sendToTerminal' })}
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
// ========================================
|
||||
// Orchestrator Page
|
||||
// ========================================
|
||||
// Visual workflow editor with React Flow, drag-drop node palette, and property panel
|
||||
// Visual workflow template editor with React Flow, drag-drop node palette, and property panel
|
||||
// Execution functionality moved to Terminal Dashboard
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { FlowCanvas } from './FlowCanvas';
|
||||
import { LeftSidebar } from './LeftSidebar';
|
||||
import { PropertyPanel } from './PropertyPanel';
|
||||
import { FlowToolbar } from './FlowToolbar';
|
||||
import { TemplateLibrary } from './TemplateLibrary';
|
||||
import { ExecutionMonitor } from './ExecutionMonitor';
|
||||
|
||||
export function OrchestratorPage() {
|
||||
const fetchFlows = useFlowStore((state) => state.fetchFlows);
|
||||
const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen);
|
||||
const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen);
|
||||
const isPropertyPanelOpen = useFlowStore((state) => state.isPropertyPanelOpen);
|
||||
const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen);
|
||||
const [isTemplateLibraryOpen, setIsTemplateLibraryOpen] = useState(false);
|
||||
|
||||
// Load flows on mount
|
||||
@@ -60,15 +58,12 @@ export function OrchestratorPage() {
|
||||
<FlowCanvas className="absolute inset-0" />
|
||||
|
||||
{/* Property Panel as overlay - only shown when a node is selected */}
|
||||
{!isMonitorPanelOpen && isPropertyPanelOpen && (
|
||||
{isPropertyPanelOpen && (
|
||||
<div className="absolute top-2 right-2 bottom-2 z-10">
|
||||
<PropertyPanel className="h-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution Monitor Panel (Right) */}
|
||||
<ExecutionMonitor />
|
||||
</div>
|
||||
|
||||
{/* Template Library Dialog */}
|
||||
|
||||
291
ccw/frontend/src/stores/executionMonitorStore.ts
Normal file
291
ccw/frontend/src/stores/executionMonitorStore.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
// ========================================
|
||||
// Execution Monitor Store
|
||||
// ========================================
|
||||
// Zustand store for execution monitoring in Terminal Dashboard.
|
||||
// Tracks active executions, handles WebSocket messages, and provides control actions.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type ExecutionStatus = 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export interface StepInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ExecutionStatus;
|
||||
output?: string;
|
||||
error?: string;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface ExecutionInfo {
|
||||
executionId: string;
|
||||
flowId: string;
|
||||
flowName: string;
|
||||
sessionKey: string;
|
||||
status: ExecutionStatus;
|
||||
totalSteps: number;
|
||||
completedSteps: number;
|
||||
currentStepId?: string;
|
||||
steps: StepInfo[];
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export type ExecutionWSMessageType =
|
||||
| 'EXECUTION_STARTED'
|
||||
| 'EXECUTION_STEP_START'
|
||||
| 'EXECUTION_STEP_PROGRESS'
|
||||
| 'EXECUTION_STEP_COMPLETE'
|
||||
| 'EXECUTION_STEP_FAILED'
|
||||
| 'EXECUTION_PAUSED'
|
||||
| 'EXECUTION_RESUMED'
|
||||
| 'EXECUTION_STOPPED'
|
||||
| 'EXECUTION_COMPLETED';
|
||||
|
||||
export interface ExecutionWSMessage {
|
||||
type: ExecutionWSMessageType;
|
||||
payload: {
|
||||
executionId: string;
|
||||
flowId: string;
|
||||
sessionKey: string;
|
||||
stepId?: string;
|
||||
stepName?: string;
|
||||
progress?: number;
|
||||
output?: string;
|
||||
error?: string;
|
||||
timestamp: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ========== State Interface ==========
|
||||
|
||||
interface ExecutionMonitorState {
|
||||
activeExecutions: Record<string, ExecutionInfo>;
|
||||
currentExecutionId: string | null;
|
||||
isPanelOpen: boolean;
|
||||
}
|
||||
|
||||
interface ExecutionMonitorActions {
|
||||
handleExecutionMessage: (msg: ExecutionWSMessage) => void;
|
||||
selectExecution: (executionId: string | null) => void;
|
||||
pauseExecution: (executionId: string) => void;
|
||||
resumeExecution: (executionId: string) => void;
|
||||
stopExecution: (executionId: string) => void;
|
||||
setPanelOpen: (open: boolean) => void;
|
||||
clearExecution: (executionId: string) => void;
|
||||
clearAllExecutions: () => void;
|
||||
}
|
||||
|
||||
type ExecutionMonitorStore = ExecutionMonitorState & ExecutionMonitorActions;
|
||||
|
||||
// ========== Initial State ==========
|
||||
|
||||
const initialState: ExecutionMonitorState = {
|
||||
activeExecutions: {},
|
||||
currentExecutionId: null,
|
||||
isPanelOpen: false,
|
||||
};
|
||||
|
||||
// ========== Store ==========
|
||||
|
||||
export const useExecutionMonitorStore = create<ExecutionMonitorStore>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
|
||||
handleExecutionMessage: (msg: ExecutionWSMessage) => {
|
||||
const { type, payload } = msg;
|
||||
const { executionId, flowId, sessionKey, stepId, stepName, output, error, timestamp } = payload;
|
||||
|
||||
set((state) => {
|
||||
const existing = state.activeExecutions[executionId];
|
||||
|
||||
switch (type) {
|
||||
case 'EXECUTION_STARTED':
|
||||
return {
|
||||
activeExecutions: {
|
||||
...state.activeExecutions,
|
||||
[executionId]: {
|
||||
executionId,
|
||||
flowId,
|
||||
flowName: stepName || 'Workflow',
|
||||
sessionKey,
|
||||
status: 'running',
|
||||
totalSteps: 0,
|
||||
completedSteps: 0,
|
||||
steps: [],
|
||||
startedAt: timestamp,
|
||||
},
|
||||
},
|
||||
currentExecutionId: executionId,
|
||||
isPanelOpen: true,
|
||||
};
|
||||
|
||||
case 'EXECUTION_STEP_START':
|
||||
if (!existing) return state;
|
||||
return {
|
||||
activeExecutions: {
|
||||
...state.activeExecutions,
|
||||
[executionId]: {
|
||||
...existing,
|
||||
status: 'running',
|
||||
currentStepId: stepId,
|
||||
steps: [
|
||||
...existing.steps.filter(s => s.id !== stepId),
|
||||
{
|
||||
id: stepId || '',
|
||||
name: stepName || '',
|
||||
status: 'running',
|
||||
startedAt: timestamp,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case 'EXECUTION_STEP_PROGRESS':
|
||||
if (!existing || !stepId) return state;
|
||||
return {
|
||||
activeExecutions: {
|
||||
...state.activeExecutions,
|
||||
[executionId]: {
|
||||
...existing,
|
||||
steps: existing.steps.map(s =>
|
||||
s.id === stepId
|
||||
? { ...s, output: (s.output || '') + (output || '') }
|
||||
: s
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case 'EXECUTION_STEP_COMPLETE':
|
||||
if (!existing) return state;
|
||||
return {
|
||||
activeExecutions: {
|
||||
...state.activeExecutions,
|
||||
[executionId]: {
|
||||
...existing,
|
||||
completedSteps: existing.completedSteps + 1,
|
||||
steps: existing.steps.map(s =>
|
||||
s.id === stepId
|
||||
? { ...s, status: 'completed', completedAt: timestamp }
|
||||
: s
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case 'EXECUTION_STEP_FAILED':
|
||||
if (!existing) return state;
|
||||
return {
|
||||
activeExecutions: {
|
||||
...state.activeExecutions,
|
||||
[executionId]: {
|
||||
...existing,
|
||||
status: 'paused',
|
||||
steps: existing.steps.map(s =>
|
||||
s.id === stepId
|
||||
? { ...s, status: 'failed', error, completedAt: timestamp }
|
||||
: s
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
case 'EXECUTION_PAUSED':
|
||||
if (!existing) return state;
|
||||
return {
|
||||
activeExecutions: {
|
||||
...state.activeExecutions,
|
||||
[executionId]: { ...existing, status: 'paused' },
|
||||
},
|
||||
};
|
||||
|
||||
case 'EXECUTION_RESUMED':
|
||||
if (!existing) return state;
|
||||
return {
|
||||
activeExecutions: {
|
||||
...state.activeExecutions,
|
||||
[executionId]: { ...existing, status: 'running' },
|
||||
},
|
||||
};
|
||||
|
||||
case 'EXECUTION_STOPPED':
|
||||
if (!existing) return state;
|
||||
return {
|
||||
activeExecutions: {
|
||||
...state.activeExecutions,
|
||||
[executionId]: { ...existing, status: 'cancelled', completedAt: timestamp },
|
||||
},
|
||||
};
|
||||
|
||||
case 'EXECUTION_COMPLETED':
|
||||
if (!existing) return state;
|
||||
return {
|
||||
activeExecutions: {
|
||||
...state.activeExecutions,
|
||||
[executionId]: { ...existing, status: 'completed', completedAt: timestamp },
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}, false, `handleExecutionMessage/${type}`);
|
||||
},
|
||||
|
||||
selectExecution: (executionId: string | null) => {
|
||||
set({ currentExecutionId: executionId }, false, 'selectExecution');
|
||||
},
|
||||
|
||||
pauseExecution: (executionId: string) => {
|
||||
// TODO: Call API to pause execution
|
||||
console.log('[ExecutionMonitor] Pause execution:', executionId);
|
||||
},
|
||||
|
||||
resumeExecution: (executionId: string) => {
|
||||
// TODO: Call API to resume execution
|
||||
console.log('[ExecutionMonitor] Resume execution:', executionId);
|
||||
},
|
||||
|
||||
stopExecution: (executionId: string) => {
|
||||
// TODO: Call API to stop execution
|
||||
console.log('[ExecutionMonitor] Stop execution:', executionId);
|
||||
},
|
||||
|
||||
setPanelOpen: (open: boolean) => {
|
||||
set({ isPanelOpen: open }, false, 'setPanelOpen');
|
||||
},
|
||||
|
||||
clearExecution: (executionId: string) => {
|
||||
set((state) => {
|
||||
const next = { ...state.activeExecutions };
|
||||
delete next[executionId];
|
||||
return {
|
||||
activeExecutions: next,
|
||||
currentExecutionId: state.currentExecutionId === executionId ? null : state.currentExecutionId,
|
||||
};
|
||||
}, false, 'clearExecution');
|
||||
},
|
||||
|
||||
clearAllExecutions: () => {
|
||||
set({ activeExecutions: {}, currentExecutionId: null }, false, 'clearAllExecutions');
|
||||
},
|
||||
}),
|
||||
{ name: 'ExecutionMonitorStore' }
|
||||
)
|
||||
);
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
export const selectActiveExecutions = (state: ExecutionMonitorStore) => state.activeExecutions;
|
||||
export const selectCurrentExecution = (state: ExecutionMonitorStore) =>
|
||||
state.currentExecutionId ? state.activeExecutions[state.currentExecutionId] : null;
|
||||
export const selectIsPanelOpen = (state: ExecutionMonitorStore) => state.isPanelOpen;
|
||||
export const selectActiveExecutionCount = (state: ExecutionMonitorStore) =>
|
||||
Object.values(state.activeExecutions).filter(e => e.status === 'running' || e.status === 'paused').length;
|
||||
@@ -28,6 +28,8 @@ export interface TerminalMeta {
|
||||
status: TerminalStatus;
|
||||
/** Number of unread alerts (errors, warnings) */
|
||||
alertCount: number;
|
||||
/** Session tag for grouping (e.g., "gemini-143052") */
|
||||
tag?: string;
|
||||
/** Whether the session is locked (executing a workflow) */
|
||||
isLocked?: boolean;
|
||||
/** Reason for the lock (e.g., workflow name) */
|
||||
|
||||
Reference in New Issue
Block a user