@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.
- package/dist/components/debug/DebugContext.d.ts +10 -0
- package/dist/components/debug/DebugNavigation.d.ts +29 -0
- package/dist/components/debug/DebugShell.d.ts +18 -0
- package/dist/components/debug/LLMCallDetail.d.ts +7 -0
- package/dist/components/debug/TimelineDetailInspector.d.ts +6 -0
- package/dist/components/debug/communication/CommunicationDiagram.d.ts +9 -0
- package/dist/components/debug/communication/DiagramHeader.d.ts +7 -0
- package/dist/components/debug/communication/ParticipantLane.d.ts +7 -0
- package/dist/components/debug/communication/TimeAxis.d.ts +9 -0
- package/dist/components/debug/communication/elements/IdleGap.d.ts +9 -0
- package/dist/components/debug/communication/elements/LLMBlock.d.ts +9 -0
- package/dist/components/debug/communication/elements/MessageArrow.d.ts +10 -0
- package/dist/components/debug/communication/elements/ToolBlock.d.ts +9 -0
- package/dist/components/debug/communication/hooks/useDiagramData.d.ts +12 -0
- package/dist/components/debug/communication/hooks/useTimeCompression.d.ts +7 -0
- package/dist/components/debug/communication/hooks/useZoomPan.d.ts +11 -0
- package/dist/components/debug/communication/popovers/ElementPopover.d.ts +8 -0
- package/dist/components/debug/communication/types.d.ts +136 -0
- package/dist/components/debug/index.d.ts +11 -0
- package/dist/components/debug/pages/AgentDetailPage.d.ts +3 -0
- package/dist/components/debug/pages/AgentsPage.d.ts +1 -0
- package/dist/components/debug/pages/CommunicationPage.d.ts +1 -0
- package/dist/components/debug/pages/DashboardPage.d.ts +1 -0
- package/dist/components/debug/pages/EventsPage.d.ts +1 -0
- package/dist/components/debug/pages/FilesPage.d.ts +1 -0
- package/dist/components/debug/pages/LLMCallPage.d.ts +1 -0
- package/dist/components/debug/pages/LLMCallsPage.d.ts +1 -0
- package/dist/components/debug/pages/LogsPage.d.ts +1 -0
- package/dist/components/debug/pages/MailboxPage.d.ts +1 -0
- package/dist/components/debug/pages/ServicesPage.d.ts +1 -0
- package/dist/components/debug/pages/TimelinePage.d.ts +1 -0
- package/dist/components/debug/pages/UserChatPage.d.ts +1 -0
- package/dist/components/debug/pages/index.d.ts +13 -0
- package/dist/index.d.ts +9 -0
- package/dist/lib/domain-utils.d.ts +7 -0
- package/dist/providers/EventPollingProvider.d.ts +27 -0
- package/dist/stores/event-store.d.ts +93 -0
- package/dist/utils/format.d.ts +1 -0
- package/package.json +43 -0
- package/src/components/debug/DebugContext.tsx +18 -0
- package/src/components/debug/DebugNavigation.tsx +55 -0
- package/src/components/debug/DebugShell.tsx +321 -0
- package/src/components/debug/LLMCallDetail.tsx +740 -0
- package/src/components/debug/TimelineDetailInspector.tsx +204 -0
- package/src/components/debug/communication/CommunicationDiagram.tsx +260 -0
- package/src/components/debug/communication/DiagramHeader.tsx +113 -0
- package/src/components/debug/communication/ParticipantLane.tsx +60 -0
- package/src/components/debug/communication/TimeAxis.tsx +106 -0
- package/src/components/debug/communication/elements/IdleGap.tsx +90 -0
- package/src/components/debug/communication/elements/LLMBlock.tsx +107 -0
- package/src/components/debug/communication/elements/MessageArrow.tsx +119 -0
- package/src/components/debug/communication/elements/ToolBlock.tsx +99 -0
- package/src/components/debug/communication/hooks/useDiagramData.ts +294 -0
- package/src/components/debug/communication/hooks/useTimeCompression.ts +140 -0
- package/src/components/debug/communication/hooks/useZoomPan.ts +87 -0
- package/src/components/debug/communication/popovers/ElementPopover.tsx +158 -0
- package/src/components/debug/communication/types.ts +180 -0
- package/src/components/debug/index.ts +37 -0
- package/src/components/debug/pages/AgentDetailPage.tsx +1295 -0
- package/src/components/debug/pages/AgentsPage.tsx +297 -0
- package/src/components/debug/pages/CommunicationPage.tsx +89 -0
- package/src/components/debug/pages/DashboardPage.tsx +1504 -0
- package/src/components/debug/pages/EventsPage.tsx +276 -0
- package/src/components/debug/pages/FilesPage.tsx +366 -0
- package/src/components/debug/pages/LLMCallPage.tsx +32 -0
- package/src/components/debug/pages/LLMCallsPage.tsx +473 -0
- package/src/components/debug/pages/LogsPage.tsx +199 -0
- package/src/components/debug/pages/MailboxPage.tsx +232 -0
- package/src/components/debug/pages/ServicesPage.tsx +193 -0
- package/src/components/debug/pages/TimelinePage.tsx +569 -0
- package/src/components/debug/pages/UserChatPage.tsx +250 -0
- package/src/components/debug/pages/index.ts +13 -0
- package/src/index.ts +55 -0
- package/src/lib/domain-utils.ts +12 -0
- package/src/providers/EventPollingProvider.tsx +60 -0
- package/src/stores/event-store.ts +497 -0
- 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
|
+
}
|