@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +12 -5
  2. package/build.ts +4 -0
  3. package/dist/client/favicon.ico +0 -0
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +1 -0
  6. package/dist/client/index.js +96 -96
  7. package/dist/types/aggregator.d.ts +4 -1
  8. package/dist/types/client/api.d.ts +5 -1
  9. package/dist/types/client/app/nav.d.ts +5 -2
  10. package/dist/types/client/data/formatters.d.ts +7 -2
  11. package/dist/types/client/data/live.d.ts +16 -11
  12. package/dist/types/client/data/query-store.d.ts +39 -0
  13. package/dist/types/client/data/query.d.ts +10 -5
  14. package/dist/types/client/data/range.d.ts +4 -3
  15. package/dist/types/client/i18n/format.d.ts +9 -0
  16. package/dist/types/client/i18n/index.d.ts +18 -0
  17. package/dist/types/client/i18n/messages/charts.d.ts +8 -0
  18. package/dist/types/client/i18n/messages/common.d.ts +153 -0
  19. package/dist/types/client/i18n/messages/costs.d.ts +92 -0
  20. package/dist/types/client/i18n/messages/en.d.ts +1255 -0
  21. package/dist/types/client/i18n/messages/errors.d.ts +78 -0
  22. package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
  23. package/dist/types/client/i18n/messages/gain.d.ts +54 -0
  24. package/dist/types/client/i18n/messages/models.d.ts +94 -0
  25. package/dist/types/client/i18n/messages/nav.d.ts +46 -0
  26. package/dist/types/client/i18n/messages/overview.d.ts +80 -0
  27. package/dist/types/client/i18n/messages/projects.d.ts +84 -0
  28. package/dist/types/client/i18n/messages/providers.d.ts +142 -0
  29. package/dist/types/client/i18n/messages/requests.d.ts +72 -0
  30. package/dist/types/client/i18n/messages/tools.d.ts +119 -0
  31. package/dist/types/client/i18n/messages/traces.d.ts +144 -0
  32. package/dist/types/client/i18n/messages/ui.d.ts +44 -0
  33. package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
  34. package/dist/types/client/i18n/types.d.ts +15 -0
  35. package/dist/types/client/traces/Minimap.d.ts +2 -0
  36. package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
  37. package/dist/types/client/traces/TranscriptList.d.ts +3 -1
  38. package/dist/types/db.d.ts +14 -21
  39. package/dist/types/embedded-client.d.ts +5 -0
  40. package/dist/types/frustration.d.ts +18 -9
  41. package/dist/types/live.d.ts +5 -3
  42. package/dist/types/locale.d.ts +16 -0
  43. package/dist/types/parser.d.ts +0 -17
  44. package/dist/types/rollup.d.ts +9 -1
  45. package/dist/types/server.d.ts +6 -0
  46. package/dist/types/shared-types.d.ts +7 -0
  47. package/dist/types/trace.d.ts +18 -6
  48. package/dist/types/types.d.ts +13 -1
  49. package/dist/types/user-metrics.d.ts +23 -1
  50. package/package.json +5 -5
  51. package/src/aggregator.ts +31 -11
  52. package/src/client/api.ts +40 -5
  53. package/src/client/app/LiveChip.tsx +14 -13
  54. package/src/client/app/Shell.tsx +19 -13
  55. package/src/client/app/ThemeToggle.tsx +4 -3
  56. package/src/client/app/nav.ts +19 -16
  57. package/src/client/data/formatters.ts +80 -13
  58. package/src/client/data/live.tsx +85 -45
  59. package/src/client/data/query-store.ts +108 -0
  60. package/src/client/data/query.ts +47 -56
  61. package/src/client/data/range.ts +34 -14
  62. package/src/client/data/series.ts +7 -1
  63. package/src/client/favicon.ico +0 -0
  64. package/src/client/i18n/format.ts +28 -0
  65. package/src/client/i18n/index.ts +53 -0
  66. package/src/client/i18n/messages/charts.ts +8 -0
  67. package/src/client/i18n/messages/common.ts +157 -0
  68. package/src/client/i18n/messages/costs.ts +104 -0
  69. package/src/client/i18n/messages/en.ts +40 -0
  70. package/src/client/i18n/messages/errors.ts +80 -0
  71. package/src/client/i18n/messages/frustration.ts +198 -0
  72. package/src/client/i18n/messages/gain.ts +54 -0
  73. package/src/client/i18n/messages/models.ts +94 -0
  74. package/src/client/i18n/messages/nav.ts +46 -0
  75. package/src/client/i18n/messages/overview.ts +90 -0
  76. package/src/client/i18n/messages/projects.ts +84 -0
  77. package/src/client/i18n/messages/providers.ts +165 -0
  78. package/src/client/i18n/messages/requests.ts +73 -0
  79. package/src/client/i18n/messages/tools.ts +120 -0
  80. package/src/client/i18n/messages/traces.ts +160 -0
  81. package/src/client/i18n/messages/ui.ts +46 -0
  82. package/src/client/i18n/messages/zh-CN.ts +43 -0
  83. package/src/client/i18n/types.ts +19 -0
  84. package/src/client/index.tsx +4 -0
  85. package/src/client/routes/CostsRoute.tsx +61 -48
  86. package/src/client/routes/ErrorsRoute.tsx +71 -47
  87. package/src/client/routes/FrustrationRoute.tsx +131 -116
  88. package/src/client/routes/GainRoute.tsx +42 -30
  89. package/src/client/routes/ModelsRoute.tsx +90 -73
  90. package/src/client/routes/OverviewRoute.tsx +109 -63
  91. package/src/client/routes/ProjectsRoute.tsx +73 -46
  92. package/src/client/routes/ProvidersRoute.tsx +151 -114
  93. package/src/client/routes/RequestsRoute.tsx +69 -46
  94. package/src/client/routes/ToolsRoute.tsx +108 -94
  95. package/src/client/routes/TracesRoute.tsx +25 -23
  96. package/src/client/styles.css +5 -2
  97. package/src/client/traces/AggregatesPanel.tsx +19 -8
  98. package/src/client/traces/Minimap.tsx +65 -27
  99. package/src/client/traces/SpanDrawer.tsx +46 -21
  100. package/src/client/traces/SummaryStrip.tsx +27 -11
  101. package/src/client/traces/TimelineCanvas.tsx +245 -116
  102. package/src/client/traces/TraceView.tsx +51 -36
  103. package/src/client/traces/TranscriptList.tsx +20 -11
  104. package/src/client/traces/traces.css +26 -0
  105. package/src/client/ui/JsonBlock.tsx +3 -2
  106. package/src/client/ui/Modal.tsx +3 -2
  107. package/src/client/ui/RequestDrawer.tsx +33 -30
  108. package/src/client/ui/SearchInput.tsx +2 -1
  109. package/src/client/ui/States.tsx +3 -2
  110. package/src/client/ui/Table.tsx +7 -3
  111. package/src/db.ts +150 -135
  112. package/src/embedded-client.ts +9 -0
  113. package/src/frustration.ts +9 -30
  114. package/src/index.ts +2 -1
  115. package/src/live.ts +36 -11
  116. package/src/locale.ts +29 -0
  117. package/src/parser.ts +37 -72
  118. package/src/port-conflict.ts +1 -1
  119. package/src/prompts/judge/frustration.json +19 -0
  120. package/src/rollup.ts +41 -20
  121. package/src/server.ts +67 -27
  122. package/src/shared-types.ts +7 -0
  123. package/src/trace.ts +54 -40
  124. package/src/types.ts +13 -1
  125. package/src/user-metrics.ts +220 -6
@@ -1,14 +1,16 @@
1
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.
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
- selection: string | null;
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: string;
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: "Input" },
48
- { kind: "model", label: "Model" },
49
- { kind: "tool", label: "Tools" },
50
- { kind: "subagent", label: "Agents" },
51
- { kind: "background", label: "Bg" },
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 = track.spans.filter(span => span.kind === kind);
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
- interface HoverState {
152
- hit: Hit;
153
- clientX: number;
154
- clientY: number;
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
- selection,
220
+ selected,
163
221
  onSelect,
164
- search,
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
- const [hover, setHover] = useState<HoverState | null>(null);
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
- setHover(hit ? { hit, clientX: event.clientX, clientY: event.clientY } : null);
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
- if (!selection) return;
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 === selection);
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
- [selection, layout, onSelect, scale, viewport, onViewportChange, clampViewport],
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 (!selection) break;
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
- // Draw.
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
- draw(canvas, layout, scale, viewport, colors, {
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, selection, hover, search]);
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
- ? `Expand ${block.track.label}`
443
- : `Collapse ${block.track.label}`
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
- ref={canvasRef}
476
- className="traces-canvas"
477
- width={Math.floor(canvasWidth * devicePixelRatio)}
478
- height={Math.floor(layout.totalHeight * devicePixelRatio)}
479
- style={{ width: canvasWidth, height: layout.totalHeight }}
480
- tabIndex={0}
481
- role="application"
482
- aria-label="Trace timeline. W and S zoom, A and D pan, 0 fits all, F focuses the selection."
483
- onPointerDown={handlePointerDown}
484
- onPointerMove={handlePointerMove}
485
- onPointerUp={handlePointerUp}
486
- onPointerLeave={() => {
487
- hoverXRef.current = null;
488
- setHover(null);
489
- }}
490
- onDoubleClick={handleDoubleClick}
491
- onKeyDown={handleKeyDown}
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
- function draw(
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
- options: DrawOptions,
521
- ): void {
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.track.spans.filter(span => span.kind === "turn");
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
- for (const lane of layout.lanes) {
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 (const span of lane.spans) {
614
- const uStart = scale.toU(span.start);
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 = scale.toU(span.end);
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 matches = needle === "" || `${span.label} ${span.detail ?? ""}`.toLowerCase().includes(needle);
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 = matches ? 1 : 0.3;
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 = (matches ? 1 : 0.3) * 0.23;
737
+ ctx.globalAlpha = alpha * 0.23;
629
738
  spanPath(ctx, x, y, w, LANE_H);
630
739
  ctx.fill();
631
- ctx.globalAlpha = matches ? 1 : 0.3;
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
- hits.current = nextHits;
806
+ return nextHits;
711
807
  }
712
808
 
713
- function renderTooltip(hover: HoverState, traceStart: number) {
714
- // Fixed positioning escapes the scroll frame's clipping; clamp to the window.
715
- const left = Math.min(hover.clientX + 14, window.innerWidth - 320);
716
- const top = hover.clientY + 16 > window.innerHeight - 140 ? hover.clientY - 120 : hover.clientY + 16;
717
- const style: React.CSSProperties = { left, top };
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.hit.kind === "marker") {
720
- const { marker } = hover.hit;
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.hit;
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 ? " · 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 && <>{formatInteger(span.tokens)} tok</>}
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 && <> · TTFT {formatDurationMs(span.ttft)}</>}
744
- {span.isError && <> · error</>}
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>}