@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,740 @@
1
+ import type { ChatMessageContentItem, LLMCallLogEntry, LLMCallMessage } from '@roj-ai/sdk'
2
+ import { estimateTokens } from '../../lib/domain-utils.js'
3
+ import { useEffect, useMemo, useState } from 'react'
4
+ import { api, getApiBaseUrl, unwrap } from '@roj-ai/client'
5
+
6
+ function isLLMCallLogEntry(data: unknown): data is LLMCallLogEntry {
7
+ return typeof data === 'object' && data !== null && 'id' in data && 'status' in data && 'request' in data
8
+ }
9
+
10
+ interface LLMCallDetailProps {
11
+ sessionId: string
12
+ callId: string
13
+ onClose?: () => void
14
+ }
15
+
16
+ export function LLMCallDetail({ sessionId, callId, onClose }: LLMCallDetailProps) {
17
+ const [call, setCall] = useState<LLMCallLogEntry | null>(null)
18
+ const [loading, setLoading] = useState(true)
19
+ const [error, setError] = useState<string | null>(null)
20
+
21
+ useEffect(() => {
22
+ let cancelled = false
23
+
24
+ const load = async () => {
25
+ try {
26
+ setLoading(true)
27
+ setError(null)
28
+ const data = unwrap(await api.call('llm.getCall', { sessionId, callId }))
29
+ if (!cancelled && isLLMCallLogEntry(data)) {
30
+ setCall(data)
31
+ }
32
+ } catch (err) {
33
+ if (!cancelled) {
34
+ setError(err instanceof Error ? err.message : 'Failed to load LLM call')
35
+ }
36
+ } finally {
37
+ if (!cancelled) {
38
+ setLoading(false)
39
+ }
40
+ }
41
+ }
42
+
43
+ load()
44
+
45
+ return () => {
46
+ cancelled = true
47
+ }
48
+ }, [sessionId, callId])
49
+
50
+ if (loading) {
51
+ return <div className="text-slate-500 text-sm p-4">Loading...</div>
52
+ }
53
+
54
+ if (error) {
55
+ return <div className="text-red-500 text-sm p-4">{error}</div>
56
+ }
57
+
58
+ if (!call) {
59
+ return <div className="text-slate-500 text-sm p-4">No data</div>
60
+ }
61
+
62
+ return (
63
+ <div className="space-y-6 text-sm">
64
+ {/* Header */}
65
+ <div className="flex items-center justify-between border-b pb-4">
66
+ <div className="flex items-center gap-3">
67
+ <StatusBadge status={call.status} />
68
+ <span className="font-mono font-medium text-lg">{call.request.model}</span>
69
+ </div>
70
+ {onClose && (
71
+ <button
72
+ onClick={onClose}
73
+ className="text-slate-400 hover:text-slate-600 text-xl"
74
+ >
75
+ ×
76
+ </button>
77
+ )}
78
+ </div>
79
+
80
+ {/* Metrics Grid (4 tiles) */}
81
+ <MetricsGrid call={call} />
82
+
83
+ {/* Error */}
84
+ {call.error && (
85
+ <div className="bg-red-50 border border-red-200 rounded-md p-4">
86
+ <h3 className="font-semibold text-red-800 mb-2">Error</h3>
87
+ <div className="font-medium text-red-700">{call.error.type}</div>
88
+ <div className="text-red-600">{call.error.message}</div>
89
+ {call.error.retryAfterMs !== undefined && (
90
+ <div className="text-red-500 text-xs mt-1">
91
+ Retry after: {call.error.retryAfterMs}ms
92
+ </div>
93
+ )}
94
+ </div>
95
+ )}
96
+
97
+ {/* Main Content - Two Column Layout */}
98
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
99
+ {/* Left: Messages */}
100
+ <div className="lg:col-span-2 space-y-4">
101
+ {/* System Prompt */}
102
+ <CollapsibleSection title="System Prompt" defaultOpen={false}>
103
+ <CollapsibleContent maxLines={10}>
104
+ {call.request.systemPrompt}
105
+ </CollapsibleContent>
106
+ </CollapsibleSection>
107
+
108
+ {/* Messages */}
109
+ <CollapsibleSection
110
+ title={`Messages (${call.request.messages.length})`}
111
+ defaultOpen={true}
112
+ >
113
+ <div className="space-y-3">
114
+ {call.request.messages.map((msg, idx) => (
115
+ <div key={idx}>
116
+ <MessageBlock message={msg} index={idx} sessionId={sessionId} />
117
+ {msg.cacheControl && <CacheBreakpointMarker />}
118
+ </div>
119
+ ))}
120
+
121
+ {/* Response */}
122
+ {call.response && (
123
+ <div className="border-t-2 border-green-300 pt-3">
124
+ <div className="text-xs text-green-600 font-medium mb-2">Response</div>
125
+
126
+ {/* Reasoning (orange box) */}
127
+ {call.response.reasoning && (
128
+ <div className="bg-orange-50 border border-orange-200 rounded-md p-3 mb-3">
129
+ <div className="text-sm font-semibold text-orange-800 mb-2">Reasoning:</div>
130
+ <CollapsibleContent maxLines={20}>
131
+ {call.response.reasoning}
132
+ </CollapsibleContent>
133
+ </div>
134
+ )}
135
+
136
+ {/* Content */}
137
+ {call.response.content && (
138
+ <div className="bg-green-50 border border-green-200 rounded-md p-3 mb-3">
139
+ <CollapsibleContent maxLines={15}>
140
+ {call.response.content}
141
+ </CollapsibleContent>
142
+ </div>
143
+ )}
144
+
145
+ {/* Tool Calls */}
146
+ {call.response.toolCalls.length > 0 && (
147
+ <div className="space-y-2">
148
+ <div className="text-xs font-medium text-slate-600">Tool Calls:</div>
149
+ {call.response.toolCalls.map((tc) => (
150
+ <div
151
+ key={tc.id}
152
+ className="bg-cyan-50 border border-cyan-200 rounded-md p-3"
153
+ >
154
+ <div className="flex items-center gap-2 mb-2">
155
+ <span className="font-mono font-medium text-cyan-800">{tc.name}</span>
156
+ <span className="text-xs text-cyan-600 font-mono bg-cyan-100 px-2 py-0.5 rounded">
157
+ {tc.id.slice(0, 8)}
158
+ </span>
159
+ </div>
160
+ <CollapsibleContent maxLines={10}>
161
+ {JSON.stringify(tc.input, null, 2)}
162
+ </CollapsibleContent>
163
+ </div>
164
+ ))}
165
+ </div>
166
+ )}
167
+
168
+ {/* Finish Reason */}
169
+ <div className="mt-2 flex items-center gap-2">
170
+ <span className="text-xs text-slate-500">Finish reason:</span>
171
+ <FinishReasonBadge reason={call.response.finishReason} />
172
+ </div>
173
+ </div>
174
+ )}
175
+ </div>
176
+ </CollapsibleSection>
177
+ </div>
178
+
179
+ {/* Right: Sidebar */}
180
+ <div className="space-y-4">
181
+ {/* Token Usage Details */}
182
+ {call.metrics && (
183
+ <SidebarPanel title="Token Details">
184
+ <div className="space-y-2">
185
+ <MetricRow label="Prompt" value={call.metrics.promptTokens.toLocaleString()} color="text-green-600" />
186
+ <MetricRow label="Completion" value={call.metrics.completionTokens.toLocaleString()} color="text-violet-600" />
187
+ {call.metrics.reasoningTokens !== undefined && (
188
+ <MetricRow label="Reasoning" value={call.metrics.reasoningTokens.toLocaleString()} color="text-purple-600" />
189
+ )}
190
+ {call.metrics.cachedTokens !== undefined && (
191
+ <MetricRow label="Cached" value={call.metrics.cachedTokens.toLocaleString()} color="text-slate-500" />
192
+ )}
193
+ <div className="border-t pt-2">
194
+ <MetricRow label="Total" value={call.metrics.totalTokens.toLocaleString()} color="text-slate-900" bold />
195
+ </div>
196
+ </div>
197
+ </SidebarPanel>
198
+ )}
199
+
200
+ {/* Tools */}
201
+ {call.request.tools && call.request.tools.length > 0 && (
202
+ <SidebarPanel title={`Tools (${call.request.toolsCount})`}>
203
+ <div className="space-y-2 max-h-96 overflow-y-auto">
204
+ {call.request.tools.map((tool) => (
205
+ <details key={tool.name} className="bg-slate-50 rounded border">
206
+ <summary className="p-2 cursor-pointer hover:bg-slate-100 text-xs">
207
+ <span className="font-mono font-medium">{tool.name}</span>
208
+ </summary>
209
+ <div className="p-2 border-t text-xs space-y-2">
210
+ <div className="text-slate-600">{tool.description}</div>
211
+ {tool.parameters && (
212
+ <details className="bg-white rounded border">
213
+ <summary className="p-2 cursor-pointer hover:bg-slate-50 text-xs font-medium text-violet-600">
214
+ JSON Schema
215
+ </summary>
216
+ <div className="p-2 border-t">
217
+ <pre className="text-xs font-mono text-slate-700 whitespace-pre-wrap overflow-x-auto max-h-64 overflow-y-auto">
218
+ {JSON.stringify(tool.parameters, null, 2)}
219
+ </pre>
220
+ </div>
221
+ </details>
222
+ )}
223
+ </div>
224
+ </details>
225
+ ))}
226
+ </div>
227
+ </SidebarPanel>
228
+ )}
229
+
230
+ {/* Request Info */}
231
+ <SidebarPanel title="Request Info">
232
+ <div className="space-y-2 text-xs">
233
+ <div>
234
+ <span className="text-slate-500">Created:</span> <span className="font-mono">{new Date(call.createdAt).toLocaleString()}</span>
235
+ </div>
236
+ {call.completedAt && (
237
+ <div>
238
+ <span className="text-slate-500">Completed:</span> <span className="font-mono">{new Date(call.completedAt).toLocaleString()}</span>
239
+ </div>
240
+ )}
241
+ <div>
242
+ <span className="text-slate-500">Agent:</span> <span className="font-mono">{call.agentId.slice(0, 12)}...</span>
243
+ </div>
244
+ </div>
245
+ </SidebarPanel>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ )
250
+ }
251
+
252
+ // ============================================================================
253
+ // Collapsible Content Component
254
+ // ============================================================================
255
+
256
+ function CollapsibleContent({ children, maxLines = 10 }: { children: string; maxLines?: number }) {
257
+ const [isExpanded, setIsExpanded] = useState(false)
258
+
259
+ const { lines, shouldCollapse, tokenCount } = useMemo(() => {
260
+ const content = children
261
+ const lines = content.split('\n')
262
+ return {
263
+ lines,
264
+ shouldCollapse: lines.length > maxLines,
265
+ tokenCount: estimateTokens(content),
266
+ }
267
+ }, [children, maxLines])
268
+
269
+ if (!shouldCollapse) {
270
+ return (
271
+ <div className="space-y-2">
272
+ <pre className="font-mono text-xs whitespace-pre-wrap break-words">{children}</pre>
273
+ <div className="flex justify-end">
274
+ <TokenCountBadge count={tokenCount} />
275
+ </div>
276
+ </div>
277
+ )
278
+ }
279
+
280
+ return (
281
+ <div className="space-y-2">
282
+ <div className={`relative ${!isExpanded ? 'max-h-64 overflow-hidden' : ''}`}>
283
+ <pre className="font-mono text-xs whitespace-pre-wrap break-words">
284
+ {isExpanded ? children : lines.slice(0, maxLines).join('\n')}
285
+ </pre>
286
+ {!isExpanded && <div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-white to-transparent pointer-events-none" />}
287
+ </div>
288
+ <div className="flex items-center justify-between">
289
+ <button
290
+ onClick={() => setIsExpanded(!isExpanded)}
291
+ className="px-3 py-1 text-xs bg-violet-100 hover:bg-violet-200 text-violet-800 rounded transition-colors"
292
+ >
293
+ {isExpanded ? 'Show less' : `Show all (${lines.length} lines)`}
294
+ </button>
295
+ <TokenCountBadge count={tokenCount} />
296
+ </div>
297
+ </div>
298
+ )
299
+ }
300
+
301
+ // ============================================================================
302
+ // Helper Components
303
+ // ============================================================================
304
+
305
+ function CollapsibleSection({
306
+ title,
307
+ defaultOpen,
308
+ children,
309
+ }: {
310
+ title: string
311
+ defaultOpen: boolean
312
+ children: React.ReactNode
313
+ }) {
314
+ const [open, setOpen] = useState(defaultOpen)
315
+
316
+ return (
317
+ <div className="border border-slate-200 rounded-md overflow-hidden">
318
+ <button
319
+ onClick={() => setOpen(!open)}
320
+ className="w-full px-4 py-3 text-left flex items-center justify-between bg-slate-50 hover:bg-slate-100 transition-colors"
321
+ >
322
+ <span className="font-medium text-slate-700">{title}</span>
323
+ <span className="text-slate-400 text-lg">{open ? '−' : '+'}</span>
324
+ </button>
325
+ {open && <div className="p-4 border-t border-slate-200">{children}</div>}
326
+ </div>
327
+ )
328
+ }
329
+
330
+ /**
331
+ * Convert a file:// URL to the session file proxy endpoint.
332
+ * Handles both real paths (file:///.../.roj/data/sessions/{id}/foo.png)
333
+ * and virtual/sandboxed paths (file:///home/user/session/foo.png).
334
+ */
335
+ function fileUrlToProxyUrl(fileUrl: string, sessionId: string): string {
336
+ const baseUrl = getApiBaseUrl()
337
+
338
+ // Try real path: extract after /sessions/{sessionId}/
339
+ const sessionMarker = `/sessions/${sessionId}/`
340
+ const idx = fileUrl.indexOf(sessionMarker)
341
+ if (idx !== -1) {
342
+ const relativePath = fileUrl.slice(idx + sessionMarker.length)
343
+ return `${baseUrl}/sessions/${sessionId}/files/${relativePath}`
344
+ }
345
+
346
+ // Try virtual/sandboxed path: /home/user/session/...
347
+ const virtualPrefix = 'file:///home/user/session/'
348
+ if (fileUrl.startsWith(virtualPrefix)) {
349
+ const relativePath = fileUrl.slice(virtualPrefix.length)
350
+ return `${baseUrl}/sessions/${sessionId}/files/${relativePath}`
351
+ }
352
+
353
+ return fileUrl
354
+ }
355
+
356
+ function MessageContentItems({ items, sessionId }: { items: ChatMessageContentItem[]; sessionId: string }) {
357
+ const textContent = items.filter((it): it is ChatMessageContentItem & { type: 'text' } => it.type === 'text').map((it) => it.text).join('\n')
358
+ const images = items.filter((it): it is ChatMessageContentItem & { type: 'image_url' } => it.type === 'image_url')
359
+
360
+ return (
361
+ <>
362
+ {textContent && <CollapsibleContent maxLines={10}>{textContent}</CollapsibleContent>}
363
+ {images.length > 0 && (
364
+ <div className="mt-2 flex flex-wrap gap-2">
365
+ {images.map((img, i) => (
366
+ <a key={i} href={fileUrlToProxyUrl(img.imageUrl.url, sessionId)} target="_blank" rel="noopener noreferrer">
367
+ <img
368
+ src={fileUrlToProxyUrl(img.imageUrl.url, sessionId)}
369
+ alt="Tool result image"
370
+ className="max-w-64 max-h-48 rounded border border-slate-300 object-contain"
371
+ />
372
+ </a>
373
+ ))}
374
+ </div>
375
+ )}
376
+ </>
377
+ )
378
+ }
379
+
380
+ function MessageBlock({ message, index, sessionId }: { message: LLMCallMessage; index: number; sessionId: string }) {
381
+ const roleColors: Record<string, string> = {
382
+ user: 'bg-blue-50 border-blue-200',
383
+ assistant: 'bg-green-50 border-green-200',
384
+ tool: 'bg-purple-50 border-purple-200',
385
+ system: 'bg-yellow-50 border-yellow-200',
386
+ }
387
+
388
+ return (
389
+ <div className={`p-3 rounded-md border ${roleColors[message.role] || 'bg-slate-50 border-slate-200'}`}>
390
+ <div className="flex items-center gap-2 mb-2">
391
+ <RoleBadge role={message.role} />
392
+ <span className="text-xs text-slate-500 font-mono">#{index + 1}</span>
393
+ {message.toolCallId && (
394
+ <span className="text-xs text-slate-500 font-mono bg-slate-100 px-1 rounded">
395
+ {message.toolCallId.slice(0, 8)}
396
+ </span>
397
+ )}
398
+ </div>
399
+
400
+ {/* Reasoning for assistant messages */}
401
+ {message.reasoning && (
402
+ <div className="bg-orange-50 border border-orange-200 rounded p-2 mb-2">
403
+ <div className="text-xs font-semibold text-orange-800 mb-1">Reasoning:</div>
404
+ <CollapsibleContent maxLines={10}>{message.reasoning}</CollapsibleContent>
405
+ </div>
406
+ )}
407
+
408
+ {/* Content */}
409
+ {typeof message.content === 'string'
410
+ ? <CollapsibleContent maxLines={10}>{message.content}</CollapsibleContent>
411
+ : <MessageContentItems items={message.content} sessionId={sessionId} />}
412
+
413
+ {/* Tool Calls */}
414
+ {message.toolCalls && message.toolCalls.length > 0 && (
415
+ <div className="mt-3 space-y-2">
416
+ <div className="text-xs font-medium text-slate-600">Tool Calls:</div>
417
+ {message.toolCalls.map((tc) => (
418
+ <div key={tc.id} className="bg-white rounded border p-2">
419
+ <div className="flex items-center gap-2 mb-1">
420
+ <span className="font-mono text-xs font-medium">{tc.name}</span>
421
+ <span className="text-xs text-slate-500 font-mono">{tc.id.slice(0, 8)}</span>
422
+ </div>
423
+ <pre className="text-xs text-slate-600 overflow-x-auto">
424
+ {JSON.stringify(tc.input, null, 2)}
425
+ </pre>
426
+ </div>
427
+ ))}
428
+ </div>
429
+ )}
430
+ </div>
431
+ )
432
+ }
433
+
434
+ function MetricTile({
435
+ icon,
436
+ iconBg,
437
+ iconColor,
438
+ title,
439
+ children,
440
+ }: {
441
+ icon: React.ReactNode
442
+ iconBg: string
443
+ iconColor: string
444
+ title: string
445
+ children: React.ReactNode
446
+ }) {
447
+ return (
448
+ <div className="bg-white rounded-md border border-slate-200 p-4">
449
+ <div className="flex items-center gap-2 mb-3">
450
+ <div className={`w-8 h-8 ${iconBg} rounded-full flex items-center justify-center`}>
451
+ <span className={iconColor}>{icon}</span>
452
+ </div>
453
+ <h3 className="text-xs font-medium text-slate-500">{title}</h3>
454
+ </div>
455
+ {children}
456
+ </div>
457
+ )
458
+ }
459
+
460
+ function SidebarPanel({ title, children }: { title: string; children: React.ReactNode }) {
461
+ return (
462
+ <div className="bg-white rounded-md border border-slate-200">
463
+ <div className="px-4 py-2 border-b border-slate-200 bg-slate-50">
464
+ <h3 className="text-xs font-semibold text-slate-700">{title}</h3>
465
+ </div>
466
+ <div className="px-4 py-3">{children}</div>
467
+ </div>
468
+ )
469
+ }
470
+
471
+ function MetricRow({
472
+ label,
473
+ value,
474
+ color,
475
+ bold,
476
+ }: {
477
+ label: string
478
+ value: string
479
+ color?: string
480
+ bold?: boolean
481
+ }) {
482
+ return (
483
+ <div className="flex justify-between text-xs">
484
+ <span className="text-slate-500">{label}</span>
485
+ <span className={`font-mono ${color || ''} ${bold ? 'font-bold' : 'font-semibold'}`}>{value}</span>
486
+ </div>
487
+ )
488
+ }
489
+
490
+ function TokenCountBadge({ count }: { count: number }) {
491
+ return (
492
+ <span className="text-xs text-violet-500 bg-violet-50 px-2 py-1 rounded-full font-mono">
493
+ ~{count.toLocaleString()} tokens
494
+ </span>
495
+ )
496
+ }
497
+
498
+ function StatusBadge({ status }: { status: 'running' | 'success' | 'error' }) {
499
+ const colors: Record<string, string> = {
500
+ running: 'bg-yellow-100 text-yellow-700',
501
+ success: 'bg-green-100 text-green-700',
502
+ error: 'bg-red-100 text-red-700',
503
+ }
504
+ return (
505
+ <span className={`text-xs px-2 py-0.5 rounded font-medium ${colors[status]}`}>
506
+ {status}
507
+ </span>
508
+ )
509
+ }
510
+
511
+ function CacheBreakpointMarker() {
512
+ return (
513
+ <div className="flex items-center gap-2 my-2" title="Prompt cache breakpoint — everything above is eligible for caching">
514
+ <div className="flex-1 border-t border-dashed border-amber-400" />
515
+ <span className="text-[10px] font-mono font-semibold text-amber-700 bg-amber-50 border border-amber-300 px-2 py-0.5 rounded uppercase tracking-wider">
516
+ Cache Breakpoint
517
+ </span>
518
+ <div className="flex-1 border-t border-dashed border-amber-400" />
519
+ </div>
520
+ )
521
+ }
522
+
523
+ function CacheBadge({ status }: { status: 'hit' | 'miss' | 'none' }) {
524
+ const colors: Record<string, string> = {
525
+ hit: 'text-green-600',
526
+ miss: 'text-orange-600',
527
+ none: 'text-slate-500',
528
+ }
529
+ const labels: Record<string, string> = {
530
+ hit: 'CACHE HIT',
531
+ miss: 'CACHE MISS',
532
+ none: 'NO CACHE',
533
+ }
534
+ return (
535
+ <span className={`text-xs font-mono font-semibold ${colors[status]}`}>
536
+ {labels[status]}
537
+ </span>
538
+ )
539
+ }
540
+
541
+ function RoleBadge({ role }: { role: string }) {
542
+ const colors: Record<string, string> = {
543
+ user: 'bg-blue-100 text-blue-700',
544
+ assistant: 'bg-green-100 text-green-700',
545
+ tool: 'bg-purple-100 text-purple-700',
546
+ system: 'bg-yellow-100 text-yellow-700',
547
+ }
548
+ return (
549
+ <span className={`text-xs px-2 py-0.5 rounded font-medium uppercase ${colors[role] || 'bg-slate-100 text-slate-700'}`}>
550
+ {role}
551
+ </span>
552
+ )
553
+ }
554
+
555
+ function FinishReasonBadge({ reason }: { reason: string }) {
556
+ const colors: Record<string, string> = {
557
+ stop: 'bg-green-100 text-green-700',
558
+ tool_calls: 'bg-cyan-100 text-cyan-700',
559
+ length: 'bg-yellow-100 text-yellow-700',
560
+ error: 'bg-red-100 text-red-700',
561
+ }
562
+ return (
563
+ <span className={`text-xs px-2 py-0.5 rounded font-medium ${colors[reason] || 'bg-slate-100 text-slate-700'}`}>
564
+ {reason}
565
+ </span>
566
+ )
567
+ }
568
+
569
+ // ============================================================================
570
+ // Icons (simple SVG)
571
+ // ============================================================================
572
+
573
+ function DollarIcon() {
574
+ return (
575
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
576
+ <path
577
+ strokeLinecap="round"
578
+ strokeLinejoin="round"
579
+ strokeWidth={2}
580
+ d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
581
+ />
582
+ </svg>
583
+ )
584
+ }
585
+
586
+ function TokenIcon() {
587
+ return (
588
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
589
+ <path
590
+ strokeLinecap="round"
591
+ strokeLinejoin="round"
592
+ strokeWidth={2}
593
+ d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
594
+ />
595
+ </svg>
596
+ )
597
+ }
598
+
599
+ function PerformanceIcon() {
600
+ return (
601
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
602
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
603
+ </svg>
604
+ )
605
+ }
606
+
607
+ function ModelIcon() {
608
+ return (
609
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
610
+ <path
611
+ strokeLinecap="round"
612
+ strokeLinejoin="round"
613
+ strokeWidth={2}
614
+ 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"
615
+ />
616
+ </svg>
617
+ )
618
+ }
619
+
620
+ // ============================================================================
621
+ // Metrics Grid Component
622
+ // ============================================================================
623
+
624
+ function MetricsGrid({ call }: { call: LLMCallLogEntry }) {
625
+ const metrics = call.metrics
626
+
627
+ const totalCost = metrics?.cost
628
+ const promptTokens = metrics?.promptTokens
629
+ const completionTokens = metrics?.completionTokens
630
+ const reasoningTokens = metrics?.reasoningTokens
631
+ const cachedTokens = metrics?.cachedTokens
632
+ const latency = metrics?.latencyMs
633
+ const generationTime = metrics?.generationTimeMs
634
+ const provider = metrics?.provider
635
+
636
+ // Calculate total tokens
637
+ const totalTokens = (promptTokens ?? 0) + (completionTokens ?? 0)
638
+
639
+ // Calculate effective rate (tokens/sec)
640
+ const tokensPerSecond = generationTime && completionTokens
641
+ ? ((completionTokens / generationTime) * 1000).toFixed(1)
642
+ : null
643
+
644
+ return (
645
+ <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
646
+ {/* Cost Tile */}
647
+ <MetricTile
648
+ icon={<DollarIcon />}
649
+ iconBg="bg-green-100"
650
+ iconColor="text-green-600"
651
+ title="Cost"
652
+ >
653
+ <div className="text-2xl font-bold text-slate-900">
654
+ {totalCost !== undefined ? `$${totalCost.toFixed(6)}` : '—'}
655
+ </div>
656
+ </MetricTile>
657
+
658
+ {/* Tokens Tile */}
659
+ <MetricTile
660
+ icon={<TokenIcon />}
661
+ iconBg="bg-violet-100"
662
+ iconColor="text-violet-600"
663
+ title="Tokens"
664
+ >
665
+ <div className="text-2xl font-bold text-slate-900">
666
+ {totalTokens.toLocaleString()}
667
+ </div>
668
+ <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs mt-1">
669
+ <span className="text-green-600">
670
+ <span className="text-slate-400">In:</span> {(promptTokens ?? 0).toLocaleString()}
671
+ </span>
672
+ <span className="text-violet-600">
673
+ <span className="text-slate-400">Out:</span> {(completionTokens ?? 0).toLocaleString()}
674
+ </span>
675
+ {reasoningTokens !== null && reasoningTokens !== undefined && reasoningTokens > 0 && (
676
+ <span className="text-purple-600">
677
+ <span className="text-slate-400">Reason:</span> {reasoningTokens.toLocaleString()}
678
+ </span>
679
+ )}
680
+ </div>
681
+ {cachedTokens !== null && cachedTokens !== undefined && (
682
+ <div className="mt-2">
683
+ <CacheBadge status={cachedTokens > 0 ? 'hit' : 'miss'} />
684
+ {cachedTokens > 0 && (
685
+ <span className="text-xs text-slate-500 ml-2">
686
+ ({cachedTokens.toLocaleString()} cached)
687
+ </span>
688
+ )}
689
+ </div>
690
+ )}
691
+ </MetricTile>
692
+
693
+ {/* Performance Tile */}
694
+ <MetricTile
695
+ icon={<PerformanceIcon />}
696
+ iconBg="bg-amber-100"
697
+ iconColor="text-amber-600"
698
+ title="Performance"
699
+ >
700
+ <div className="text-2xl font-bold text-slate-900">
701
+ {latency !== undefined && latency !== null ? `${(latency / 1000).toFixed(2)}s` : '—'}
702
+ </div>
703
+ <div className="space-y-1 text-xs mt-1">
704
+ {generationTime !== null && generationTime !== undefined && (
705
+ <div className="text-slate-600">
706
+ <span className="text-slate-400">Generation:</span> {(generationTime / 1000).toFixed(2)}s
707
+ </div>
708
+ )}
709
+ {tokensPerSecond && (
710
+ <div className="text-purple-600 font-medium">
711
+ {tokensPerSecond} tok/s
712
+ </div>
713
+ )}
714
+ </div>
715
+ </MetricTile>
716
+
717
+ {/* Model & Provider Tile */}
718
+ <MetricTile
719
+ icon={<ModelIcon />}
720
+ iconBg="bg-purple-100"
721
+ iconColor="text-purple-600"
722
+ title="Model"
723
+ >
724
+ <div className="font-mono text-sm font-medium text-slate-900 truncate" title={call.request.model}>
725
+ {call.request.model.split('/').pop()}
726
+ </div>
727
+ {provider && (
728
+ <div className="text-xs text-slate-500 mt-1">
729
+ <span className="text-slate-400">via</span> {provider}
730
+ </div>
731
+ )}
732
+ {call.response?.finishReason && (
733
+ <div className="mt-2">
734
+ <FinishReasonBadge reason={call.response.finishReason} />
735
+ </div>
736
+ )}
737
+ </MetricTile>
738
+ </div>
739
+ )
740
+ }