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