@linxiraos/pi-stats 1.1.26 → 1.2.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 +12 -5
- package/build.ts +4 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +1 -0
- package/dist/client/index.js +96 -96
- package/dist/types/aggregator.d.ts +4 -1
- package/dist/types/client/api.d.ts +5 -1
- package/dist/types/client/app/nav.d.ts +5 -2
- package/dist/types/client/data/formatters.d.ts +7 -2
- package/dist/types/client/data/live.d.ts +16 -11
- package/dist/types/client/data/query-store.d.ts +39 -0
- package/dist/types/client/data/query.d.ts +10 -5
- package/dist/types/client/data/range.d.ts +4 -3
- package/dist/types/client/i18n/format.d.ts +9 -0
- package/dist/types/client/i18n/index.d.ts +18 -0
- package/dist/types/client/i18n/messages/charts.d.ts +8 -0
- package/dist/types/client/i18n/messages/common.d.ts +153 -0
- package/dist/types/client/i18n/messages/costs.d.ts +92 -0
- package/dist/types/client/i18n/messages/en.d.ts +1255 -0
- package/dist/types/client/i18n/messages/errors.d.ts +78 -0
- package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
- package/dist/types/client/i18n/messages/gain.d.ts +54 -0
- package/dist/types/client/i18n/messages/models.d.ts +94 -0
- package/dist/types/client/i18n/messages/nav.d.ts +46 -0
- package/dist/types/client/i18n/messages/overview.d.ts +80 -0
- package/dist/types/client/i18n/messages/projects.d.ts +84 -0
- package/dist/types/client/i18n/messages/providers.d.ts +142 -0
- package/dist/types/client/i18n/messages/requests.d.ts +72 -0
- package/dist/types/client/i18n/messages/tools.d.ts +119 -0
- package/dist/types/client/i18n/messages/traces.d.ts +144 -0
- package/dist/types/client/i18n/messages/ui.d.ts +44 -0
- package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
- package/dist/types/client/i18n/types.d.ts +15 -0
- package/dist/types/client/traces/Minimap.d.ts +2 -0
- package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
- package/dist/types/client/traces/TranscriptList.d.ts +3 -1
- package/dist/types/db.d.ts +14 -21
- package/dist/types/embedded-client.d.ts +5 -0
- package/dist/types/frustration.d.ts +18 -9
- package/dist/types/live.d.ts +5 -3
- package/dist/types/locale.d.ts +16 -0
- package/dist/types/parser.d.ts +0 -17
- package/dist/types/rollup.d.ts +9 -1
- package/dist/types/server.d.ts +6 -0
- package/dist/types/shared-types.d.ts +7 -0
- package/dist/types/trace.d.ts +18 -6
- package/dist/types/types.d.ts +13 -1
- package/dist/types/user-metrics.d.ts +23 -1
- package/package.json +5 -5
- package/src/aggregator.ts +31 -11
- package/src/client/api.ts +40 -5
- package/src/client/app/LiveChip.tsx +14 -13
- package/src/client/app/Shell.tsx +19 -13
- package/src/client/app/ThemeToggle.tsx +4 -3
- package/src/client/app/nav.ts +19 -16
- package/src/client/data/formatters.ts +80 -13
- package/src/client/data/live.tsx +85 -45
- package/src/client/data/query-store.ts +108 -0
- package/src/client/data/query.ts +47 -56
- package/src/client/data/range.ts +34 -14
- package/src/client/data/series.ts +7 -1
- package/src/client/favicon.ico +0 -0
- package/src/client/i18n/format.ts +28 -0
- package/src/client/i18n/index.ts +53 -0
- package/src/client/i18n/messages/charts.ts +8 -0
- package/src/client/i18n/messages/common.ts +157 -0
- package/src/client/i18n/messages/costs.ts +104 -0
- package/src/client/i18n/messages/en.ts +40 -0
- package/src/client/i18n/messages/errors.ts +80 -0
- package/src/client/i18n/messages/frustration.ts +198 -0
- package/src/client/i18n/messages/gain.ts +54 -0
- package/src/client/i18n/messages/models.ts +94 -0
- package/src/client/i18n/messages/nav.ts +46 -0
- package/src/client/i18n/messages/overview.ts +90 -0
- package/src/client/i18n/messages/projects.ts +84 -0
- package/src/client/i18n/messages/providers.ts +165 -0
- package/src/client/i18n/messages/requests.ts +73 -0
- package/src/client/i18n/messages/tools.ts +120 -0
- package/src/client/i18n/messages/traces.ts +160 -0
- package/src/client/i18n/messages/ui.ts +46 -0
- package/src/client/i18n/messages/zh-CN.ts +43 -0
- package/src/client/i18n/types.ts +19 -0
- package/src/client/index.tsx +4 -0
- package/src/client/routes/CostsRoute.tsx +61 -48
- package/src/client/routes/ErrorsRoute.tsx +71 -47
- package/src/client/routes/FrustrationRoute.tsx +131 -116
- package/src/client/routes/GainRoute.tsx +42 -30
- package/src/client/routes/ModelsRoute.tsx +90 -73
- package/src/client/routes/OverviewRoute.tsx +109 -63
- package/src/client/routes/ProjectsRoute.tsx +73 -46
- package/src/client/routes/ProvidersRoute.tsx +151 -114
- package/src/client/routes/RequestsRoute.tsx +69 -46
- package/src/client/routes/ToolsRoute.tsx +108 -94
- package/src/client/routes/TracesRoute.tsx +25 -23
- package/src/client/styles.css +5 -2
- package/src/client/traces/AggregatesPanel.tsx +19 -8
- package/src/client/traces/Minimap.tsx +65 -27
- package/src/client/traces/SpanDrawer.tsx +46 -21
- package/src/client/traces/SummaryStrip.tsx +27 -11
- package/src/client/traces/TimelineCanvas.tsx +245 -116
- package/src/client/traces/TraceView.tsx +51 -36
- package/src/client/traces/TranscriptList.tsx +20 -11
- package/src/client/traces/traces.css +26 -0
- package/src/client/ui/JsonBlock.tsx +3 -2
- package/src/client/ui/Modal.tsx +3 -2
- package/src/client/ui/RequestDrawer.tsx +33 -30
- package/src/client/ui/SearchInput.tsx +2 -1
- package/src/client/ui/States.tsx +3 -2
- package/src/client/ui/Table.tsx +7 -3
- package/src/db.ts +150 -135
- package/src/embedded-client.ts +9 -0
- package/src/frustration.ts +9 -30
- package/src/index.ts +2 -1
- package/src/live.ts +36 -11
- package/src/locale.ts +29 -0
- package/src/parser.ts +37 -72
- package/src/port-conflict.ts +1 -1
- package/src/prompts/judge/frustration.json +19 -0
- package/src/rollup.ts +41 -20
- package/src/server.ts +67 -27
- package/src/shared-types.ts +7 -0
- package/src/trace.ts +54 -40
- package/src/types.ts +13 -1
- package/src/user-metrics.ts +220 -6
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The trace flamegraph:
|
|
3
|
-
* labels, collapse chevrons) and a
|
|
4
|
-
*
|
|
5
|
-
* fit/focus keys, hover tooltips,
|
|
2
|
+
* The trace flamegraph: a DPR-scaled scene canvas with DOM hover/selection
|
|
3
|
+
* boxes on top, a DOM gutter (track/lane labels, collapse chevrons) and a
|
|
4
|
+
* DOM tooltip. Implements the Chrome DevTools interaction contract:
|
|
5
|
+
* cursor-anchored wheel zoom, drag pan, WASD, fit/focus keys, hover tooltips,
|
|
6
|
+
* click-to-select, double-click-to-zoom.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
|
9
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
10
11
|
import { createPortal } from "react-dom";
|
|
11
12
|
import { formatDurationMs, formatInteger } from "../data/formatters";
|
|
13
|
+
import { t } from "../i18n";
|
|
12
14
|
import type { TraceMarker, TraceSpan, TraceSpanKind, TraceTrack } from "../types";
|
|
13
15
|
import { buildTicks, formatOffset, type TraceScale } from "./time-scale";
|
|
14
16
|
import { type TraceTheme, useTraceTheme } from "./trace-colors";
|
|
@@ -23,9 +25,11 @@ export interface TimelineCanvasProps {
|
|
|
23
25
|
scale: TraceScale;
|
|
24
26
|
viewport: TimelineViewport;
|
|
25
27
|
onViewportChange: (viewport: TimelineViewport) => void;
|
|
26
|
-
|
|
28
|
+
/** The selected span with its track (resolved by the trace view). */
|
|
29
|
+
selected: { span: TraceSpan; track: TraceTrack } | null;
|
|
27
30
|
onSelect: (spanId: string | null) => void;
|
|
28
|
-
search
|
|
31
|
+
/** Spans matching the search; `null` when not searching (everything at full opacity). */
|
|
32
|
+
matchIds: ReadonlySet<string> | null;
|
|
29
33
|
collapsed: ReadonlySet<string>;
|
|
30
34
|
onToggleCollapse: (trackId: string) => void;
|
|
31
35
|
traceStart: number;
|
|
@@ -42,13 +46,15 @@ const MIN_SPAN_PX = 2;
|
|
|
42
46
|
const HIT_SLOP_PX = 4;
|
|
43
47
|
const LABEL_MIN_PX = 56;
|
|
44
48
|
const SPAN_RADIUS = 3;
|
|
49
|
+
/** Opacity of spans that don't match the search. */
|
|
50
|
+
const DIM_ALPHA = 0.3;
|
|
45
51
|
|
|
46
52
|
const LANE_ORDER: Array<{ kind: TraceSpanKind; label: string }> = [
|
|
47
|
-
{ kind: "turn", label: "
|
|
48
|
-
{ kind: "model", label: "
|
|
49
|
-
{ kind: "tool", label: "
|
|
50
|
-
{ kind: "subagent", label: "
|
|
51
|
-
{ kind: "background", label: "
|
|
53
|
+
{ kind: "turn", label: t("common.input") },
|
|
54
|
+
{ kind: "model", label: t("common.model") },
|
|
55
|
+
{ kind: "tool", label: t("trace.tools") },
|
|
56
|
+
{ kind: "subagent", label: t("trace.agents") },
|
|
57
|
+
{ kind: "background", label: t("trace.bg") },
|
|
52
58
|
];
|
|
53
59
|
|
|
54
60
|
interface LaneRow {
|
|
@@ -65,6 +71,8 @@ interface TrackBlock {
|
|
|
65
71
|
hasChildren: boolean;
|
|
66
72
|
headerY: number;
|
|
67
73
|
lanes: LaneRow[];
|
|
74
|
+
/** Turn spans of the track (start order), for the alternating turn bands. */
|
|
75
|
+
turnSpans: TraceSpan[];
|
|
68
76
|
/** Full vertical extent of the block (header top → last lane bottom). */
|
|
69
77
|
y0: number;
|
|
70
78
|
y1: number;
|
|
@@ -99,18 +107,27 @@ function buildLayout(tracks: TraceTrack[], collapsed: ReadonlySet<string>): Time
|
|
|
99
107
|
for (const track of tracks) {
|
|
100
108
|
if (isHidden(track)) continue;
|
|
101
109
|
const depth = track.id === "main" ? 0 : track.id.split("/").length;
|
|
110
|
+
const byKind: Record<TraceSpanKind, TraceSpan[]> = {
|
|
111
|
+
turn: [],
|
|
112
|
+
model: [],
|
|
113
|
+
tool: [],
|
|
114
|
+
subagent: [],
|
|
115
|
+
background: [],
|
|
116
|
+
};
|
|
117
|
+
for (const span of track.spans) byKind[span.kind].push(span);
|
|
102
118
|
const block: TrackBlock = {
|
|
103
119
|
track,
|
|
104
120
|
depth,
|
|
105
121
|
hasChildren: hasChildren.has(track.id),
|
|
106
122
|
headerY: y,
|
|
107
123
|
lanes: [],
|
|
124
|
+
turnSpans: byKind.turn,
|
|
108
125
|
y0: y,
|
|
109
126
|
y1: y,
|
|
110
127
|
};
|
|
111
128
|
y += HEADER_H;
|
|
112
129
|
for (const { kind, label } of LANE_ORDER) {
|
|
113
|
-
const spans =
|
|
130
|
+
const spans = byKind[kind];
|
|
114
131
|
// Main always shows the core lanes; optional lanes appear when populated.
|
|
115
132
|
const isCore = kind === "turn" || kind === "model" || kind === "tool";
|
|
116
133
|
if (spans.length === 0 && !(track.id === "main" && isCore)) continue;
|
|
@@ -126,6 +143,47 @@ function buildLayout(tracks: TraceTrack[], collapsed: ReadonlySet<string>): Time
|
|
|
126
143
|
return { blocks, lanes, totalHeight: Math.max(y, RULER_H + 48) };
|
|
127
144
|
}
|
|
128
145
|
|
|
146
|
+
/** Per-lane span positions on the virtual axis, parallel to `LaneRow.spans`. */
|
|
147
|
+
interface LaneAxis {
|
|
148
|
+
startU: Float64Array;
|
|
149
|
+
endU: Float64Array;
|
|
150
|
+
/** Running max of `endU`; monotonic, so the first visible span is a binary search away. */
|
|
151
|
+
maxEndU: Float64Array;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function buildLaneAxes(
|
|
155
|
+
layout: { lanes: ReadonlyArray<{ spans: ReadonlyArray<Pick<TraceSpan, "start" | "end">> }> },
|
|
156
|
+
scale: Pick<TraceScale, "toU">,
|
|
157
|
+
): LaneAxis[] {
|
|
158
|
+
return layout.lanes.map(lane => {
|
|
159
|
+
const n = lane.spans.length;
|
|
160
|
+
const startU = new Float64Array(n);
|
|
161
|
+
const endU = new Float64Array(n);
|
|
162
|
+
const maxEndU = new Float64Array(n);
|
|
163
|
+
let maxEnd = Number.NEGATIVE_INFINITY;
|
|
164
|
+
for (let i = 0; i < n; i++) {
|
|
165
|
+
const span = lane.spans[i];
|
|
166
|
+
startU[i] = scale.toU(span.start);
|
|
167
|
+
endU[i] = scale.toU(span.end);
|
|
168
|
+
maxEnd = Math.max(maxEnd, endU[i]);
|
|
169
|
+
maxEndU[i] = maxEnd;
|
|
170
|
+
}
|
|
171
|
+
return { startU, endU, maxEndU };
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** First index whose running-max end reaches `u0`: every earlier span ends before the viewport. */
|
|
176
|
+
export function firstVisible(maxEndU: Float64Array, u0: number): number {
|
|
177
|
+
let lo = 0;
|
|
178
|
+
let hi = maxEndU.length;
|
|
179
|
+
while (lo < hi) {
|
|
180
|
+
const mid = (lo + hi) >>> 1;
|
|
181
|
+
if (maxEndU[mid] < u0) lo = mid + 1;
|
|
182
|
+
else hi = mid;
|
|
183
|
+
}
|
|
184
|
+
return lo;
|
|
185
|
+
}
|
|
186
|
+
|
|
129
187
|
interface SpanHit {
|
|
130
188
|
kind: "span";
|
|
131
189
|
x: number;
|
|
@@ -148,10 +206,10 @@ interface MarkerHit {
|
|
|
148
206
|
|
|
149
207
|
type Hit = SpanHit | MarkerHit;
|
|
150
208
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
209
|
+
function sameTarget(a: Hit | null, b: Hit | null): boolean {
|
|
210
|
+
if (a === null || b === null) return a === b;
|
|
211
|
+
if (a.kind === "span") return b.kind === "span" && a.span === b.span;
|
|
212
|
+
return b.kind === "marker" && a.marker === b.marker;
|
|
155
213
|
}
|
|
156
214
|
|
|
157
215
|
export function TimelineCanvas({
|
|
@@ -159,9 +217,9 @@ export function TimelineCanvas({
|
|
|
159
217
|
scale,
|
|
160
218
|
viewport,
|
|
161
219
|
onViewportChange,
|
|
162
|
-
|
|
220
|
+
selected,
|
|
163
221
|
onSelect,
|
|
164
|
-
|
|
222
|
+
matchIds,
|
|
165
223
|
collapsed,
|
|
166
224
|
onToggleCollapse,
|
|
167
225
|
traceStart,
|
|
@@ -169,8 +227,14 @@ export function TimelineCanvas({
|
|
|
169
227
|
const colors = useTraceTheme();
|
|
170
228
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
171
229
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
230
|
+
const tooltipRef = useRef<HTMLDivElement>(null);
|
|
172
231
|
const [canvasWidth, setCanvasWidth] = useState(800);
|
|
173
|
-
|
|
232
|
+
// Hovered target only; the pointer position lives in a ref and moves the
|
|
233
|
+
// tooltip directly, so moving within one span doesn't re-render.
|
|
234
|
+
const [hover, setHover] = useState<Hit | null>(null);
|
|
235
|
+
const hoverRef = useRef<Hit | null>(null);
|
|
236
|
+
hoverRef.current = hover;
|
|
237
|
+
const pointerRef = useRef({ clientX: 0, clientY: 0 });
|
|
174
238
|
const hitsRef = useRef<Hit[]>([]);
|
|
175
239
|
const dragRef = useRef<{ pointerId: number; lastX: number; moved: boolean } | null>(null);
|
|
176
240
|
const hoverXRef = useRef<number | null>(null);
|
|
@@ -181,6 +245,7 @@ export function TimelineCanvas({
|
|
|
181
245
|
viewportRef.current = viewport;
|
|
182
246
|
|
|
183
247
|
const layout = useMemo(() => buildLayout(tracks, collapsed), [tracks, collapsed]);
|
|
248
|
+
const laneAxes = useMemo(() => buildLaneAxes(layout, scale), [layout, scale]);
|
|
184
249
|
|
|
185
250
|
// Observe container width (gutter excluded).
|
|
186
251
|
useEffect(() => {
|
|
@@ -287,6 +352,16 @@ export function TimelineCanvas({
|
|
|
287
352
|
return null;
|
|
288
353
|
}, []);
|
|
289
354
|
|
|
355
|
+
// Fixed positioning escapes the scroll frame's clipping; clamp to the window.
|
|
356
|
+
const positionTooltip = useCallback(() => {
|
|
357
|
+
const tooltip = tooltipRef.current;
|
|
358
|
+
if (!tooltip) return;
|
|
359
|
+
const { clientX, clientY } = pointerRef.current;
|
|
360
|
+
const left = Math.min(clientX + 14, window.innerWidth - 320);
|
|
361
|
+
const top = clientY + 16 > window.innerHeight - 140 ? clientY - 120 : clientY + 16;
|
|
362
|
+
tooltip.style.transform = `translate(${left}px, ${top}px)`;
|
|
363
|
+
}, []);
|
|
364
|
+
|
|
290
365
|
const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
|
291
366
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
292
367
|
dragRef.current = { pointerId: event.pointerId, lastX: event.clientX, moved: false };
|
|
@@ -308,8 +383,10 @@ export function TimelineCanvas({
|
|
|
308
383
|
setHover(null);
|
|
309
384
|
return;
|
|
310
385
|
}
|
|
386
|
+
pointerRef.current = { clientX: event.clientX, clientY: event.clientY };
|
|
311
387
|
const hit = hitAt(event.clientX, event.clientY);
|
|
312
|
-
|
|
388
|
+
if (sameTarget(hit, hoverRef.current)) positionTooltip();
|
|
389
|
+
else setHover(hit);
|
|
313
390
|
};
|
|
314
391
|
|
|
315
392
|
const handlePointerUp = (event: React.PointerEvent<HTMLCanvasElement>) => {
|
|
@@ -328,9 +405,10 @@ export function TimelineCanvas({
|
|
|
328
405
|
|
|
329
406
|
const selectSibling = useCallback(
|
|
330
407
|
(direction: 1 | -1) => {
|
|
331
|
-
|
|
408
|
+
const selectedId = selected?.span.id;
|
|
409
|
+
if (!selectedId) return;
|
|
332
410
|
for (const lane of layout.lanes) {
|
|
333
|
-
const index = lane.spans.findIndex(span => span.id ===
|
|
411
|
+
const index = lane.spans.findIndex(span => span.id === selectedId);
|
|
334
412
|
if (index === -1) continue;
|
|
335
413
|
const next = lane.spans[index + direction];
|
|
336
414
|
if (!next) return;
|
|
@@ -344,7 +422,7 @@ export function TimelineCanvas({
|
|
|
344
422
|
return;
|
|
345
423
|
}
|
|
346
424
|
},
|
|
347
|
-
[
|
|
425
|
+
[selected, layout, onSelect, scale, viewport, onViewportChange, clampViewport],
|
|
348
426
|
);
|
|
349
427
|
|
|
350
428
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLCanvasElement>) => {
|
|
@@ -370,12 +448,9 @@ export function TimelineCanvas({
|
|
|
370
448
|
onViewportChange({ u0: scale.domain[0], u1: scale.domain[1] });
|
|
371
449
|
break;
|
|
372
450
|
case "f":
|
|
373
|
-
case "F":
|
|
374
|
-
if (
|
|
375
|
-
const selected = tracks.flatMap(track => track.spans).find(s => s.id === selection);
|
|
376
|
-
if (selected) zoomToSpan(selected);
|
|
451
|
+
case "F":
|
|
452
|
+
if (selected) zoomToSpan(selected.span);
|
|
377
453
|
break;
|
|
378
|
-
}
|
|
379
454
|
case "Escape":
|
|
380
455
|
onSelect(null);
|
|
381
456
|
break;
|
|
@@ -396,23 +471,30 @@ export function TimelineCanvas({
|
|
|
396
471
|
canvasRef.current?.focus({ preventScroll: true });
|
|
397
472
|
}, []);
|
|
398
473
|
|
|
399
|
-
//
|
|
474
|
+
// Scene: everything except hover/selection, so pointer moves never repaint it.
|
|
400
475
|
useEffect(() => {
|
|
401
476
|
const canvas = canvasRef.current;
|
|
402
477
|
if (!canvas) return;
|
|
403
478
|
const raf = requestAnimationFrame(() => {
|
|
404
|
-
|
|
405
|
-
width: canvasWidth,
|
|
406
|
-
selection,
|
|
407
|
-
hoverId: hover?.hit.kind === "span" ? hover.hit.span.id : null,
|
|
408
|
-
search,
|
|
409
|
-
hits: hitsRef,
|
|
410
|
-
});
|
|
479
|
+
hitsRef.current = drawScene(canvas, layout, laneAxes, scale, viewport, colors, canvasWidth, matchIds);
|
|
411
480
|
});
|
|
412
481
|
return () => cancelAnimationFrame(raf);
|
|
413
|
-
}, [layout, scale, viewport, colors, canvasWidth,
|
|
482
|
+
}, [layout, laneAxes, scale, viewport, colors, canvasWidth, matchIds]);
|
|
483
|
+
|
|
484
|
+
// Hover tint + selection outline are DOM boxes over the scene, so pointer
|
|
485
|
+
// moves never repaint it and need no second full-size bitmap.
|
|
486
|
+
const hoverBox = hover?.kind === "span" ? placeSpan(hover, layout, scale, viewport, canvasWidth) : null;
|
|
487
|
+
const selectedBox = selected ? placeSpan(selected, layout, scale, viewport, canvasWidth) : null;
|
|
488
|
+
|
|
489
|
+
// A new tooltip target mounts at the latest pointer position.
|
|
490
|
+
useLayoutEffect(() => {
|
|
491
|
+
if (hover) positionTooltip();
|
|
492
|
+
}, [hover, positionTooltip]);
|
|
414
493
|
|
|
415
|
-
const tooltip = hover ? renderTooltip(hover, traceStart) : null;
|
|
494
|
+
const tooltip = hover ? renderTooltip(hover, traceStart, tooltipRef) : null;
|
|
495
|
+
const pixelWidth = Math.floor(canvasWidth * devicePixelRatio);
|
|
496
|
+
const pixelHeight = Math.floor(layout.totalHeight * devicePixelRatio);
|
|
497
|
+
const cssSize = { width: canvasWidth, height: layout.totalHeight };
|
|
416
498
|
|
|
417
499
|
return (
|
|
418
500
|
<div ref={containerRef} className="traces-timeline">
|
|
@@ -439,8 +521,8 @@ export function TimelineCanvas({
|
|
|
439
521
|
onClick={() => onToggleCollapse(block.track.id)}
|
|
440
522
|
aria-label={
|
|
441
523
|
collapsed.has(block.track.id)
|
|
442
|
-
?
|
|
443
|
-
:
|
|
524
|
+
? t("trace.expand-track", { track: block.track.label })
|
|
525
|
+
: t("trace.collapse-track", { track: block.track.label })
|
|
444
526
|
}
|
|
445
527
|
className="traces-chevron"
|
|
446
528
|
>
|
|
@@ -471,39 +553,62 @@ export function TimelineCanvas({
|
|
|
471
553
|
))}
|
|
472
554
|
</div>
|
|
473
555
|
|
|
474
|
-
<canvas
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
556
|
+
<div className="traces-canvas-stack" style={cssSize}>
|
|
557
|
+
<canvas
|
|
558
|
+
ref={canvasRef}
|
|
559
|
+
className="traces-canvas"
|
|
560
|
+
width={pixelWidth}
|
|
561
|
+
height={pixelHeight}
|
|
562
|
+
style={cssSize}
|
|
563
|
+
tabIndex={0}
|
|
564
|
+
role="application"
|
|
565
|
+
aria-label={t("trace.canvas-aria")}
|
|
566
|
+
onPointerDown={handlePointerDown}
|
|
567
|
+
onPointerMove={handlePointerMove}
|
|
568
|
+
onPointerUp={handlePointerUp}
|
|
569
|
+
onPointerLeave={() => {
|
|
570
|
+
hoverXRef.current = null;
|
|
571
|
+
setHover(null);
|
|
572
|
+
}}
|
|
573
|
+
onDoubleClick={handleDoubleClick}
|
|
574
|
+
onKeyDown={handleKeyDown}
|
|
575
|
+
/>
|
|
576
|
+
{hoverBox && hover?.kind === "span" && (
|
|
577
|
+
<div
|
|
578
|
+
className="traces-canvas-hover"
|
|
579
|
+
aria-hidden="true"
|
|
580
|
+
style={{
|
|
581
|
+
left: hoverBox.x,
|
|
582
|
+
top: hoverBox.y,
|
|
583
|
+
width: hoverBox.w,
|
|
584
|
+
height: LANE_H,
|
|
585
|
+
borderRadius: SPAN_RADIUS,
|
|
586
|
+
opacity: matchIds === null || matchIds.has(hover.span.id) ? 1 : DIM_ALPHA,
|
|
587
|
+
}}
|
|
588
|
+
/>
|
|
589
|
+
)}
|
|
590
|
+
{selectedBox && selected && (
|
|
591
|
+
<div
|
|
592
|
+
className="traces-canvas-selection"
|
|
593
|
+
aria-hidden="true"
|
|
594
|
+
style={{
|
|
595
|
+
left: selectedBox.x - 1,
|
|
596
|
+
top: selectedBox.y - 1,
|
|
597
|
+
width: selectedBox.w + 2,
|
|
598
|
+
height: LANE_H + 2,
|
|
599
|
+
borderRadius: SPAN_RADIUS,
|
|
600
|
+
borderColor: colors.selection,
|
|
601
|
+
opacity: matchIds === null || matchIds.has(selected.span.id) ? 1 : DIM_ALPHA,
|
|
602
|
+
}}
|
|
603
|
+
/>
|
|
604
|
+
)}
|
|
605
|
+
</div>
|
|
493
606
|
|
|
494
607
|
{tooltip && createPortal(tooltip, document.body)}
|
|
495
608
|
</div>
|
|
496
609
|
);
|
|
497
610
|
}
|
|
498
611
|
|
|
499
|
-
interface DrawOptions {
|
|
500
|
-
width: number;
|
|
501
|
-
selection: string | null;
|
|
502
|
-
hoverId: string | null;
|
|
503
|
-
search: string;
|
|
504
|
-
hits: React.MutableRefObject<Hit[]>;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
612
|
/** Rounded span rect, degrading to a plain rect for slivers. */
|
|
508
613
|
function spanPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number): void {
|
|
509
614
|
ctx.beginPath();
|
|
@@ -511,17 +616,20 @@ function spanPath(ctx: CanvasRenderingContext2D, x: number, y: number, w: number
|
|
|
511
616
|
else ctx.rect(x, y, w, h);
|
|
512
617
|
}
|
|
513
618
|
|
|
514
|
-
|
|
619
|
+
/** Paint the scene and return the hit-test boxes, topmost last. */
|
|
620
|
+
function drawScene(
|
|
515
621
|
canvas: HTMLCanvasElement,
|
|
516
622
|
layout: TimelineLayout,
|
|
623
|
+
laneAxes: LaneAxis[],
|
|
517
624
|
scale: TraceScale,
|
|
518
625
|
viewport: TimelineViewport,
|
|
519
626
|
colors: TraceTheme,
|
|
520
|
-
|
|
521
|
-
|
|
627
|
+
width: number,
|
|
628
|
+
matchIds: ReadonlySet<string> | null,
|
|
629
|
+
): Hit[] {
|
|
630
|
+
const nextHits: Hit[] = [];
|
|
522
631
|
const ctx = canvas.getContext("2d");
|
|
523
|
-
if (!ctx) return;
|
|
524
|
-
const { width, selection, hoverId, search, hits } = options;
|
|
632
|
+
if (!ctx) return nextHits;
|
|
525
633
|
const height = layout.totalHeight;
|
|
526
634
|
const dpr = devicePixelRatio;
|
|
527
635
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
@@ -530,12 +638,10 @@ function draw(
|
|
|
530
638
|
const { u0, u1 } = viewport;
|
|
531
639
|
const uSpan = Math.max(u1 - u0, 1e-9);
|
|
532
640
|
const toX = (u: number) => ((u - u0) / uSpan) * width;
|
|
533
|
-
const needle = search.trim().toLowerCase();
|
|
534
|
-
const nextHits: Hit[] = [];
|
|
535
641
|
|
|
536
642
|
// Turn bands: alternating fill between turn boundaries per track.
|
|
537
643
|
for (const block of layout.blocks) {
|
|
538
|
-
const turnSpans = block.
|
|
644
|
+
const turnSpans = block.turnSpans;
|
|
539
645
|
for (let i = 0; i < turnSpans.length; i++) {
|
|
540
646
|
const bandStart = toX(scale.toU(turnSpans[i].start));
|
|
541
647
|
const bandEnd = i + 1 < turnSpans.length ? toX(scale.toU(turnSpans[i + 1].start)) : width;
|
|
@@ -559,7 +665,8 @@ function draw(
|
|
|
559
665
|
}
|
|
560
666
|
}
|
|
561
667
|
|
|
562
|
-
// Idle-gap bridges.
|
|
668
|
+
// Idle-gap bridges and the ruler share the mono label font.
|
|
669
|
+
ctx.font = `9px ${colors.fontMono}`;
|
|
563
670
|
for (const gap of scale.gaps) {
|
|
564
671
|
const x = toX(gap.uMid);
|
|
565
672
|
if (x < -24 || x > width + 24) continue;
|
|
@@ -572,14 +679,12 @@ function draw(
|
|
|
572
679
|
ctx.stroke();
|
|
573
680
|
ctx.restore();
|
|
574
681
|
ctx.fillStyle = colors.tick;
|
|
575
|
-
ctx.font = `9px ${colors.fontMono}`;
|
|
576
682
|
ctx.textAlign = "center";
|
|
577
683
|
ctx.fillText(`⋯ ${formatDurationMs(gap.t1 - gap.t0)}`, x, RULER_H - 4);
|
|
578
684
|
ctx.textAlign = "left";
|
|
579
685
|
}
|
|
580
686
|
|
|
581
687
|
// Ruler: major ticks get a full-height gridline, minors stay in the ruler.
|
|
582
|
-
ctx.font = `9px ${colors.fontMono}`;
|
|
583
688
|
for (const tick of buildTicks(scale, u0, u1, width)) {
|
|
584
689
|
const x = Math.round(toX(tick.u)) + 0.5;
|
|
585
690
|
ctx.strokeStyle = colors.grid;
|
|
@@ -607,28 +712,32 @@ function draw(
|
|
|
607
712
|
ctx.lineTo(width, RULER_H + 0.5);
|
|
608
713
|
ctx.stroke();
|
|
609
714
|
|
|
610
|
-
// Spans per lane.
|
|
611
|
-
|
|
715
|
+
// Spans per lane. Labels are the only text from here on.
|
|
716
|
+
ctx.font = `10px ${colors.fontSans}`;
|
|
717
|
+
for (let laneIndex = 0; laneIndex < layout.lanes.length; laneIndex++) {
|
|
718
|
+
const lane = layout.lanes[laneIndex];
|
|
719
|
+
const { startU, endU, maxEndU } = laneAxes[laneIndex];
|
|
612
720
|
const isTurnLane = lane.kind === "turn";
|
|
613
|
-
for (
|
|
614
|
-
const uStart =
|
|
721
|
+
for (let i = firstVisible(maxEndU, u0); i < lane.spans.length; i++) {
|
|
722
|
+
const uStart = startU[i];
|
|
615
723
|
if (uStart > u1) break; // spans sorted by start
|
|
616
|
-
const uEnd =
|
|
724
|
+
const uEnd = endU[i];
|
|
617
725
|
if (uEnd < u0) continue;
|
|
726
|
+
const span = lane.spans[i];
|
|
618
727
|
const x = toX(uStart);
|
|
619
728
|
const w = Math.max(MIN_SPAN_PX, toX(uEnd) - x);
|
|
620
729
|
const y = lane.y;
|
|
621
|
-
const
|
|
730
|
+
const alpha = matchIds === null || matchIds.has(span.id) ? 1 : DIM_ALPHA;
|
|
622
731
|
const color = colors.category[span.kind];
|
|
623
732
|
|
|
624
|
-
ctx.globalAlpha =
|
|
733
|
+
ctx.globalAlpha = alpha;
|
|
625
734
|
if (isTurnLane) {
|
|
626
735
|
// Turn ranges read as context, not work: soft fill + solid start cap.
|
|
627
736
|
ctx.fillStyle = color;
|
|
628
|
-
ctx.globalAlpha =
|
|
737
|
+
ctx.globalAlpha = alpha * 0.23;
|
|
629
738
|
spanPath(ctx, x, y, w, LANE_H);
|
|
630
739
|
ctx.fill();
|
|
631
|
-
ctx.globalAlpha =
|
|
740
|
+
ctx.globalAlpha = alpha;
|
|
632
741
|
ctx.fillStyle = color;
|
|
633
742
|
ctx.fillRect(x, y, 2, LANE_H);
|
|
634
743
|
} else {
|
|
@@ -660,25 +769,12 @@ function draw(
|
|
|
660
769
|
ctx.stroke();
|
|
661
770
|
ctx.restore();
|
|
662
771
|
}
|
|
663
|
-
if (hoverId === span.id) {
|
|
664
|
-
ctx.fillStyle = "rgba(255,255,255,0.16)";
|
|
665
|
-
spanPath(ctx, x, y, w, LANE_H);
|
|
666
|
-
ctx.fill();
|
|
667
|
-
}
|
|
668
|
-
if (selection === span.id) {
|
|
669
|
-
ctx.strokeStyle = colors.selection;
|
|
670
|
-
ctx.lineWidth = 1.5;
|
|
671
|
-
spanPath(ctx, x - 1, y - 1, w + 2, LANE_H + 2);
|
|
672
|
-
ctx.stroke();
|
|
673
|
-
ctx.lineWidth = 1;
|
|
674
|
-
}
|
|
675
772
|
if (w > LABEL_MIN_PX) {
|
|
676
773
|
ctx.save();
|
|
677
774
|
ctx.beginPath();
|
|
678
775
|
ctx.rect(x + 3, y, w - 6, LANE_H);
|
|
679
776
|
ctx.clip();
|
|
680
777
|
ctx.fillStyle = isTurnLane ? colors.tick : colors.spanText;
|
|
681
|
-
ctx.font = `10px ${colors.fontSans}`;
|
|
682
778
|
ctx.fillText(span.label, x + 5, y + LANE_H - 5);
|
|
683
779
|
ctx.restore();
|
|
684
780
|
}
|
|
@@ -707,19 +803,43 @@ function draw(
|
|
|
707
803
|
}
|
|
708
804
|
}
|
|
709
805
|
|
|
710
|
-
|
|
806
|
+
return nextHits;
|
|
711
807
|
}
|
|
712
808
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
809
|
+
interface SpanBox {
|
|
810
|
+
x: number;
|
|
811
|
+
y: number;
|
|
812
|
+
w: number;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Scene geometry of a drawn span, or null when it isn't on screen. */
|
|
816
|
+
function placeSpan(
|
|
817
|
+
{ span, track }: { span: TraceSpan; track: TraceTrack },
|
|
818
|
+
layout: TimelineLayout,
|
|
819
|
+
scale: TraceScale,
|
|
820
|
+
viewport: TimelineViewport,
|
|
821
|
+
width: number,
|
|
822
|
+
): SpanBox | null {
|
|
823
|
+
const { u0, u1 } = viewport;
|
|
824
|
+
const uStart = scale.toU(span.start);
|
|
825
|
+
const uEnd = scale.toU(span.end);
|
|
826
|
+
if (uStart > u1 || uEnd < u0) return null;
|
|
827
|
+
const lane = layout.lanes.find(candidate => candidate.track === track && candidate.kind === span.kind);
|
|
828
|
+
if (!lane) return null;
|
|
829
|
+
const uSpan = Math.max(u1 - u0, 1e-9);
|
|
830
|
+
const x = ((uStart - u0) / uSpan) * width;
|
|
831
|
+
const xEnd = ((uEnd - u0) / uSpan) * width;
|
|
832
|
+
return { x, y: lane.y, w: Math.max(MIN_SPAN_PX, xEnd - x) };
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function renderTooltip(hover: Hit, traceStart: number, ref: React.RefObject<HTMLDivElement | null>) {
|
|
836
|
+
// Positioned by `transform` from the pointer handler (see positionTooltip).
|
|
837
|
+
const style: React.CSSProperties = { left: 0, top: 0 };
|
|
718
838
|
|
|
719
|
-
if (hover.
|
|
720
|
-
const { marker } = hover
|
|
839
|
+
if (hover.kind === "marker") {
|
|
840
|
+
const { marker } = hover;
|
|
721
841
|
return (
|
|
722
|
-
<div className="traces-tooltip" style={style}>
|
|
842
|
+
<div ref={ref} className="traces-tooltip" style={style}>
|
|
723
843
|
<div className="traces-tooltip-title">{marker.label}</div>
|
|
724
844
|
<div className="muted num">
|
|
725
845
|
{new Date(marker.time).toLocaleTimeString()} ({formatOffset(marker.time - traceStart)})
|
|
@@ -728,20 +848,29 @@ function renderTooltip(hover: HoverState, traceStart: number) {
|
|
|
728
848
|
);
|
|
729
849
|
}
|
|
730
850
|
|
|
731
|
-
const { span } = hover
|
|
851
|
+
const { span } = hover;
|
|
732
852
|
return (
|
|
733
|
-
<div className="traces-tooltip" style={style}>
|
|
853
|
+
<div ref={ref} className="traces-tooltip" style={style}>
|
|
734
854
|
<div className="traces-tooltip-title truncate">{span.label}</div>
|
|
735
855
|
<div className="muted num">
|
|
736
856
|
{formatDurationMs(span.end - span.start)} · {new Date(span.start).toLocaleTimeString()} (
|
|
737
|
-
{formatOffset(span.start - traceStart)}){span.unterminated ?
|
|
857
|
+
{formatOffset(span.start - traceStart)}){span.unterminated ? ` · ${t("trace.unterminated")}` : ""}
|
|
738
858
|
</div>
|
|
739
859
|
{span.kind === "model" && (
|
|
740
860
|
<div className="muted num">
|
|
741
|
-
{span.tokens !== undefined &&
|
|
861
|
+
{span.tokens !== undefined && (
|
|
862
|
+
<>
|
|
863
|
+
{formatInteger(span.tokens)} {t("trace.tok")}
|
|
864
|
+
</>
|
|
865
|
+
)}
|
|
742
866
|
{span.cost !== undefined && <> · ${span.cost.toFixed(4)}</>}
|
|
743
|
-
{span.ttft !== undefined &&
|
|
744
|
-
|
|
867
|
+
{span.ttft !== undefined && (
|
|
868
|
+
<>
|
|
869
|
+
{" "}
|
|
870
|
+
· {t("trace.ttft")} {formatDurationMs(span.ttft)}
|
|
871
|
+
</>
|
|
872
|
+
)}
|
|
873
|
+
{span.isError && <> · {t("common.error")}</>}
|
|
745
874
|
</div>
|
|
746
875
|
)}
|
|
747
876
|
{span.kind === "subagent" && span.model && <div className="muted num">{span.model}</div>}
|