@roj-ai/debug 0.0.2

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.
Files changed (77) hide show
  1. package/dist/components/debug/DebugContext.d.ts +10 -0
  2. package/dist/components/debug/DebugNavigation.d.ts +29 -0
  3. package/dist/components/debug/DebugShell.d.ts +18 -0
  4. package/dist/components/debug/LLMCallDetail.d.ts +7 -0
  5. package/dist/components/debug/TimelineDetailInspector.d.ts +6 -0
  6. package/dist/components/debug/communication/CommunicationDiagram.d.ts +9 -0
  7. package/dist/components/debug/communication/DiagramHeader.d.ts +7 -0
  8. package/dist/components/debug/communication/ParticipantLane.d.ts +7 -0
  9. package/dist/components/debug/communication/TimeAxis.d.ts +9 -0
  10. package/dist/components/debug/communication/elements/IdleGap.d.ts +9 -0
  11. package/dist/components/debug/communication/elements/LLMBlock.d.ts +9 -0
  12. package/dist/components/debug/communication/elements/MessageArrow.d.ts +10 -0
  13. package/dist/components/debug/communication/elements/ToolBlock.d.ts +9 -0
  14. package/dist/components/debug/communication/hooks/useDiagramData.d.ts +12 -0
  15. package/dist/components/debug/communication/hooks/useTimeCompression.d.ts +7 -0
  16. package/dist/components/debug/communication/hooks/useZoomPan.d.ts +11 -0
  17. package/dist/components/debug/communication/popovers/ElementPopover.d.ts +8 -0
  18. package/dist/components/debug/communication/types.d.ts +136 -0
  19. package/dist/components/debug/index.d.ts +11 -0
  20. package/dist/components/debug/pages/AgentDetailPage.d.ts +3 -0
  21. package/dist/components/debug/pages/AgentsPage.d.ts +1 -0
  22. package/dist/components/debug/pages/CommunicationPage.d.ts +1 -0
  23. package/dist/components/debug/pages/DashboardPage.d.ts +1 -0
  24. package/dist/components/debug/pages/EventsPage.d.ts +1 -0
  25. package/dist/components/debug/pages/FilesPage.d.ts +1 -0
  26. package/dist/components/debug/pages/LLMCallPage.d.ts +1 -0
  27. package/dist/components/debug/pages/LLMCallsPage.d.ts +1 -0
  28. package/dist/components/debug/pages/LogsPage.d.ts +1 -0
  29. package/dist/components/debug/pages/MailboxPage.d.ts +1 -0
  30. package/dist/components/debug/pages/ServicesPage.d.ts +1 -0
  31. package/dist/components/debug/pages/TimelinePage.d.ts +1 -0
  32. package/dist/components/debug/pages/UserChatPage.d.ts +1 -0
  33. package/dist/components/debug/pages/index.d.ts +13 -0
  34. package/dist/index.d.ts +9 -0
  35. package/dist/lib/domain-utils.d.ts +7 -0
  36. package/dist/providers/EventPollingProvider.d.ts +27 -0
  37. package/dist/stores/event-store.d.ts +93 -0
  38. package/dist/utils/format.d.ts +1 -0
  39. package/package.json +43 -0
  40. package/src/components/debug/DebugContext.tsx +18 -0
  41. package/src/components/debug/DebugNavigation.tsx +55 -0
  42. package/src/components/debug/DebugShell.tsx +321 -0
  43. package/src/components/debug/LLMCallDetail.tsx +740 -0
  44. package/src/components/debug/TimelineDetailInspector.tsx +204 -0
  45. package/src/components/debug/communication/CommunicationDiagram.tsx +260 -0
  46. package/src/components/debug/communication/DiagramHeader.tsx +113 -0
  47. package/src/components/debug/communication/ParticipantLane.tsx +60 -0
  48. package/src/components/debug/communication/TimeAxis.tsx +106 -0
  49. package/src/components/debug/communication/elements/IdleGap.tsx +90 -0
  50. package/src/components/debug/communication/elements/LLMBlock.tsx +107 -0
  51. package/src/components/debug/communication/elements/MessageArrow.tsx +119 -0
  52. package/src/components/debug/communication/elements/ToolBlock.tsx +99 -0
  53. package/src/components/debug/communication/hooks/useDiagramData.ts +294 -0
  54. package/src/components/debug/communication/hooks/useTimeCompression.ts +140 -0
  55. package/src/components/debug/communication/hooks/useZoomPan.ts +87 -0
  56. package/src/components/debug/communication/popovers/ElementPopover.tsx +158 -0
  57. package/src/components/debug/communication/types.ts +180 -0
  58. package/src/components/debug/index.ts +37 -0
  59. package/src/components/debug/pages/AgentDetailPage.tsx +1295 -0
  60. package/src/components/debug/pages/AgentsPage.tsx +297 -0
  61. package/src/components/debug/pages/CommunicationPage.tsx +89 -0
  62. package/src/components/debug/pages/DashboardPage.tsx +1504 -0
  63. package/src/components/debug/pages/EventsPage.tsx +276 -0
  64. package/src/components/debug/pages/FilesPage.tsx +366 -0
  65. package/src/components/debug/pages/LLMCallPage.tsx +32 -0
  66. package/src/components/debug/pages/LLMCallsPage.tsx +473 -0
  67. package/src/components/debug/pages/LogsPage.tsx +199 -0
  68. package/src/components/debug/pages/MailboxPage.tsx +232 -0
  69. package/src/components/debug/pages/ServicesPage.tsx +193 -0
  70. package/src/components/debug/pages/TimelinePage.tsx +569 -0
  71. package/src/components/debug/pages/UserChatPage.tsx +250 -0
  72. package/src/components/debug/pages/index.ts +13 -0
  73. package/src/index.ts +55 -0
  74. package/src/lib/domain-utils.ts +12 -0
  75. package/src/providers/EventPollingProvider.tsx +60 -0
  76. package/src/stores/event-store.ts +497 -0
  77. package/src/utils/format.ts +8 -0
@@ -0,0 +1,1504 @@
1
+ import type { TimelineItem } from "@roj-ai/shared";
2
+ import { AgentId } from "@roj-ai/shared";
3
+ import { type FormEvent, useCallback, useMemo, useRef, useState } from "react";
4
+ import { api, unwrap } from "@roj-ai/client";
5
+ import {
6
+ useEventStore,
7
+ useMetrics,
8
+ useSessionInfo,
9
+ useTimeline,
10
+ } from "../../../stores/event-store";
11
+ import { formatDuration } from "../../../utils/format";
12
+ import { useDebugNavigate } from "../DebugNavigation";
13
+ import { TimelineDetailInspector } from "../TimelineDetailInspector";
14
+
15
+ export function DashboardPage() {
16
+ const sessionInfo = useSessionInfo();
17
+ const agentDetailProjection = useEventStore(
18
+ (s) => s.agentDetailProjectionState,
19
+ );
20
+ const metrics = useMetrics();
21
+ const timeline = useTimeline();
22
+ const debugNavigate = useDebugNavigate();
23
+
24
+ const failedLLMCalls = useMemo(
25
+ () =>
26
+ timeline.filter((item) => item.type === "llm" && item.status === "error"),
27
+ [timeline],
28
+ );
29
+
30
+ const failedToolCalls = useMemo(
31
+ () =>
32
+ timeline.filter(
33
+ (item) => item.type === "tool" && item.status === "error",
34
+ ),
35
+ [timeline],
36
+ );
37
+
38
+ const agents = useMemo(
39
+ () => Array.from(agentDetailProjection.agents.values()),
40
+ [agentDetailProjection],
41
+ );
42
+
43
+ const agentNameById = useMemo(() => {
44
+ const map = new Map<AgentId, string>();
45
+ for (const agent of agents) map.set(agent.id, agent.definitionName);
46
+ return map;
47
+ }, [agents]);
48
+
49
+ const pausedAgents = useMemo(
50
+ () => agents.filter((a) => a.status === "paused"),
51
+ [agents],
52
+ );
53
+
54
+ if (!sessionInfo.id) {
55
+ return <div className="text-gray-400 text-sm">Loading session data...</div>;
56
+ }
57
+
58
+ return (
59
+ <div className="space-y-4">
60
+ {/* Paused agents banner */}
61
+ {pausedAgents.length > 0 && (
62
+ <div className="bg-red-50 border border-red-200 rounded-2xl p-4 space-y-2">
63
+ {pausedAgents.map((agent) => (
64
+ <div key={agent.id} className="flex items-center gap-3">
65
+ <div className="w-1.5 h-1.5 rounded-full bg-red-500 shrink-0" />
66
+ <span className="text-sm font-semibold text-red-700 flex-1">
67
+ {agent.definitionName}
68
+ <span className="font-normal text-red-500 ml-1.5">
69
+ paused
70
+ {agent.pauseMessage
71
+ ? `: ${agent.pauseMessage}`
72
+ : ` (${agent.pauseReason})`}
73
+ </span>
74
+ </span>
75
+ <ResumeAgentButton
76
+ sessionId={sessionInfo.id!}
77
+ agentId={agent.id}
78
+ />
79
+ </div>
80
+ ))}
81
+ </div>
82
+ )}
83
+
84
+ {/* Top row: Session + Metrics */}
85
+ <div className="grid grid-cols-1 lg:grid-cols-5 gap-4">
86
+ {/* Session Card */}
87
+ <div className="lg:col-span-2 bg-white rounded-3xl shadow-soft p-5">
88
+ <div className="flex items-center gap-3 mb-4">
89
+ <div className="w-9 h-9 rounded-xl bg-accent-lime flex items-center justify-center shrink-0">
90
+ <svg
91
+ className="w-4 h-4 text-gray-900"
92
+ fill="none"
93
+ stroke="currentColor"
94
+ viewBox="0 0 24 24"
95
+ >
96
+ <path
97
+ strokeLinecap="round"
98
+ strokeLinejoin="round"
99
+ strokeWidth={1.5}
100
+ d="M13 10V3L4 14h7v7l9-11h-7z"
101
+ />
102
+ </svg>
103
+ </div>
104
+ <div className="flex-1 min-w-0">
105
+ <div className="font-bold text-gray-900">
106
+ {sessionInfo.presetId}
107
+ </div>
108
+ <div className="text-[11px] text-gray-400 font-mono truncate">
109
+ {sessionInfo.id}
110
+ </div>
111
+ </div>
112
+ <StatusBadge status={sessionInfo.status} />
113
+ </div>
114
+ <div className="grid grid-cols-2 gap-x-5 gap-y-2.5">
115
+ {sessionInfo.createdAt !== null && (
116
+ <ConfigItem
117
+ label="Started"
118
+ value={new Date(sessionInfo.createdAt).toLocaleString()}
119
+ />
120
+ )}
121
+ {sessionInfo.closedAt ? (
122
+ <ConfigItem
123
+ label="Closed"
124
+ value={new Date(sessionInfo.closedAt).toLocaleString()}
125
+ />
126
+ ) : (
127
+ <div />
128
+ )}
129
+ <ConfigItem
130
+ label="Duration"
131
+ value={formatDuration(metrics.durationMs)}
132
+ />
133
+ {sessionInfo.workspaceDir && (
134
+ <ConfigItem
135
+ label="Workspace"
136
+ value={sessionInfo.workspaceDir}
137
+ mono
138
+ />
139
+ )}
140
+ </div>
141
+ </div>
142
+
143
+ {/* Metrics Card */}
144
+ <div className="lg:col-span-3 bg-white rounded-3xl shadow-soft p-5">
145
+ <h3 className="text-sm font-semibold text-gray-900 mb-4">Overview</h3>
146
+ <div className="grid grid-cols-3 gap-x-5 gap-y-4">
147
+ <MetricItem
148
+ label="Total Tokens"
149
+ value={metrics.totalTokens.toLocaleString()}
150
+ sub={`${metrics.promptTokens.toLocaleString()} / ${metrics.completionTokens.toLocaleString()}`}
151
+ />
152
+ <MetricItem
153
+ label="Cost"
154
+ value={
155
+ metrics.totalCost !== undefined && metrics.totalCost > 0
156
+ ? `$${metrics.totalCost.toFixed(4)}`
157
+ : "$0.00"
158
+ }
159
+ accent
160
+ />
161
+ <MetricItem
162
+ label="LLM Calls"
163
+ value={metrics.llmCalls.toString()}
164
+ error={
165
+ failedLLMCalls.length > 0
166
+ ? `${failedLLMCalls.length} failed`
167
+ : undefined
168
+ }
169
+ />
170
+ <MetricItem
171
+ label="Tool Calls"
172
+ value={metrics.toolCalls.toString()}
173
+ error={
174
+ failedToolCalls.length > 0
175
+ ? `${failedToolCalls.length} failed`
176
+ : undefined
177
+ }
178
+ />
179
+ <MetricItem label="Agents" value={metrics.agentCount.toString()} />
180
+ <MetricItem
181
+ label="Duration"
182
+ value={formatDuration(metrics.durationMs)}
183
+ />
184
+ </div>
185
+
186
+ {/* Per-provider breakdown */}
187
+ {Object.keys(metrics.byProvider).length > 0 && (
188
+ <div className="mt-4 pt-3 border-t border-gray-100">
189
+ <div className="text-[10px] font-medium uppercase tracking-wider text-gray-400 mb-2">
190
+ By Provider
191
+ </div>
192
+ <div className="space-y-1.5">
193
+ {Object.entries(metrics.byProvider).map(([name, p]) => (
194
+ <div key={name} className="flex items-center gap-3 text-xs">
195
+ <span className="font-medium text-gray-700 w-20">
196
+ {name}
197
+ </span>
198
+ <span className="text-gray-500 tabular-nums">
199
+ {p.llmCalls} calls
200
+ </span>
201
+ <span className="text-gray-500 tabular-nums">
202
+ {p.totalTokens.toLocaleString()} tok
203
+ </span>
204
+ {p.totalCost > 0 && (
205
+ <span className="text-green-600 tabular-nums">
206
+ ${p.totalCost.toFixed(4)}
207
+ </span>
208
+ )}
209
+ </div>
210
+ ))}
211
+ </div>
212
+ </div>
213
+ )}
214
+ </div>
215
+ </div>
216
+
217
+ {/* Activity Timeline */}
218
+ {timeline.length > 0 && <ActivityTimeline timeline={timeline} />}
219
+
220
+ {/* LLM Cost & Cache by Agent */}
221
+ <LLMCostByAgent agentNameById={agentNameById} />
222
+
223
+ {/* Bottom: Agents + Status */}
224
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
225
+ {/* Spawned Agents */}
226
+ <div className="bg-white rounded-3xl shadow-soft p-5">
227
+ <div className="flex items-center justify-between mb-3">
228
+ <div className="flex items-center gap-2.5">
229
+ <svg
230
+ className="w-4 h-4 text-gray-400"
231
+ fill="none"
232
+ stroke="currentColor"
233
+ viewBox="0 0 24 24"
234
+ >
235
+ <path
236
+ strokeLinecap="round"
237
+ strokeLinejoin="round"
238
+ strokeWidth={1.5}
239
+ d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
240
+ />
241
+ </svg>
242
+ <h3 className="text-sm font-semibold text-gray-900">
243
+ Spawned Agents
244
+ </h3>
245
+ </div>
246
+ <span className="text-[11px] bg-gray-100 text-gray-500 px-2 py-0.5 rounded-full font-medium tabular-nums">
247
+ {agents.length}
248
+ </span>
249
+ </div>
250
+ {agents.length === 0 ? (
251
+ <div className="text-gray-400 text-sm py-6 text-center">
252
+ No agents spawned
253
+ </div>
254
+ ) : (
255
+ <div className="space-y-1.5">
256
+ {agents.map((agent) => (
257
+ <button
258
+ key={agent.id}
259
+ onClick={() => debugNavigate(`agents/${agent.id}`)}
260
+ className="w-full bg-gray-50 rounded-xl px-3 py-2.5 flex items-center gap-2.5 hover:bg-gray-100 transition-colors text-left"
261
+ >
262
+ <div className="w-7 h-7 rounded-lg bg-white flex items-center justify-center shadow-card shrink-0">
263
+ <svg
264
+ className="w-3.5 h-3.5 text-gray-400"
265
+ fill="none"
266
+ stroke="currentColor"
267
+ viewBox="0 0 24 24"
268
+ >
269
+ <path
270
+ strokeLinecap="round"
271
+ strokeLinejoin="round"
272
+ strokeWidth={1.5}
273
+ d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
274
+ />
275
+ </svg>
276
+ </div>
277
+ <div className="flex-1 min-w-0">
278
+ <div className="font-medium text-sm text-gray-900 leading-tight">
279
+ {agent.definitionName}
280
+ </div>
281
+ <div className="text-[10px] text-gray-400 font-mono">
282
+ {agent.id.slice(0, 12)}
283
+ </div>
284
+ </div>
285
+ <AgentStatusBadge status={agent.status} />
286
+ <svg
287
+ className="w-3.5 h-3.5 text-gray-300 shrink-0"
288
+ fill="none"
289
+ stroke="currentColor"
290
+ viewBox="0 0 24 24"
291
+ >
292
+ <path
293
+ strokeLinecap="round"
294
+ strokeLinejoin="round"
295
+ strokeWidth={2}
296
+ d="M9 5l7 7-7 7"
297
+ />
298
+ </svg>
299
+ </button>
300
+ ))}
301
+ </div>
302
+ )}
303
+ </div>
304
+
305
+ {/* Status / Failed Operations */}
306
+ <div className="bg-white rounded-3xl shadow-soft p-5">
307
+ <div className="flex items-center gap-2.5 mb-3">
308
+ <svg
309
+ className="w-4 h-4 text-gray-400"
310
+ fill="none"
311
+ stroke="currentColor"
312
+ viewBox="0 0 24 24"
313
+ >
314
+ <path
315
+ strokeLinecap="round"
316
+ strokeLinejoin="round"
317
+ strokeWidth={1.5}
318
+ d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
319
+ />
320
+ </svg>
321
+ <h3 className="text-sm font-semibold text-gray-900">Status</h3>
322
+ </div>
323
+
324
+ {failedLLMCalls.length === 0 && failedToolCalls.length === 0 ? (
325
+ <div className="bg-accent-lime/15 rounded-xl py-6 flex flex-col items-center gap-2">
326
+ <div className="w-10 h-10 rounded-full bg-accent-lime flex items-center justify-center">
327
+ <svg
328
+ className="w-5 h-5 text-gray-900"
329
+ fill="none"
330
+ stroke="currentColor"
331
+ viewBox="0 0 24 24"
332
+ >
333
+ <path
334
+ strokeLinecap="round"
335
+ strokeLinejoin="round"
336
+ strokeWidth={2}
337
+ d="M5 13l4 4L19 7"
338
+ />
339
+ </svg>
340
+ </div>
341
+ <div className="text-sm font-semibold text-gray-900">
342
+ All operations healthy
343
+ </div>
344
+ <div className="text-[11px] text-gray-500">
345
+ No failures detected
346
+ </div>
347
+ </div>
348
+ ) : (
349
+ <div className="space-y-3">
350
+ {failedLLMCalls.length > 0 && (
351
+ <div>
352
+ <div className="flex items-center gap-1.5 mb-1.5">
353
+ <div className="w-1.5 h-1.5 rounded-full bg-red-500" />
354
+ <span className="text-[11px] font-semibold text-gray-600">
355
+ Failed LLM Calls ({failedLLMCalls.length})
356
+ </span>
357
+ </div>
358
+ <div className="space-y-1">
359
+ {failedLLMCalls.map((call) => (
360
+ <button
361
+ key={call.id}
362
+ onClick={() =>
363
+ debugNavigate(
364
+ `llm-calls/${call.llmCallId ?? call.id}`,
365
+ )
366
+ }
367
+ className="w-full text-left bg-red-50 rounded-lg px-3 py-2 hover:bg-red-100 transition-colors"
368
+ >
369
+ <div className="flex items-center gap-2 text-[11px]">
370
+ <span className="text-gray-400">
371
+ {new Date(call.startedAt).toLocaleTimeString()}
372
+ </span>
373
+ <span className="font-mono font-medium text-gray-700">
374
+ {call.agentName}
375
+ </span>
376
+ {call.model && (
377
+ <span className="text-gray-400">{call.model}</span>
378
+ )}
379
+ </div>
380
+ {call.error && (
381
+ <div className="text-[11px] text-red-600 mt-0.5 truncate">
382
+ {call.error}
383
+ </div>
384
+ )}
385
+ </button>
386
+ ))}
387
+ </div>
388
+ </div>
389
+ )}
390
+
391
+ {failedToolCalls.length > 0 && (
392
+ <div>
393
+ <div className="flex items-center gap-1.5 mb-1.5">
394
+ <div className="w-1.5 h-1.5 rounded-full bg-red-500" />
395
+ <span className="text-[11px] font-semibold text-gray-600">
396
+ Failed Tool Calls ({failedToolCalls.length})
397
+ </span>
398
+ </div>
399
+ <div className="space-y-1">
400
+ {failedToolCalls.map((call) => (
401
+ <div
402
+ key={call.id}
403
+ className="bg-red-50 rounded-lg px-3 py-2"
404
+ >
405
+ <div className="flex items-center gap-2 text-[11px]">
406
+ <span className="text-gray-400">
407
+ {new Date(call.startedAt).toLocaleTimeString()}
408
+ </span>
409
+ <span className="font-mono font-medium text-gray-700">
410
+ {call.agentName}
411
+ </span>
412
+ <span className="font-medium text-gray-600">
413
+ {call.toolName}
414
+ </span>
415
+ </div>
416
+ {call.error && (
417
+ <div className="text-[11px] text-red-600 mt-0.5 truncate">
418
+ {call.error}
419
+ </div>
420
+ )}
421
+ </div>
422
+ ))}
423
+ </div>
424
+ </div>
425
+ )}
426
+ </div>
427
+ )}
428
+ </div>
429
+ </div>
430
+ </div>
431
+ );
432
+ }
433
+
434
+ function ConfigItem({
435
+ label,
436
+ value,
437
+ mono,
438
+ }: {
439
+ label: string;
440
+ value: string;
441
+ mono?: boolean;
442
+ }) {
443
+ return (
444
+ <div>
445
+ <div className="text-[10px] text-gray-400 uppercase tracking-wider font-medium">
446
+ {label}
447
+ </div>
448
+ <div
449
+ className={`text-gray-700 truncate ${mono ? "font-mono text-[11px]" : "text-sm"}`}
450
+ title={value}
451
+ >
452
+ {value}
453
+ </div>
454
+ </div>
455
+ );
456
+ }
457
+
458
+ function MetricItem({
459
+ label,
460
+ value,
461
+ sub,
462
+ error,
463
+ accent,
464
+ }: {
465
+ label: string;
466
+ value: string;
467
+ sub?: string;
468
+ error?: string;
469
+ accent?: boolean;
470
+ }) {
471
+ return (
472
+ <div>
473
+ <div className="text-[10px] font-medium uppercase tracking-wider text-gray-400 mb-0.5">
474
+ {label}
475
+ </div>
476
+ <div
477
+ className={`text-xl font-bold tabular-nums tracking-tight ${accent ? "text-green-600" : "text-gray-900"}`}
478
+ >
479
+ {value}
480
+ </div>
481
+ {sub && (
482
+ <div className="text-[10px] text-gray-400 tabular-nums mt-0.5">
483
+ {sub}
484
+ </div>
485
+ )}
486
+ {error && (
487
+ <div className="text-[10px] text-red-500 font-medium mt-0.5">
488
+ {error}
489
+ </div>
490
+ )}
491
+ </div>
492
+ );
493
+ }
494
+
495
+ function StatusBadge({ status }: { status: "active" | "closed" }) {
496
+ return status === "active" ? (
497
+ <div className="flex items-center gap-1.5 bg-accent-lime/30 px-2.5 py-1 rounded-full">
498
+ <div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
499
+ <span className="text-[11px] font-semibold text-gray-700">Active</span>
500
+ </div>
501
+ ) : (
502
+ <div className="flex items-center gap-1.5 bg-gray-100 px-2.5 py-1 rounded-full">
503
+ <div className="w-1.5 h-1.5 rounded-full bg-gray-400" />
504
+ <span className="text-[11px] font-semibold text-gray-500">Closed</span>
505
+ </div>
506
+ );
507
+ }
508
+
509
+ function AgentStatusBadge({ status }: { status: string }) {
510
+ const styles: Record<string, string> = {
511
+ pending: "bg-gray-100 text-gray-600",
512
+ inferring: "bg-accent-lime/30 text-gray-700",
513
+ tool_exec: "bg-accent-peri/30 text-gray-700",
514
+ errored: "bg-red-100 text-red-700",
515
+ done: "bg-green-100 text-green-700",
516
+ };
517
+ return (
518
+ <span
519
+ className={`text-[10px] px-2 py-0.5 rounded-full font-semibold ${styles[status] ?? "bg-gray-100 text-gray-600"}`}
520
+ >
521
+ {status}
522
+ </span>
523
+ );
524
+ }
525
+
526
+ // ============================================================================
527
+ // LLM Cost & Cache by Agent
528
+ // ============================================================================
529
+
530
+ interface AgentCostRow {
531
+ agentId: AgentId;
532
+ agentName: string;
533
+ calls: number;
534
+ errors: number;
535
+ cost: number;
536
+ uncachedPromptTokens: number;
537
+ cachedTokens: number;
538
+ cacheWriteTokens: number;
539
+ completionTokens: number;
540
+ }
541
+
542
+ function formatTokens(n: number): string {
543
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
544
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
545
+ return n.toString();
546
+ }
547
+
548
+ function ratioColor(ratio: number): string {
549
+ if (ratio >= 0.7) return "text-green-600";
550
+ if (ratio >= 0.4) return "text-amber-600";
551
+ return "text-red-600";
552
+ }
553
+
554
+ function LLMCostByAgent({
555
+ agentNameById,
556
+ }: {
557
+ agentNameById: Map<AgentId, string>;
558
+ }) {
559
+ const debugNavigate = useDebugNavigate();
560
+ const timeline = useTimeline();
561
+
562
+ const { rows, totals } = useMemo(() => {
563
+ const llmItems = timeline.filter((it) => it.type === "llm");
564
+ const byAgent = new Map<AgentId, AgentCostRow>();
565
+ let totalCost = 0;
566
+ let totalUncachedPrompt = 0;
567
+ let totalCached = 0;
568
+ let totalCacheWrite = 0;
569
+ let totalCompletion = 0;
570
+ let totalCalls = 0;
571
+ let totalErrors = 0;
572
+
573
+ for (const item of llmItems) {
574
+ const isError = item.status === "error";
575
+ let row = byAgent.get(item.agentId);
576
+ if (!row) {
577
+ row = {
578
+ agentId: item.agentId,
579
+ agentName: agentNameById.get(item.agentId) ?? item.agentId,
580
+ calls: 0,
581
+ errors: 0,
582
+ cost: 0,
583
+ uncachedPromptTokens: 0,
584
+ cachedTokens: 0,
585
+ cacheWriteTokens: 0,
586
+ completionTokens: 0,
587
+ };
588
+ byAgent.set(item.agentId, row);
589
+ }
590
+ row.calls += 1;
591
+ totalCalls += 1;
592
+ if (isError) {
593
+ row.errors += 1;
594
+ totalErrors += 1;
595
+ }
596
+ // promptTokens (= Anthropic input_tokens) is TOTAL input including cached/cache-write.
597
+ // Uncached = total - cached - cache_write.
598
+ const totalInput = item.promptTokens ?? 0;
599
+ const cached = item.cachedTokens ?? 0;
600
+ const cacheWrite = item.cacheWriteTokens ?? 0;
601
+ const uncached = totalInput - cached - cacheWrite;
602
+
603
+ row.cost += item.cost ?? 0;
604
+ row.uncachedPromptTokens += uncached;
605
+ row.cachedTokens += cached;
606
+ row.cacheWriteTokens += cacheWrite;
607
+ row.completionTokens += item.completionTokens ?? 0;
608
+ totalCost += item.cost ?? 0;
609
+ totalUncachedPrompt += uncached;
610
+ totalCached += cached;
611
+ totalCacheWrite += cacheWrite;
612
+ totalCompletion += item.completionTokens ?? 0;
613
+ }
614
+
615
+ const sorted = Array.from(byAgent.values()).sort((a, b) => b.cost - a.cost);
616
+ // Cache ratio: cached / total input (= uncached + cached + cacheWrite)
617
+ const totalInputAll = totalUncachedPrompt + totalCached + totalCacheWrite;
618
+ const cacheRatio = totalInputAll > 0 ? totalCached / totalInputAll : 0;
619
+
620
+ return {
621
+ rows: sorted,
622
+ totals: {
623
+ calls: totalCalls,
624
+ errors: totalErrors,
625
+ cost: totalCost,
626
+ uncachedPromptTokens: totalUncachedPrompt,
627
+ cachedTokens: totalCached,
628
+ cacheWriteTokens: totalCacheWrite,
629
+ completionTokens: totalCompletion,
630
+ cacheRatio,
631
+ },
632
+ };
633
+ }, [timeline, agentNameById]);
634
+
635
+ if (rows.length === 0) {
636
+ return null;
637
+ }
638
+
639
+ const maxCost = Math.max(...rows.map((r) => r.cost), 0.0001);
640
+
641
+ return (
642
+ <div className="bg-white rounded-3xl shadow-soft p-5">
643
+ <div className="flex items-center justify-between mb-4">
644
+ <div>
645
+ <h3 className="text-sm font-semibold text-gray-900">
646
+ LLM Cost & Cache by Agent
647
+ </h3>
648
+ <div className="text-[10px] text-gray-400 mt-0.5">
649
+ Cache ratio = cached / (cached + uncached input). Low ratio means
650
+ the prompt cache isn't being reused effectively.
651
+ </div>
652
+ </div>
653
+ <div className="flex items-center gap-5">
654
+ <SummaryStat
655
+ label="Total Cost"
656
+ value={`$${totals.cost.toFixed(4)}`}
657
+ accent
658
+ />
659
+ <SummaryStat
660
+ label="Cache Hit Ratio"
661
+ value={`${(totals.cacheRatio * 100).toFixed(1)}%`}
662
+ valueClass={ratioColor(totals.cacheRatio)}
663
+ />
664
+ <SummaryStat
665
+ label="Uncached Input"
666
+ value={formatTokens(totals.uncachedPromptTokens)}
667
+ />
668
+ <SummaryStat
669
+ label="Cached Read"
670
+ value={formatTokens(totals.cachedTokens)}
671
+ />
672
+ <SummaryStat
673
+ label="Cache Write"
674
+ value={formatTokens(totals.cacheWriteTokens)}
675
+ />
676
+ <SummaryStat
677
+ label="Completion"
678
+ value={formatTokens(totals.completionTokens)}
679
+ />
680
+ </div>
681
+ </div>
682
+
683
+ <div className="overflow-x-auto">
684
+ <table className="w-full text-xs">
685
+ <thead>
686
+ <tr className="text-[10px] font-medium uppercase tracking-wider text-gray-400 border-b border-gray-100">
687
+ <th className="text-left py-2 pl-1 font-medium">Agent</th>
688
+ <th className="text-right py-2 font-medium">Calls</th>
689
+ <th className="text-right py-2 font-medium">Cost</th>
690
+ <th
691
+ className="py-2 pl-4 pr-2 font-medium"
692
+ style={{ width: "34%" }}
693
+ >
694
+ Cost share
695
+ </th>
696
+ <th className="text-right py-2 font-medium">Uncached In</th>
697
+ <th className="text-right py-2 font-medium">Cached</th>
698
+ <th className="text-right py-2 font-medium">Cache Write</th>
699
+ <th className="text-right py-2 font-medium">Out</th>
700
+ <th className="text-right py-2 pr-1 font-medium">Cache %</th>
701
+ </tr>
702
+ </thead>
703
+ <tbody>
704
+ {rows.map((r) => {
705
+ const inputTotal =
706
+ r.uncachedPromptTokens + r.cachedTokens + r.cacheWriteTokens;
707
+ const cacheRatio =
708
+ inputTotal > 0 ? r.cachedTokens / inputTotal : 0;
709
+ const costPct = (r.cost / maxCost) * 100;
710
+ return (
711
+ <tr
712
+ key={r.agentId}
713
+ className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer"
714
+ onClick={() => debugNavigate(`agents/${r.agentId}`)}
715
+ >
716
+ <td className="py-2 pl-1">
717
+ <div className="font-medium text-gray-900">
718
+ {r.agentName}
719
+ </div>
720
+ <div className="text-[10px] text-gray-400 font-mono">
721
+ {r.agentId.slice(0, 12)}
722
+ </div>
723
+ </td>
724
+ <td className="text-right py-2 tabular-nums text-gray-700">
725
+ {r.calls}
726
+ {r.errors > 0 && (
727
+ <span className="text-red-500 ml-1">({r.errors}!)</span>
728
+ )}
729
+ </td>
730
+ <td className="text-right py-2 tabular-nums font-semibold text-green-700">
731
+ ${r.cost.toFixed(4)}
732
+ </td>
733
+ <td className="py-2 pl-4 pr-2">
734
+ <div className="h-2 bg-gray-100 rounded-full overflow-hidden">
735
+ <div
736
+ className="h-full bg-green-500 rounded-full"
737
+ style={{ width: `${costPct}%` }}
738
+ />
739
+ </div>
740
+ </td>
741
+ <td className="text-right py-2 tabular-nums text-gray-700">
742
+ {formatTokens(r.uncachedPromptTokens)}
743
+ </td>
744
+ <td className="text-right py-2 tabular-nums text-gray-500">
745
+ {formatTokens(r.cachedTokens)}
746
+ </td>
747
+ <td className="text-right py-2 tabular-nums text-gray-500">
748
+ {formatTokens(r.cacheWriteTokens)}
749
+ </td>
750
+ <td className="text-right py-2 tabular-nums text-gray-500">
751
+ {formatTokens(r.completionTokens)}
752
+ </td>
753
+ <td
754
+ className={`text-right py-2 pr-1 tabular-nums font-semibold ${ratioColor(cacheRatio)}`}
755
+ >
756
+ {(cacheRatio * 100).toFixed(0)}%
757
+ </td>
758
+ </tr>
759
+ );
760
+ })}
761
+ </tbody>
762
+ </table>
763
+ </div>
764
+ </div>
765
+ );
766
+ }
767
+
768
+ function SummaryStat({
769
+ label,
770
+ value,
771
+ accent,
772
+ valueClass,
773
+ }: {
774
+ label: string;
775
+ value: string;
776
+ accent?: boolean;
777
+ valueClass?: string;
778
+ }) {
779
+ return (
780
+ <div className="text-right">
781
+ <div className="text-[9px] font-medium uppercase tracking-wider text-gray-400">
782
+ {label}
783
+ </div>
784
+ <div
785
+ className={`text-sm font-bold tabular-nums ${
786
+ valueClass ?? (accent ? "text-green-600" : "text-gray-900")
787
+ }`}
788
+ >
789
+ {value}
790
+ </div>
791
+ </div>
792
+ );
793
+ }
794
+
795
+ function ResumeAgentButton({
796
+ sessionId,
797
+ agentId,
798
+ }: {
799
+ sessionId: string;
800
+ agentId: string;
801
+ }) {
802
+ const [resuming, setResuming] = useState(false);
803
+
804
+ const handleResume = useCallback(
805
+ async (e: FormEvent) => {
806
+ e.stopPropagation();
807
+ setResuming(true);
808
+ try {
809
+ unwrap(
810
+ await api.call("agents.resume", {
811
+ sessionId,
812
+ agentId: AgentId(agentId),
813
+ }),
814
+ );
815
+ } catch {
816
+ // Error is visible via state change (or lack thereof)
817
+ } finally {
818
+ setResuming(false);
819
+ }
820
+ },
821
+ [sessionId, agentId],
822
+ );
823
+
824
+ return (
825
+ <button
826
+ type="button"
827
+ onClick={handleResume}
828
+ disabled={resuming}
829
+ className="text-[11px] font-semibold text-white bg-red-600 hover:bg-red-700 px-2.5 py-1 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed transition-colors shrink-0 cursor-pointer"
830
+ >
831
+ {resuming ? "Resuming..." : "Resume"}
832
+ </button>
833
+ );
834
+ }
835
+
836
+ function formatChartTime(ts: number, rangeMs: number): string {
837
+ const d = new Date(ts);
838
+ const hh = d.getHours().toString().padStart(2, "0");
839
+ const mm = d.getMinutes().toString().padStart(2, "0");
840
+ const ss = d.getSeconds().toString().padStart(2, "0");
841
+ if (rangeMs < 120_000) return `${mm}:${ss}`;
842
+ return `${hh}:${mm}:${ss}`;
843
+ }
844
+
845
+ const MAX_LANES = 8;
846
+
847
+ type NarrowItem = {
848
+ x: number;
849
+ type: "llm" | "tool";
850
+ isError: boolean;
851
+ source: TimelineItem;
852
+ };
853
+ type NarrowCluster = { centerX: number; items: NarrowItem[] };
854
+
855
+ /** Group nearby narrow items into clusters for fan-marker rendering */
856
+ function groupNarrowItems(
857
+ narrowItems: NarrowItem[],
858
+ minDist = 10,
859
+ ): NarrowCluster[] {
860
+ const clusters: Array<NarrowCluster & { sumX: number }> = [];
861
+ for (const item of narrowItems) {
862
+ const existing = clusters.find(
863
+ (c) => Math.abs(c.centerX - item.x) < minDist,
864
+ );
865
+ if (existing) {
866
+ existing.sumX += item.x;
867
+ existing.items.push(item);
868
+ existing.centerX = existing.sumX / existing.items.length;
869
+ } else {
870
+ clusters.push({ centerX: item.x, items: [item], sumX: item.x });
871
+ }
872
+ }
873
+ return clusters;
874
+ }
875
+
876
+ function dotColor(item: NarrowItem): string {
877
+ if (item.isError) return "#EF4444";
878
+ return item.type === "llm" ? "#7CA3F0" : "#84CC16";
879
+ }
880
+
881
+ function ActivityTimeline({ timeline }: { timeline: TimelineItem[] }) {
882
+ const sessionId = useEventStore((s) => s.sessionInfoState.id ?? "");
883
+ const items = timeline.filter((it) => it.type !== "compaction");
884
+ const hasItems = items.length > 0;
885
+
886
+ // Time range
887
+ const allTimes = items.flatMap((it) => [
888
+ it.startedAt,
889
+ ...(it.completedAt ? [it.completedAt] : []),
890
+ ]);
891
+ const minTime = Math.min(...allTimes);
892
+ const maxTime = Math.max(...allTimes);
893
+ const timeRange = Math.max(maxTime - minTime, 1000);
894
+
895
+ // Group by agent, sorted by first appearance
896
+ const agentMap = new Map<string, TimelineItem[]>();
897
+ for (const item of items) {
898
+ const list = agentMap.get(item.agentName) ?? [];
899
+ list.push(item);
900
+ agentMap.set(item.agentName, list);
901
+ }
902
+ const allAgents = [...agentMap.entries()].sort(
903
+ (a, b) =>
904
+ Math.min(...a[1].map((i) => i.startedAt)) -
905
+ Math.min(...b[1].map((i) => i.startedAt)),
906
+ );
907
+ const displayAgents = allAgents.slice(0, MAX_LANES);
908
+ const hiddenCount = allAgents.length - displayAgents.length;
909
+
910
+ // Layout constants
911
+ const leftMargin = 64;
912
+ const rightMargin = 6;
913
+ const laneHeight = 12;
914
+ const chartWidth = 900;
915
+ const bottomMargin = 10;
916
+ const contentWidth = chartWidth - leftMargin - rightMargin;
917
+ const minBarWidth = 5;
918
+
919
+ const timeToX = (t: number) =>
920
+ leftMargin + ((t - minTime) / timeRange) * contentWidth;
921
+
922
+ // Pre-compute which items are narrow (too small to see as bars) per lane
923
+ const laneNarrowItems = displayAgents.map(([, agentItems]) => {
924
+ const narrow: NarrowItem[] = [];
925
+ for (const item of agentItems) {
926
+ const x1 = timeToX(item.startedAt);
927
+ const x2 = timeToX(item.completedAt ?? maxTime);
928
+ const w = x2 - x1;
929
+ if (w < minBarWidth) {
930
+ narrow.push({
931
+ x: x1,
932
+ type: item.type as "llm" | "tool",
933
+ isError: item.status === "error",
934
+ source: item,
935
+ });
936
+ }
937
+ }
938
+ return narrow;
939
+ });
940
+ const hasNarrowItems = laneNarrowItems.some((n) => n.length > 0);
941
+
942
+ const laneGap = hasNarrowItems ? 8 : 2;
943
+ const topMargin = hasNarrowItems ? 8 : 0;
944
+ const lanesHeight =
945
+ topMargin +
946
+ displayAgents.length * laneHeight +
947
+ Math.max(0, displayAgents.length - 1) * laneGap;
948
+ const totalHeight = lanesHeight + bottomMargin;
949
+
950
+ const getLaneY = (index: number) =>
951
+ topMargin + index * (laneHeight + laneGap);
952
+
953
+ // Time labels (~5)
954
+ const labelCount = 5;
955
+ const timeLabels = Array.from({ length: labelCount }, (_, i) => {
956
+ const t = minTime + (i / (labelCount - 1)) * timeRange;
957
+ return { x: timeToX(t), label: formatChartTime(t, timeRange) };
958
+ });
959
+
960
+ // Fan marker geometry
961
+ const stemHeight = 5;
962
+ const dotRadius = 1.5;
963
+ const dotSpacing = 4;
964
+ const minDotGap = dotRadius * 2 + 1;
965
+
966
+ // Stats
967
+ const llmItems = items.filter(
968
+ (it) => it.type === "llm" && it.durationMs !== undefined,
969
+ );
970
+ const toolItems = items.filter(
971
+ (it) => it.type === "tool" && it.durationMs !== undefined,
972
+ );
973
+ const avgLlmMs =
974
+ llmItems.length > 0
975
+ ? llmItems.reduce((s, it) => s + (it.durationMs ?? 0), 0) /
976
+ llmItems.length
977
+ : 0;
978
+ const totalLlmMs = llmItems.reduce((s, it) => s + (it.durationMs ?? 0), 0);
979
+ const totalToolMs = toolItems.reduce((s, it) => s + (it.durationMs ?? 0), 0);
980
+ const totalMs = totalLlmMs + totalToolMs;
981
+ const llmPercent = totalMs > 0 ? Math.round((totalLlmMs / totalMs) * 100) : 0;
982
+
983
+ const completedItems = items.filter((it) => it.durationMs !== undefined);
984
+ const longest =
985
+ completedItems.length > 0
986
+ ? completedItems.reduce((max, it) =>
987
+ (it.durationMs ?? 0) > (max.durationMs ?? 0) ? it : max,
988
+ )
989
+ : null;
990
+
991
+ // Hover popover state
992
+ const containerRef = useRef<HTMLDivElement>(null);
993
+ const debugNavigate = useDebugNavigate();
994
+ const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
995
+ const [tooltip, setTooltip] = useState<{
996
+ item: TimelineItem;
997
+ x: number;
998
+ y: number;
999
+ } | null>(null);
1000
+ const [modalItem, setModalItem] = useState<TimelineItem | null>(null);
1001
+
1002
+ const showTooltip = useCallback((item: TimelineItem, e: React.MouseEvent) => {
1003
+ if (hideTimer.current) clearTimeout(hideTimer.current);
1004
+ const rect = containerRef.current?.getBoundingClientRect();
1005
+ if (!rect) return;
1006
+ setTooltip({ item, x: e.clientX - rect.left, y: e.clientY - rect.top });
1007
+ }, []);
1008
+
1009
+ const scheduleHide = useCallback(() => {
1010
+ hideTimer.current = setTimeout(() => setTooltip(null), 150);
1011
+ }, []);
1012
+
1013
+ const cancelHide = useCallback(() => {
1014
+ if (hideTimer.current) clearTimeout(hideTimer.current);
1015
+ }, []);
1016
+
1017
+ if (!hasItems) return null;
1018
+
1019
+ return (
1020
+ <div className="bg-white rounded-3xl shadow-soft p-5">
1021
+ <h3 className="text-sm font-semibold text-gray-900 mb-2">
1022
+ Activity Timeline
1023
+ </h3>
1024
+
1025
+ {/* Chart + popover container */}
1026
+ <div ref={containerRef} className="relative">
1027
+ <svg
1028
+ viewBox={`0 0 ${chartWidth} ${totalHeight}`}
1029
+ className="w-full"
1030
+ preserveAspectRatio="xMidYMid meet"
1031
+ >
1032
+ {/* Vertical grid lines */}
1033
+ {timeLabels.map(({ x }, i) => (
1034
+ <line
1035
+ key={`grid-${i}`}
1036
+ x1={x}
1037
+ y1={topMargin}
1038
+ x2={x}
1039
+ y2={lanesHeight}
1040
+ stroke="#F3F4F6"
1041
+ strokeWidth={0.5}
1042
+ />
1043
+ ))}
1044
+
1045
+ {/* Agent lanes */}
1046
+ {displayAgents.map(([agentName, agentItems], laneIndex) => {
1047
+ const y = getLaneY(laneIndex);
1048
+ const narrowItems = laneNarrowItems[laneIndex];
1049
+ const clusters = groupNarrowItems(narrowItems);
1050
+
1051
+ // Wide items render as bars, narrow items render as fan markers
1052
+ const wideItems = agentItems.filter((item) => {
1053
+ const x1 = timeToX(item.startedAt);
1054
+ const x2 = timeToX(item.completedAt ?? maxTime);
1055
+ return x2 - x1 >= minBarWidth;
1056
+ });
1057
+
1058
+ return (
1059
+ <g key={agentName}>
1060
+ {/* Agent name */}
1061
+ <text
1062
+ x={leftMargin - 4}
1063
+ y={y + laneHeight / 2 + 2}
1064
+ textAnchor="end"
1065
+ fill="#6B7280"
1066
+ fontSize={5}
1067
+ fontFamily="ui-monospace, monospace"
1068
+ >
1069
+ {agentName.length > 12
1070
+ ? agentName.slice(0, 12) + "\u2026"
1071
+ : agentName}
1072
+ </text>
1073
+
1074
+ {/* Lane background */}
1075
+ <rect
1076
+ x={leftMargin}
1077
+ y={y}
1078
+ width={contentWidth}
1079
+ height={laneHeight}
1080
+ fill="#F4F5F7"
1081
+ rx={1}
1082
+ />
1083
+
1084
+ {/* Wide operation bars */}
1085
+ {wideItems.map((item) => {
1086
+ const x1 = timeToX(item.startedAt);
1087
+ const x2 = timeToX(item.completedAt ?? maxTime);
1088
+ const w = x2 - x1;
1089
+ const isRunning = item.status === "running";
1090
+ const isError = item.status === "error";
1091
+
1092
+ return (
1093
+ <rect
1094
+ key={item.id}
1095
+ x={x1}
1096
+ y={y + 2}
1097
+ width={w}
1098
+ height={laneHeight - 4}
1099
+ fill={
1100
+ isError
1101
+ ? "#EF4444"
1102
+ : item.type === "llm"
1103
+ ? "#7CA3F0"
1104
+ : "#84CC16"
1105
+ }
1106
+ rx={1}
1107
+ opacity={isRunning ? 0.4 : 1}
1108
+ strokeDasharray={isRunning ? "3 2" : undefined}
1109
+ stroke={isRunning ? "#6B7280" : "none"}
1110
+ strokeWidth={isRunning ? 0.5 : 0}
1111
+ style={{ cursor: "pointer" }}
1112
+ onMouseEnter={(e) => showTooltip(item, e)}
1113
+ onMouseLeave={scheduleHide}
1114
+ onClick={() => setModalItem(item)}
1115
+ />
1116
+ );
1117
+ })}
1118
+
1119
+ {/* Fan markers for narrow/clustered items — globally resolved per lane */}
1120
+ {(() => {
1121
+ if (clusters.length === 0) return null;
1122
+
1123
+ // Collect all dots across all clusters
1124
+ const allDots: Array<{
1125
+ x: number;
1126
+ color: string;
1127
+ clusterX: number;
1128
+ stemColor: string;
1129
+ source: TimelineItem;
1130
+ }> = [];
1131
+ const ticks: Array<{ x: number; hasError: boolean }> = [];
1132
+
1133
+ for (const cluster of clusters) {
1134
+ const bx = cluster.centerX;
1135
+ const hasErr = cluster.items.some((it) => it.isError);
1136
+ ticks.push({ x: bx, hasError: hasErr });
1137
+ const fw =
1138
+ Math.max(0, cluster.items.length - 1) * dotSpacing;
1139
+ const sc = hasErr ? "#EF4444" : "#9CA3AF";
1140
+ for (let i = 0; i < cluster.items.length; i++) {
1141
+ allDots.push({
1142
+ x: bx - fw / 2 + i * dotSpacing,
1143
+ color: dotColor(cluster.items[i]),
1144
+ clusterX: bx,
1145
+ stemColor: sc,
1146
+ source: cluster.items[i].source,
1147
+ });
1148
+ }
1149
+ }
1150
+
1151
+ // Sort by x, resolve overlaps, then clamp with backward pass
1152
+ allDots.sort((a, b) => a.x - b.x);
1153
+ const xMin = leftMargin + dotRadius;
1154
+ const xMax = leftMargin + contentWidth - dotRadius;
1155
+
1156
+ // Forward pass: push right to resolve overlaps
1157
+ for (let i = 1; i < allDots.length; i++) {
1158
+ if (allDots[i].x - allDots[i - 1].x < minDotGap) {
1159
+ allDots[i] = {
1160
+ ...allDots[i],
1161
+ x: allDots[i - 1].x + minDotGap,
1162
+ };
1163
+ }
1164
+ }
1165
+
1166
+ // Clamp rightmost, then backward pass to spread left
1167
+ if (
1168
+ allDots.length > 0 &&
1169
+ allDots[allDots.length - 1].x > xMax
1170
+ ) {
1171
+ allDots[allDots.length - 1] = {
1172
+ ...allDots[allDots.length - 1],
1173
+ x: xMax,
1174
+ };
1175
+ }
1176
+ for (let i = allDots.length - 2; i >= 0; i--) {
1177
+ if (allDots[i + 1].x - allDots[i].x < minDotGap) {
1178
+ allDots[i] = {
1179
+ ...allDots[i],
1180
+ x: allDots[i + 1].x - minDotGap,
1181
+ };
1182
+ }
1183
+ }
1184
+
1185
+ // Clamp leftmost
1186
+ if (allDots.length > 0 && allDots[0].x < xMin) {
1187
+ allDots[0] = { ...allDots[0], x: xMin };
1188
+ }
1189
+
1190
+ const dotY = y - stemHeight;
1191
+
1192
+ return (
1193
+ <>
1194
+ {ticks.map((tick, ti) => (
1195
+ <line
1196
+ key={`t-${ti}`}
1197
+ x1={tick.x}
1198
+ y1={y}
1199
+ x2={tick.x}
1200
+ y2={y + laneHeight}
1201
+ stroke={tick.hasError ? "#EF4444" : "#9CA3AF"}
1202
+ strokeWidth={tick.hasError ? 1.5 : 1}
1203
+ />
1204
+ ))}
1205
+ {allDots.map((dot, di) => (
1206
+ <g
1207
+ key={`fd-${di}`}
1208
+ style={{ cursor: "pointer" }}
1209
+ onMouseEnter={(e) => showTooltip(dot.source, e)}
1210
+ onMouseLeave={scheduleHide}
1211
+ onClick={() => setModalItem(dot.source)}
1212
+ >
1213
+ <path
1214
+ d={`M ${dot.clusterX} ${y - 0.5} Q ${dot.clusterX} ${y - stemHeight * 0.4}, ${dot.x} ${dotY}`}
1215
+ fill="none"
1216
+ stroke={dot.stemColor}
1217
+ strokeWidth={0.5}
1218
+ opacity={0.3}
1219
+ />
1220
+ {/* Invisible hit area for small dots */}
1221
+ <circle
1222
+ cx={dot.x}
1223
+ cy={dotY}
1224
+ r={dotRadius + 3}
1225
+ fill="transparent"
1226
+ />
1227
+ <circle
1228
+ cx={dot.x}
1229
+ cy={dotY}
1230
+ r={dotRadius}
1231
+ fill={dot.color}
1232
+ />
1233
+ </g>
1234
+ ))}
1235
+ </>
1236
+ );
1237
+ })()}
1238
+ </g>
1239
+ );
1240
+ })}
1241
+
1242
+ {/* Time axis labels */}
1243
+ {timeLabels.map(({ x, label }, i) => (
1244
+ <text
1245
+ key={`time-${i}`}
1246
+ x={x}
1247
+ y={totalHeight - 1}
1248
+ textAnchor="middle"
1249
+ fill="#9CA3AF"
1250
+ fontSize={5}
1251
+ >
1252
+ {label}
1253
+ </text>
1254
+ ))}
1255
+ </svg>
1256
+
1257
+ {/* Hover tooltip */}
1258
+ {tooltip && (
1259
+ <div
1260
+ className="absolute z-50 bg-white rounded-xl shadow-lg border border-gray-100 py-2 px-2.5 text-[11px] pointer-events-auto"
1261
+ style={{
1262
+ left: tooltip.x,
1263
+ top: tooltip.y - 8,
1264
+ transform: "translate(-50%, -100%)",
1265
+ }}
1266
+ onMouseEnter={cancelHide}
1267
+ onMouseLeave={scheduleHide}
1268
+ >
1269
+ <HoverTooltipContent
1270
+ item={tooltip.item}
1271
+ onViewDetail={() => {
1272
+ setModalItem(tooltip.item);
1273
+ setTooltip(null);
1274
+ }}
1275
+ />
1276
+ </div>
1277
+ )}
1278
+ </div>
1279
+
1280
+ {hiddenCount > 0 && (
1281
+ <div className="text-[10px] text-gray-400 mt-0.5 text-right">
1282
+ +{hiddenCount} more agents
1283
+ </div>
1284
+ )}
1285
+
1286
+ {/* Footer: legend + stats */}
1287
+ <div className="flex items-center gap-4 mt-3 pt-2.5 border-t border-gray-100 text-[11px] text-gray-500">
1288
+ <div className="flex items-center gap-1.5">
1289
+ <div
1290
+ className="w-2 h-2 rounded-sm"
1291
+ style={{ backgroundColor: "#7CA3F0" }}
1292
+ />
1293
+ <span>LLM</span>
1294
+ </div>
1295
+ <div className="flex items-center gap-1.5">
1296
+ <div
1297
+ className="w-2 h-2 rounded-sm"
1298
+ style={{ backgroundColor: "#84CC16" }}
1299
+ />
1300
+ <span>Tool</span>
1301
+ </div>
1302
+ <div className="flex items-center gap-1.5">
1303
+ <div
1304
+ className="w-2 h-2 rounded-full"
1305
+ style={{ backgroundColor: "#EF4444" }}
1306
+ />
1307
+ <span>Error</span>
1308
+ </div>
1309
+ {hasNarrowItems && (
1310
+ <div className="flex items-center gap-1.5">
1311
+ <svg width="8" height="8" viewBox="0 0 8 8">
1312
+ <circle cx="2" cy="2" r="1.5" fill="#9CA3AF" />
1313
+ <circle cx="6" cy="2" r="1.5" fill="#9CA3AF" />
1314
+ <line
1315
+ x1="4"
1316
+ y1="7"
1317
+ x2="2"
1318
+ y2="3"
1319
+ stroke="#9CA3AF"
1320
+ strokeWidth="0.5"
1321
+ />
1322
+ <line
1323
+ x1="4"
1324
+ y1="7"
1325
+ x2="6"
1326
+ y2="3"
1327
+ stroke="#9CA3AF"
1328
+ strokeWidth="0.5"
1329
+ />
1330
+ </svg>
1331
+ <span>Clustered</span>
1332
+ </div>
1333
+ )}
1334
+
1335
+ {completedItems.length > 0 && (
1336
+ <>
1337
+ <div className="w-px h-3 bg-gray-200 ml-1" />
1338
+ {llmItems.length > 0 && (
1339
+ <div>
1340
+ <span className="text-gray-400">Avg LLM </span>
1341
+ <span className="font-semibold text-gray-700 tabular-nums">
1342
+ {formatDuration(avgLlmMs)}
1343
+ </span>
1344
+ </div>
1345
+ )}
1346
+ {longest && (
1347
+ <div>
1348
+ <span className="text-gray-400">Longest </span>
1349
+ <span className="font-semibold text-gray-700 tabular-nums">
1350
+ {formatDuration(longest.durationMs ?? 0)}
1351
+ </span>
1352
+ <span className="text-gray-400 ml-1">
1353
+ {longest.toolName ?? longest.model ?? ""}
1354
+ </span>
1355
+ </div>
1356
+ )}
1357
+ {totalMs > 0 && (
1358
+ <div className="flex items-center gap-1.5 ml-auto">
1359
+ <div className="w-16 h-1.5 rounded-full bg-gray-200 overflow-hidden flex">
1360
+ <div
1361
+ className="h-full"
1362
+ style={{
1363
+ width: `${llmPercent}%`,
1364
+ backgroundColor: "#7CA3F0",
1365
+ }}
1366
+ />
1367
+ <div
1368
+ className="h-full"
1369
+ style={{
1370
+ width: `${100 - llmPercent}%`,
1371
+ backgroundColor: "#84CC16",
1372
+ }}
1373
+ />
1374
+ </div>
1375
+ <span className="text-gray-400 tabular-nums">
1376
+ {llmPercent}%
1377
+ </span>
1378
+ </div>
1379
+ )}
1380
+ </>
1381
+ )}
1382
+ </div>
1383
+
1384
+ {/* Detail Modal */}
1385
+ {modalItem && (
1386
+ <div
1387
+ className="fixed inset-0 z-50 flex items-center justify-center bg-black/30"
1388
+ onClick={() => setModalItem(null)}
1389
+ >
1390
+ <div
1391
+ className="bg-white rounded-2xl shadow-lg w-full max-w-2xl max-h-[80vh] flex flex-col mx-4"
1392
+ onClick={(e) => e.stopPropagation()}
1393
+ >
1394
+ <div className="flex items-center justify-between px-5 py-3 border-b border-gray-100 shrink-0">
1395
+ <h3 className="text-sm font-semibold text-gray-900">
1396
+ Detail Inspector
1397
+ </h3>
1398
+ <button
1399
+ type="button"
1400
+ onClick={() => setModalItem(null)}
1401
+ className="text-gray-400 hover:text-gray-600 cursor-pointer"
1402
+ >
1403
+ <svg
1404
+ className="w-4 h-4"
1405
+ fill="none"
1406
+ stroke="currentColor"
1407
+ viewBox="0 0 24 24"
1408
+ >
1409
+ <path
1410
+ strokeLinecap="round"
1411
+ strokeLinejoin="round"
1412
+ strokeWidth={2}
1413
+ d="M6 18L18 6M6 6l12 12"
1414
+ />
1415
+ </svg>
1416
+ </button>
1417
+ </div>
1418
+ <div className="flex-1 overflow-auto p-5">
1419
+ <TimelineDetailInspector
1420
+ sessionId={sessionId}
1421
+ item={modalItem}
1422
+ onNavigate={(path) => {
1423
+ debugNavigate(path);
1424
+ setModalItem(null);
1425
+ }}
1426
+ />
1427
+ </div>
1428
+ </div>
1429
+ </div>
1430
+ )}
1431
+ </div>
1432
+ );
1433
+ }
1434
+
1435
+ function HoverTooltipContent({
1436
+ item,
1437
+ onViewDetail,
1438
+ }: {
1439
+ item: TimelineItem;
1440
+ onViewDetail: () => void;
1441
+ }) {
1442
+ const isLLM = item.type === "llm";
1443
+ const statusColor =
1444
+ item.status === "error"
1445
+ ? "text-red-600"
1446
+ : item.status === "running"
1447
+ ? "text-amber-500"
1448
+ : "text-green-600";
1449
+
1450
+ return (
1451
+ <div className="min-w-36">
1452
+ <div className="flex items-center gap-1.5 mb-1">
1453
+ <div
1454
+ className="w-1.5 h-1.5 rounded-full"
1455
+ style={{
1456
+ backgroundColor:
1457
+ item.status === "error"
1458
+ ? "#EF4444"
1459
+ : isLLM
1460
+ ? "#7CA3F0"
1461
+ : "#84CC16",
1462
+ }}
1463
+ />
1464
+ <span className="font-semibold text-gray-900">
1465
+ {isLLM
1466
+ ? (item.model?.split("/").pop() ?? "LLM")
1467
+ : (item.toolName ?? "Tool")}
1468
+ </span>
1469
+ <span className={`ml-auto text-[10px] ${statusColor}`}>
1470
+ {item.status}
1471
+ </span>
1472
+ </div>
1473
+ <div className="text-gray-400 space-y-0.5">
1474
+ <div>
1475
+ <span className="text-gray-700 font-mono">{item.agentName}</span>
1476
+ </div>
1477
+ {item.durationMs !== undefined && (
1478
+ <div>
1479
+ <span className="text-gray-700 font-semibold tabular-nums">
1480
+ {formatDuration(item.durationMs)}
1481
+ </span>
1482
+ {isLLM && item.cost !== undefined && item.cost > 0 && (
1483
+ <span className="text-green-600 ml-2">
1484
+ ${item.cost.toFixed(4)}
1485
+ </span>
1486
+ )}
1487
+ </div>
1488
+ )}
1489
+ {item.error && (
1490
+ <div className="text-red-500 truncate max-w-48" title={item.error}>
1491
+ {item.error}
1492
+ </div>
1493
+ )}
1494
+ </div>
1495
+ <button
1496
+ type="button"
1497
+ onClick={onViewDetail}
1498
+ className="mt-1.5 pt-1.5 border-t border-gray-100 w-full text-left text-accent-peri hover:underline font-medium cursor-pointer"
1499
+ >
1500
+ View details &rarr;
1501
+ </button>
1502
+ </div>
1503
+ );
1504
+ }