@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,294 @@
1
+ import type { AgentTreeNode } from '@roj-ai/shared'
2
+ import type { BuiltinEvent, DomainEvent } from '@roj-ai/sdk'
3
+ import { useMemo } from 'react'
4
+ import type { DiagramData, DiagramLLMBlock, DiagramMessage, DiagramParticipant, DiagramToolBlock, ParticipantRole, ParticipantStatus } from '../types'
5
+ import { useTimeCompression } from './useTimeCompression'
6
+
7
+ interface UseDiagramDataProps {
8
+ events: DomainEvent[]
9
+ agents: AgentTreeNode[]
10
+ }
11
+
12
+ export function useDiagramData({ events, agents }: UseDiagramDataProps): DiagramData & {
13
+ timestampToY: (timestamp: number) => number
14
+ formatIdleDuration: (ms: number) => string
15
+ } {
16
+ // Extract all timestamps for compression
17
+ const timestamps = useMemo(() => {
18
+ return events.map((e) => e.timestamp).sort((a, b) => a - b)
19
+ }, [events])
20
+
21
+ const { segments, totalHeight, timestampToY, formatIdleDuration } = useTimeCompression(timestamps)
22
+
23
+ // Build participants from agents
24
+ const participants = useMemo((): DiagramParticipant[] => {
25
+ const result: DiagramParticipant[] = []
26
+
27
+ // User is always first
28
+ const sessionStart = events.find((e) => e.type === 'session_created')?.timestamp ?? Date.now()
29
+ result.push({
30
+ id: 'user',
31
+ name: 'User',
32
+ role: 'user',
33
+ spawnedAt: sessionStart,
34
+ status: 'idle',
35
+ columnIndex: 0,
36
+ })
37
+
38
+ // Flatten agent tree with BFS to maintain hierarchy order
39
+ const flattenAgents = (nodes: AgentTreeNode[], parentRole: ParticipantRole | null = null): void => {
40
+ for (const agent of nodes) {
41
+ const role = determineRole(agent.definitionName, parentRole)
42
+ result.push({
43
+ id: agent.id as DiagramParticipant['id'],
44
+ name: formatAgentName(agent.definitionName),
45
+ role,
46
+ spawnedAt: getAgentSpawnTime(events, agent.id),
47
+ status: mapAgentStatus(agent.status),
48
+ columnIndex: result.length,
49
+ })
50
+ flattenAgents(agent.children, role)
51
+ }
52
+ }
53
+
54
+ flattenAgents(agents)
55
+
56
+ return result
57
+ }, [agents, events])
58
+
59
+ // Build messages from mailbox events with parallel message offset
60
+ const messages = useMemo((): DiagramMessage[] => {
61
+ const rawMessages: DiagramMessage[] = []
62
+
63
+ for (const event of events) {
64
+ const e = event as BuiltinEvent
65
+ if (e.type === 'mailbox_message') {
66
+ const fromId = e.message.from === 'user' ? 'user' : e.message.from
67
+ rawMessages.push({
68
+ id: e.message.id,
69
+ fromId: fromId as DiagramMessage['fromId'],
70
+ toId: e.toAgentId,
71
+ timestamp: e.timestamp,
72
+ content: e.message.content,
73
+ yPosition: timestampToY(e.timestamp),
74
+ })
75
+ }
76
+
77
+ // User messages to agents (sent via send_message endpoint)
78
+ if (e.type === 'user_message_sent') {
79
+ rawMessages.push({
80
+ id: e.messageId,
81
+ fromId: e.agentId,
82
+ toId: 'user',
83
+ timestamp: e.timestamp,
84
+ content: e.message,
85
+ yPosition: timestampToY(e.timestamp),
86
+ })
87
+ }
88
+ }
89
+
90
+ // Sort by timestamp
91
+ rawMessages.sort((a, b) => a.timestamp - b.timestamp)
92
+
93
+ // Offset parallel messages (same timestamp or within 100ms)
94
+ const PARALLEL_THRESHOLD_MS = 100
95
+ const MESSAGE_Y_OFFSET = 14
96
+
97
+ const result: DiagramMessage[] = []
98
+ let parallelGroup: DiagramMessage[] = []
99
+
100
+ for (const msg of rawMessages) {
101
+ if (parallelGroup.length === 0) {
102
+ parallelGroup.push(msg)
103
+ } else {
104
+ const lastMsg = parallelGroup[parallelGroup.length - 1]
105
+ if (Math.abs(msg.timestamp - lastMsg.timestamp) <= PARALLEL_THRESHOLD_MS) {
106
+ parallelGroup.push(msg)
107
+ } else {
108
+ // Process previous group
109
+ result.push(...offsetParallelMessages(parallelGroup, MESSAGE_Y_OFFSET))
110
+ parallelGroup = [msg]
111
+ }
112
+ }
113
+ }
114
+
115
+ // Process last group
116
+ if (parallelGroup.length > 0) {
117
+ result.push(...offsetParallelMessages(parallelGroup, MESSAGE_Y_OFFSET))
118
+ }
119
+
120
+ return result
121
+ }, [events, timestampToY])
122
+
123
+ // Build LLM blocks from inference events
124
+ const llmBlocks = useMemo((): DiagramLLMBlock[] => {
125
+ const result: DiagramLLMBlock[] = []
126
+ const pendingInferences = new Map<string, { event: Extract<BuiltinEvent, { type: 'inference_started' }>; idx: number }>()
127
+
128
+ for (const event of events) {
129
+ const e = event as BuiltinEvent
130
+ if (e.type === 'inference_started') {
131
+ const idx = result.length
132
+ result.push({
133
+ id: `llm-${e.agentId}-${e.timestamp}`,
134
+ participantId: e.agentId,
135
+ startTime: e.timestamp,
136
+ status: 'running',
137
+ yStart: timestampToY(e.timestamp),
138
+ yEnd: timestampToY(e.timestamp) + 30, // Minimum height
139
+ })
140
+ pendingInferences.set(e.agentId, { event: e, idx })
141
+ }
142
+
143
+ if (e.type === 'inference_completed') {
144
+ const pending = pendingInferences.get(e.agentId)
145
+ if (pending) {
146
+ result[pending.idx] = {
147
+ ...result[pending.idx],
148
+ endTime: e.timestamp,
149
+ status: 'success',
150
+ model: e.metrics.model,
151
+ tokens: e.metrics.totalTokens,
152
+ llmCallId: e.llmCallId,
153
+ yEnd: timestampToY(e.timestamp),
154
+ }
155
+ pendingInferences.delete(e.agentId)
156
+ }
157
+ }
158
+
159
+ if (e.type === 'inference_failed') {
160
+ const pending = pendingInferences.get(e.agentId)
161
+ if (pending) {
162
+ result[pending.idx] = {
163
+ ...result[pending.idx],
164
+ endTime: e.timestamp,
165
+ status: 'error',
166
+ llmCallId: e.llmCallId,
167
+ yEnd: timestampToY(e.timestamp),
168
+ }
169
+ pendingInferences.delete(e.agentId)
170
+ }
171
+ }
172
+ }
173
+
174
+ return result
175
+ }, [events, timestampToY])
176
+
177
+ // Build tool blocks from tool events
178
+ const toolBlocks = useMemo((): DiagramToolBlock[] => {
179
+ const result: DiagramToolBlock[] = []
180
+ const pendingTools = new Map<string, { event: Extract<BuiltinEvent, { type: 'tool_started' }>; idx: number }>()
181
+
182
+ for (const event of events) {
183
+ const e = event as BuiltinEvent
184
+ if (e.type === 'tool_started') {
185
+ const idx = result.length
186
+ result.push({
187
+ id: `tool-${e.toolCallId}`,
188
+ toolCallId: e.toolCallId,
189
+ participantId: e.agentId,
190
+ toolName: e.toolName,
191
+ startTime: e.timestamp,
192
+ status: 'running',
193
+ yStart: timestampToY(e.timestamp),
194
+ yEnd: timestampToY(e.timestamp) + 20, // Minimum height
195
+ })
196
+ pendingTools.set(e.toolCallId, { event: e, idx })
197
+ }
198
+
199
+ if (e.type === 'tool_completed') {
200
+ const pending = pendingTools.get(e.toolCallId)
201
+ if (pending) {
202
+ result[pending.idx] = {
203
+ ...result[pending.idx],
204
+ endTime: e.timestamp,
205
+ status: 'success',
206
+ yEnd: timestampToY(e.timestamp),
207
+ }
208
+ pendingTools.delete(e.toolCallId)
209
+ }
210
+ }
211
+
212
+ if (e.type === 'tool_failed') {
213
+ const pending = pendingTools.get(e.toolCallId)
214
+ if (pending) {
215
+ result[pending.idx] = {
216
+ ...result[pending.idx],
217
+ endTime: e.timestamp,
218
+ status: 'error',
219
+ yEnd: timestampToY(e.timestamp),
220
+ }
221
+ pendingTools.delete(e.toolCallId)
222
+ }
223
+ }
224
+ }
225
+
226
+ return result
227
+ }, [events, timestampToY])
228
+
229
+ const sessionStartTime = useMemo(() => {
230
+ const sessionCreated = events.find((e) => e.type === 'session_created')
231
+ return sessionCreated?.timestamp ?? Date.now()
232
+ }, [events])
233
+
234
+ return {
235
+ participants,
236
+ messages,
237
+ llmBlocks,
238
+ toolBlocks,
239
+ timeSegments: segments,
240
+ totalHeight,
241
+ sessionStartTime,
242
+ timestampToY,
243
+ formatIdleDuration,
244
+ }
245
+ }
246
+
247
+ // Helper functions
248
+
249
+ function determineRole(definitionName: string, parentRole: ParticipantRole | null): ParticipantRole {
250
+ const lower = definitionName.toLowerCase()
251
+ if (lower.includes('communicator')) return 'communicator'
252
+ if (lower.includes('orchestrator')) return 'orchestrator'
253
+ if (parentRole === 'orchestrator' || parentRole === 'worker') return 'worker'
254
+ if (parentRole === null) return 'communicator' // Root agent without orchestrator
255
+ return 'worker'
256
+ }
257
+
258
+ function formatAgentName(definitionName: string): string {
259
+ // Convert kebab-case or snake_case to Title Case
260
+ return definitionName
261
+ .replace(/[-_]/g, ' ')
262
+ .replace(/\b\w/g, (c) => c.toUpperCase())
263
+ .replace(/^Entry$/, 'Entry Agent')
264
+ }
265
+
266
+ function getAgentSpawnTime(events: DomainEvent[], agentId: string): number {
267
+ const spawnEvent = events.find(
268
+ (e) => e.type === 'agent_spawned' && 'agentId' in e && (e as BuiltinEvent & { type: 'agent_spawned' }).agentId === agentId,
269
+ )
270
+ return spawnEvent?.timestamp ?? Date.now()
271
+ }
272
+
273
+ function mapAgentStatus(status: AgentTreeNode['status']): ParticipantStatus {
274
+ // AgentTreeNode.status is from common-schemas AgentStatus:
275
+ // "idle" | "thinking" | "responding" | "waiting_for_user" | "error"
276
+ // which matches ParticipantStatus exactly
277
+ return status
278
+ }
279
+
280
+ function offsetParallelMessages(messages: DiagramMessage[], offset: number): DiagramMessage[] {
281
+ if (messages.length <= 1) {
282
+ return messages
283
+ }
284
+
285
+ // Center the group around the original Y position
286
+ const baseY = messages[0].yPosition
287
+ const totalHeight = (messages.length - 1) * offset
288
+ const startY = baseY - totalHeight / 2
289
+
290
+ return messages.map((msg, idx) => ({
291
+ ...msg,
292
+ yPosition: startY + idx * offset,
293
+ }))
294
+ }
@@ -0,0 +1,140 @@
1
+ import { useMemo } from 'react'
2
+ import type { TimeCompressionConfig, TimeSegment } from '../types'
3
+
4
+ const DEFAULT_CONFIG: TimeCompressionConfig = {
5
+ pixelsPerSecond: 50,
6
+ maxIdleHeight: 40,
7
+ idleThresholdMs: 30000, // 30 seconds
8
+ }
9
+
10
+ export function useTimeCompression(
11
+ timestamps: number[],
12
+ config: Partial<TimeCompressionConfig> = {},
13
+ ): {
14
+ segments: TimeSegment[]
15
+ totalHeight: number
16
+ timestampToY: (timestamp: number) => number
17
+ formatIdleDuration: (ms: number) => string
18
+ } {
19
+ const mergedConfig = { ...DEFAULT_CONFIG, ...config }
20
+
21
+ const segments = useMemo(() => {
22
+ if (timestamps.length === 0) {
23
+ return []
24
+ }
25
+
26
+ const sorted = [...timestamps].sort((a, b) => a - b)
27
+ const result: TimeSegment[] = []
28
+
29
+ let currentStart = sorted[0]
30
+ let lastTimestamp = sorted[0]
31
+ let isIdle = false
32
+
33
+ for (let i = 1; i < sorted.length; i++) {
34
+ const gap = sorted[i] - lastTimestamp
35
+
36
+ if (gap > mergedConfig.idleThresholdMs && !isIdle) {
37
+ // End active segment, start idle
38
+ result.push({
39
+ startTime: currentStart,
40
+ endTime: lastTimestamp,
41
+ type: 'active',
42
+ displayHeight: Math.max(
43
+ 20,
44
+ ((lastTimestamp - currentStart) / 1000) * mergedConfig.pixelsPerSecond,
45
+ ),
46
+ actualDuration: lastTimestamp - currentStart,
47
+ })
48
+ currentStart = lastTimestamp
49
+ isIdle = true
50
+ } else if (gap <= mergedConfig.idleThresholdMs && isIdle) {
51
+ // End idle segment, start active
52
+ result.push({
53
+ startTime: currentStart,
54
+ endTime: sorted[i],
55
+ type: 'idle',
56
+ displayHeight: mergedConfig.maxIdleHeight,
57
+ actualDuration: sorted[i] - currentStart,
58
+ })
59
+ currentStart = sorted[i]
60
+ isIdle = false
61
+ }
62
+
63
+ lastTimestamp = sorted[i]
64
+ }
65
+
66
+ // Close final segment
67
+ if (currentStart !== lastTimestamp || result.length === 0) {
68
+ result.push({
69
+ startTime: currentStart,
70
+ endTime: lastTimestamp,
71
+ type: isIdle ? 'idle' : 'active',
72
+ displayHeight: isIdle
73
+ ? mergedConfig.maxIdleHeight
74
+ : Math.max(
75
+ 20,
76
+ ((lastTimestamp - currentStart) / 1000) * mergedConfig.pixelsPerSecond,
77
+ ),
78
+ actualDuration: lastTimestamp - currentStart,
79
+ })
80
+ }
81
+
82
+ return result
83
+ }, [timestamps, mergedConfig.idleThresholdMs, mergedConfig.pixelsPerSecond, mergedConfig.maxIdleHeight])
84
+
85
+ const totalHeight = useMemo(() => {
86
+ return segments.reduce((sum, seg) => sum + seg.displayHeight, 0)
87
+ }, [segments])
88
+
89
+ const timestampToY = useMemo(() => {
90
+ return (timestamp: number): number => {
91
+ let y = 0
92
+
93
+ for (const segment of segments) {
94
+ if (timestamp < segment.startTime) {
95
+ return y
96
+ }
97
+
98
+ if (timestamp <= segment.endTime) {
99
+ if (segment.type === 'idle') {
100
+ // Linear interpolation within idle segment
101
+ const progress = (timestamp - segment.startTime) / (segment.endTime - segment.startTime)
102
+ return y + progress * segment.displayHeight
103
+ } else {
104
+ // Active segment: proportional to time
105
+ const segmentDuration = segment.endTime - segment.startTime
106
+ if (segmentDuration === 0) {
107
+ return y
108
+ }
109
+ const progress = (timestamp - segment.startTime) / segmentDuration
110
+ return y + progress * segment.displayHeight
111
+ }
112
+ }
113
+
114
+ y += segment.displayHeight
115
+ }
116
+
117
+ return y
118
+ }
119
+ }, [segments])
120
+
121
+ const formatIdleDuration = (ms: number): string => {
122
+ const seconds = Math.floor(ms / 1000)
123
+ const minutes = Math.floor(seconds / 60)
124
+ const hours = Math.floor(minutes / 60)
125
+ const days = Math.floor(hours / 24)
126
+
127
+ if (days > 0) {
128
+ return `${days}d ${hours % 24}h idle`
129
+ }
130
+ if (hours > 0) {
131
+ return `${hours}h ${minutes % 60}m idle`
132
+ }
133
+ if (minutes > 0) {
134
+ return `${minutes}m idle`
135
+ }
136
+ return `${seconds}s idle`
137
+ }
138
+
139
+ return { segments, totalHeight, timestampToY, formatIdleDuration }
140
+ }
@@ -0,0 +1,87 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import type { ZoomPanState } from '../types'
3
+
4
+ const MIN_ZOOM = 0.25
5
+ const MAX_ZOOM = 4
6
+ const ZOOM_STEP = 0.25
7
+
8
+ export function useZoomPan(totalHeight: number) {
9
+ const [state, setState] = useState<ZoomPanState>({
10
+ zoom: 1,
11
+ scrollY: 0,
12
+ autoScroll: true,
13
+ })
14
+
15
+ const containerRef = useRef<HTMLDivElement>(null)
16
+ const prevTotalHeightRef = useRef(totalHeight)
17
+
18
+ // Auto-scroll to bottom when new content arrives
19
+ useEffect(() => {
20
+ if (state.autoScroll && totalHeight > prevTotalHeightRef.current && containerRef.current) {
21
+ containerRef.current.scrollTop = containerRef.current.scrollHeight
22
+ }
23
+ prevTotalHeightRef.current = totalHeight
24
+ }, [totalHeight, state.autoScroll])
25
+
26
+ const zoomIn = useCallback(() => {
27
+ setState((prev) => ({
28
+ ...prev,
29
+ zoom: Math.min(MAX_ZOOM, prev.zoom + ZOOM_STEP),
30
+ }))
31
+ }, [])
32
+
33
+ const zoomOut = useCallback(() => {
34
+ setState((prev) => ({
35
+ ...prev,
36
+ zoom: Math.max(MIN_ZOOM, prev.zoom - ZOOM_STEP),
37
+ }))
38
+ }, [])
39
+
40
+ const resetZoom = useCallback(() => {
41
+ setState((prev) => ({
42
+ ...prev,
43
+ zoom: 1,
44
+ }))
45
+ }, [])
46
+
47
+ const toggleAutoScroll = useCallback(() => {
48
+ setState((prev) => ({
49
+ ...prev,
50
+ autoScroll: !prev.autoScroll,
51
+ }))
52
+ }, [])
53
+
54
+ const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
55
+ const target = e.target as HTMLDivElement
56
+ const isAtBottom = Math.abs(target.scrollHeight - target.clientHeight - target.scrollTop) < 10
57
+
58
+ setState((prev) => ({
59
+ ...prev,
60
+ scrollY: target.scrollTop,
61
+ // Disable auto-scroll if user scrolled up manually
62
+ autoScroll: prev.autoScroll ? isAtBottom : prev.autoScroll,
63
+ }))
64
+ }, [])
65
+
66
+ const handleWheel = useCallback((e: React.WheelEvent<HTMLDivElement>) => {
67
+ if (e.ctrlKey || e.metaKey) {
68
+ e.preventDefault()
69
+ const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
70
+ setState((prev) => ({
71
+ ...prev,
72
+ zoom: Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, prev.zoom + delta)),
73
+ }))
74
+ }
75
+ }, [])
76
+
77
+ return {
78
+ state,
79
+ containerRef,
80
+ zoomIn,
81
+ zoomOut,
82
+ resetZoom,
83
+ toggleAutoScroll,
84
+ handleScroll,
85
+ handleWheel,
86
+ }
87
+ }
@@ -0,0 +1,158 @@
1
+ import type { DiagramLLMBlock, DiagramMessage, DiagramToolBlock, PopoverElement, TimeSegment } from '../types'
2
+
3
+ interface ElementPopoverProps {
4
+ element: PopoverElement
5
+ x: number
6
+ y: number
7
+ }
8
+
9
+ export function ElementPopover({ element, x, y }: ElementPopoverProps) {
10
+ // Offset from cursor
11
+ const offsetX = 12
12
+ const offsetY = 12
13
+
14
+ return (
15
+ <div
16
+ className="fixed z-50 pointer-events-none"
17
+ style={{
18
+ left: x + offsetX,
19
+ top: y + offsetY,
20
+ }}
21
+ >
22
+ <div className="bg-slate-900 text-white text-[11px] rounded-md shadow-xl p-2.5 max-w-[280px] border border-slate-700">
23
+ {element.type === 'message' && <MessagePopover data={element.data} />}
24
+ {element.type === 'llm' && <LLMPopover data={element.data} />}
25
+ {element.type === 'tool' && <ToolPopover data={element.data} />}
26
+ {element.type === 'idle' && <IdlePopover data={element.data} />}
27
+ </div>
28
+ </div>
29
+ )
30
+ }
31
+
32
+ function MessagePopover({ data }: { data: DiagramMessage }) {
33
+ return (
34
+ <div className="space-y-2">
35
+ <div className="flex items-center gap-2">
36
+ <span className="w-1.5 h-1.5 rounded-full bg-blue-400" />
37
+ <span className="font-medium text-blue-300">Message</span>
38
+ </div>
39
+
40
+ <div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 text-[10px]">
41
+ <span className="text-slate-500">From</span>
42
+ <span className="text-slate-300 font-mono">{String(data.fromId).slice(0, 12)}</span>
43
+ <span className="text-slate-500">To</span>
44
+ <span className="text-slate-300 font-mono">{String(data.toId).slice(0, 12)}</span>
45
+ <span className="text-slate-500">Time</span>
46
+ <span className="text-slate-300">{new Date(data.timestamp).toLocaleTimeString()}</span>
47
+ </div>
48
+
49
+ <div className="pt-1.5 border-t border-slate-700/50">
50
+ <div className="text-slate-300 leading-relaxed line-clamp-4">
51
+ {data.content}
52
+ </div>
53
+ </div>
54
+ </div>
55
+ )
56
+ }
57
+
58
+ function LLMPopover({ data }: { data: DiagramLLMBlock }) {
59
+ const duration = data.endTime
60
+ ? ((data.endTime - data.startTime) / 1000).toFixed(1) + 's'
61
+ : null
62
+
63
+ return (
64
+ <div className="space-y-2">
65
+ <div className="flex items-center gap-2">
66
+ <span className="w-1.5 h-1.5 rounded-full bg-violet-400" />
67
+ <span className="font-medium text-violet-300">LLM Inference</span>
68
+ {data.status === 'running' && <span className="text-[9px] bg-amber-500/20 text-amber-300 px-1.5 py-0.5 rounded">Running</span>}
69
+ {data.status === 'error' && <span className="text-[9px] bg-red-500/20 text-red-300 px-1.5 py-0.5 rounded">Error</span>}
70
+ </div>
71
+
72
+ <div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 text-[10px]">
73
+ {data.model && (
74
+ <>
75
+ <span className="text-slate-500">Model</span>
76
+ <span className="text-slate-300 font-mono">{data.model.split('/').pop()}</span>
77
+ </>
78
+ )}
79
+ {duration && (
80
+ <>
81
+ <span className="text-slate-500">Duration</span>
82
+ <span className="text-slate-300">{duration}</span>
83
+ </>
84
+ )}
85
+ {data.tokens && (
86
+ <>
87
+ <span className="text-slate-500">Tokens</span>
88
+ <span className="text-slate-300">{data.tokens.toLocaleString()}</span>
89
+ </>
90
+ )}
91
+ </div>
92
+
93
+ {data.llmCallId && (
94
+ <div className="pt-1.5 border-t border-slate-700/50 text-[9px] text-slate-500">
95
+ Click to view details
96
+ </div>
97
+ )}
98
+ </div>
99
+ )
100
+ }
101
+
102
+ function ToolPopover({ data }: { data: DiagramToolBlock }) {
103
+ const duration = data.endTime
104
+ ? ((data.endTime - data.startTime) / 1000).toFixed(1) + 's'
105
+ : null
106
+
107
+ return (
108
+ <div className="space-y-2">
109
+ <div className="flex items-center gap-2">
110
+ <span className="w-1.5 h-1.5 rounded-full bg-teal-400" />
111
+ <span className="font-medium text-teal-300">Tool Execution</span>
112
+ {data.status === 'running' && <span className="text-[9px] bg-amber-500/20 text-amber-300 px-1.5 py-0.5 rounded">Running</span>}
113
+ {data.status === 'error' && <span className="text-[9px] bg-red-500/20 text-red-300 px-1.5 py-0.5 rounded">Error</span>}
114
+ </div>
115
+
116
+ <div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 text-[10px]">
117
+ <span className="text-slate-500">Tool</span>
118
+ <span className="text-slate-300 font-mono">{data.toolName}</span>
119
+ {duration && (
120
+ <>
121
+ <span className="text-slate-500">Duration</span>
122
+ <span className="text-slate-300">{duration}</span>
123
+ </>
124
+ )}
125
+ </div>
126
+ </div>
127
+ )
128
+ }
129
+
130
+ function IdlePopover({ data }: { data: TimeSegment }) {
131
+ const formatDuration = (ms: number): string => {
132
+ const seconds = Math.floor(ms / 1000)
133
+ const minutes = Math.floor(seconds / 60)
134
+ const hours = Math.floor(minutes / 60)
135
+
136
+ if (hours > 0) return `${hours}h ${minutes % 60}m`
137
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`
138
+ return `${seconds}s`
139
+ }
140
+
141
+ return (
142
+ <div className="space-y-2">
143
+ <div className="flex items-center gap-2">
144
+ <span className="w-1.5 h-1.5 rounded-full bg-slate-400" />
145
+ <span className="font-medium text-slate-300">Idle Period</span>
146
+ </div>
147
+
148
+ <div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 text-[10px]">
149
+ <span className="text-slate-500">Duration</span>
150
+ <span className="text-slate-300">{formatDuration(data.actualDuration)}</span>
151
+ <span className="text-slate-500">From</span>
152
+ <span className="text-slate-300">{new Date(data.startTime).toLocaleTimeString()}</span>
153
+ <span className="text-slate-500">To</span>
154
+ <span className="text-slate-300">{new Date(data.endTime).toLocaleTimeString()}</span>
155
+ </div>
156
+ </div>
157
+ )
158
+ }