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:
catlog22
2026-02-07 19:28:33 +08:00
parent ba5f4eba84
commit d43696d756
90 changed files with 8462 additions and 616 deletions

View 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';

View File

@@ -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;

View 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;

View 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';

View 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

View 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;

View 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';

View 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';

View File

@@ -28,9 +28,8 @@ export function LogBlockList({ executionId, className }: LogBlockListProps) {
// Get blocks directly from store using the getBlocks selector
// This avoids duplicate logic and leverages store-side caching
const blocks = useCliStreamStore(
(state) => executionId ? state.getBlocks(executionId) : [],
(a: LogBlockData[], b: LogBlockData[]) => a === b // Shallow comparison - arrays are cached in store
);
(state) => executionId ? state.getBlocks(executionId) : []
) as LogBlockData[];
// Get execution status for empty state display
const currentExecution = useCliStreamStore((state) =>

View File

@@ -47,7 +47,7 @@ export interface ModalAction {
label: string;
icon?: React.ComponentType<{ className?: string }>;
onClick: (content: string) => void | Promise<void>;
variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'success';
variant?: 'default' | 'outline' | 'ghost' | 'destructive' | 'secondary';
disabled?: boolean;
}

View File

@@ -78,7 +78,7 @@ const statusLabelKeys: Record<SessionMetadata['status'], string> = {
// Type variant configuration for session type badges (unique colors for each type)
const typeVariantConfig: Record<
SessionMetadata['type'],
NonNullable<SessionMetadata['type']>,
{ variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info' | 'review'; icon: React.ElementType }
> = {
review: { variant: 'review', icon: Search }, // Purple
@@ -91,7 +91,7 @@ const typeVariantConfig: Record<
};
// Type label keys for i18n
const typeLabelKeys: Record<SessionMetadata['type'], string> = {
const typeLabelKeys: Record<NonNullable<SessionMetadata['type']>, string> = {
review: 'sessions.type.review',
tdd: 'sessions.type.tdd',
test: 'sessions.type.test',

View File

@@ -44,9 +44,9 @@ const ContextAssembler = React.forwardRef<HTMLDivElement, ContextAssemblerProps>
const nodeRegex = /\{\{node:([^}]+)\}\}/g;
const varRegex = /\{\{var:([^}]+)\}\}/g;
let match;
let match: RegExpExecArray | null;
while ((match = nodeRegex.exec(value)) !== null) {
const node = availableNodes.find((n) => n.id === match[1]);
const node = availableNodes.find((n) => n.id === match![1]);
extracted.push({
nodeId: match[1],
label: node?.label,
@@ -98,7 +98,7 @@ const ContextAssembler = React.forwardRef<HTMLDivElement, ContextAssemblerProps>
const addNode = (nodeId: string) => {
const node = availableNodes.find((n) => n.id === nodeId);
if (node && !rules.find((r) => r.nodeId === nodeId)) {
const newRules = [...rules, { nodeId, label: node.label, variable: node.outputVariable, includeOutput: true, transform: "raw" }];
const newRules: ContextRule[] = [...rules, { nodeId, label: node.label, variable: node.outputVariable, includeOutput: true, transform: "raw" as const }];
setRules(newRules);
updateTemplate(newRules);
}
@@ -106,7 +106,7 @@ const ContextAssembler = React.forwardRef<HTMLDivElement, ContextAssemblerProps>
const addVariable = (variableName: string) => {
if (!rules.find((r) => r.variable === variableName && !r.nodeId)) {
const newRules = [...rules, { nodeId: "", variable: variableName, includeOutput: true, transform: "raw" }];
const newRules: ContextRule[] = [...rules, { nodeId: "", variable: variableName, includeOutput: true, transform: "raw" as const }];
setRules(newRules);
updateTemplate(newRules);
}

View File

@@ -291,11 +291,11 @@ export function WorkspaceSelector({ className }: WorkspaceSelectorProps) {
</DropdownMenu>
{/* Hidden file input for folder selection */}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<input
ref={folderInputRef}
type="file"
webkitdirectory=""
directory=""
{...({ webkitdirectory: '', directory: '' } as any)}
style={{ display: 'none' }}
onChange={handleFolderSelect}
aria-hidden="true"