@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,473 @@
1
+ import type { LLMCallLogEntry } from '@roj-ai/sdk'
2
+ import type { AgentId } from '@roj-ai/shared'
3
+ import { useCallback, useEffect, useMemo, useState } from 'react'
4
+ import { api, unwrap } from '@roj-ai/client'
5
+ import { useEventStore } from '../../../stores/event-store'
6
+ import { DebugLink, useDebugSessionId } from '../DebugNavigation'
7
+
8
+ function isLLMCallLogEntry(data: unknown): data is LLMCallLogEntry {
9
+ return typeof data === 'object' && data !== null && 'id' in data && 'status' in data && 'request' in data
10
+ }
11
+
12
+ const PAGE_SIZE = 50
13
+ const FETCH_LIMIT = 1000
14
+
15
+ type SortField = 'time' | 'duration' | 'tokens' | 'cost'
16
+ type SortDirection = 'asc' | 'desc'
17
+ type StatusFilter = 'all' | 'running' | 'success' | 'error'
18
+
19
+ export function LLMCallsPage() {
20
+ const sessionId = useDebugSessionId()
21
+ const [calls, setCalls] = useState<LLMCallLogEntry[]>([])
22
+ const [total, setTotal] = useState(0)
23
+ const [loading, setLoading] = useState(true)
24
+ const [error, setError] = useState<string | null>(null)
25
+
26
+ const [offset, setOffset] = useState(0)
27
+ const [agentFilter, setAgentFilter] = useState<string>('all')
28
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
29
+ const [providerFilter, setProviderFilter] = useState<string>('all')
30
+ const [modelFilter, setModelFilter] = useState<string>('all')
31
+ const [sortField, setSortField] = useState<SortField>('time')
32
+ const [sortDirection, setSortDirection] = useState<SortDirection>('desc')
33
+
34
+ const agentProjection = useEventStore((s) => s.agentDetailProjectionState)
35
+ const agentNameById = useMemo(() => {
36
+ const map = new Map<AgentId, string>()
37
+ for (const agent of agentProjection.agents.values()) {
38
+ map.set(agent.id, agent.definitionName)
39
+ }
40
+ return map
41
+ }, [agentProjection])
42
+
43
+ const resolveAgentName = useCallback(
44
+ (agentId: AgentId) => agentNameById.get(agentId) ?? agentId,
45
+ [agentNameById],
46
+ )
47
+
48
+ const load = useCallback(async () => {
49
+ if (!sessionId) return
50
+ try {
51
+ const data = unwrap(await api.call('llm.getCalls', {
52
+ sessionId,
53
+ limit: FETCH_LIMIT,
54
+ offset: 0,
55
+ }))
56
+ setCalls(data.calls.filter(isLLMCallLogEntry))
57
+ setTotal(data.total)
58
+ setError(null)
59
+ } catch (err) {
60
+ setError(err instanceof Error ? err.message : 'Failed to load LLM calls')
61
+ } finally {
62
+ setLoading(false)
63
+ }
64
+ }, [sessionId])
65
+
66
+ useEffect(() => {
67
+ load()
68
+ const interval = setInterval(load, 5000)
69
+ return () => clearInterval(interval)
70
+ }, [load])
71
+
72
+ const uniqueAgents = useMemo(() => {
73
+ const ids = new Set<AgentId>()
74
+ for (const call of calls) ids.add(call.agentId)
75
+ return Array.from(ids)
76
+ .map((id) => ({ id, name: resolveAgentName(id) }))
77
+ .sort((a, b) => a.name.localeCompare(b.name))
78
+ }, [calls, resolveAgentName])
79
+
80
+ const uniqueProviders = useMemo(() => {
81
+ const set = new Set<string>()
82
+ for (const call of calls) {
83
+ const p = call.metrics?.provider
84
+ if (p) set.add(p)
85
+ }
86
+ return Array.from(set).sort()
87
+ }, [calls])
88
+
89
+ const uniqueModels = useMemo(() => {
90
+ const set = new Set<string>()
91
+ for (const call of calls) set.add(call.request.model)
92
+ return Array.from(set).sort()
93
+ }, [calls])
94
+
95
+ const filteredCalls = useMemo(() => {
96
+ return calls.filter((call) => {
97
+ if (agentFilter !== 'all' && call.agentId !== agentFilter) return false
98
+ if (statusFilter !== 'all' && call.status !== statusFilter) return false
99
+ if (providerFilter !== 'all' && (call.metrics?.provider ?? '') !== providerFilter) return false
100
+ if (modelFilter !== 'all' && call.request.model !== modelFilter) return false
101
+ return true
102
+ })
103
+ }, [calls, agentFilter, statusFilter, providerFilter, modelFilter])
104
+
105
+ const sortedCalls = useMemo(() => {
106
+ const sorted = [...filteredCalls]
107
+ const direction = sortDirection === 'asc' ? 1 : -1
108
+ sorted.sort((a, b) => {
109
+ let aVal: number
110
+ let bVal: number
111
+ switch (sortField) {
112
+ case 'time':
113
+ aVal = a.createdAt
114
+ bVal = b.createdAt
115
+ break
116
+ case 'duration':
117
+ aVal = a.metrics?.latencyMs ?? a.durationMs ?? 0
118
+ bVal = b.metrics?.latencyMs ?? b.durationMs ?? 0
119
+ break
120
+ case 'tokens':
121
+ aVal = a.metrics?.totalTokens ?? 0
122
+ bVal = b.metrics?.totalTokens ?? 0
123
+ break
124
+ case 'cost':
125
+ aVal = a.metrics?.cost ?? 0
126
+ bVal = b.metrics?.cost ?? 0
127
+ break
128
+ }
129
+ return (aVal - bVal) * direction
130
+ })
131
+ return sorted
132
+ }, [filteredCalls, sortField, sortDirection])
133
+
134
+ const paginatedCalls = useMemo(
135
+ () => sortedCalls.slice(offset, offset + PAGE_SIZE),
136
+ [sortedCalls, offset],
137
+ )
138
+
139
+ const filteredTotal = sortedCalls.length
140
+ const totalPages = Math.max(1, Math.ceil(filteredTotal / PAGE_SIZE))
141
+ const currentPage = Math.floor(offset / PAGE_SIZE) + 1
142
+
143
+ useEffect(() => {
144
+ if (offset >= filteredTotal && offset > 0) setOffset(0)
145
+ }, [filteredTotal, offset])
146
+
147
+ const totalTokens = sortedCalls.reduce((sum, c) => sum + (c.metrics?.totalTokens ?? 0), 0)
148
+ const totalCost = sortedCalls.reduce((sum, c) => sum + (c.metrics?.cost ?? 0), 0)
149
+
150
+ const toggleSort = (field: SortField) => {
151
+ if (sortField === field) {
152
+ setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
153
+ } else {
154
+ setSortField(field)
155
+ setSortDirection('desc')
156
+ }
157
+ setOffset(0)
158
+ }
159
+
160
+ const resetPagination = () => setOffset(0)
161
+
162
+ return (
163
+ <div className="space-y-4">
164
+ {/* Summary */}
165
+ <div className="flex items-center gap-6 text-sm">
166
+ <span className="text-slate-600">
167
+ <span className="font-medium text-slate-900">{filteredTotal}</span>
168
+ {filteredTotal !== total && <span className="text-slate-400"> / {total}</span>} calls
169
+ </span>
170
+ <span className="text-slate-600">
171
+ <span className="font-medium text-slate-900">{totalTokens.toLocaleString()}</span> tokens
172
+ </span>
173
+ {totalCost > 0 && (
174
+ <span className="text-slate-600">
175
+ <span className="font-medium text-green-600">${totalCost.toFixed(4)}</span> cost
176
+ </span>
177
+ )}
178
+ </div>
179
+
180
+ {/* Filters */}
181
+ <div className="flex flex-wrap items-center gap-3 text-sm">
182
+ <FilterSelect
183
+ label='Agent'
184
+ value={agentFilter}
185
+ onChange={(v) => {
186
+ setAgentFilter(v)
187
+ resetPagination()
188
+ }}
189
+ options={[
190
+ { value: 'all', label: 'All agents' },
191
+ ...uniqueAgents.map((a) => ({ value: a.id, label: a.name })),
192
+ ]}
193
+ />
194
+ <FilterSelect
195
+ label='Status'
196
+ value={statusFilter}
197
+ onChange={(v) => {
198
+ setStatusFilter(v as StatusFilter)
199
+ resetPagination()
200
+ }}
201
+ options={[
202
+ { value: 'all', label: 'All statuses' },
203
+ { value: 'running', label: 'running' },
204
+ { value: 'success', label: 'success' },
205
+ { value: 'error', label: 'error' },
206
+ ]}
207
+ />
208
+ <FilterSelect
209
+ label='Provider'
210
+ value={providerFilter}
211
+ onChange={(v) => {
212
+ setProviderFilter(v)
213
+ resetPagination()
214
+ }}
215
+ options={[
216
+ { value: 'all', label: 'All providers' },
217
+ ...uniqueProviders.map((p) => ({ value: p, label: p })),
218
+ ]}
219
+ />
220
+ <FilterSelect
221
+ label='Model'
222
+ value={modelFilter}
223
+ onChange={(v) => {
224
+ setModelFilter(v)
225
+ resetPagination()
226
+ }}
227
+ options={[
228
+ { value: 'all', label: 'All models' },
229
+ ...uniqueModels.map((m) => ({ value: m, label: m })),
230
+ ]}
231
+ />
232
+ </div>
233
+
234
+ {/* Error */}
235
+ {error && <div className="text-red-500 text-sm">{error}</div>}
236
+
237
+ {/* Table */}
238
+ <div className="bg-white rounded-md border border-slate-200 overflow-hidden">
239
+ {loading && calls.length === 0
240
+ ? <div className="p-4 text-slate-500 text-sm">Loading...</div>
241
+ : paginatedCalls.length === 0
242
+ ? <div className="p-4 text-slate-500 text-sm">No LLM calls found</div>
243
+ : (
244
+ <div className="overflow-x-auto">
245
+ <table className="w-full text-sm">
246
+ <thead className="bg-slate-50 border-b border-slate-200">
247
+ <tr>
248
+ <SortableHeader
249
+ label='Time'
250
+ field='time'
251
+ sortField={sortField}
252
+ sortDirection={sortDirection}
253
+ onClick={toggleSort}
254
+ align='left'
255
+ />
256
+ <th className="px-3 py-2 text-left font-medium text-slate-600">Status</th>
257
+ <th className="px-3 py-2 text-left font-medium text-slate-600">Provider</th>
258
+ <th className="px-3 py-2 text-left font-medium text-slate-600">Model</th>
259
+ <SortableHeader
260
+ label='Duration'
261
+ field='duration'
262
+ sortField={sortField}
263
+ sortDirection={sortDirection}
264
+ onClick={toggleSort}
265
+ align='right'
266
+ />
267
+ <SortableHeader
268
+ label='Tokens'
269
+ field='tokens'
270
+ sortField={sortField}
271
+ sortDirection={sortDirection}
272
+ onClick={toggleSort}
273
+ align='right'
274
+ />
275
+ <SortableHeader
276
+ label='Cost'
277
+ field='cost'
278
+ sortField={sortField}
279
+ sortDirection={sortDirection}
280
+ onClick={toggleSort}
281
+ align='right'
282
+ />
283
+ <th className="px-3 py-2 text-left font-medium text-slate-600">Agent</th>
284
+ </tr>
285
+ </thead>
286
+ <tbody className="divide-y divide-slate-200">
287
+ {paginatedCalls.map((call) => (
288
+ <CallRow
289
+ key={call.id}
290
+ call={call}
291
+ agentName={resolveAgentName(call.agentId)}
292
+ />
293
+ ))}
294
+ </tbody>
295
+ </table>
296
+ </div>
297
+ )}
298
+ </div>
299
+
300
+ {/* Pagination */}
301
+ {totalPages > 1 && (
302
+ <div className="flex items-center justify-between">
303
+ <button
304
+ onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
305
+ disabled={offset === 0}
306
+ className="px-3 py-1 text-sm border border-slate-300 rounded hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
307
+ >
308
+ Previous
309
+ </button>
310
+ <span className="text-sm text-slate-600">
311
+ Page {currentPage} of {totalPages}
312
+ </span>
313
+ <button
314
+ onClick={() => setOffset(offset + PAGE_SIZE)}
315
+ disabled={currentPage >= totalPages}
316
+ className="px-3 py-1 text-sm border border-slate-300 rounded hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
317
+ >
318
+ Next
319
+ </button>
320
+ </div>
321
+ )}
322
+ </div>
323
+ )
324
+ }
325
+
326
+ function FilterSelect({
327
+ label,
328
+ value,
329
+ onChange,
330
+ options,
331
+ }: {
332
+ label: string
333
+ value: string
334
+ onChange: (value: string) => void
335
+ options: { value: string; label: string }[]
336
+ }) {
337
+ return (
338
+ <label className="text-slate-600 flex items-center gap-2">
339
+ <span>{label}:</span>
340
+ <select
341
+ value={value}
342
+ onChange={(e) => onChange(e.target.value)}
343
+ className="border border-slate-300 rounded px-2 py-1 text-sm bg-white"
344
+ >
345
+ {options.map((opt) => (
346
+ <option key={opt.value} value={opt.value}>
347
+ {opt.label}
348
+ </option>
349
+ ))}
350
+ </select>
351
+ </label>
352
+ )
353
+ }
354
+
355
+ function SortableHeader({
356
+ label,
357
+ field,
358
+ sortField,
359
+ sortDirection,
360
+ onClick,
361
+ align,
362
+ }: {
363
+ label: string
364
+ field: SortField
365
+ sortField: SortField
366
+ sortDirection: SortDirection
367
+ onClick: (field: SortField) => void
368
+ align: 'left' | 'right'
369
+ }) {
370
+ const active = sortField === field
371
+ const arrow = active ? (sortDirection === 'asc' ? '▲' : '▼') : ''
372
+ return (
373
+ <th className={`px-3 py-2 font-medium text-slate-600 ${align === 'right' ? 'text-right' : 'text-left'}`}>
374
+ <button
375
+ type='button'
376
+ onClick={() => onClick(field)}
377
+ className={`inline-flex items-center gap-1 hover:text-slate-900 ${active ? 'text-slate-900' : ''}`}
378
+ >
379
+ <span>{label}</span>
380
+ {arrow && <span className="text-xs">{arrow}</span>}
381
+ </button>
382
+ </th>
383
+ )
384
+ }
385
+
386
+ function CallRow({ call, agentName }: { call: LLMCallLogEntry; agentName: string }) {
387
+ const metrics = call.metrics
388
+
389
+ const promptTokens = metrics?.promptTokens ?? 0
390
+ const completionTokens = metrics?.completionTokens ?? 0
391
+ const totalTokens = promptTokens + completionTokens
392
+ const cost = metrics?.cost
393
+ const latency = metrics?.latencyMs ?? call.durationMs
394
+
395
+ return (
396
+ <tr className="hover:bg-slate-50">
397
+ {/* Time */}
398
+ <td className="px-3 py-2 font-mono text-xs text-slate-500">
399
+ <DebugLink
400
+ to={`llm-calls/${call.id}`}
401
+ className="hover:text-violet-600"
402
+ >
403
+ {new Date(call.createdAt).toLocaleTimeString()}
404
+ </DebugLink>
405
+ </td>
406
+
407
+ {/* Status */}
408
+ <td className="px-3 py-2">
409
+ <StatusBadge status={call.status} />
410
+ </td>
411
+
412
+ {/* Provider */}
413
+ <td className="px-3 py-2 text-xs text-slate-500">
414
+ {call.metrics?.provider ?? '—'}
415
+ </td>
416
+
417
+ {/* Model */}
418
+ <td className="px-3 py-2">
419
+ <DebugLink
420
+ to={`llm-calls/${call.id}`}
421
+ className="font-mono text-xs hover:text-violet-600"
422
+ >
423
+ {call.request.model.split('/').pop()}
424
+ </DebugLink>
425
+ </td>
426
+
427
+ {/* Duration */}
428
+ <td className="px-3 py-2 text-right font-mono text-xs">
429
+ {latency !== undefined ? `${(latency / 1000).toFixed(2)}s` : '—'}
430
+ </td>
431
+
432
+ {/* Tokens */}
433
+ <td className="px-3 py-2 text-right">
434
+ <div className="font-mono text-xs">
435
+ <span className="text-green-600">{promptTokens.toLocaleString()}</span>
436
+ <span className="text-slate-400">/</span>
437
+ <span className="text-violet-600">{completionTokens.toLocaleString()}</span>
438
+ </div>
439
+ <div className="text-xs text-slate-500">{totalTokens.toLocaleString()} total</div>
440
+ </td>
441
+
442
+ {/* Cost */}
443
+ <td className="px-3 py-2 text-right font-mono text-xs">
444
+ {cost !== undefined ? <span className="text-green-600">${cost.toFixed(5)}</span> : <span className="text-slate-400">—</span>}
445
+ </td>
446
+
447
+ {/* Agent */}
448
+ <td className="px-3 py-2">
449
+ <span title={call.agentId}>
450
+ <DebugLink
451
+ to={`agents/${call.agentId}`}
452
+ className="text-xs text-slate-600 hover:text-violet-600 whitespace-nowrap"
453
+ >
454
+ {agentName}
455
+ </DebugLink>
456
+ </span>
457
+ </td>
458
+ </tr>
459
+ )
460
+ }
461
+
462
+ function StatusBadge({ status }: { status: 'running' | 'success' | 'error' }) {
463
+ const styles: Record<string, string> = {
464
+ running: 'bg-yellow-100 text-yellow-700',
465
+ success: 'bg-green-100 text-green-700',
466
+ error: 'bg-red-100 text-red-700',
467
+ }
468
+ return (
469
+ <span className={`text-xs px-2 py-0.5 rounded-full font-medium ${styles[status]}`}>
470
+ {status}
471
+ </span>
472
+ )
473
+ }
@@ -0,0 +1,199 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { api } from "@roj-ai/client";
3
+ import { useDebugSessionId } from "../DebugNavigation";
4
+
5
+ type LogLevel = "debug" | "info" | "warn" | "error";
6
+
7
+ interface LogEntry {
8
+ timestamp: string;
9
+ level: LogLevel;
10
+ message: string;
11
+ [key: string]: unknown;
12
+ }
13
+
14
+ const LOG_LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
15
+
16
+ const LEVEL_STYLES: Record<LogLevel, string> = {
17
+ debug: "text-slate-400",
18
+ info: "text-slate-700",
19
+ warn: "text-yellow-700 bg-yellow-50",
20
+ error: "text-red-700 bg-red-50",
21
+ };
22
+
23
+ const BADGE_STYLES: Record<LogLevel, string> = {
24
+ debug: "bg-slate-100 text-slate-500",
25
+ info: "bg-blue-100 text-blue-700",
26
+ warn: "bg-yellow-100 text-yellow-700",
27
+ error: "bg-red-100 text-red-700",
28
+ };
29
+
30
+ function parseLogEntry(line: string): LogEntry | null {
31
+ try {
32
+ const parsed = JSON.parse(line) as Record<string, unknown>;
33
+ return {
34
+ timestamp: (parsed.timestamp as string) ?? "",
35
+ level: (LOG_LEVELS.includes(parsed.level as LogLevel)
36
+ ? parsed.level
37
+ : "info") as LogLevel,
38
+ message: (parsed.message as string) ?? "",
39
+ ...parsed,
40
+ };
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function LogsPage() {
47
+ const sessionId = useDebugSessionId();
48
+ const [entries, setEntries] = useState<LogEntry[]>([]);
49
+ const [offset, setOffset] = useState(0);
50
+ const [minLevel, setMinLevel] = useState<LogLevel>("debug");
51
+ const [expandedRows, setExpandedRows] = useState<Set<number>>(new Set());
52
+ const containerRef = useRef<HTMLDivElement>(null);
53
+ const autoScrollRef = useRef(true);
54
+
55
+ const poll = useCallback(async () => {
56
+ if (!sessionId) return;
57
+ try {
58
+ const result = await api.call("logs.tail", { sessionId, since: offset });
59
+ if (!result.ok) return;
60
+ const data = result.value;
61
+ if (data.lines.length > 0) {
62
+ const newEntries = data.lines
63
+ .map(parseLogEntry)
64
+ .filter((e: LogEntry | null): e is LogEntry => e !== null);
65
+ setEntries((prev) => [...prev, ...newEntries]);
66
+ }
67
+ setOffset(data.offset);
68
+ } catch {
69
+ // ignore fetch errors
70
+ }
71
+ }, [sessionId, offset]);
72
+
73
+ useEffect(() => {
74
+ poll();
75
+ const interval = setInterval(poll, 2000);
76
+ return () => clearInterval(interval);
77
+ }, [poll]);
78
+
79
+ // Auto-scroll
80
+ useEffect(() => {
81
+ if (autoScrollRef.current && containerRef.current) {
82
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
83
+ }
84
+ });
85
+
86
+ const handleScroll = () => {
87
+ const el = containerRef.current;
88
+ if (!el) return;
89
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
90
+ autoScrollRef.current = atBottom;
91
+ };
92
+
93
+ const toggleRow = (index: number) => {
94
+ setExpandedRows((prev) => {
95
+ const next = new Set(prev);
96
+ if (next.has(index)) {
97
+ next.delete(index);
98
+ } else {
99
+ next.add(index);
100
+ }
101
+ return next;
102
+ });
103
+ };
104
+
105
+ if (!sessionId) return null;
106
+
107
+ const minLevelIndex = LOG_LEVELS.indexOf(minLevel);
108
+ const filtered = entries.filter(
109
+ (e) => LOG_LEVELS.indexOf(e.level) >= minLevelIndex,
110
+ );
111
+
112
+ return (
113
+ <div className="flex flex-col h-full gap-3">
114
+ {/* Toolbar */}
115
+ <div className="flex items-center gap-4 text-sm shrink-0">
116
+ <span className="text-slate-600">
117
+ <span className="font-medium text-slate-900">{filtered.length}</span>
118
+ {filtered.length !== entries.length && (
119
+ <span className="text-slate-400"> / {entries.length}</span>
120
+ )}{" "}
121
+ lines
122
+ </span>
123
+ <label className="flex items-center gap-2 text-slate-600">
124
+ Min level:
125
+ <select
126
+ value={minLevel}
127
+ onChange={(e) => setMinLevel(e.target.value as LogLevel)}
128
+ className="text-sm border border-slate-300 rounded px-2 py-1 bg-white"
129
+ >
130
+ {LOG_LEVELS.map((l) => (
131
+ <option key={l} value={l}>
132
+ {l}
133
+ </option>
134
+ ))}
135
+ </select>
136
+ </label>
137
+ <span
138
+ className={`text-xs px-2 py-0.5 rounded-full ${autoScrollRef.current ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-500"}`}
139
+ >
140
+ {autoScrollRef.current ? "auto-scroll on" : "auto-scroll off"}
141
+ </span>
142
+ </div>
143
+
144
+ {/* Log entries */}
145
+ <div
146
+ ref={containerRef}
147
+ onScroll={handleScroll}
148
+ className="flex-1 overflow-auto bg-white rounded-md border border-slate-200 font-mono text-xs"
149
+ >
150
+ {entries.length === 0 ? (
151
+ <div className="p-4 text-slate-500 text-sm font-sans">
152
+ No log entries yet
153
+ </div>
154
+ ) : (
155
+ <table className="w-full">
156
+ <tbody>
157
+ {filtered.map((entry) => {
158
+ const globalIndex = entries.indexOf(entry);
159
+ const expanded = expandedRows.has(globalIndex);
160
+ const { timestamp, level, message, ...context } = entry;
161
+ const hasContext = Object.keys(context).length > 0;
162
+ const time = timestamp
163
+ ? new Date(timestamp).toLocaleTimeString()
164
+ : "";
165
+
166
+ return (
167
+ <tr
168
+ key={globalIndex}
169
+ onClick={() => hasContext && toggleRow(globalIndex)}
170
+ className={`border-b border-slate-100 align-top ${LEVEL_STYLES[level]} ${hasContext ? "cursor-pointer hover:bg-slate-50" : ""}`}
171
+ >
172
+ <td className="px-2 py-1 text-slate-400 whitespace-nowrap w-0">
173
+ {time}
174
+ </td>
175
+ <td className="px-2 py-1 w-0">
176
+ <span
177
+ className={`px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase ${BADGE_STYLES[level]}`}
178
+ >
179
+ {level}
180
+ </span>
181
+ </td>
182
+ <td className="px-2 py-1 break-all">
183
+ <div>{message}</div>
184
+ {expanded && hasContext && (
185
+ <pre className="mt-1 p-2 bg-slate-50 rounded border border-slate-200 text-[11px] whitespace-pre-wrap break-words">
186
+ {JSON.stringify(context, null, 2)}
187
+ </pre>
188
+ )}
189
+ </td>
190
+ </tr>
191
+ );
192
+ })}
193
+ </tbody>
194
+ </table>
195
+ )}
196
+ </div>
197
+ </div>
198
+ );
199
+ }