@elizaos/client 1.6.0-beta.1 → 1.6.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,700 @@
1
+ import { useAgentRunDetail, useAgentRuns } from '@/hooks/use-query-hooks';
2
+ import { cn } from '@/lib/utils';
3
+ import type { UUID } from '@elizaos/core';
4
+ import {
5
+ Activity,
6
+ AlertCircle,
7
+ CheckCircle,
8
+ ChevronDown,
9
+ ChevronRight,
10
+ Clock,
11
+ Database,
12
+ Eye,
13
+ XCircle,
14
+ Zap,
15
+ } from 'lucide-react';
16
+ import React, { useMemo, useState } from 'react';
17
+
18
+ type AgentRunTimelineProps = {
19
+ agentId: UUID;
20
+ };
21
+
22
+ type RunStatus = 'completed' | 'started' | 'timeout' | 'error';
23
+
24
+ interface TimelineEvent {
25
+ id: string;
26
+ type:
27
+ | 'RUN_STARTED'
28
+ | 'RUN_ENDED'
29
+ | 'ACTION_STARTED'
30
+ | 'ACTION_COMPLETED'
31
+ | 'MODEL_USED'
32
+ | 'EVALUATOR_COMPLETED'
33
+ | 'EMBEDDING_EVENT';
34
+ timestamp: number;
35
+ duration?: number;
36
+ data: Record<string, unknown>;
37
+ parentId?: string;
38
+ }
39
+
40
+ interface ProcessedRun {
41
+ id: string;
42
+ name: string;
43
+ status: RunStatus;
44
+ startTime: number;
45
+ endTime?: number;
46
+ duration?: number;
47
+ children: ProcessedEvent[];
48
+ counts: {
49
+ actions: number;
50
+ modelCalls: number;
51
+ errors: number;
52
+ evaluators: number;
53
+ };
54
+ }
55
+
56
+ interface ProcessedEvent {
57
+ id: string;
58
+ name: string;
59
+ type: 'action' | 'attempt' | 'model' | 'evaluator' | 'embedding';
60
+ status: 'completed' | 'failed' | 'running';
61
+ startTime: number;
62
+ duration?: number;
63
+ icon: React.ComponentType<{ className?: string }>;
64
+ attempts?: ProcessedEvent[];
65
+ children?: ProcessedEvent[];
66
+ }
67
+
68
+ function formatDuration(durationMs?: number | null): string {
69
+ if (!durationMs || durationMs < 0) return '—';
70
+ if (durationMs < 1000) return `${durationMs.toFixed(2)}ms`;
71
+ const seconds = (durationMs / 1000).toFixed(2);
72
+ return `${seconds}s`;
73
+ }
74
+
75
+ function formatTime(timestamp: number): string {
76
+ return new Date(timestamp).toLocaleTimeString('en-US', {
77
+ hour12: false,
78
+ hour: '2-digit',
79
+ minute: '2-digit',
80
+ second: '2-digit',
81
+ fractionalSecondDigits: 3,
82
+ });
83
+ }
84
+
85
+ function getStatusIcon(status: string) {
86
+ switch (status) {
87
+ case 'completed':
88
+ return CheckCircle;
89
+ case 'error':
90
+ case 'failed':
91
+ return XCircle;
92
+ case 'timeout':
93
+ return AlertCircle;
94
+ case 'running':
95
+ case 'started':
96
+ return Clock;
97
+ default:
98
+ return Activity;
99
+ }
100
+ }
101
+
102
+ function getStatusColor(status: string): string {
103
+ switch (status) {
104
+ case 'completed':
105
+ return 'text-green-700 bg-green-100 border-green-300 dark:text-green-400 dark:bg-green-900/30 dark:border-green-800';
106
+ case 'error':
107
+ case 'failed':
108
+ return 'text-red-700 bg-red-100 border-red-300 dark:text-red-400 dark:bg-red-900/30 dark:border-red-800';
109
+ case 'timeout':
110
+ return 'text-yellow-700 bg-yellow-100 border-yellow-300 dark:text-yellow-400 dark:bg-yellow-900/30 dark:border-yellow-800';
111
+ case 'running':
112
+ case 'started':
113
+ return 'text-blue-700 bg-blue-100 border-blue-300 dark:text-blue-400 dark:bg-blue-900/30 dark:border-blue-800';
114
+ default:
115
+ return 'text-muted-foreground bg-muted border-border';
116
+ }
117
+ }
118
+
119
+ function getEventIcon(type: string) {
120
+ switch (type) {
121
+ case 'action':
122
+ return Activity;
123
+ case 'attempt':
124
+ return Clock;
125
+ case 'model':
126
+ return Eye;
127
+ case 'evaluator':
128
+ return Database;
129
+ case 'embedding':
130
+ return Zap;
131
+ default:
132
+ return Activity;
133
+ }
134
+ }
135
+
136
+ // Removed page-level time scale to avoid confusing global durations across runs
137
+
138
+ export const AgentRunTimeline: React.FC<AgentRunTimelineProps> = ({ agentId }) => {
139
+ const [selectedRunId, setSelectedRunId] = useState<UUID | null>(null);
140
+ const [expandedRuns, setExpandedRuns] = useState<Set<string>>(new Set());
141
+
142
+ const runsQuery = useAgentRuns(agentId);
143
+ const runs = runsQuery.data?.runs ?? [];
144
+
145
+ // Auto-select first run
146
+ React.useEffect(() => {
147
+ if (!selectedRunId && runs.length > 0) {
148
+ setSelectedRunId(runs[0].runId);
149
+ }
150
+ }, [runs, selectedRunId]);
151
+
152
+ const runDetailQuery = useAgentRunDetail(agentId, selectedRunId);
153
+
154
+ const selectedRunSummary = useMemo(
155
+ () => runs.find((run) => run.runId === selectedRunId) ?? runs[0] ?? null,
156
+ [runs, selectedRunId]
157
+ );
158
+
159
+ // Process run data into hierarchical structure
160
+ const processedRuns = useMemo((): ProcessedRun[] => {
161
+ if (runs.length === 0) {
162
+ return [];
163
+ }
164
+
165
+ return runs.map((runSummary) => {
166
+ // For now, create a processed run from the summary data
167
+ // We'll only show detailed events for the selected run
168
+ const isSelected = runSummary.runId === selectedRunId;
169
+ const runDetail = isSelected ? runDetailQuery.data : null;
170
+
171
+ let processedEvents: ProcessedEvent[] = [];
172
+
173
+ if (runDetail && runDetail.events) {
174
+ // Build grouped actions with attempts and nested events
175
+ const orderedEvents: ProcessedEvent[] = [];
176
+ const actionMap = new Map<string, ProcessedEvent>();
177
+ const inFlightAttempt = new Map<string, ProcessedEvent>();
178
+
179
+ const eventsSorted = [...runDetail.events].sort((a, b) => a.timestamp - b.timestamp);
180
+
181
+ eventsSorted.forEach((event, index) => {
182
+ switch (event.type) {
183
+ case 'ACTION_STARTED': {
184
+ const actionName =
185
+ (event.data.actionName as string) ||
186
+ (event.data.actionId as string) ||
187
+ `Action ${index}`;
188
+ const actionKey = (event.data.actionId as string) || actionName;
189
+
190
+ let actionEvent = actionMap.get(actionKey);
191
+ if (!actionEvent) {
192
+ actionEvent = {
193
+ id: `action-${actionKey}`,
194
+ name: actionName,
195
+ type: 'action',
196
+ status: 'running',
197
+ startTime: event.timestamp,
198
+ icon: Activity,
199
+ attempts: [],
200
+ };
201
+ actionMap.set(actionKey, actionEvent);
202
+ orderedEvents.push(actionEvent);
203
+ } else {
204
+ // For multiple attempts, keep earliest start
205
+ actionEvent.startTime = Math.min(actionEvent.startTime, event.timestamp);
206
+ actionEvent.status = 'running';
207
+ }
208
+
209
+ const attemptIndex = (actionEvent.attempts?.length || 0) + 1;
210
+ const attemptEvent: ProcessedEvent = {
211
+ id: `attempt-${actionKey}-${attemptIndex}`,
212
+ name: `Attempt ${attemptIndex}`,
213
+ type: 'attempt',
214
+ status: 'running',
215
+ startTime: event.timestamp,
216
+ icon: Clock,
217
+ attempts: [], // will hold nested child events like model calls
218
+ };
219
+ actionEvent.attempts = [...(actionEvent.attempts || []), attemptEvent];
220
+ inFlightAttempt.set(actionKey, attemptEvent);
221
+ break;
222
+ }
223
+ case 'ACTION_COMPLETED': {
224
+ const actionName =
225
+ (event.data.actionName as string) ||
226
+ (event.data.actionId as string) ||
227
+ `Action ${index}`;
228
+ const actionKey = (event.data.actionId as string) || actionName;
229
+ let actionEvent = actionMap.get(actionKey);
230
+ if (!actionEvent) {
231
+ // If we missed the start, create a placeholder action with a single attempt
232
+ actionEvent = {
233
+ id: `action-${actionKey}`,
234
+ name: actionName,
235
+ type: 'action',
236
+ status: 'running',
237
+ startTime: event.timestamp,
238
+ icon: Activity,
239
+ attempts: [],
240
+ };
241
+ actionMap.set(actionKey, actionEvent);
242
+ orderedEvents.push(actionEvent);
243
+ }
244
+
245
+ let attempt = inFlightAttempt.get(actionKey);
246
+ if (!attempt) {
247
+ // Missing start; synthesize an attempt starting at completion time
248
+ attempt = {
249
+ id: `attempt-${actionKey}-1`,
250
+ name: 'Attempt 1',
251
+ type: 'attempt',
252
+ status: 'running',
253
+ startTime: event.timestamp,
254
+ icon: Clock,
255
+ attempts: [],
256
+ };
257
+ actionEvent.attempts = [...(actionEvent.attempts || []), attempt];
258
+ }
259
+
260
+ const success = (event.data.success as boolean | undefined) !== false;
261
+ attempt.duration = Math.max(0, event.timestamp - attempt.startTime);
262
+ attempt.status = success ? 'completed' : 'failed';
263
+ inFlightAttempt.delete(actionKey);
264
+
265
+ actionEvent.status = success ? 'completed' : 'failed';
266
+ const firstAttemptStart = (actionEvent.attempts || [attempt])[0].startTime;
267
+ actionEvent.duration = Math.max(0, event.timestamp - firstAttemptStart);
268
+ break;
269
+ }
270
+ case 'MODEL_USED': {
271
+ const modelType = (event.data.modelType as string) || 'Model Call';
272
+ const modelEvent: ProcessedEvent = {
273
+ id: `model-${index}`,
274
+ name: modelType,
275
+ type: 'model',
276
+ status: 'completed',
277
+ startTime: event.timestamp,
278
+ duration: (event.data.executionTime as number) || undefined,
279
+ icon: Eye,
280
+ };
281
+ const actionContext = (event.data.actionContext as string | undefined) || undefined;
282
+ const targetKey = actionContext || Array.from(inFlightAttempt.keys()).pop();
283
+ if (targetKey) {
284
+ const attempt = inFlightAttempt.get(targetKey);
285
+ if (attempt) {
286
+ attempt.attempts = [...(attempt.attempts || []), modelEvent];
287
+ } else {
288
+ // If no running attempt, attach to the last attempt of the action
289
+ const actionEvent = actionMap.get(targetKey);
290
+ const lastAttempt = actionEvent?.attempts && actionEvent.attempts[actionEvent.attempts.length - 1];
291
+ if (lastAttempt) {
292
+ lastAttempt.attempts = [...(lastAttempt.attempts || []), modelEvent];
293
+ } else {
294
+ orderedEvents.push(modelEvent);
295
+ }
296
+ }
297
+ } else {
298
+ orderedEvents.push(modelEvent);
299
+ }
300
+ break;
301
+ }
302
+ case 'EVALUATOR_COMPLETED': {
303
+ const evaluatorName = (event.data.evaluatorName as string) || `Evaluator ${index}`;
304
+ orderedEvents.push({
305
+ id: `evaluator-${index}`,
306
+ name: evaluatorName,
307
+ type: 'evaluator',
308
+ status: 'completed',
309
+ startTime: event.timestamp,
310
+ icon: Database,
311
+ });
312
+ break;
313
+ }
314
+ case 'EMBEDDING_EVENT': {
315
+ const status = (event.data.status as string) || 'completed';
316
+ const embeddingEvent: ProcessedEvent = {
317
+ id: `embedding-${index}`,
318
+ name: `Embedding ${status}`,
319
+ type: 'embedding',
320
+ status: status === 'failed' ? 'failed' : 'completed',
321
+ startTime: event.timestamp,
322
+ duration: (event.data.durationMs as number) || undefined,
323
+ icon: Zap,
324
+ };
325
+ const key = Array.from(inFlightAttempt.keys()).pop();
326
+ if (key) {
327
+ const attempt = inFlightAttempt.get(key);
328
+ if (attempt) {
329
+ attempt.attempts = [...(attempt.attempts || []), embeddingEvent];
330
+ } else {
331
+ orderedEvents.push(embeddingEvent);
332
+ }
333
+ } else {
334
+ orderedEvents.push(embeddingEvent);
335
+ }
336
+ break;
337
+ }
338
+ default:
339
+ break;
340
+ }
341
+ });
342
+
343
+ processedEvents = orderedEvents;
344
+ }
345
+
346
+ return {
347
+ id: runSummary.runId,
348
+ name: `Run ${formatTime(runSummary.startedAt || Date.now())}`,
349
+ status: runSummary.status as RunStatus,
350
+ startTime: runSummary.startedAt || Date.now(),
351
+ endTime: runSummary.endedAt || undefined,
352
+ duration:
353
+ runSummary.durationMs ??
354
+ (runSummary.endedAt != null && runSummary.startedAt != null
355
+ ? runSummary.endedAt - runSummary.startedAt
356
+ : undefined),
357
+ children: processedEvents.sort((a, b) => a.startTime - b.startTime),
358
+ counts: runSummary.counts || { actions: 0, modelCalls: 0, errors: 0, evaluators: 0 },
359
+ };
360
+ });
361
+ }, [runs, runDetailQuery.data, selectedRunId]);
362
+
363
+ // Helper function to calculate timeline bounds for a single run
364
+ const calculateRunTimelineBounds = (run: ProcessedRun) => {
365
+ let earliestStart = run.startTime;
366
+ let latestEnd = run.endTime || run.startTime + (run.duration || 0);
367
+
368
+ const scanEvent = (ev: ProcessedEvent) => {
369
+ earliestStart = Math.min(earliestStart, ev.startTime);
370
+ const end = ev.startTime + (ev.duration || 0);
371
+ latestEnd = Math.max(latestEnd, end);
372
+ (ev.attempts || []).forEach(scanEvent);
373
+ (ev.children || []).forEach(scanEvent);
374
+ };
375
+
376
+ run.children.forEach(scanEvent);
377
+
378
+ const totalDuration = latestEnd - earliestStart;
379
+
380
+ // Ensure we have at least a minimal duration for visualization
381
+ const minDuration = 100; // 100ms minimum for visualization
382
+
383
+ return {
384
+ startTime: earliestStart,
385
+ endTime: latestEnd,
386
+ totalDuration: Math.max(totalDuration, minDuration),
387
+ };
388
+ };
389
+
390
+ // Toggle expansion of runs
391
+ const toggleRunExpansion = (runId: string) => {
392
+ const newExpanded = new Set(expandedRuns);
393
+ if (newExpanded.has(runId)) {
394
+ newExpanded.delete(runId);
395
+ } else {
396
+ newExpanded.add(runId);
397
+ }
398
+ setExpandedRuns(newExpanded);
399
+ };
400
+
401
+ const isLoading = runsQuery.isLoading || runDetailQuery.isLoading;
402
+ const errorMessage = runsQuery.error ? (runsQuery.error as Error).message : undefined;
403
+ const hasRuns = runs.length > 0;
404
+
405
+ return (
406
+ <div className="space-y-4">
407
+ <div className="flex flex-wrap items-center justify-between gap-4">
408
+ <div>
409
+ <h2 className="text-lg font-semibold">Agent Runs</h2>
410
+ <p className="text-sm text-muted-foreground">
411
+ Hierarchical view of agent execution with timing details.
412
+ </p>
413
+ </div>
414
+ {/* Intentionally no global time scale; each row shows its own proportional bar */}
415
+ </div>
416
+
417
+ {!isLoading && errorMessage && (
418
+ <div className="px-4 py-8 text-sm text-red-500">Failed to load runs: {errorMessage}</div>
419
+ )}
420
+ {!isLoading && !errorMessage && !hasRuns && (
421
+ <div className="px-4 py-8 text-sm text-muted-foreground">No runs available yet.</div>
422
+ )}
423
+
424
+ {processedRuns.length > 0 && (
425
+ <div className="space-y-2">
426
+ {processedRuns.map((run) => (
427
+ <div key={run.id}>
428
+ <RunItem
429
+ run={run}
430
+ isExpanded={expandedRuns.has(run.id)}
431
+ isSelected={selectedRunId === run.id}
432
+ onToggle={() => {
433
+ toggleRunExpansion(run.id);
434
+ // Also set this as the selected run when expanded
435
+ if (!expandedRuns.has(run.id)) {
436
+ setSelectedRunId(run.id as UUID);
437
+ }
438
+ }}
439
+ level={0}
440
+ timelineBounds={calculateRunTimelineBounds(run)}
441
+ />
442
+ </div>
443
+ ))}
444
+ </div>
445
+ )}
446
+ </div>
447
+ );
448
+ };
449
+
450
+ // RunItem component for hierarchical display
451
+ interface RunItemProps {
452
+ run: ProcessedRun;
453
+ isExpanded: boolean;
454
+ onToggle: () => void;
455
+ level: number;
456
+ isSelected?: boolean;
457
+ timelineBounds: {
458
+ startTime: number;
459
+ endTime: number;
460
+ totalDuration: number;
461
+ };
462
+ }
463
+
464
+ const RunItem: React.FC<RunItemProps> = ({
465
+ run,
466
+ isExpanded,
467
+ onToggle,
468
+ level,
469
+ isSelected,
470
+ timelineBounds,
471
+ }) => {
472
+ const StatusIcon = getStatusIcon(run.status);
473
+ const indent = level * 24;
474
+
475
+ // Calculate timing bar parameters based on timeline bounds
476
+ // For the run itself, we want the bar to span the full width since it represents the entire timeline
477
+ const { startTime: timelineStart, totalDuration: timelineTotal } = timelineBounds;
478
+ const runDuration = run.duration || 0;
479
+
480
+ // For a run at the top level, the bar should show the full duration
481
+ const isRootRun = level === 0;
482
+ const startOffset = isRootRun ? 0 :
483
+ (timelineTotal > 0 ? ((run.startTime - timelineStart) / timelineTotal) * 100 : 0);
484
+ const widthPercent = isRootRun ? 100 :
485
+ (timelineTotal > 0 ? (runDuration / timelineTotal) * 100 : 0);
486
+
487
+ return (
488
+ <div>
489
+ {/* Main run row */}
490
+ <div
491
+ className={cn(
492
+ 'flex items-center gap-3 p-3 cursor-pointer transition-colors',
493
+ isRootRun ? 'bg-card border rounded-lg' : '',
494
+ isSelected && !isRootRun && 'bg-primary/10'
495
+ )}
496
+ style={{ paddingLeft: `${12 + indent}px` }}
497
+ onClick={onToggle}
498
+ >
499
+ {/* Expand/collapse button */}
500
+ <button className="flex-shrink-0 w-4 h-4 flex items-center justify-center">
501
+ {run.children.length > 0 &&
502
+ (isExpanded ? (
503
+ <ChevronDown className="w-3 h-3" />
504
+ ) : (
505
+ <ChevronRight className="w-3 h-3" />
506
+ ))}
507
+ </button>
508
+
509
+ {/* Status icon */}
510
+ <div
511
+ className={cn(
512
+ 'flex-shrink-0 w-5 h-5 rounded-full flex items-center justify-center text-xs',
513
+ getStatusColor(run.status)
514
+ )}
515
+ >
516
+ <StatusIcon className="w-3 h-3" />
517
+ </div>
518
+
519
+ {/* Task name and details */}
520
+ <div className="flex-1 min-w-0">
521
+ <div className="flex items-center gap-2">
522
+ <span className="font-medium text-sm">{run.name}</span>
523
+ <span
524
+ className={cn(
525
+ 'px-1.5 py-0.5 text-xs rounded-full border',
526
+ getStatusColor(run.status)
527
+ )}
528
+ >
529
+ {run.status === 'completed' ? '✓' : run.status === 'error' ? '✗' : '○'}
530
+ </span>
531
+ </div>
532
+ <div className="text-xs text-muted-foreground mt-1">
533
+ {run.counts.actions} actions • {run.counts.modelCalls} model calls • {run.counts.errors}{' '}
534
+ errors
535
+ </div>
536
+ </div>
537
+
538
+ {/* Timing bar with integrated duration display */}
539
+ <div className="flex-shrink-0 w-40 h-5 relative bg-muted rounded-none overflow-hidden">
540
+ <div
541
+ className={cn(
542
+ 'absolute top-0 bottom-0 transition-all flex items-center justify-center',
543
+ run.status === 'completed'
544
+ ? 'bg-primary'
545
+ : run.status === 'error'
546
+ ? 'bg-destructive'
547
+ : run.status === 'timeout'
548
+ ? 'bg-yellow-500 dark:bg-yellow-600'
549
+ : 'bg-primary/70'
550
+ )}
551
+ style={{
552
+ left: `${Math.max(0, Math.min(startOffset, 98))}%`,
553
+ width: `${Math.max(2, Math.min(widthPercent, 100 - Math.max(0, Math.min(startOffset, 98))))}%`,
554
+ }}
555
+ >
556
+ {/* Show duration on the bar for parent runs */}
557
+ {isRootRun && (
558
+ <span className="text-xs font-mono text-primary-foreground px-1 truncate">
559
+ {formatDuration(run.duration)}
560
+ </span>
561
+ )}
562
+ </div>
563
+ {/* Show duration outside for child runs */}
564
+ {!isRootRun && (
565
+ <div className="absolute right-1 top-0 bottom-0 flex items-center">
566
+ <span className="text-xs font-mono text-muted-foreground">
567
+ {formatDuration(run.duration)}
568
+ </span>
569
+ </div>
570
+ )}
571
+ </div>
572
+ </div>
573
+
574
+ {/* Children */}
575
+ {isExpanded && run.children.length > 0 && (
576
+ <div className={cn(
577
+ "border-l border-border ml-4",
578
+ isRootRun ? "mt-2 pt-2" : ""
579
+ )}>
580
+ {run.children.map((child) => (
581
+ <EventItem
582
+ key={child.id}
583
+ event={child}
584
+ level={level + 1}
585
+ timelineBounds={timelineBounds}
586
+ />
587
+ ))}
588
+ </div>
589
+ )}
590
+ </div>
591
+ );
592
+ };
593
+
594
+ // EventItem component for individual events
595
+ interface EventItemProps {
596
+ event: ProcessedEvent;
597
+ level: number;
598
+ timelineBounds: {
599
+ startTime: number;
600
+ endTime: number;
601
+ totalDuration: number;
602
+ };
603
+ }
604
+
605
+ const EventItem: React.FC<EventItemProps> = ({ event, level, timelineBounds }) => {
606
+ const IconComponent = event.icon;
607
+ const StatusIcon = getStatusIcon(event.status);
608
+ const indent = level * 24;
609
+ const [expanded, setExpanded] = React.useState(true);
610
+ const hasNested = (event.attempts && event.attempts.length > 0) || (event.children && event.children.length > 0);
611
+
612
+ // Calculate timing bar parameters based on timeline bounds
613
+ const { startTime: timelineStart, totalDuration: timelineTotal } = timelineBounds;
614
+ const eventDuration = event.duration || 0;
615
+ const startOffset =
616
+ timelineTotal > 0 ? ((event.startTime - timelineStart) / timelineTotal) * 100 : 0;
617
+
618
+ // Calculate width with a minimum visible size
619
+ // Use logarithmic scaling for very small durations to maintain visibility
620
+ let widthPercent = 0;
621
+ if (timelineTotal > 0 && eventDuration > 0) {
622
+ const rawPercent = (eventDuration / timelineTotal) * 100;
623
+ // Ensure minimum visibility of 5% for any event with duration
624
+ widthPercent = Math.max(5, rawPercent);
625
+ // For very small events (< 2%), apply scaling to make them more visible
626
+ if (rawPercent < 2) {
627
+ widthPercent = 3 + (rawPercent * 2); // Scale up small events
628
+ }
629
+ }
630
+
631
+ return (
632
+ <div>
633
+ <div
634
+ className="flex items-center gap-3 p-2 text-sm hover:bg-muted/30"
635
+ style={{ paddingLeft: `${12 + indent}px` }}
636
+ >
637
+ {/* Expand/collapse if nested */}
638
+ <button className="w-4 h-4 flex items-center justify-center" onClick={() => hasNested && setExpanded(!expanded)}>
639
+ {hasNested ? (expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />) : null}
640
+ </button>
641
+
642
+ {/* Event icon */}
643
+ <div className="flex-shrink-0 w-4 h-4 flex items-center justify-center">
644
+ <IconComponent className="w-3 h-3 text-muted-foreground" />
645
+ </div>
646
+
647
+ {/* Status icon */}
648
+ <div
649
+ className={cn(
650
+ 'flex-shrink-0 w-4 h-4 rounded-sm flex items-center justify-center text-xs',
651
+ getStatusColor(event.status)
652
+ )}
653
+ >
654
+ <StatusIcon className="w-2.5 h-2.5" />
655
+ </div>
656
+
657
+ {/* Event name */}
658
+ <div className="flex-1 min-w-0">
659
+ <span className="text-sm">{event.name}</span>
660
+ </div>
661
+
662
+ {/* Mini timing bar with duration */}
663
+ <div className="flex-shrink-0 w-40 h-3 relative bg-muted rounded-none overflow-hidden flex items-center">
664
+ <div
665
+ className={cn(
666
+ 'absolute h-full transition-all',
667
+ event.status === 'completed'
668
+ ? 'bg-primary/80'
669
+ : event.status === 'failed'
670
+ ? 'bg-destructive/80'
671
+ : 'bg-primary/60'
672
+ )}
673
+ style={{
674
+ left: `${Math.max(0, Math.min(startOffset, 98))}%`,
675
+ width: `${Math.max(3, Math.min(widthPercent, 100 - Math.max(0, Math.min(startOffset, 98))))}%`,
676
+ }}
677
+ />
678
+ {/* Duration text overlay */}
679
+ <div className="absolute right-1 text-xs font-mono text-muted-foreground z-10">
680
+ {formatDuration(event.duration)}
681
+ </div>
682
+ </div>
683
+ </div>
684
+
685
+ {/* Nested attempts or children */}
686
+ {hasNested && expanded && (
687
+ <div className="border-l border-border ml-4">
688
+ {(event.attempts || []).map((child) => (
689
+ <EventItem key={child.id} event={child} level={level + 1} timelineBounds={timelineBounds} />
690
+ ))}
691
+ {(event.children || []).map((child) => (
692
+ <EventItem key={child.id} event={child} level={level + 1} timelineBounds={timelineBounds} />
693
+ ))}
694
+ </div>
695
+ )}
696
+ </div>
697
+ );
698
+ };
699
+
700
+ export default AgentRunTimeline;