StageProgress.tsx
1 // Copyright (c) 2026 VPL Solutions. All rights reserved. 2 // Licensed under the MIT License. See LICENSE for details. 3 // 4 // StageProgress — live animated stage indicator for aiPolaris DAG (ADR-011) 5 // Shows Planning → Retrieving → Synthesizing as each node completes. 6 7 import { BrainCircuit, Search, PenLine, Check, XCircle, Loader2 } from 'lucide-react'; 8 import type { DagNodeState, DagStage } from '../../api/aipolaris'; 9 10 interface StageProgressProps { 11 nodes: DagNodeState[]; 12 } 13 14 const STAGE_ICONS: Record<DagStage, React.ComponentType<{ className?: string }>> = { 15 planner: BrainCircuit, 16 retriever: Search, 17 synthesizer: PenLine, 18 }; 19 20 const STAGE_COLORS: Record<DagStage, { idle: string; running: string; done: string; error: string }> = { 21 planner: { 22 idle: 'text-gray-300 dark:text-gray-600', 23 running: 'text-purple-500 dark:text-purple-400', 24 done: 'text-purple-600 dark:text-purple-400', 25 error: 'text-red-500', 26 }, 27 retriever: { 28 idle: 'text-gray-300 dark:text-gray-600', 29 running: 'text-blue-500 dark:text-blue-400', 30 done: 'text-blue-600 dark:text-blue-400', 31 error: 'text-red-500', 32 }, 33 synthesizer: { 34 idle: 'text-gray-300 dark:text-gray-600', 35 running: 'text-emerald-500 dark:text-emerald-400', 36 done: 'text-emerald-600 dark:text-emerald-400', 37 error: 'text-red-500', 38 }, 39 }; 40 41 const CONNECTOR_COLORS: Record<string, string> = { 42 idle: 'bg-gray-200 dark:bg-gray-700', 43 running: 'bg-gray-200 dark:bg-gray-700', 44 done: 'bg-purple-300 dark:bg-purple-700', 45 error: 'bg-red-300 dark:bg-red-700', 46 }; 47 48 function StageNode({ node }: { node: DagNodeState }) { 49 const Icon = STAGE_ICONS[node.stage]; 50 const colors = STAGE_COLORS[node.stage]; 51 const iconColor = colors[node.status]; 52 53 return ( 54 <div className="flex flex-col items-center gap-1 min-w-[72px]"> 55 {/* Icon container */} 56 <div className={[ 57 'relative flex items-center justify-center w-9 h-9 rounded-full border-2 transition-all duration-300', 58 node.status === 'idle' 59 ? 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50' 60 : node.status === 'running' 61 ? 'border-current bg-white dark:bg-gray-900 shadow-sm ' + iconColor 62 : node.status === 'done' 63 ? 'border-current bg-white dark:bg-gray-900 ' + iconColor 64 : 'border-red-400 bg-white dark:bg-gray-900', 65 ].join(' ')}> 66 {node.status === 'running' ? ( 67 <Loader2 className={`w-4 h-4 animate-spin ${iconColor}`} /> 68 ) : node.status === 'done' ? ( 69 <Check className={`w-4 h-4 ${iconColor}`} /> 70 ) : node.status === 'error' ? ( 71 <XCircle className="w-4 h-4 text-red-500" /> 72 ) : ( 73 <Icon className={`w-4 h-4 ${iconColor}`} /> 74 )} 75 </div> 76 77 {/* Label */} 78 <span className={[ 79 'text-[10px] font-semibold uppercase tracking-wider transition-colors', 80 node.status === 'idle' ? 'text-gray-400 dark:text-gray-600' : iconColor, 81 ].join(' ')}> 82 {node.label} 83 </span> 84 85 {/* Sublabel */} 86 <span className={[ 87 'text-[10px] text-center leading-tight transition-colors', 88 node.status === 'idle' ? 'text-gray-300 dark:text-gray-700' : 89 node.status === 'running' ? 'text-gray-500 dark:text-gray-400 animate-pulse' : 90 node.status === 'error' ? 'text-red-400' : 91 'text-gray-500 dark:text-gray-400', 92 ].join(' ')}> 93 {node.status === 'running' ? node.sublabel : 94 node.status === 'done' && node.latency_ms 95 ? `${Math.round(node.latency_ms)}ms` 96 : node.sublabel} 97 </span> 98 </div> 99 ); 100 } 101 102 export function StageProgress({ nodes }: StageProgressProps) { 103 if (nodes.every(n => n.status === 'idle')) return null; 104 105 return ( 106 <div className="flex items-center justify-center gap-0 py-3"> 107 {nodes.map((node, idx) => ( 108 <div key={node.stage} className="flex items-center"> 109 <StageNode node={node} /> 110 {idx < nodes.length - 1 && ( 111 <div className="flex items-center mx-1 mb-5"> 112 {/* Animated connector arrow */} 113 <div className={[ 114 'h-0.5 w-8 transition-all duration-500', 115 CONNECTOR_COLORS[nodes[idx].status] ?? CONNECTOR_COLORS.idle, 116 ].join(' ')} /> 117 <div className={[ 118 'w-0 h-0 border-t-[3px] border-b-[3px] border-l-[5px] border-t-transparent border-b-transparent transition-colors duration-500', 119 nodes[idx].status === 'done' 120 ? 'border-l-purple-300 dark:border-l-purple-700' 121 : 'border-l-gray-200 dark:border-l-gray-700', 122 ].join(' ')} /> 123 </div> 124 )} 125 </div> 126 ))} 127 </div> 128 ); 129 }