/ src / components / ui / StageProgress.tsx
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  }