/ src / components / ui / DagTrace.tsx
DagTrace.tsx
  1  // Copyright (c) 2026 VPL Solutions. All rights reserved.
  2  // Licensed under the MIT License. See LICENSE for details.
  3  //
  4  // DagTrace — Interactive animated Planner → Retriever → Synthesizer visualization
  5  // ADR-011: Makes the aiPolaris DAG execution tangible and auditable in real time.
  6  //
  7  // Design:
  8  //   - Three node cards animate in as each stage completes (not shown upfront)
  9  //   - Connecting arrows draw left-to-right as stages complete
 10  //   - Clicking a node expands a drawer showing that stage's output
 11  //   - Nodes pulse amber while running, green on done, red on error
 12  //   - Error nodes show an actionable recovery hint
 13  //
 14  // Trace data is memory-only — never written to localStorage. ADR-011.
 15  
 16  import { useState } from 'react';
 17  import {
 18    BrainCircuit, Search, PenLine, ChevronDown, ChevronUp,
 19    AlertCircle, FileText, Hash, Clock, CheckCircle2, XCircle,
 20    Loader2, ArrowRight,
 21  } from 'lucide-react';
 22  import type { DagNodeState, DagStage, PolarisCitation, PolarisChunk } from '../../api/aipolaris';
 23  
 24  // ── Stage config ──────────────────────────────────────────────────────────────
 25  
 26  interface StageConfig {
 27    icon: React.ComponentType<{ className?: string }>;
 28    accent: string;          // Tailwind color token base
 29    ringColor: string;
 30    bgIdle: string;
 31    bgRunning: string;
 32    bgDone: string;
 33    bgError: string;
 34    borderRunning: string;
 35    borderDone: string;
 36    borderError: string;
 37    textRunning: string;
 38    textDone: string;
 39  }
 40  
 41  const STAGE_CONFIG: Record<DagStage, StageConfig> = {
 42    planner: {
 43      icon: BrainCircuit,
 44      accent: 'purple',
 45      ringColor:     'ring-purple-400 dark:ring-purple-500',
 46      bgIdle:        'bg-gray-50 dark:bg-gray-800/40',
 47      bgRunning:     'bg-purple-50 dark:bg-purple-900/20',
 48      bgDone:        'bg-white dark:bg-white/[0.03]',
 49      bgError:       'bg-red-50 dark:bg-red-900/20',
 50      borderRunning: 'border-purple-300 dark:border-purple-600',
 51      borderDone:    'border-purple-200 dark:border-purple-800',
 52      borderError:   'border-red-300 dark:border-red-700',
 53      textRunning:   'text-purple-600 dark:text-purple-400',
 54      textDone:      'text-purple-700 dark:text-purple-300',
 55    },
 56    retriever: {
 57      icon: Search,
 58      accent: 'blue',
 59      ringColor:     'ring-blue-400 dark:ring-blue-500',
 60      bgIdle:        'bg-gray-50 dark:bg-gray-800/40',
 61      bgRunning:     'bg-blue-50 dark:bg-blue-900/20',
 62      bgDone:        'bg-white dark:bg-white/[0.03]',
 63      bgError:       'bg-red-50 dark:bg-red-900/20',
 64      borderRunning: 'border-blue-300 dark:border-blue-600',
 65      borderDone:    'border-blue-200 dark:border-blue-800',
 66      borderError:   'border-red-300 dark:border-red-700',
 67      textRunning:   'text-blue-600 dark:text-blue-400',
 68      textDone:      'text-blue-700 dark:text-blue-300',
 69    },
 70    synthesizer: {
 71      icon: PenLine,
 72      accent: 'emerald',
 73      ringColor:     'ring-emerald-400 dark:ring-emerald-500',
 74      bgIdle:        'bg-gray-50 dark:bg-gray-800/40',
 75      bgRunning:     'bg-emerald-50 dark:bg-emerald-900/20',
 76      bgDone:        'bg-white dark:bg-white/[0.03]',
 77      bgError:       'bg-red-50 dark:bg-red-900/20',
 78      borderRunning: 'border-emerald-300 dark:border-emerald-600',
 79      borderDone:    'border-emerald-200 dark:border-emerald-800',
 80      borderError:   'border-red-300 dark:border-red-700',
 81      textRunning:   'text-emerald-600 dark:text-emerald-400',
 82      textDone:      'text-emerald-700 dark:text-emerald-300',
 83    },
 84  };
 85  
 86  // ── Drawer content per stage ──────────────────────────────────────────────────
 87  
 88  function PlannerDrawer({ subTasks }: { subTasks?: string[] }) {
 89    if (!subTasks?.length) return (
 90      <p className="text-xs text-gray-400 dark:text-gray-500 italic">No sub-tasks recorded.</p>
 91    );
 92    return (
 93      <div className="space-y-1.5">
 94        <p className="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2">
 95          Decomposed into {subTasks.length} sub-task{subTasks.length !== 1 ? 's' : ''}
 96        </p>
 97        {subTasks.map((task, i) => (
 98          <div key={i} className="flex items-start gap-2">
 99            <span className="shrink-0 mt-0.5 w-4 h-4 rounded-full bg-purple-100 dark:bg-purple-900/40 text-purple-600 dark:text-purple-400 text-[9px] font-bold flex items-center justify-center">
100              {i + 1}
101            </span>
102            <span className="text-xs text-gray-700 dark:text-gray-300 leading-snug">{task}</span>
103          </div>
104        ))}
105      </div>
106    );
107  }
108  
109  function RetrieverDrawer({ chunks }: { chunks?: PolarisChunk[] }) {
110    if (!chunks?.length) return (
111      <p className="text-xs text-gray-400 dark:text-gray-500 italic">No chunks retrieved.</p>
112    );
113    return (
114      <div className="space-y-2.5">
115        <p className="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2">
116          {chunks.length} chunk{chunks.length !== 1 ? 's' : ''} retrieved
117        </p>
118        {chunks.map((chunk, i) => (
119          <div key={i} className="rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50 dark:bg-white/[0.02] p-2.5 space-y-1.5">
120            <div className="flex items-start justify-between gap-2">
121              <div className="flex items-center gap-1.5 min-w-0">
122                <FileText className="w-3 h-3 text-blue-400 shrink-0" />
123                <span className="text-xs font-medium text-gray-800 dark:text-gray-200 truncate">{chunk.title}</span>
124              </div>
125              {/* Reranker score bar */}
126              <div className="flex items-center gap-1.5 shrink-0">
127                <div className="w-16 h-1.5 rounded-full bg-gray-200 dark:bg-gray-700 overflow-hidden">
128                  <div
129                    className="h-full rounded-full bg-blue-400 dark:bg-blue-500 transition-all duration-500"
130                    style={{ width: `${Math.round(chunk.reranker_score * 100)}%` }}
131                  />
132                </div>
133                <span className="text-[10px] text-gray-400 dark:text-gray-500 font-mono w-7 text-right">
134                  {(chunk.reranker_score * 100).toFixed(0)}%
135                </span>
136              </div>
137            </div>
138            <p className="text-[11px] text-gray-500 dark:text-gray-400 leading-snug line-clamp-2">
139              {chunk.content}
140            </p>
141            <div className="flex items-center gap-1 text-[10px] text-gray-400 dark:text-gray-500">
142              <Hash className="w-2.5 h-2.5" />
143              <span className="font-mono truncate">{chunk.source}</span>
144            </div>
145          </div>
146        ))}
147      </div>
148    );
149  }
150  
151  function SynthesizerDrawer({ citations }: { citations?: PolarisCitation[] }) {
152    if (!citations?.length) return (
153      <p className="text-xs text-gray-400 dark:text-gray-500 italic">No citations available.</p>
154    );
155    return (
156      <div className="space-y-2">
157        <p className="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2">
158          {citations.length} citation{citations.length !== 1 ? 's' : ''} used
159        </p>
160        {citations.map((citation, i) => (
161          <div key={i} className="rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50 dark:bg-white/[0.02] p-2.5 space-y-1">
162            <div className="flex items-center gap-1.5">
163              <CheckCircle2 className="w-3 h-3 text-emerald-400 shrink-0" />
164              <span className="text-xs font-medium text-gray-800 dark:text-gray-200">{citation.title}</span>
165            </div>
166            <p className="text-[11px] text-gray-500 dark:text-gray-400 leading-snug italic line-clamp-3 pl-4">
167              "{citation.excerpt}"
168            </p>
169            <div className="flex items-center gap-1 text-[10px] text-gray-400 dark:text-gray-500 pl-4">
170              <Hash className="w-2.5 h-2.5" />
171              <span className="font-mono truncate">{citation.source}</span>
172            </div>
173          </div>
174        ))}
175      </div>
176    );
177  }
178  
179  // ── Node card ─────────────────────────────────────────────────────────────────
180  
181  interface NodeCardProps {
182    node: DagNodeState;
183    isExpanded: boolean;
184    onToggle: () => void;
185  }
186  
187  function NodeCard({ node, isExpanded, onToggle }: NodeCardProps) {
188    const cfg = STAGE_CONFIG[node.stage];
189    const Icon = cfg.icon;
190    const isClickable = node.status === 'done' || node.status === 'error';
191  
192    const bg = node.status === 'idle'    ? cfg.bgIdle
193             : node.status === 'running' ? cfg.bgRunning
194             : node.status === 'done'    ? cfg.bgDone
195                                         : cfg.bgError;
196  
197    const border = node.status === 'running' ? cfg.borderRunning
198                 : node.status === 'done'    ? cfg.borderDone
199                 : node.status === 'error'   ? cfg.borderError
200                                             : 'border-gray-100 dark:border-white/5';
201  
202    return (
203      <div className={[
204        'flex flex-col rounded-xl border transition-all duration-300 overflow-hidden',
205        bg, border,
206        node.status === 'running' ? 'shadow-sm ring-1 ring-offset-0 ' + cfg.ringColor : '',
207        isClickable ? 'cursor-pointer' : 'cursor-default',
208      ].join(' ')}>
209        {/* Card header */}
210        <div
211          className="flex items-center gap-2.5 px-3 py-2.5"
212          onClick={isClickable ? onToggle : undefined}
213          role={isClickable ? 'button' : undefined}
214          tabIndex={isClickable ? 0 : undefined}
215          onKeyDown={isClickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle(); } } : undefined}
216          aria-expanded={isClickable ? isExpanded : undefined}
217          aria-label={isClickable ? `${node.stage.charAt(0).toUpperCase() + node.stage.slice(1)} stage — ${node.status}. ${isExpanded ? 'Collapse' : 'Expand'} details` : undefined}
218        >
219          {/* Status icon */}
220          <div className={[
221            'flex items-center justify-center w-7 h-7 rounded-lg transition-all duration-200',
222            node.status === 'idle'    ? 'bg-gray-100 dark:bg-gray-700/50' :
223            node.status === 'running' ? `bg-${cfg.accent}-100 dark:bg-${cfg.accent}-900/30` :
224            node.status === 'done'    ? `bg-${cfg.accent}-50 dark:bg-${cfg.accent}-900/20` :
225                                        'bg-red-100 dark:bg-red-900/30',
226          ].join(' ')}>
227            {node.status === 'running' ? (
228              <Loader2 className={`w-3.5 h-3.5 animate-spin ${cfg.textRunning}`} />
229            ) : node.status === 'done' ? (
230              <Icon className={`w-3.5 h-3.5 ${cfg.textDone}`} />
231            ) : node.status === 'error' ? (
232              <XCircle className="w-3.5 h-3.5 text-red-500" />
233            ) : (
234              <Icon className="w-3.5 h-3.5 text-gray-300 dark:text-gray-500" />
235            )}
236          </div>
237  
238          {/* Label + sublabel */}
239          <div className="flex-1 min-w-0">
240            <div className="flex items-center gap-1.5">
241              <span className={[
242                'text-xs font-semibold',
243                node.status === 'idle'  ? 'text-gray-400 dark:text-gray-500' :
244                node.status === 'error' ? 'text-red-600 dark:text-red-400' :
245                                          cfg.textDone,
246              ].join(' ')}>
247                {node.label}
248              </span>
249              {node.status === 'running' && (
250                <span className={`text-[10px] ${cfg.textRunning} animate-pulse`}>
251                  {node.sublabel}
252                </span>
253              )}
254            </div>
255            {node.status === 'done' && (
256              <div className="flex items-center gap-2 mt-0.5">
257                <span className="text-[10px] text-gray-400 dark:text-gray-500">{node.sublabel}</span>
258                {node.latency_ms !== null && (
259                  <span className="flex items-center gap-0.5 text-[10px] text-gray-300 dark:text-gray-500">
260                    <Clock className="w-2.5 h-2.5" />
261                    {Math.round(node.latency_ms)}ms
262                  </span>
263                )}
264              </div>
265            )}
266          </div>
267  
268          {/* Expand chevron */}
269          {isClickable && (
270            <div className="shrink-0 text-gray-300 dark:text-gray-500">
271              {isExpanded
272                ? <ChevronUp className="w-3.5 h-3.5" />
273                : <ChevronDown className="w-3.5 h-3.5" />
274              }
275            </div>
276          )}
277        </div>
278  
279        {/* Error hint */}
280        {node.status === 'error' && node.errorHint && (
281          <div className="flex items-start gap-2 px-3 pb-2.5 text-xs text-red-500 dark:text-red-400">
282            <AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
283            <span>{node.errorHint}</span>
284          </div>
285        )}
286  
287        {/* Expandable drawer */}
288        {isExpanded && node.status === 'done' && (
289          <div className="border-t border-gray-100 dark:border-white/5 px-3 py-3 animate-in slide-in-from-top-1 duration-200">
290            {node.stage === 'planner'     && <PlannerDrawer subTasks={node.subTasks} />}
291            {node.stage === 'retriever'   && <RetrieverDrawer chunks={node.chunks} />}
292            {node.stage === 'synthesizer' && <SynthesizerDrawer citations={node.citations} />}
293          </div>
294        )}
295      </div>
296    );
297  }
298  
299  // ── Connector arrow ───────────────────────────────────────────────────────────
300  
301  function ConnectorArrow({ active }: { active: boolean }) {
302    return (
303      <div className="flex items-center justify-center self-start mt-[22px] mx-1 shrink-0">
304        <div className={[
305          'h-px w-5 transition-all duration-500',
306          active ? 'bg-gray-300 dark:bg-gray-600' : 'bg-gray-150 dark:bg-gray-800',
307        ].join(' ')} />
308        <ArrowRight className={[
309          'w-3 h-3 -ml-1 transition-colors duration-500',
310          active ? 'text-gray-300 dark:text-gray-500' : 'text-gray-150 dark:text-gray-800',
311        ].join(' ')} />
312      </div>
313    );
314  }
315  
316  // ── Main DagTrace component ───────────────────────────────────────────────────
317  
318  interface DagTraceProps {
319    nodes: DagNodeState[];
320    traceId?: string;
321  }
322  
323  export function DagTrace({ nodes, traceId }: DagTraceProps) {
324    const [expandedStage, setExpandedStage] = useState<DagStage | null>(null);
325  
326    // Only render once at least one node is no longer idle
327    const hasStarted = nodes.some(n => n.status !== 'idle');
328    if (!hasStarted) return null;
329  
330    const toggleExpand = (stage: DagStage) => {
331      setExpandedStage(prev => prev === stage ? null : stage);
332    };
333  
334    const connectorActive = (idx: number) =>
335      nodes[idx].status === 'done' || nodes[idx].status === 'error';
336  
337    return (
338      <div className="rounded-xl border border-gray-100 dark:border-white/[0.06] bg-gray-50/50 dark:bg-white/[0.01] p-3 space-y-2">
339        {/* Header */}
340        <div className="flex items-center justify-between">
341          <div className="flex items-center gap-1.5">
342            <div className="w-1.5 h-1.5 rounded-full bg-purple-400 dark:bg-purple-500 animate-pulse" />
343            <span className="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
344              Execution trace
345            </span>
346          </div>
347          {traceId && (
348            <span className="font-mono text-[10px] text-gray-300 dark:text-gray-700">
349              {traceId.slice(0, 8)}
350            </span>
351          )}
352        </div>
353  
354        {/* DAG pipeline */}
355        <div className="flex items-start">
356          {nodes.map((node, idx) => (
357            <div key={node.stage} className="flex items-start flex-1 min-w-0">
358              <div className="flex-1 min-w-0">
359                <NodeCard
360                  node={node}
361                  isExpanded={expandedStage === node.stage}
362                  onToggle={() => toggleExpand(node.stage)}
363                />
364              </div>
365              {idx < nodes.length - 1 && (
366                <ConnectorArrow active={connectorActive(idx)} />
367              )}
368            </div>
369          ))}
370        </div>
371  
372        {/* Footer hint */}
373        <p className="text-[10px] text-gray-300 dark:text-gray-700 text-center">
374          Click a completed node to inspect its output
375        </p>
376      </div>
377    );
378  }