@oh-my-pi/omp-stats 18.0.11 → 18.1.0
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/CHANGELOG.md +11 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +101 -101
- package/dist/client/styles.css +265 -0
- package/dist/types/client/api.d.ts +7 -1
- package/dist/types/client/app/routes.d.ts +1 -1
- package/dist/types/client/data/useHashRoute.d.ts +2 -0
- package/dist/types/client/routes/TracesRoute.d.ts +11 -0
- package/dist/types/client/routes/index.d.ts +1 -0
- package/dist/types/client/traces/AggregatesPanel.d.ts +9 -0
- package/dist/types/client/traces/Minimap.d.ts +14 -0
- package/dist/types/client/traces/SpanDrawer.d.ts +14 -0
- package/dist/types/client/traces/SummaryStrip.d.ts +9 -0
- package/dist/types/client/traces/TimelineCanvas.d.ts +25 -0
- package/dist/types/client/traces/TraceView.d.ts +11 -0
- package/dist/types/client/traces/TranscriptList.d.ts +24 -0
- package/dist/types/client/traces/time-scale.d.ts +40 -0
- package/dist/types/client/traces/trace-colors.d.ts +31 -0
- package/dist/types/db.d.ts +24 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/parser.d.ts +8 -0
- package/dist/types/server.d.ts +3 -2
- package/dist/types/shared-types.d.ts +130 -0
- package/dist/types/trace.d.ts +33 -0
- package/dist/types/types.d.ts +35 -1
- package/package.json +8 -8
- package/src/client/App.tsx +11 -1
- package/src/client/api.ts +23 -0
- package/src/client/app/routes.ts +7 -0
- package/src/client/data/useHashRoute.ts +28 -10
- package/src/client/routes/ProvidersRoute.tsx +1 -0
- package/src/client/routes/TracesRoute.tsx +182 -0
- package/src/client/routes/index.ts +1 -0
- package/src/client/styles.css +316 -0
- package/src/client/traces/AggregatesPanel.tsx +88 -0
- package/src/client/traces/Minimap.tsx +177 -0
- package/src/client/traces/SpanDrawer.tsx +309 -0
- package/src/client/traces/SummaryStrip.tsx +37 -0
- package/src/client/traces/TimelineCanvas.tsx +768 -0
- package/src/client/traces/TraceView.tsx +344 -0
- package/src/client/traces/TranscriptList.tsx +166 -0
- package/src/client/traces/time-scale.ts +250 -0
- package/src/client/traces/trace-colors.ts +77 -0
- package/src/db.ts +77 -0
- package/src/index.ts +1 -6
- package/src/parser.ts +55 -2
- package/src/server.ts +69 -17
- package/src/shared-types.ts +138 -0
- package/src/trace.ts +1002 -0
- package/src/types.ts +45 -1
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trace flamegraph: one DPR-scaled canvas with a DOM gutter (track/lane
|
|
3
|
+
* labels, collapse chevrons) and a DOM tooltip. Implements the Chrome
|
|
4
|
+
* DevTools interaction contract: cursor-anchored wheel zoom, drag pan, WASD,
|
|
5
|
+
* fit/focus keys, hover tooltips, click-to-select, double-click-to-zoom.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
9
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import { formatDurationMs } from "../data/formatters";
|
|
11
|
+
import type { TraceMarker, TraceSpan, TraceSpanKind, TraceTrack } from "../types";
|
|
12
|
+
import { useSystemTheme } from "../useSystemTheme";
|
|
13
|
+
import { buildTicks, formatOffset, type TraceScale } from "./time-scale";
|
|
14
|
+
import { TRACE_THEMES, type TraceTheme } from "./trace-colors";
|
|
15
|
+
|
|
16
|
+
export interface TimelineViewport {
|
|
17
|
+
u0: number;
|
|
18
|
+
u1: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TimelineCanvasProps {
|
|
22
|
+
tracks: TraceTrack[];
|
|
23
|
+
scale: TraceScale;
|
|
24
|
+
viewport: TimelineViewport;
|
|
25
|
+
onViewportChange: (viewport: TimelineViewport) => void;
|
|
26
|
+
selection: string | null;
|
|
27
|
+
onSelect: (spanId: string | null) => void;
|
|
28
|
+
search: string;
|
|
29
|
+
collapsed: ReadonlySet<string>;
|
|
30
|
+
onToggleCollapse: (trackId: string) => void;
|
|
31
|
+
traceStart: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const RULER_H = 28;
|
|
35
|
+
const HEADER_H = 22;
|
|
36
|
+
const LANE_H = 18;
|
|
37
|
+
const LANE_GAP = 3;
|
|
38
|
+
const TRACK_GAP = 14;
|
|
39
|
+
const GUTTER_W = 200;
|
|
40
|
+
const MIN_WINDOW_U = 10;
|
|
41
|
+
const MIN_SPAN_PX = 2;
|
|
42
|
+
const HIT_SLOP_PX = 4;
|
|
43
|
+
const LABEL_MIN_PX = 56;
|
|
44
|
+
const SPAN_RADIUS = 3;
|
|
45
|
+
|
|
46
|
+
const LANE_ORDER: Array<{ kind: TraceSpanKind; label: string }> = [
|
|
47
|
+
{ kind: "turn", label: "Input" },
|
|
48
|
+
{ kind: "model", label: "Model" },
|
|
49
|
+
{ kind: "tool", label: "Tools" },
|
|
50
|
+
{ kind: "subagent", label: "Agents" },
|
|
51
|
+
{ kind: "background", label: "Bg" },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
interface LaneRow {
|
|
55
|
+
track: TraceTrack;
|
|
56
|
+
kind: TraceSpanKind;
|
|
57
|
+
label: string;
|
|
58
|
+
y: number;
|
|
59
|
+
spans: TraceSpan[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface TrackBlock {
|
|
63
|
+
track: TraceTrack;
|
|
64
|
+
depth: number;
|
|
65
|
+
hasChildren: boolean;
|
|
66
|
+
headerY: number;
|
|
67
|
+
lanes: LaneRow[];
|
|
68
|
+
/** Full vertical extent of the block (header top → last lane bottom). */
|
|
69
|
+
y0: number;
|
|
70
|
+
y1: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface TimelineLayout {
|
|
74
|
+
blocks: TrackBlock[];
|
|
75
|
+
lanes: LaneRow[];
|
|
76
|
+
totalHeight: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Compute vertical layout for the visible (non-collapsed) track tree. */
|
|
80
|
+
function buildLayout(tracks: TraceTrack[], collapsed: ReadonlySet<string>): TimelineLayout {
|
|
81
|
+
const byId = new Map(tracks.map(track => [track.id, track]));
|
|
82
|
+
const hasChildren = new Set<string>();
|
|
83
|
+
for (const track of tracks) {
|
|
84
|
+
if (track.parentId) hasChildren.add(track.parentId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const isHidden = (track: TraceTrack): boolean => {
|
|
88
|
+
let parentId = track.parentId;
|
|
89
|
+
while (parentId) {
|
|
90
|
+
if (collapsed.has(parentId)) return true;
|
|
91
|
+
parentId = byId.get(parentId)?.parentId ?? null;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const blocks: TrackBlock[] = [];
|
|
97
|
+
const lanes: LaneRow[] = [];
|
|
98
|
+
let y = RULER_H + 6;
|
|
99
|
+
for (const track of tracks) {
|
|
100
|
+
if (isHidden(track)) continue;
|
|
101
|
+
const depth = track.id === "main" ? 0 : track.id.split("/").length;
|
|
102
|
+
const block: TrackBlock = {
|
|
103
|
+
track,
|
|
104
|
+
depth,
|
|
105
|
+
hasChildren: hasChildren.has(track.id),
|
|
106
|
+
headerY: y,
|
|
107
|
+
lanes: [],
|
|
108
|
+
y0: y,
|
|
109
|
+
y1: y,
|
|
110
|
+
};
|
|
111
|
+
y += HEADER_H;
|
|
112
|
+
for (const { kind, label } of LANE_ORDER) {
|
|
113
|
+
const spans = track.spans.filter(span => span.kind === kind);
|
|
114
|
+
// Main always shows the core lanes; optional lanes appear when populated.
|
|
115
|
+
const isCore = kind === "turn" || kind === "model" || kind === "tool";
|
|
116
|
+
if (spans.length === 0 && !(track.id === "main" && isCore)) continue;
|
|
117
|
+
const lane: LaneRow = { track, kind, label, y, spans };
|
|
118
|
+
block.lanes.push(lane);
|
|
119
|
+
lanes.push(lane);
|
|
120
|
+
y += LANE_H + LANE_GAP;
|
|
121
|
+
}
|
|
122
|
+
block.y1 = y - LANE_GAP;
|
|
123
|
+
blocks.push(block);
|
|
124
|
+
y += TRACK_GAP;
|
|
125
|
+
}
|
|
126
|
+
return { blocks, lanes, totalHeight: Math.max(y, RULER_H + 48) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface SpanHit {
|
|
130
|
+
kind: "span";
|
|
131
|
+
x: number;
|
|
132
|
+
y: number;
|
|
133
|
+
w: number;
|
|
134
|
+
h: number;
|
|
135
|
+
span: TraceSpan;
|
|
136
|
+
track: TraceTrack;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface MarkerHit {
|
|
140
|
+
kind: "marker";
|
|
141
|
+
x: number;
|
|
142
|
+
y: number;
|
|
143
|
+
w: number;
|
|
144
|
+
h: number;
|
|
145
|
+
marker: TraceMarker;
|
|
146
|
+
track: TraceTrack;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
type Hit = SpanHit | MarkerHit;
|
|
150
|
+
|
|
151
|
+
interface HoverState {
|
|
152
|
+
hit: Hit;
|
|
153
|
+
clientX: number;
|
|
154
|
+
clientY: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function TimelineCanvas({
|
|
158
|
+
tracks,
|
|
159
|
+
scale,
|
|
160
|
+
viewport,
|
|
161
|
+
onViewportChange,
|
|
162
|
+
selection,
|
|
163
|
+
onSelect,
|
|
164
|
+
search,
|
|
165
|
+
collapsed,
|
|
166
|
+
onToggleCollapse,
|
|
167
|
+
traceStart,
|
|
168
|
+
}: TimelineCanvasProps) {
|
|
169
|
+
const theme = useSystemTheme();
|
|
170
|
+
const colors = TRACE_THEMES[theme];
|
|
171
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
172
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
173
|
+
const [canvasWidth, setCanvasWidth] = useState(800);
|
|
174
|
+
const [hover, setHover] = useState<HoverState | null>(null);
|
|
175
|
+
const hitsRef = useRef<Hit[]>([]);
|
|
176
|
+
const dragRef = useRef<{ pointerId: number; lastX: number; moved: boolean } | null>(null);
|
|
177
|
+
const hoverXRef = useRef<number | null>(null);
|
|
178
|
+
// Authoritative viewport for input handlers: wheel/drag events fire faster
|
|
179
|
+
// than React re-renders, and computing each step from the stale `viewport`
|
|
180
|
+
// prop would drop deltas within a frame (sluggish, jumpy zoom).
|
|
181
|
+
const viewportRef = useRef(viewport);
|
|
182
|
+
viewportRef.current = viewport;
|
|
183
|
+
|
|
184
|
+
const layout = useMemo(() => buildLayout(tracks, collapsed), [tracks, collapsed]);
|
|
185
|
+
|
|
186
|
+
// Observe container width (gutter excluded).
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
const container = containerRef.current;
|
|
189
|
+
if (!container) return;
|
|
190
|
+
const observer = new ResizeObserver(entries => {
|
|
191
|
+
const width = Math.max(100, Math.floor(entries[0].contentRect.width - GUTTER_W));
|
|
192
|
+
setCanvasWidth(width);
|
|
193
|
+
});
|
|
194
|
+
observer.observe(container);
|
|
195
|
+
return () => observer.disconnect();
|
|
196
|
+
}, []);
|
|
197
|
+
|
|
198
|
+
const clampViewport = useCallback(
|
|
199
|
+
(u0: number, u1: number): TimelineViewport => {
|
|
200
|
+
const [d0, d1] = scale.domain;
|
|
201
|
+
let span = Math.max(MIN_WINDOW_U, u1 - u0);
|
|
202
|
+
span = Math.min(span, d1 - d0 || MIN_WINDOW_U);
|
|
203
|
+
let start = u0;
|
|
204
|
+
if (start < d0) start = d0;
|
|
205
|
+
if (start + span > d1) start = d1 - span;
|
|
206
|
+
return { u0: start, u1: start + span };
|
|
207
|
+
},
|
|
208
|
+
[scale],
|
|
209
|
+
);
|
|
210
|
+
const applyViewport = useCallback(
|
|
211
|
+
(next: TimelineViewport) => {
|
|
212
|
+
viewportRef.current = next;
|
|
213
|
+
onViewportChange(next);
|
|
214
|
+
},
|
|
215
|
+
[onViewportChange],
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
const zoomAt = useCallback(
|
|
219
|
+
(factor: number, anchorPx: number | null) => {
|
|
220
|
+
const width = canvasWidth;
|
|
221
|
+
const { u0, u1 } = viewportRef.current;
|
|
222
|
+
const span = u1 - u0;
|
|
223
|
+
const anchorFrac = anchorPx === null ? 0.5 : Math.min(1, Math.max(0, anchorPx / width));
|
|
224
|
+
const anchorU = u0 + span * anchorFrac;
|
|
225
|
+
const nextSpan = span * factor;
|
|
226
|
+
applyViewport(clampViewport(anchorU - nextSpan * anchorFrac, anchorU + nextSpan * (1 - anchorFrac)));
|
|
227
|
+
},
|
|
228
|
+
[canvasWidth, applyViewport, clampViewport],
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
const panBy = useCallback(
|
|
232
|
+
(deltaU: number) => {
|
|
233
|
+
const { u0, u1 } = viewportRef.current;
|
|
234
|
+
applyViewport(clampViewport(u0 + deltaU, u1 + deltaU));
|
|
235
|
+
},
|
|
236
|
+
[applyViewport, clampViewport],
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const zoomToSpan = useCallback(
|
|
240
|
+
(span: TraceSpan) => {
|
|
241
|
+
const s = scale.toU(span.start);
|
|
242
|
+
const e = scale.toU(span.end);
|
|
243
|
+
const pad = Math.max((e - s) * 0.1, MIN_WINDOW_U / 2);
|
|
244
|
+
applyViewport(clampViewport(s - pad, e + pad));
|
|
245
|
+
},
|
|
246
|
+
[scale, applyViewport, clampViewport],
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
// Native wheel listener: React's synthetic wheel is passive, preventDefault needs this.
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
const canvas = canvasRef.current;
|
|
252
|
+
if (!canvas) return;
|
|
253
|
+
const handleWheel = (event: WheelEvent) => {
|
|
254
|
+
event.preventDefault();
|
|
255
|
+
const rect = canvas.getBoundingClientRect();
|
|
256
|
+
const x = event.clientX - rect.left;
|
|
257
|
+
hoverXRef.current = x;
|
|
258
|
+
setHover(null);
|
|
259
|
+
const width = canvas.clientWidth || 1;
|
|
260
|
+
const spanU = () => viewportRef.current.u1 - viewportRef.current.u0;
|
|
261
|
+
if (event.shiftKey) {
|
|
262
|
+
// Shift+wheel: pan by the dominant delta.
|
|
263
|
+
const deltaPx = Math.abs(event.deltaY) >= Math.abs(event.deltaX) ? event.deltaY : event.deltaX;
|
|
264
|
+
panBy((deltaPx / width) * spanU());
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Trackpads emit mixed deltas: horizontal component pans, vertical
|
|
268
|
+
// component zooms at the cursor (pinch arrives as ctrl+wheel deltaY).
|
|
269
|
+
if (event.deltaX !== 0) panBy((event.deltaX / width) * spanU());
|
|
270
|
+
if (event.deltaY !== 0) zoomAt(1.0015 ** event.deltaY, x);
|
|
271
|
+
};
|
|
272
|
+
canvas.addEventListener("wheel", handleWheel, { passive: false });
|
|
273
|
+
return () => canvas.removeEventListener("wheel", handleWheel);
|
|
274
|
+
}, [zoomAt, panBy]);
|
|
275
|
+
|
|
276
|
+
const hitAt = useCallback((clientX: number, clientY: number): Hit | null => {
|
|
277
|
+
const canvas = canvasRef.current;
|
|
278
|
+
if (!canvas) return null;
|
|
279
|
+
const rect = canvas.getBoundingClientRect();
|
|
280
|
+
const x = clientX - rect.left;
|
|
281
|
+
const y = clientY - rect.top;
|
|
282
|
+
const hits = hitsRef.current;
|
|
283
|
+
// Last drawn wins (topmost).
|
|
284
|
+
for (let i = hits.length - 1; i >= 0; i--) {
|
|
285
|
+
const hit = hits[i];
|
|
286
|
+
if (x >= hit.x && x <= hit.x + hit.w && y >= hit.y && y <= hit.y + hit.h) return hit;
|
|
287
|
+
}
|
|
288
|
+
return null;
|
|
289
|
+
}, []);
|
|
290
|
+
|
|
291
|
+
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
|
292
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
293
|
+
dragRef.current = { pointerId: event.pointerId, lastX: event.clientX, moved: false };
|
|
294
|
+
event.currentTarget.focus();
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
|
298
|
+
const rect = event.currentTarget.getBoundingClientRect();
|
|
299
|
+
hoverXRef.current = event.clientX - rect.left;
|
|
300
|
+
const drag = dragRef.current;
|
|
301
|
+
if (drag && drag.pointerId === event.pointerId) {
|
|
302
|
+
const deltaX = event.clientX - drag.lastX;
|
|
303
|
+
if (Math.abs(deltaX) > 0) {
|
|
304
|
+
if (Math.abs(deltaX) > 2) drag.moved = true;
|
|
305
|
+
drag.lastX = event.clientX;
|
|
306
|
+
const span = viewportRef.current.u1 - viewportRef.current.u0;
|
|
307
|
+
panBy((-deltaX / (canvasWidth || 1)) * span);
|
|
308
|
+
}
|
|
309
|
+
setHover(null);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const hit = hitAt(event.clientX, event.clientY);
|
|
313
|
+
setHover(hit ? { hit, clientX: event.clientX, clientY: event.clientY } : null);
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const handlePointerUp = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
|
317
|
+
const drag = dragRef.current;
|
|
318
|
+
dragRef.current = null;
|
|
319
|
+
if (drag?.moved) return;
|
|
320
|
+
const hit = hitAt(event.clientX, event.clientY);
|
|
321
|
+
if (hit?.kind === "span") onSelect(hit.span.id);
|
|
322
|
+
else onSelect(null);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
const handleDoubleClick = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
|
326
|
+
const hit = hitAt(event.clientX, event.clientY);
|
|
327
|
+
if (hit?.kind === "span") zoomToSpan(hit.span);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const selectSibling = useCallback(
|
|
331
|
+
(direction: 1 | -1) => {
|
|
332
|
+
if (!selection) return;
|
|
333
|
+
for (const lane of layout.lanes) {
|
|
334
|
+
const index = lane.spans.findIndex(span => span.id === selection);
|
|
335
|
+
if (index === -1) continue;
|
|
336
|
+
const next = lane.spans[index + direction];
|
|
337
|
+
if (!next) return;
|
|
338
|
+
onSelect(next.id);
|
|
339
|
+
// Center offscreen selections.
|
|
340
|
+
const u = scale.toU(next.start);
|
|
341
|
+
if (u < viewport.u0 || u > viewport.u1) {
|
|
342
|
+
const span = viewport.u1 - viewport.u0;
|
|
343
|
+
onViewportChange(clampViewport(u - span / 2, u + span / 2));
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
[selection, layout, onSelect, scale, viewport, onViewportChange, clampViewport],
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLCanvasElement>) => {
|
|
352
|
+
const span = viewport.u1 - viewport.u0;
|
|
353
|
+
switch (event.key) {
|
|
354
|
+
case "w":
|
|
355
|
+
case "W":
|
|
356
|
+
zoomAt(1 / 1.3, hoverXRef.current);
|
|
357
|
+
break;
|
|
358
|
+
case "s":
|
|
359
|
+
case "S":
|
|
360
|
+
zoomAt(1.3, hoverXRef.current);
|
|
361
|
+
break;
|
|
362
|
+
case "a":
|
|
363
|
+
case "A":
|
|
364
|
+
panBy(-span * 0.2);
|
|
365
|
+
break;
|
|
366
|
+
case "d":
|
|
367
|
+
case "D":
|
|
368
|
+
panBy(span * 0.2);
|
|
369
|
+
break;
|
|
370
|
+
case "0":
|
|
371
|
+
onViewportChange({ u0: scale.domain[0], u1: scale.domain[1] });
|
|
372
|
+
break;
|
|
373
|
+
case "f":
|
|
374
|
+
case "F": {
|
|
375
|
+
if (!selection) break;
|
|
376
|
+
const selected = tracks.flatMap(track => track.spans).find(s => s.id === selection);
|
|
377
|
+
if (selected) zoomToSpan(selected);
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
case "Escape":
|
|
381
|
+
onSelect(null);
|
|
382
|
+
break;
|
|
383
|
+
case ",":
|
|
384
|
+
selectSibling(-1);
|
|
385
|
+
break;
|
|
386
|
+
case ".":
|
|
387
|
+
selectSibling(1);
|
|
388
|
+
break;
|
|
389
|
+
default:
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
event.preventDefault();
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// Focus the canvas on mount so keyboard navigation works immediately.
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
canvasRef.current?.focus({ preventScroll: true });
|
|
398
|
+
}, []);
|
|
399
|
+
|
|
400
|
+
// Draw.
|
|
401
|
+
useEffect(() => {
|
|
402
|
+
const canvas = canvasRef.current;
|
|
403
|
+
if (!canvas) return;
|
|
404
|
+
const raf = requestAnimationFrame(() => {
|
|
405
|
+
draw(canvas, layout, scale, viewport, colors, {
|
|
406
|
+
width: canvasWidth,
|
|
407
|
+
selection,
|
|
408
|
+
hoverId: hover?.hit.kind === "span" ? hover.hit.span.id : null,
|
|
409
|
+
search,
|
|
410
|
+
hits: hitsRef,
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
return () => cancelAnimationFrame(raf);
|
|
414
|
+
}, [layout, scale, viewport, colors, canvasWidth, selection, hover, search]);
|
|
415
|
+
|
|
416
|
+
const tooltip = hover ? renderTooltip(hover, traceStart) : null;
|
|
417
|
+
|
|
418
|
+
return (
|
|
419
|
+
<div ref={containerRef} className="stats-trace-timeline">
|
|
420
|
+
{/* Gutter: track/lane labels + collapse chevrons (DOM, not canvas). */}
|
|
421
|
+
<div style={{ width: GUTTER_W, flexShrink: 0, position: "relative", height: layout.totalHeight }}>
|
|
422
|
+
{layout.blocks.map(block => (
|
|
423
|
+
<div key={block.track.id}>
|
|
424
|
+
<div
|
|
425
|
+
style={{
|
|
426
|
+
position: "absolute",
|
|
427
|
+
top: block.headerY,
|
|
428
|
+
left: 4 + block.depth * 14,
|
|
429
|
+
right: 8,
|
|
430
|
+
height: HEADER_H,
|
|
431
|
+
display: "flex",
|
|
432
|
+
alignItems: "center",
|
|
433
|
+
gap: 5,
|
|
434
|
+
minWidth: 0,
|
|
435
|
+
}}
|
|
436
|
+
>
|
|
437
|
+
{block.hasChildren ? (
|
|
438
|
+
<button
|
|
439
|
+
type="button"
|
|
440
|
+
onClick={() => onToggleCollapse(block.track.id)}
|
|
441
|
+
aria-label={
|
|
442
|
+
collapsed.has(block.track.id)
|
|
443
|
+
? `Expand ${block.track.label}`
|
|
444
|
+
: `Collapse ${block.track.label}`
|
|
445
|
+
}
|
|
446
|
+
className="stats-trace-chevron"
|
|
447
|
+
>
|
|
448
|
+
{collapsed.has(block.track.id) ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
|
449
|
+
</button>
|
|
450
|
+
) : (
|
|
451
|
+
<span style={{ width: 12, flexShrink: 0 }} />
|
|
452
|
+
)}
|
|
453
|
+
<span className="stats-trace-gutter-track truncate" style={{ minWidth: 0 }}>
|
|
454
|
+
{block.track.label}
|
|
455
|
+
</span>
|
|
456
|
+
{block.track.model && (
|
|
457
|
+
<span className="stats-trace-gutter-model truncate" style={{ flexShrink: 1, minWidth: 0 }}>
|
|
458
|
+
{block.track.model}
|
|
459
|
+
</span>
|
|
460
|
+
)}
|
|
461
|
+
</div>
|
|
462
|
+
{block.lanes.map(lane => (
|
|
463
|
+
<div
|
|
464
|
+
key={`${lane.track.id}:${lane.kind}`}
|
|
465
|
+
className="stats-trace-gutter-lane"
|
|
466
|
+
style={{ top: lane.y, left: 20 + block.depth * 14, lineHeight: `${LANE_H}px` }}
|
|
467
|
+
>
|
|
468
|
+
{lane.label}
|
|
469
|
+
</div>
|
|
470
|
+
))}
|
|
471
|
+
</div>
|
|
472
|
+
))}
|
|
473
|
+
</div>
|
|
474
|
+
|
|
475
|
+
<canvas
|
|
476
|
+
ref={canvasRef}
|
|
477
|
+
className="stats-trace-canvas"
|
|
478
|
+
width={Math.floor(canvasWidth * devicePixelRatio)}
|
|
479
|
+
height={Math.floor(layout.totalHeight * devicePixelRatio)}
|
|
480
|
+
style={{ width: canvasWidth, height: layout.totalHeight }}
|
|
481
|
+
tabIndex={0}
|
|
482
|
+
role="application"
|
|
483
|
+
aria-label="Trace timeline. W and S zoom, A and D pan, 0 fits all, F focuses the selection."
|
|
484
|
+
onPointerDown={handlePointerDown}
|
|
485
|
+
onPointerMove={handlePointerMove}
|
|
486
|
+
onPointerUp={handlePointerUp}
|
|
487
|
+
onPointerLeave={() => {
|
|
488
|
+
hoverXRef.current = null;
|
|
489
|
+
setHover(null);
|
|
490
|
+
}}
|
|
491
|
+
onDoubleClick={handleDoubleClick}
|
|
492
|
+
onKeyDown={handleKeyDown}
|
|
493
|
+
/>
|
|
494
|
+
|
|
495
|
+
{tooltip}
|
|
496
|
+
</div>
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
interface DrawOptions {
|
|
501
|
+
width: number;
|
|
502
|
+
selection: string | null;
|
|
503
|
+
hoverId: string | null;
|
|
504
|
+
search: string;
|
|
505
|
+
hits: React.MutableRefObject<Hit[]>;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Rounded span rect, degrading to a plain rect for slivers. */
|
|
509
|
+
function spanPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): void {
|
|
510
|
+
ctx.beginPath();
|
|
511
|
+
if (w >= SPAN_RADIUS * 2 + 1) ctx.roundRect(x, y, w, h, SPAN_RADIUS);
|
|
512
|
+
else ctx.rect(x, y, w, h);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function draw(
|
|
516
|
+
canvas: HTMLCanvasElement,
|
|
517
|
+
layout: TimelineLayout,
|
|
518
|
+
scale: TraceScale,
|
|
519
|
+
viewport: TimelineViewport,
|
|
520
|
+
colors: TraceTheme,
|
|
521
|
+
options: DrawOptions,
|
|
522
|
+
): void {
|
|
523
|
+
const ctx = canvas.getContext("2d");
|
|
524
|
+
if (!ctx) return;
|
|
525
|
+
const { width, selection, hoverId, search, hits } = options;
|
|
526
|
+
const height = layout.totalHeight;
|
|
527
|
+
const dpr = devicePixelRatio;
|
|
528
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
529
|
+
ctx.clearRect(0, 0, width, height);
|
|
530
|
+
|
|
531
|
+
const { u0, u1 } = viewport;
|
|
532
|
+
const uSpan = Math.max(u1 - u0, 1e-9);
|
|
533
|
+
const toX = (u: number) => ((u - u0) / uSpan) * width;
|
|
534
|
+
const needle = search.trim().toLowerCase();
|
|
535
|
+
const nextHits: Hit[] = [];
|
|
536
|
+
|
|
537
|
+
// Turn bands: alternating fill between turn boundaries per track.
|
|
538
|
+
for (const block of layout.blocks) {
|
|
539
|
+
const turnSpans = block.track.spans.filter(span => span.kind === "turn");
|
|
540
|
+
for (let i = 0; i < turnSpans.length; i++) {
|
|
541
|
+
const bandStart = toX(scale.toU(turnSpans[i].start));
|
|
542
|
+
const bandEnd = i + 1 < turnSpans.length ? toX(scale.toU(turnSpans[i + 1].start)) : width;
|
|
543
|
+
if (bandEnd < 0 || bandStart > width) continue;
|
|
544
|
+
if (i % 2 === 1) {
|
|
545
|
+
ctx.fillStyle = colors.turnBand;
|
|
546
|
+
ctx.fillRect(
|
|
547
|
+
Math.max(0, bandStart),
|
|
548
|
+
block.y0,
|
|
549
|
+
Math.min(width, bandEnd) - Math.max(0, bandStart),
|
|
550
|
+
block.y1 - block.y0,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
if (bandStart >= 0 && bandStart <= width) {
|
|
554
|
+
ctx.strokeStyle = colors.grid;
|
|
555
|
+
ctx.beginPath();
|
|
556
|
+
ctx.moveTo(Math.round(bandStart) + 0.5, block.y0);
|
|
557
|
+
ctx.lineTo(Math.round(bandStart) + 0.5, block.y1);
|
|
558
|
+
ctx.stroke();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Idle-gap bridges.
|
|
564
|
+
for (const gap of scale.gaps) {
|
|
565
|
+
const x = toX(gap.uMid);
|
|
566
|
+
if (x < -24 || x > width + 24) continue;
|
|
567
|
+
ctx.save();
|
|
568
|
+
ctx.strokeStyle = colors.grid;
|
|
569
|
+
ctx.setLineDash([2, 4]);
|
|
570
|
+
ctx.beginPath();
|
|
571
|
+
ctx.moveTo(x, RULER_H);
|
|
572
|
+
ctx.lineTo(x, height);
|
|
573
|
+
ctx.stroke();
|
|
574
|
+
ctx.restore();
|
|
575
|
+
ctx.fillStyle = colors.tick;
|
|
576
|
+
ctx.font = "9px system-ui, sans-serif";
|
|
577
|
+
ctx.textAlign = "center";
|
|
578
|
+
ctx.fillText(`⋯ ${formatDurationMs(gap.t1 - gap.t0)}`, x, RULER_H - 4);
|
|
579
|
+
ctx.textAlign = "left";
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Ruler: major ticks get a full-height gridline, minors stay in the ruler.
|
|
583
|
+
ctx.font = "9px system-ui, sans-serif";
|
|
584
|
+
for (const tick of buildTicks(scale, u0, u1, width)) {
|
|
585
|
+
const x = Math.round(toX(tick.u)) + 0.5;
|
|
586
|
+
ctx.strokeStyle = colors.grid;
|
|
587
|
+
ctx.beginPath();
|
|
588
|
+
if (tick.major) {
|
|
589
|
+
ctx.moveTo(x, 4);
|
|
590
|
+
ctx.lineTo(x, height);
|
|
591
|
+
} else {
|
|
592
|
+
ctx.moveTo(x, RULER_H - 8);
|
|
593
|
+
ctx.lineTo(x, RULER_H);
|
|
594
|
+
}
|
|
595
|
+
ctx.stroke();
|
|
596
|
+
ctx.fillStyle = colors.tick;
|
|
597
|
+
if (tick.major) {
|
|
598
|
+
ctx.fillText(tick.label, x + 4, 11);
|
|
599
|
+
} else {
|
|
600
|
+
ctx.globalAlpha = 0.75;
|
|
601
|
+
ctx.fillText(tick.label, x + 3, RULER_H - 10);
|
|
602
|
+
ctx.globalAlpha = 1;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
ctx.strokeStyle = colors.grid;
|
|
606
|
+
ctx.beginPath();
|
|
607
|
+
ctx.moveTo(0, RULER_H + 0.5);
|
|
608
|
+
ctx.lineTo(width, RULER_H + 0.5);
|
|
609
|
+
ctx.stroke();
|
|
610
|
+
|
|
611
|
+
// Spans per lane.
|
|
612
|
+
for (const lane of layout.lanes) {
|
|
613
|
+
const isTurnLane = lane.kind === "turn";
|
|
614
|
+
for (const span of lane.spans) {
|
|
615
|
+
const uStart = scale.toU(span.start);
|
|
616
|
+
if (uStart > u1) break; // spans sorted by start
|
|
617
|
+
const uEnd = scale.toU(span.end);
|
|
618
|
+
if (uEnd < u0) continue;
|
|
619
|
+
const x = toX(uStart);
|
|
620
|
+
const w = Math.max(MIN_SPAN_PX, toX(uEnd) - x);
|
|
621
|
+
const y = lane.y;
|
|
622
|
+
const matches = needle === "" || `${span.label} ${span.detail ?? ""}`.toLowerCase().includes(needle);
|
|
623
|
+
const color = colors.category[span.kind];
|
|
624
|
+
|
|
625
|
+
ctx.globalAlpha = matches ? 1 : 0.3;
|
|
626
|
+
if (isTurnLane) {
|
|
627
|
+
// Turn ranges read as context, not work: soft fill + solid start cap.
|
|
628
|
+
ctx.fillStyle = `${color}3a`;
|
|
629
|
+
spanPath(ctx, x, y, w, LANE_H);
|
|
630
|
+
ctx.fill();
|
|
631
|
+
ctx.fillStyle = color;
|
|
632
|
+
ctx.fillRect(x, y, 2, LANE_H);
|
|
633
|
+
} else {
|
|
634
|
+
ctx.fillStyle = color;
|
|
635
|
+
spanPath(ctx, x, y, w, LANE_H);
|
|
636
|
+
ctx.fill();
|
|
637
|
+
}
|
|
638
|
+
if (span.isError) {
|
|
639
|
+
ctx.fillStyle = `${colors.error}55`;
|
|
640
|
+
spanPath(ctx, x, y, w, LANE_H);
|
|
641
|
+
ctx.fill();
|
|
642
|
+
ctx.fillStyle = colors.error;
|
|
643
|
+
ctx.fillRect(x, y, 2, LANE_H);
|
|
644
|
+
}
|
|
645
|
+
if (span.kind === "model" && typeof span.ttft === "number" && span.ttft > 0 && w > 20) {
|
|
646
|
+
const ttftX = toX(scale.toU(span.start + span.ttft));
|
|
647
|
+
if (ttftX > x + 1 && ttftX < x + w - 1) {
|
|
648
|
+
ctx.fillStyle = "rgba(0,0,0,0.5)";
|
|
649
|
+
ctx.fillRect(ttftX, y + 3, 1, LANE_H - 6);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (span.unterminated) {
|
|
653
|
+
ctx.save();
|
|
654
|
+
ctx.strokeStyle = colors.tick;
|
|
655
|
+
ctx.setLineDash([2, 2]);
|
|
656
|
+
ctx.beginPath();
|
|
657
|
+
ctx.moveTo(x + w - 0.5, y);
|
|
658
|
+
ctx.lineTo(x + w - 0.5, y + LANE_H);
|
|
659
|
+
ctx.stroke();
|
|
660
|
+
ctx.restore();
|
|
661
|
+
}
|
|
662
|
+
if (hoverId === span.id) {
|
|
663
|
+
ctx.fillStyle = "rgba(255,255,255,0.16)";
|
|
664
|
+
spanPath(ctx, x, y, w, LANE_H);
|
|
665
|
+
ctx.fill();
|
|
666
|
+
}
|
|
667
|
+
if (selection === span.id) {
|
|
668
|
+
ctx.strokeStyle = colors.selection;
|
|
669
|
+
ctx.lineWidth = 1.5;
|
|
670
|
+
spanPath(ctx, x - 1, y - 1, w + 2, LANE_H + 2);
|
|
671
|
+
ctx.stroke();
|
|
672
|
+
ctx.lineWidth = 1;
|
|
673
|
+
}
|
|
674
|
+
if (w > LABEL_MIN_PX) {
|
|
675
|
+
ctx.save();
|
|
676
|
+
ctx.beginPath();
|
|
677
|
+
ctx.rect(x + 3, y, w - 6, LANE_H);
|
|
678
|
+
ctx.clip();
|
|
679
|
+
ctx.fillStyle = isTurnLane ? colors.tick : colors.spanText;
|
|
680
|
+
ctx.font = "10px system-ui, sans-serif";
|
|
681
|
+
ctx.fillText(span.label, x + 5, y + LANE_H - 5);
|
|
682
|
+
ctx.restore();
|
|
683
|
+
}
|
|
684
|
+
ctx.globalAlpha = 1;
|
|
685
|
+
|
|
686
|
+
const hitW = Math.max(w, HIT_SLOP_PX);
|
|
687
|
+
nextHits.push({ kind: "span", x: x - (hitW - w) / 2, y, w: hitW, h: LANE_H, span, track: lane.track });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Markers: diamonds on track header rows.
|
|
692
|
+
for (const block of layout.blocks) {
|
|
693
|
+
for (const marker of block.track.markers) {
|
|
694
|
+
const x = toX(scale.toU(marker.time));
|
|
695
|
+
if (x < -6 || x > width + 6) continue;
|
|
696
|
+
const cy = block.headerY + HEADER_H / 2;
|
|
697
|
+
ctx.fillStyle = colors.marker;
|
|
698
|
+
ctx.beginPath();
|
|
699
|
+
ctx.moveTo(x, cy - 4);
|
|
700
|
+
ctx.lineTo(x + 4, cy);
|
|
701
|
+
ctx.lineTo(x, cy + 4);
|
|
702
|
+
ctx.lineTo(x - 4, cy);
|
|
703
|
+
ctx.closePath();
|
|
704
|
+
ctx.fill();
|
|
705
|
+
nextHits.push({ kind: "marker", x: x - 5, y: cy - 5, w: 10, h: 10, marker, track: block.track });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
hits.current = nextHits;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function renderTooltip(hover: HoverState, traceStart: number) {
|
|
713
|
+
// Fixed positioning escapes the scroll frame's clipping; clamp to the window.
|
|
714
|
+
const left = Math.min(hover.clientX + 14, window.innerWidth - 320);
|
|
715
|
+
const top = hover.clientY + 16 > window.innerHeight - 140 ? hover.clientY - 120 : hover.clientY + 16;
|
|
716
|
+
const style: React.CSSProperties = {
|
|
717
|
+
position: "fixed",
|
|
718
|
+
left,
|
|
719
|
+
top,
|
|
720
|
+
zIndex: 60,
|
|
721
|
+
maxWidth: 300,
|
|
722
|
+
pointerEvents: "none",
|
|
723
|
+
background: "var(--surface-2)",
|
|
724
|
+
border: "1px solid var(--border-strong)",
|
|
725
|
+
borderRadius: 6,
|
|
726
|
+
padding: "7px 10px",
|
|
727
|
+
fontSize: 11,
|
|
728
|
+
lineHeight: 1.5,
|
|
729
|
+
boxShadow: "0 1px 2px rgba(0,0,0,0.2), 0 4px 16px rgba(0,0,0,0.25)",
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
if (hover.hit.kind === "marker") {
|
|
733
|
+
const { marker } = hover.hit;
|
|
734
|
+
return (
|
|
735
|
+
<div style={style}>
|
|
736
|
+
<div className="stats-font-medium stats-text-primary">{marker.label}</div>
|
|
737
|
+
<div className="stats-text-muted">
|
|
738
|
+
{new Date(marker.time).toLocaleTimeString()} ({formatOffset(marker.time - traceStart)})
|
|
739
|
+
</div>
|
|
740
|
+
</div>
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const { span } = hover.hit;
|
|
745
|
+
return (
|
|
746
|
+
<div style={style}>
|
|
747
|
+
<div className="stats-font-medium stats-text-primary truncate">{span.label}</div>
|
|
748
|
+
<div className="stats-text-muted">
|
|
749
|
+
{formatDurationMs(span.end - span.start)} · {new Date(span.start).toLocaleTimeString()} (
|
|
750
|
+
{formatOffset(span.start - traceStart)}){span.unterminated ? " · unterminated" : ""}
|
|
751
|
+
</div>
|
|
752
|
+
{span.kind === "model" && (
|
|
753
|
+
<div className="stats-text-muted">
|
|
754
|
+
{span.tokens !== undefined && <>{span.tokens.toLocaleString()} tok</>}
|
|
755
|
+
{span.cost !== undefined && <> · ${span.cost.toFixed(4)}</>}
|
|
756
|
+
{span.ttft !== undefined && <> · TTFT {formatDurationMs(span.ttft)}</>}
|
|
757
|
+
{span.isError && <> · error</>}
|
|
758
|
+
</div>
|
|
759
|
+
)}
|
|
760
|
+
{span.kind === "subagent" && span.model && <div className="stats-text-muted">{span.model}</div>}
|
|
761
|
+
{span.detail && (
|
|
762
|
+
<div className="stats-text-secondary" style={{ wordBreak: "break-word" }}>
|
|
763
|
+
{span.detail}
|
|
764
|
+
</div>
|
|
765
|
+
)}
|
|
766
|
+
</div>
|
|
767
|
+
);
|
|
768
|
+
}
|