mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: Implement phases 6 to 9 of the review cycle fix process, including discovery, batching, parallel planning, execution, and completion
- Added Phase 6: Fix Discovery & Batching with intelligent grouping and batching of findings. - Added Phase 7: Fix Parallel Planning to launch planning agents for concurrent analysis and aggregation of partial plans. - Added Phase 8: Fix Execution for stage-based execution of fixes with conservative test verification. - Added Phase 9: Fix Completion to aggregate results, generate summary reports, and handle session completion. - Introduced new frontend components: ResizeHandle for draggable resizing of sidebar panels and useResizablePanel hook for managing panel sizes with localStorage persistence. - Added PowerShell script for checking TypeScript errors in source code, excluding test files.
This commit is contained in:
160
ccw/frontend/src/components/orchestrator/ExecutionHeader.tsx
Normal file
160
ccw/frontend/src/components/orchestrator/ExecutionHeader.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
// ========================================
|
||||
// Execution Header Component
|
||||
// ========================================
|
||||
// Displays execution overview with status badge, progress bar, duration, and current node
|
||||
|
||||
import { Clock, ArrowRight, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ExecutionState } from '@/types/execution';
|
||||
import type { NodeExecutionState } from '@/types/execution';
|
||||
|
||||
interface ExecutionHeaderProps {
|
||||
/** Current execution state */
|
||||
execution: ExecutionState | null;
|
||||
/** Node execution states keyed by node ID */
|
||||
nodeStates: Record<string, NodeExecutionState>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status badge component showing execution status
|
||||
*/
|
||||
function StatusBadge({ status }: { status: ExecutionState['status'] }) {
|
||||
const config = {
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
className: 'bg-muted text-muted-foreground border-border',
|
||||
},
|
||||
running: {
|
||||
label: 'Running',
|
||||
className: 'bg-primary/10 text-primary border-primary/50',
|
||||
},
|
||||
paused: {
|
||||
label: 'Paused',
|
||||
className: 'bg-amber-500/10 text-amber-500 border-amber-500/50',
|
||||
},
|
||||
completed: {
|
||||
label: 'Completed',
|
||||
className: 'bg-green-500/10 text-green-500 border-green-500/50',
|
||||
},
|
||||
failed: {
|
||||
label: 'Failed',
|
||||
className: 'bg-destructive/10 text-destructive border-destructive/50',
|
||||
},
|
||||
};
|
||||
|
||||
const { label, className } = config[status];
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-md text-xs font-medium border',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human-readable string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.floor((ms % 60000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ExecutionHeader component displays the execution overview
|
||||
*
|
||||
* Shows:
|
||||
* - Status badge (pending/running/completed/failed)
|
||||
* - Progress bar with completion percentage
|
||||
* - Elapsed time
|
||||
* - Current executing node (if any)
|
||||
* - Error message (if failed)
|
||||
*/
|
||||
export function ExecutionHeader({ execution, nodeStates }: ExecutionHeaderProps) {
|
||||
if (!execution) {
|
||||
return (
|
||||
<div className="p-4 border-b border-border">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
No execution in progress
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate progress
|
||||
const completedCount = Object.values(nodeStates).filter(
|
||||
(n) => n.status === 'completed'
|
||||
).length;
|
||||
const totalCount = Object.keys(nodeStates).length;
|
||||
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0;
|
||||
|
||||
// Get current node info
|
||||
const currentNodeState = execution.currentNodeId
|
||||
? nodeStates[execution.currentNodeId]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-border space-y-3">
|
||||
{/* Status and Progress */}
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusBadge status={execution.status} />
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="flex-1">
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full transition-all duration-300 ease-out',
|
||||
execution.status === 'failed' && 'bg-destructive',
|
||||
execution.status === 'completed' && 'bg-green-500',
|
||||
(execution.status === 'running' || execution.status === 'pending') &&
|
||||
'bg-primary'
|
||||
)}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Completion count */}
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{completedCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Details: Duration, Current Node, Error */}
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
{/* Elapsed time */}
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="tabular-nums">{formatDuration(execution.elapsedMs)}</span>
|
||||
</div>
|
||||
|
||||
{/* Current node */}
|
||||
{currentNodeState && execution.status === 'running' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Current:</span>
|
||||
<span className="font-medium">{execution.currentNodeId}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{execution.status === 'failed' && (
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>Execution failed</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ExecutionHeader.displayName = 'ExecutionHeader';
|
||||
@@ -0,0 +1,72 @@
|
||||
// ========================================
|
||||
// ExecutionMonitor Integration Example
|
||||
// ========================================
|
||||
// This file demonstrates how to use ExecutionHeader and NodeExecutionChain components
|
||||
// in a typical execution monitoring scenario
|
||||
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { ExecutionHeader, NodeExecutionChain } from '@/components/orchestrator';
|
||||
|
||||
/**
|
||||
* Example execution monitor component
|
||||
*
|
||||
* This example shows how to integrate ExecutionHeader and NodeExecutionChain
|
||||
* with the executionStore and flowStore.
|
||||
*/
|
||||
export function ExecutionMonitorExample() {
|
||||
// Get execution state from executionStore
|
||||
const currentExecution = useExecutionStore((state) => state.currentExecution);
|
||||
const nodeStates = useExecutionStore((state) => state.nodeStates);
|
||||
const selectedNodeId = useExecutionStore((state) => state.selectedNodeId);
|
||||
const selectNode = useExecutionStore((state) => state.selectNode);
|
||||
|
||||
// Get flow nodes from flowStore
|
||||
const nodes = useFlowStore((state) => state.nodes);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Execution Overview Header */}
|
||||
<ExecutionHeader
|
||||
execution={currentExecution}
|
||||
nodeStates={nodeStates}
|
||||
/>
|
||||
|
||||
{/* Node Execution Chain */}
|
||||
<NodeExecutionChain
|
||||
nodes={nodes}
|
||||
nodeStates={nodeStates}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onNodeSelect={selectNode}
|
||||
/>
|
||||
|
||||
{/* Rest of the monitor UI would go here */}
|
||||
{/* - Node Detail Panel */}
|
||||
{/* - Tool Calls Timeline */}
|
||||
{/* - Global Logs */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Integration Notes:
|
||||
*
|
||||
* 1. ExecutionHeader requires:
|
||||
* - execution: ExecutionState from executionStore.currentExecution
|
||||
* - nodeStates: Record<string, NodeExecutionState> from executionStore.nodeStates
|
||||
*
|
||||
* 2. NodeExecutionChain requires:
|
||||
* - nodes: FlowNode[] from flowStore.nodes
|
||||
* - nodeStates: Record<string, NodeExecutionState> from executionStore.nodeStates
|
||||
* - selectedNodeId: string | null from executionStore.selectedNodeId
|
||||
* - onNodeSelect: (nodeId: string) => void (use executionStore.selectNode)
|
||||
*
|
||||
* 3. Data flow:
|
||||
* - WebSocket messages update executionStore
|
||||
* - ExecutionHeader reacts to execution state changes
|
||||
* - NodeExecutionChain reacts to node state changes
|
||||
* - Clicking a node calls selectNode, updating selectedNodeId
|
||||
* - Selected node can be used to show detail panel
|
||||
*/
|
||||
|
||||
export default ExecutionMonitorExample;
|
||||
425
ccw/frontend/src/components/orchestrator/NodeDetailPanel.tsx
Normal file
425
ccw/frontend/src/components/orchestrator/NodeDetailPanel.tsx
Normal file
@@ -0,0 +1,425 @@
|
||||
// ========================================
|
||||
// Node Detail Panel Component
|
||||
// ========================================
|
||||
// Tab panel displaying node execution details: Output, Tool Calls, Logs, Variables
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Terminal,
|
||||
Wrench,
|
||||
FileText,
|
||||
Database,
|
||||
FileText as FileTextIcon,
|
||||
Circle,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StreamingOutput } from '@/components/shared/StreamingOutput';
|
||||
import { ToolCallsTimeline } from './ToolCallsTimeline';
|
||||
import type { ExecutionLog, NodeExecutionOutput, NodeExecutionState } from '@/types/execution';
|
||||
import type { ToolCallExecution } from '@/types/toolCall';
|
||||
import type { CliOutputLine } from '@/stores/cliStreamStore';
|
||||
import type { FlowNode } from '@/types/flow';
|
||||
|
||||
// ========== Tab Types ==========
|
||||
|
||||
type DetailTabId = 'output' | 'toolCalls' | 'logs' | 'variables';
|
||||
|
||||
interface DetailTab {
|
||||
id: DetailTabId;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const DETAIL_TABS: DetailTab[] = [
|
||||
{ id: 'output', label: 'Output Stream', icon: Terminal },
|
||||
{ id: 'toolCalls', label: 'Tool Calls', icon: Wrench },
|
||||
{ id: 'logs', label: 'Logs', icon: FileText },
|
||||
{ id: 'variables', label: 'Variables', icon: Database },
|
||||
];
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Get log level color class
|
||||
*/
|
||||
function getLogLevelColor(level: ExecutionLog['level']): string {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'text-red-500 bg-red-500/10';
|
||||
case 'warn':
|
||||
return 'text-yellow-600 bg-yellow-500/10 dark:text-yellow-500';
|
||||
case 'info':
|
||||
return 'text-blue-500 bg-blue-500/10';
|
||||
case 'debug':
|
||||
return 'text-gray-500 bg-gray-500/10';
|
||||
default:
|
||||
return 'text-foreground bg-muted';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to locale time string
|
||||
*/
|
||||
function formatLogTimestamp(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Tab Components ==========
|
||||
|
||||
interface OutputTabProps {
|
||||
outputs: CliOutputLine[];
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
function OutputTab({ outputs, isStreaming }: OutputTabProps) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<StreamingOutput
|
||||
outputs={outputs}
|
||||
isStreaming={isStreaming}
|
||||
autoScroll={true}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCallsTabProps {
|
||||
toolCalls: ToolCallExecution[];
|
||||
onToggleExpand: (callId: string) => void;
|
||||
}
|
||||
|
||||
function ToolCallsTab({ toolCalls, onToggleExpand }: ToolCallsTabProps) {
|
||||
return (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<ToolCallsTimeline
|
||||
toolCalls={toolCalls}
|
||||
onToggleExpand={onToggleExpand}
|
||||
className="p-3"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface LogsTabProps {
|
||||
logs: ExecutionLog[];
|
||||
}
|
||||
|
||||
function LogsTab({ logs }: LogsTabProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
useEffect(() => {
|
||||
if (logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
if (logs.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<FileTextIcon className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No logs for this node</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full overflow-y-auto p-3 font-mono text-xs">
|
||||
<div className="space-y-1">
|
||||
{logs.map((log, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex gap-2 p-2 rounded',
|
||||
getLogLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
<span className="shrink-0 opacity-70">
|
||||
{formatLogTimestamp(log.timestamp)}
|
||||
</span>
|
||||
<span className="shrink-0 font-semibold opacity-80 uppercase">
|
||||
[{log.level}]
|
||||
</span>
|
||||
<span className="flex-1 break-all">{log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VariablesTabProps {
|
||||
node: FlowNode;
|
||||
nodeOutput: NodeExecutionOutput | undefined;
|
||||
nodeState: NodeExecutionState | undefined;
|
||||
}
|
||||
|
||||
function VariablesTab({ node, nodeOutput, nodeState }: VariablesTabProps) {
|
||||
const variables = useMemo(() => {
|
||||
const vars: Record<string, unknown> = {};
|
||||
|
||||
// Add outputName if available
|
||||
if (node.data.outputName) {
|
||||
vars[`{{${node.data.outputName}}}`] = nodeState?.result ?? '<pending>';
|
||||
} else if (nodeState?.result) {
|
||||
// Also add result if available even without outputName
|
||||
vars['result'] = nodeState.result;
|
||||
}
|
||||
|
||||
// Add any variables stored in nodeOutput
|
||||
if (nodeOutput?.variables) {
|
||||
Object.entries(nodeOutput.variables).forEach(([key, value]) => {
|
||||
vars[`{{${key}}}`] = value;
|
||||
});
|
||||
}
|
||||
|
||||
// Add execution metadata
|
||||
if (nodeOutput) {
|
||||
vars['_execution'] = {
|
||||
startTime: new Date(nodeOutput.startTime).toISOString(),
|
||||
endTime: nodeOutput.endTime ? new Date(nodeOutput.endTime).toISOString() : '<running>',
|
||||
outputCount: nodeOutput.outputs.length,
|
||||
toolCallCount: nodeOutput.toolCalls.length,
|
||||
logCount: nodeOutput.logs.length,
|
||||
};
|
||||
}
|
||||
|
||||
return vars;
|
||||
}, [node, nodeOutput, nodeState]);
|
||||
|
||||
const variableEntries = useMemo(() => {
|
||||
return Object.entries(variables).sort(([a], [b]) => a.localeCompare(b));
|
||||
}, [variables]);
|
||||
|
||||
if (variableEntries.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Database className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No variables defined</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-3">
|
||||
<div className="space-y-2">
|
||||
{variableEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="p-2 bg-muted/30 rounded border border-border"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs font-mono text-primary font-semibold">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="text-xs text-muted-foreground overflow-x-auto">
|
||||
{typeof value === 'object'
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
interface NodeDetailPanelProps {
|
||||
/** Currently selected node */
|
||||
node: FlowNode | null;
|
||||
/** Node execution output data */
|
||||
nodeOutput: NodeExecutionOutput | undefined;
|
||||
/** Node execution state */
|
||||
nodeState: NodeExecutionState | undefined;
|
||||
/** Tool calls for this node */
|
||||
toolCalls: ToolCallExecution[];
|
||||
/** Whether the node is currently executing */
|
||||
isExecuting: boolean;
|
||||
/** Callback to toggle tool call expand */
|
||||
onToggleToolCallExpand: (callId: string) => void;
|
||||
/** Optional CSS class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* NodeDetailPanel displays detailed information about a selected node
|
||||
*
|
||||
* Features:
|
||||
* - Tab-based layout (Output/Tool Calls/Logs/Variables)
|
||||
* - Auto-scroll to bottom for output/logs
|
||||
* - Expandable tool call cards
|
||||
* - Variable inspection
|
||||
*/
|
||||
export function NodeDetailPanel({
|
||||
node,
|
||||
nodeOutput,
|
||||
nodeState,
|
||||
toolCalls,
|
||||
isExecuting,
|
||||
onToggleToolCallExpand,
|
||||
className,
|
||||
}: NodeDetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<DetailTabId>('output');
|
||||
|
||||
// Reset to output tab when node changes
|
||||
useEffect(() => {
|
||||
setActiveTab('output');
|
||||
}, [node?.id]);
|
||||
|
||||
// Handle tab change
|
||||
const handleTabChange = useCallback((tabId: DetailTabId) => {
|
||||
setActiveTab(tabId);
|
||||
}, []);
|
||||
|
||||
// Handle toggle tool call expand
|
||||
const handleToggleToolCallExpand = useCallback(
|
||||
(callId: string) => {
|
||||
onToggleToolCallExpand(callId);
|
||||
},
|
||||
[onToggleToolCallExpand]
|
||||
);
|
||||
|
||||
// If no node selected, show empty state
|
||||
if (!node) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'h-64 border-t border-border flex items-center justify-center',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Circle className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">Select a node to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const outputs = nodeOutput?.outputs ?? [];
|
||||
const logs = nodeOutput?.logs ?? [];
|
||||
|
||||
// Render active tab content
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'output':
|
||||
return <OutputTab outputs={outputs} isStreaming={isExecuting} />;
|
||||
case 'toolCalls':
|
||||
return (
|
||||
<ToolCallsTab
|
||||
toolCalls={toolCalls}
|
||||
onToggleExpand={handleToggleToolCallExpand}
|
||||
/>
|
||||
);
|
||||
case 'logs':
|
||||
return <LogsTab logs={logs} />;
|
||||
case 'variables':
|
||||
return <VariablesTab node={node} nodeOutput={nodeOutput} nodeState={nodeState} />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get tab counts for badges
|
||||
const tabCounts = {
|
||||
output: outputs.length,
|
||||
toolCalls: toolCalls.length,
|
||||
logs: logs.length,
|
||||
variables: 1, // At least outputName or execution metadata
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('h-64 border-t border-border flex flex-col', className)}>
|
||||
{/* Tab Headers */}
|
||||
<div className="flex items-center gap-1 px-2 pt-2 border-b border-border shrink-0">
|
||||
{DETAIL_TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.id;
|
||||
const count = tabCounts[tab.id];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => handleTabChange(tab.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 rounded-t-lg text-xs font-medium transition-colors',
|
||||
'border-b-2 -mb-px',
|
||||
isActive
|
||||
? 'border-primary text-primary bg-primary/5'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
<span>{tab.label}</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-full text-[10px] font-medium',
|
||||
isActive
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Node status indicator */}
|
||||
{nodeState && (
|
||||
<div className="px-3 py-1.5 border-b border-border bg-muted/30 shrink-0 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{nodeState.status === 'running' && (
|
||||
<Loader2 className="h-3.5 w-3.5 text-primary animate-spin" />
|
||||
)}
|
||||
{nodeState.status === 'completed' && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
|
||||
)}
|
||||
{nodeState.status === 'failed' && (
|
||||
<XCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Status: <span className="font-medium text-foreground capitalize">{nodeState.status}</span>
|
||||
</span>
|
||||
</div>
|
||||
{nodeState.error && (
|
||||
<div className="flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span className="truncate max-w-[200px]">{nodeState.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
NodeDetailPanel.displayName = 'NodeDetailPanel';
|
||||
|
||||
export default NodeDetailPanel;
|
||||
145
ccw/frontend/src/components/orchestrator/NodeExecutionChain.tsx
Normal file
145
ccw/frontend/src/components/orchestrator/NodeExecutionChain.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
// ========================================
|
||||
// Node Execution Chain Component
|
||||
// ========================================
|
||||
// Horizontal chain display of all nodes with execution status
|
||||
|
||||
import { Circle, Loader2, CheckCircle2, XCircle, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FlowNode } from '@/types/flow';
|
||||
import type { NodeExecutionState } from '@/types/execution';
|
||||
|
||||
interface NodeExecutionChainProps {
|
||||
/** All nodes in the flow */
|
||||
nodes: FlowNode[];
|
||||
/** Node execution states keyed by node ID */
|
||||
nodeStates: Record<string, NodeExecutionState>;
|
||||
/** Currently selected node ID */
|
||||
selectedNodeId: string | null;
|
||||
/** Callback when a node is clicked */
|
||||
onNodeSelect: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status icon for a node
|
||||
*/
|
||||
function getNodeStatusIcon(status?: NodeExecutionState['status']) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Circle className="w-3.5 h-3.5 text-muted-foreground" />;
|
||||
case 'running':
|
||||
return <Loader2 className="w-3.5 h-3.5 text-primary animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-3.5 h-3.5 text-destructive" />;
|
||||
default:
|
||||
return <Circle className="w-3.5 h-3.5 text-muted-foreground opacity-50" />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node card styles based on execution state
|
||||
*/
|
||||
function getNodeCardStyles(
|
||||
state: NodeExecutionState | undefined,
|
||||
isSelected: boolean
|
||||
): string {
|
||||
const baseStyles = cn(
|
||||
'px-3 py-2 rounded-lg border transition-all duration-200',
|
||||
'hover:bg-muted/50 hover:border-primary/50',
|
||||
'min-w-[140px] max-w-[180px]'
|
||||
);
|
||||
|
||||
const stateStyles = cn(
|
||||
!state && 'border-border opacity-50',
|
||||
state?.status === 'running' &&
|
||||
'border-primary bg-primary/5 animate-pulse shadow-sm shadow-primary/20',
|
||||
state?.status === 'completed' &&
|
||||
'border-green-500/50 bg-green-500/5',
|
||||
state?.status === 'failed' &&
|
||||
'border-destructive bg-destructive/10',
|
||||
state?.status === 'pending' &&
|
||||
'border-muted-foreground/30'
|
||||
);
|
||||
|
||||
const selectedStyles = isSelected
|
||||
? 'border-primary bg-primary/10 ring-2 ring-primary/20'
|
||||
: '';
|
||||
|
||||
return cn(baseStyles, stateStyles, selectedStyles);
|
||||
}
|
||||
|
||||
/**
|
||||
* NodeExecutionChain displays a horizontal chain of all nodes
|
||||
*
|
||||
* Features:
|
||||
* - Nodes arranged horizontally with arrow connectors
|
||||
* - Visual status indicators (pending/running/completed/failed)
|
||||
* - Pulse animation for running nodes
|
||||
* - Click to select a node
|
||||
* - Selected node highlighting
|
||||
*/
|
||||
export function NodeExecutionChain({
|
||||
nodes,
|
||||
nodeStates,
|
||||
selectedNodeId,
|
||||
onNodeSelect,
|
||||
}: NodeExecutionChainProps) {
|
||||
if (nodes.length === 0) {
|
||||
return (
|
||||
<div className="p-4 border-b border-border">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
No nodes in flow
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 border-b border-border overflow-x-auto">
|
||||
<div className="flex items-center gap-2 min-w-max">
|
||||
{nodes.map((node, index) => {
|
||||
const state = nodeStates[node.id];
|
||||
const isSelected = selectedNodeId === node.id;
|
||||
const nodeLabel = node.data.label || node.id;
|
||||
|
||||
return (
|
||||
<div key={node.id} className="flex items-center gap-2">
|
||||
{/* Node card */}
|
||||
<button
|
||||
onClick={() => onNodeSelect(node.id)}
|
||||
className={getNodeCardStyles(state, isSelected)}
|
||||
type="button"
|
||||
aria-label={`Select node ${nodeLabel}`}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status icon */}
|
||||
{getNodeStatusIcon(state?.status)}
|
||||
|
||||
{/* Node label */}
|
||||
<span className="text-sm font-medium truncate">
|
||||
{nodeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Connector arrow */}
|
||||
{index < nodes.length - 1 && (
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'w-4 h-4 flex-shrink-0',
|
||||
'text-muted-foreground/50'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
NodeExecutionChain.displayName = 'NodeExecutionChain';
|
||||
110
ccw/frontend/src/components/orchestrator/README.md
Normal file
110
ccw/frontend/src/components/orchestrator/README.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Orchestrator Components
|
||||
|
||||
## Tool Call Timeline Components
|
||||
|
||||
### Components
|
||||
|
||||
#### `ToolCallCard`
|
||||
Expandable card displaying tool call details with status, output, and results.
|
||||
|
||||
**Props:**
|
||||
- `toolCall`: `ToolCallExecution` - Tool call execution data
|
||||
- `isExpanded`: `boolean` - Whether the card is expanded
|
||||
- `onToggle`: `() => void` - Callback when toggle expand/collapse
|
||||
- `className?`: `string` - Optional CSS class name
|
||||
|
||||
**Features:**
|
||||
- Status icon (pending/executing/success/error/canceled)
|
||||
- Kind icon (execute/patch/thinking/web_search/mcp_tool/file_operation)
|
||||
- Duration display
|
||||
- Expand/collapse animation
|
||||
- stdout/stderr output with syntax highlighting
|
||||
- Exit code badge
|
||||
- Error message display
|
||||
- Result display
|
||||
|
||||
#### `ToolCallsTimeline`
|
||||
Vertical timeline displaying tool calls in chronological order.
|
||||
|
||||
**Props:**
|
||||
- `toolCalls`: `ToolCallExecution[]` - Array of tool call executions
|
||||
- `onToggleExpand`: `(callId: string) => void` - Callback when tool call toggled
|
||||
- `className?`: `string` - Optional CSS class name
|
||||
|
||||
**Features:**
|
||||
- Chronological sorting by start time
|
||||
- Timeline dot with status color
|
||||
- Auto-expand executing tool calls
|
||||
- Auto-scroll to executing tool call
|
||||
- Empty state with icon
|
||||
- Summary statistics (total/success/error/running)
|
||||
- Loading indicator when tools are executing
|
||||
|
||||
### Usage Example
|
||||
|
||||
```tsx
|
||||
import { ToolCallsTimeline } from '@/components/orchestrator';
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
|
||||
function ToolCallsTab({ nodeId }: { nodeId: string }) {
|
||||
const toolCalls = useExecutionStore((state) =>
|
||||
state.getToolCallsForNode(nodeId)
|
||||
);
|
||||
const toggleToolCallExpanded = useExecutionStore(
|
||||
(state) => state.toggleToolCallExpanded
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<ToolCallsTimeline
|
||||
toolCalls={toolCalls}
|
||||
onToggleExpand={(callId) => toggleToolCallExpanded(nodeId, callId)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Integration with ExecutionStore
|
||||
|
||||
The components integrate with `executionStore` for state management:
|
||||
|
||||
```tsx
|
||||
// Get tool calls for a node
|
||||
const toolCalls = useExecutionStore((state) =>
|
||||
state.getToolCallsForNode(nodeId)
|
||||
);
|
||||
|
||||
// Toggle expand state
|
||||
const handleToggle = (callId: string) => {
|
||||
useExecutionStore.getState().toggleToolCallExpanded(nodeId, callId);
|
||||
};
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
WebSocket Message
|
||||
│
|
||||
▼
|
||||
useWebSocket (parsing)
|
||||
│
|
||||
▼
|
||||
executionStore.startToolCall()
|
||||
│
|
||||
▼
|
||||
ToolCallsTimeline (re-render)
|
||||
│
|
||||
▼
|
||||
ToolCallCard (display)
|
||||
```
|
||||
|
||||
### Styling
|
||||
|
||||
Components use Tailwind CSS with the following conventions:
|
||||
- `border-border` - Border color
|
||||
- `bg-muted` - Muted background
|
||||
- `text-destructive` - Error text color
|
||||
- `text-green-500` - Success text color
|
||||
- `text-primary` - Primary text color
|
||||
- `animate-pulse` - Pulse animation for executing status
|
||||
317
ccw/frontend/src/components/orchestrator/ToolCallCard.tsx
Normal file
317
ccw/frontend/src/components/orchestrator/ToolCallCard.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
// ========================================
|
||||
// Tool Call Card Component
|
||||
// ========================================
|
||||
// Expandable card displaying tool call details with status, output, and results
|
||||
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
Terminal,
|
||||
Wrench,
|
||||
FileEdit,
|
||||
Brain,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ToolCallExecution } from '@/types/toolCall';
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Get status icon for tool call
|
||||
*/
|
||||
function getToolCallStatusIcon(status: ToolCallExecution['status']) {
|
||||
const iconClassName = 'h-4 w-4';
|
||||
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Clock className={cn(iconClassName, 'text-muted-foreground')} />;
|
||||
case 'executing':
|
||||
return <Loader2 className={cn(iconClassName, 'text-primary animate-spin')} />;
|
||||
case 'success':
|
||||
return <CheckCircle2 className={cn(iconClassName, 'text-green-500')} />;
|
||||
case 'error':
|
||||
return <AlertCircle className={cn(iconClassName, 'text-destructive')} />;
|
||||
case 'canceled':
|
||||
return <XCircle className={cn(iconClassName, 'text-muted-foreground')} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get kind icon for tool call
|
||||
*/
|
||||
function getToolCallKindIcon(kind: ToolCallExecution['kind']) {
|
||||
const iconClassName = 'h-3.5 w-3.5';
|
||||
|
||||
switch (kind) {
|
||||
case 'execute':
|
||||
return <Terminal className={iconClassName} />;
|
||||
case 'patch':
|
||||
return <FileEdit className={iconClassName} />;
|
||||
case 'thinking':
|
||||
return <Brain className={iconClassName} />;
|
||||
case 'web_search':
|
||||
return <Search className={iconClassName} />;
|
||||
case 'mcp_tool':
|
||||
return <Wrench className={iconClassName} />;
|
||||
case 'file_operation':
|
||||
return <FileText className={iconClassName} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get kind label for tool call
|
||||
*/
|
||||
function getToolCallKindLabel(kind: ToolCallExecution['kind']): string {
|
||||
switch (kind) {
|
||||
case 'execute':
|
||||
return 'Execute';
|
||||
case 'patch':
|
||||
return 'Patch';
|
||||
case 'thinking':
|
||||
return 'Thinking';
|
||||
case 'web_search':
|
||||
return 'Web Search';
|
||||
case 'mcp_tool':
|
||||
return 'MCP Tool';
|
||||
case 'file_operation':
|
||||
return 'File Operation';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get border color class for tool call status
|
||||
*/
|
||||
function getToolCallBorderClass(status: ToolCallExecution['status']): string {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'border-l-2 border-l-yellow-500/50';
|
||||
case 'executing':
|
||||
return 'border-l-2 border-l-blue-500';
|
||||
case 'success':
|
||||
return 'border-l-2 border-l-green-500';
|
||||
case 'error':
|
||||
return 'border-l-2 border-l-destructive';
|
||||
case 'canceled':
|
||||
return 'border-l-2 border-l-muted-foreground/50';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format duration in milliseconds to human readable string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}m ${remainingSeconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate duration from start and end time
|
||||
*/
|
||||
function calculateDuration(startTime: number, endTime?: number): string {
|
||||
const duration = (endTime || Date.now()) - startTime;
|
||||
return formatDuration(duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp to locale time string
|
||||
*/
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Component Interfaces ==========
|
||||
|
||||
export interface ToolCallCardProps {
|
||||
/** Tool call execution data */
|
||||
toolCall: ToolCallExecution;
|
||||
/** Whether the card is expanded */
|
||||
isExpanded?: boolean;
|
||||
/** Callback when toggle expand/collapse */
|
||||
onToggle: () => void;
|
||||
/** Optional CSS class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export const ToolCallCard = memo(function ToolCallCard({
|
||||
toolCall,
|
||||
isExpanded = false,
|
||||
onToggle,
|
||||
className,
|
||||
}: ToolCallCardProps) {
|
||||
const duration = calculateDuration(toolCall.startTime, toolCall.endTime);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border border-border rounded-lg overflow-hidden transition-colors',
|
||||
getToolCallBorderClass(toolCall.status),
|
||||
toolCall.status === 'executing' && 'animate-pulse-subtle',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 cursor-pointer transition-colors',
|
||||
'hover:bg-muted/30'
|
||||
)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{/* Expand/Collapse Icon */}
|
||||
<div className="shrink-0">
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Icon */}
|
||||
<div className="shrink-0">{getToolCallStatusIcon(toolCall.status)}</div>
|
||||
|
||||
{/* Kind Icon */}
|
||||
<div className="shrink-0 text-muted-foreground">
|
||||
{getToolCallKindIcon(toolCall.kind)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{toolCall.description}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getToolCallKindLabel(toolCall.kind)}
|
||||
</p>
|
||||
{toolCall.subtype && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">·</span>
|
||||
<p className="text-xs text-muted-foreground">{toolCall.subtype}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div className="text-xs text-muted-foreground font-mono shrink-0">
|
||||
{duration}
|
||||
</div>
|
||||
|
||||
{/* Exit Code Badge (if completed) */}
|
||||
{toolCall.exitCode !== undefined && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs font-mono px-2 py-0.5 rounded shrink-0',
|
||||
toolCall.exitCode === 0
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: 'bg-destructive/10 text-destructive'
|
||||
)}
|
||||
>
|
||||
Exit: {toolCall.exitCode}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-3 bg-muted/20 space-y-3">
|
||||
{/* Metadata */}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span>Started: {formatTimestamp(toolCall.startTime)}</span>
|
||||
{toolCall.endTime && (
|
||||
<span>Ended: {formatTimestamp(toolCall.endTime)}</span>
|
||||
)}
|
||||
<span>Duration: {duration}</span>
|
||||
</div>
|
||||
|
||||
{/* stdout Output */}
|
||||
{toolCall.outputBuffer.stdout && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1.5 flex items-center gap-1.5">
|
||||
<Terminal className="h-3 w-3" />
|
||||
Output (stdout):
|
||||
</div>
|
||||
<pre className="text-xs bg-background rounded border border-border p-2 overflow-x-auto max-h-48 overflow-y-auto">
|
||||
{toolCall.outputBuffer.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* stderr Output */}
|
||||
{toolCall.outputBuffer.stderr && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-destructive mb-1.5 flex items-center gap-1.5">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
Error Output (stderr):
|
||||
</div>
|
||||
<pre className="text-xs bg-destructive/10 text-destructive rounded border border-destructive/20 p-2 overflow-x-auto max-h-48 overflow-y-auto">
|
||||
{toolCall.outputBuffer.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{toolCall.error && (
|
||||
<div className="text-xs text-destructive bg-destructive/10 rounded p-2 border border-destructive/20">
|
||||
<span className="font-medium">Error: </span>
|
||||
{toolCall.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result Display (if available) */}
|
||||
{toolCall.result !== undefined && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1.5">
|
||||
Result:
|
||||
</div>
|
||||
<pre className="text-xs bg-background rounded border border-border p-2 overflow-x-auto max-h-48 overflow-y-auto">
|
||||
{typeof toolCall.result === 'string'
|
||||
? toolCall.result
|
||||
: JSON.stringify(toolCall.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output Lines Count */}
|
||||
{toolCall.outputLines.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{toolCall.outputLines.length} output line{toolCall.outputLines.length !== 1 ? 's' : ''} captured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison for performance optimization
|
||||
return (
|
||||
prevProps.isExpanded === nextProps.isExpanded &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.toolCall.callId === nextProps.toolCall.callId &&
|
||||
prevProps.toolCall.status === nextProps.toolCall.status &&
|
||||
prevProps.toolCall.description === nextProps.toolCall.description &&
|
||||
prevProps.toolCall.endTime === nextProps.toolCall.endTime &&
|
||||
prevProps.toolCall.exitCode === nextProps.toolCall.exitCode &&
|
||||
prevProps.toolCall.error === nextProps.toolCall.error &&
|
||||
prevProps.toolCall.outputBuffer.stdout === nextProps.toolCall.outputBuffer.stdout &&
|
||||
prevProps.toolCall.outputBuffer.stderr === nextProps.toolCall.outputBuffer.stderr
|
||||
);
|
||||
});
|
||||
|
||||
export default ToolCallCard;
|
||||
233
ccw/frontend/src/components/orchestrator/ToolCallsTimeline.tsx
Normal file
233
ccw/frontend/src/components/orchestrator/ToolCallsTimeline.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
// ========================================
|
||||
// Tool Calls Timeline Component
|
||||
// ========================================
|
||||
// Vertical timeline displaying tool calls in chronological order
|
||||
|
||||
import React, { memo, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Wrench, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ToolCallCard } from './ToolCallCard';
|
||||
import type { ToolCallExecution } from '@/types/toolCall';
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
/**
|
||||
* Format timestamp to locale time string
|
||||
*/
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timeline dot color based on tool call status
|
||||
*/
|
||||
function getTimelineDotClass(status: ToolCallExecution['status']): string {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-500';
|
||||
case 'executing':
|
||||
return 'bg-blue-500 animate-ping';
|
||||
case 'success':
|
||||
return 'bg-green-500';
|
||||
case 'error':
|
||||
return 'bg-destructive';
|
||||
case 'canceled':
|
||||
return 'bg-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tool call is currently executing
|
||||
*/
|
||||
function isExecutingToolCall(toolCall: ToolCallExecution): boolean {
|
||||
return toolCall.status === 'executing' || toolCall.status === 'pending';
|
||||
}
|
||||
|
||||
// ========== Component Interfaces ==========
|
||||
|
||||
export interface ToolCallsTimelineProps {
|
||||
/** Array of tool call executions to display */
|
||||
toolCalls: ToolCallExecution[];
|
||||
/** Callback when a tool call is toggled (expanded/collapsed) */
|
||||
onToggleExpand: (callId: string) => void;
|
||||
/** Optional CSS class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// ========== Internal Components ==========
|
||||
|
||||
interface ToolCallTimelineItemProps {
|
||||
/** Tool call execution data */
|
||||
call: ToolCallExecution;
|
||||
/** Callback when toggle expand/collapse */
|
||||
onToggle: () => void;
|
||||
/** Whether this is the last item in timeline */
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual timeline item with timestamp and tool call card
|
||||
*/
|
||||
const ToolCallTimelineItem = memo(function ToolCallTimelineItem({
|
||||
call,
|
||||
onToggle,
|
||||
isLast,
|
||||
}: ToolCallTimelineItemProps) {
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to this item if it's executing
|
||||
useEffect(() => {
|
||||
if (isExecutingToolCall(call) && itemRef.current) {
|
||||
itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}, [call.status]);
|
||||
|
||||
return (
|
||||
<div ref={itemRef} className="relative pl-6 pb-1">
|
||||
{/* Timeline vertical line */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-2 w-px bg-border',
|
||||
!isLast && 'bottom-0'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute left-0 top-2.5 w-2 h-2 rounded-full',
|
||||
'border-2 border-background',
|
||||
getTimelineDotClass(call.status)
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="text-xs text-muted-foreground mb-1.5 font-mono">
|
||||
{formatTimestamp(call.startTime)}
|
||||
</div>
|
||||
|
||||
{/* Tool Call Card */}
|
||||
<ToolCallCard
|
||||
toolCall={call}
|
||||
isExpanded={call.isExpanded}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison for performance
|
||||
return (
|
||||
prevProps.isLast === nextProps.isLast &&
|
||||
prevProps.call.callId === nextProps.call.callId &&
|
||||
prevProps.call.status === nextProps.call.status &&
|
||||
prevProps.call.isExpanded === nextProps.call.isExpanded &&
|
||||
prevProps.call.endTime === nextProps.call.endTime
|
||||
);
|
||||
});
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function ToolCallsTimeline({
|
||||
toolCalls,
|
||||
onToggleExpand,
|
||||
className,
|
||||
}: ToolCallsTimelineProps) {
|
||||
// Auto-expand executing tool calls
|
||||
const adjustedToolCalls = useMemo(() => {
|
||||
return toolCalls.map((call) => {
|
||||
// Auto-expand if executing
|
||||
if (isExecutingToolCall(call) && !call.isExpanded) {
|
||||
return { ...call, isExpanded: true };
|
||||
}
|
||||
return call;
|
||||
});
|
||||
}, [toolCalls]);
|
||||
|
||||
// Handle toggle expand
|
||||
const handleToggleExpand = useCallback(
|
||||
(callId: string) => {
|
||||
onToggleExpand(callId);
|
||||
},
|
||||
[onToggleExpand]
|
||||
);
|
||||
|
||||
// Sort tool calls by start time (chronological order)
|
||||
const sortedToolCalls = useMemo(() => {
|
||||
return [...adjustedToolCalls].sort((a, b) => a.startTime - b.startTime);
|
||||
}, [adjustedToolCalls]);
|
||||
|
||||
// Empty state
|
||||
if (sortedToolCalls.length === 0) {
|
||||
return (
|
||||
<div className={cn('p-8 text-center', className)}>
|
||||
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
||||
<Wrench className="h-12 w-12 opacity-50" />
|
||||
<p className="text-sm">暂无工具调用</p>
|
||||
<p className="text-xs">等待执行开始...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state (executing calls present)
|
||||
const hasExecutingCalls = sortedToolCalls.some((call) =>
|
||||
isExecutingToolCall(call)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1', className)}>
|
||||
{/* Status indicator */}
|
||||
{hasExecutingCalls && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 mb-2 text-xs text-primary bg-primary/5 rounded border border-primary/20">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<span>工具执行中...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline items */}
|
||||
{sortedToolCalls.map((call, index) => (
|
||||
<ToolCallTimelineItem
|
||||
key={call.callId}
|
||||
call={call}
|
||||
onToggle={() => handleToggleExpand(call.callId)}
|
||||
isLast={index === sortedToolCalls.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Summary stats */}
|
||||
{sortedToolCalls.length > 0 && (
|
||||
<div className="mt-4 px-3 py-2 text-xs text-muted-foreground bg-muted/30 rounded border border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Total: {sortedToolCalls.length} tool call{sortedToolCalls.length !== 1 ? 's' : ''}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
||||
Success: {sortedToolCalls.filter((c) => c.status === 'success').length}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-destructive" />
|
||||
Error: {sortedToolCalls.filter((c) => c.status === 'error').length}
|
||||
</span>
|
||||
{hasExecutingCalls && (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse" />
|
||||
Running: {sortedToolCalls.filter((c) => isExecutingToolCall(c)).length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolCallsTimeline;
|
||||
|
||||
// Re-export ToolCallCard for direct usage
|
||||
export { ToolCallCard } from './ToolCallCard';
|
||||
8
ccw/frontend/src/components/orchestrator/index.ts
Normal file
8
ccw/frontend/src/components/orchestrator/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// ========================================
|
||||
// Orchestrator Components Export
|
||||
// ========================================
|
||||
|
||||
export { ExecutionHeader } from './ExecutionHeader';
|
||||
export { NodeExecutionChain } from './NodeExecutionChain';
|
||||
export { ToolCallCard, type ToolCallCardProps } from './ToolCallCard';
|
||||
export { ToolCallsTimeline, type ToolCallsTimelineProps } from './ToolCallsTimeline';
|
||||
Reference in New Issue
Block a user