@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
@@ -8,7 +8,8 @@ import { ArrowLeft, ChevronLeft, ChevronRight, RefreshCw } from "lucide-react";
8
8
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
9
  import { getSessionTrace } from "../api";
10
10
  import { useQuery } from "../data/query";
11
- import type { TraceSpan, TraceSpanKind, TraceTrack } from "../types";
11
+ import { t } from "../i18n";
12
+ import type { SessionTrace, TraceSpan, TraceSpanKind, TraceTrack } from "../types";
12
13
  import { Card, ChartSkeleton, PageHeader, QueryView, SearchInput, Segmented } from "../ui";
13
14
  import { AggregatesPanel } from "./AggregatesPanel";
14
15
  import { Minimap } from "./Minimap";
@@ -26,21 +27,25 @@ export interface TraceViewProps {
26
27
  }
27
28
 
28
29
  const MODE_OPTIONS: Array<{ value: AxisMode; label: string; title?: string }> = [
29
- { value: "time", label: "Duration", title: "Real wall-clock time" },
30
- { value: "turns", label: "Turns", title: "Equal width per user turn" },
31
- { value: "calls", label: "Calls", title: "Equal width per model/tool call boundary" },
30
+ { value: "time", label: t("common.duration"), title: t("trace.mode-time-title") },
31
+ { value: "turns", label: t("trace.turns"), title: t("trace.mode-turns-title") },
32
+ { value: "calls", label: t("trace.calls"), title: t("trace.mode-calls-title") },
32
33
  ];
33
34
 
34
35
  const LEGEND: Array<{ kind: TraceSpanKind; label: string }> = [
35
- { kind: "turn", label: "Input" },
36
- { kind: "model", label: "Model" },
37
- { kind: "tool", label: "Tool" },
38
- { kind: "subagent", label: "Agent" },
39
- { kind: "background", label: "Background" },
36
+ { kind: "turn", label: t("common.input") },
37
+ { kind: "model", label: t("common.model") },
38
+ { kind: "tool", label: t("trace.tool") },
39
+ { kind: "subagent", label: t("trace.agent") },
40
+ { kind: "background", label: t("trace.background") },
40
41
  ];
41
42
 
42
43
  export function TraceView({ file, active, onBack }: TraceViewProps) {
43
- const query = useQuery(["trace", file], () => getSessionTrace(file), { pollMs: 15000, enabled: active });
44
+ const query = useQuery<SessionTrace>(
45
+ ["trace", file],
46
+ ({ signal }, previous) => getSessionTrace(file, signal, previous),
47
+ { pollMs: 15000, enabled: active },
48
+ );
44
49
  // A previous session's trace must never render under this file's header.
45
50
  const trace = query.stale ? null : query.data;
46
51
 
@@ -89,7 +94,10 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
89
94
  });
90
95
  }, [scale]);
91
96
 
92
- const effectiveViewport: TimelineViewport = viewport ?? { u0: scale.domain[0], u1: scale.domain[1] };
97
+ const effectiveViewport = useMemo<TimelineViewport>(
98
+ () => viewport ?? { u0: scale.domain[0], u1: scale.domain[1] },
99
+ [viewport, scale],
100
+ );
93
101
 
94
102
  const allSpans = useMemo(() => {
95
103
  const spans: Array<{ span: TraceSpan; track: TraceTrack }> = [];
@@ -105,6 +113,11 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
105
113
  if (!needle) return [];
106
114
  return allSpans.filter(({ span }) => `${span.label} ${span.detail ?? ""}`.toLowerCase().includes(needle));
107
115
  }, [allSpans, search]);
116
+ // Shared with the canvas and transcript so the match test runs once per search.
117
+ const matchIds = useMemo(
118
+ () => (search.trim() ? new Set(matches.map(({ span }) => span.id)) : null),
119
+ [matches, search],
120
+ );
108
121
 
109
122
  const centerOnSpan = useCallback(
110
123
  (span: TraceSpan) => {
@@ -198,6 +211,15 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
198
211
  [tracks],
199
212
  );
200
213
 
214
+ const openFromTranscript = useCallback(
215
+ (spanId: string) => {
216
+ selectAndReveal(spanId);
217
+ setDrawerOpen(true);
218
+ },
219
+ [selectAndReveal],
220
+ );
221
+ const closeDrawer = useCallback(() => setDrawerOpen(false), []);
222
+
201
223
  const traceStart = trace?.startedAt ?? 0;
202
224
 
203
225
  const traceQuery = { ...query, data: trace };
@@ -207,7 +229,7 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
207
229
  <div>
208
230
  <button type="button" className="btn" data-variant="ghost" data-size="sm" onClick={onBack}>
209
231
  <ArrowLeft size={14} aria-hidden="true" />
210
- Sessions
232
+ {t("trace.sessions")}
211
233
  </button>
212
234
  </div>
213
235
  <PageHeader
@@ -220,8 +242,8 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
220
242
  data-variant="ghost"
221
243
  data-icon="true"
222
244
  onClick={query.refetch}
223
- aria-label="Refresh trace"
224
- title="Refresh trace"
245
+ aria-label={t("trace.refresh")}
246
+ title={t("trace.refresh")}
225
247
  >
226
248
  <RefreshCw size={14} className={query.refreshing ? "traces-spin" : undefined} />
227
249
  </button>
@@ -235,8 +257,8 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
235
257
 
236
258
  <Card
237
259
  index={1}
238
- title="Timeline"
239
- description="W/S zoom · A/D pan · drag pan · wheel zoom · 0 fit · F focus selection · dbl-click focus · Esc deselect"
260
+ title={t("trace.timeline")}
261
+ description={t("trace.timeline-hint")}
240
262
  actions={
241
263
  <>
242
264
  <button
@@ -246,7 +268,7 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
246
268
  data-size="sm"
247
269
  onClick={() => collapseAll(false)}
248
270
  >
249
- Expand all
271
+ {t("trace.expand-all")}
250
272
  </button>
251
273
  <button
252
274
  type="button"
@@ -255,7 +277,7 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
255
277
  data-size="sm"
256
278
  onClick={() => collapseAll(true)}
257
279
  >
258
- Collapse all
280
+ {t("trace.collapse-all")}
259
281
  </button>
260
282
  </>
261
283
  }
@@ -267,7 +289,7 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
267
289
  value={mode}
268
290
  onChange={setMode}
269
291
  size="sm"
270
- aria-label="Axis mode"
292
+ aria-label={t("trace.axis-mode")}
271
293
  />
272
294
  {mode === "time" && (
273
295
  <label className="traces-check">
@@ -276,7 +298,7 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
276
298
  checked={compressIdle}
277
299
  onChange={event => setCompressIdle(event.target.checked)}
278
300
  />
279
- Compress idle
301
+ {t("trace.compress-idle")}
280
302
  </label>
281
303
  )}
282
304
  <div className="traces-search">
@@ -286,7 +308,7 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
286
308
  setSearch(value);
287
309
  setMatchIndex(0);
288
310
  }}
289
- placeholder="Search spans…"
311
+ placeholder={t("trace.search-placeholder")}
290
312
  width={200}
291
313
  />
292
314
  {search.trim() && (
@@ -303,7 +325,7 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
303
325
  data-size="sm"
304
326
  data-icon="true"
305
327
  onClick={() => cycleMatch(-1)}
306
- aria-label="Previous match"
328
+ aria-label={t("trace.previous-match")}
307
329
  disabled={matches.length === 0}
308
330
  >
309
331
  <ChevronLeft size={14} />
@@ -315,7 +337,7 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
315
337
  data-size="sm"
316
338
  data-icon="true"
317
339
  onClick={() => cycleMatch(1)}
318
- aria-label="Next match"
340
+ aria-label={t("trace.next-match")}
319
341
  disabled={matches.length === 0}
320
342
  >
321
343
  <ChevronRight size={14} />
@@ -346,9 +368,9 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
346
368
  scale={scale}
347
369
  viewport={effectiveViewport}
348
370
  onViewportChange={setViewport}
349
- selection={selection}
371
+ selected={selected}
350
372
  onSelect={selectAndOpen}
351
- search={search}
373
+ matchIds={matchIds}
352
374
  collapsed={collapsed}
353
375
  onToggleCollapse={toggleCollapse}
354
376
  traceStart={traceStart}
@@ -356,20 +378,13 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
356
378
  </div>
357
379
  </Card>
358
380
 
359
- <Card
360
- index={2}
361
- title="Transcript"
362
- description="Every span and marker in order; click to inspect"
363
- flush
364
- >
381
+ <Card index={2} title={t("trace.transcript")} description={t("trace.transcript-description")} flush>
365
382
  <TranscriptList
366
383
  tracks={tracks}
367
384
  selection={selection}
368
- onSelect={spanId => {
369
- selectAndReveal(spanId);
370
- setDrawerOpen(true);
371
- }}
385
+ onSelect={openFromTranscript}
372
386
  search={search}
387
+ matchIds={matchIds}
373
388
  traceStart={traceStart}
374
389
  />
375
390
  </Card>
@@ -381,7 +396,7 @@ export function TraceView({ file, active, onBack }: TraceViewProps) {
381
396
  <SpanDrawer
382
397
  span={drawerOpen ? (selected?.span ?? null) : null}
383
398
  track={selected?.track ?? null}
384
- onClose={() => setDrawerOpen(false)}
399
+ onClose={closeDrawer}
385
400
  onOpenChildTrack={openChildTrack}
386
401
  />
387
402
  </div>
@@ -4,8 +4,9 @@
4
4
  * selection. Hand-rolled fixed-row virtualization (32px rows, spacer divs).
5
5
  */
6
6
 
7
- import { useEffect, useMemo, useRef, useState } from "react";
7
+ import { memo, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { formatDurationMs } from "../data/formatters";
9
+ import { t } from "../i18n";
9
10
  import { EmptyState } from "../ui";
10
11
  import type { TraceMarker, TraceSpan, TraceTrack } from "../types";
11
12
  import { formatOffset } from "./time-scale";
@@ -25,6 +26,8 @@ export interface TranscriptListProps {
25
26
  selection: string | null;
26
27
  onSelect: (spanId: string) => void;
27
28
  search: string;
29
+ /** Spans matching `search` (from the trace view); `null` when not searching. */
30
+ matchIds: ReadonlySet<string> | null;
28
31
  traceStart: number;
29
32
  }
30
33
 
@@ -49,20 +52,26 @@ export function buildTranscriptRows(tracks: TraceTrack[]): TranscriptRow[] {
49
52
  return rows;
50
53
  }
51
54
 
52
- export function TranscriptList({ tracks, selection, onSelect, search, traceStart }: TranscriptListProps) {
55
+ export const TranscriptList = memo(function TranscriptList({
56
+ tracks,
57
+ selection,
58
+ onSelect,
59
+ search,
60
+ matchIds,
61
+ traceStart,
62
+ }: TranscriptListProps) {
53
63
  const colors = useTraceTheme();
54
64
  const containerRef = useRef<HTMLDivElement>(null);
55
65
  const [scrollTop, setScrollTop] = useState(0);
56
66
 
57
67
  const allRows = useMemo(() => buildTranscriptRows(tracks), [tracks]);
58
68
  const rows = useMemo(() => {
69
+ if (!matchIds) return allRows;
59
70
  const needle = search.trim().toLowerCase();
60
- if (!needle) return allRows;
61
- return allRows.filter(row => {
62
- const text = row.span ? `${row.span.label} ${row.span.detail ?? ""}` : (row.marker?.label ?? "");
63
- return text.toLowerCase().includes(needle);
64
- });
65
- }, [allRows, search]);
71
+ return allRows.filter(row =>
72
+ row.span ? matchIds.has(row.span.id) : (row.marker?.label ?? "").toLowerCase().includes(needle),
73
+ );
74
+ }, [allRows, search, matchIds]);
66
75
 
67
76
  // Scroll the selected row into view when selection changes externally.
68
77
  useEffect(() => {
@@ -106,7 +115,7 @@ export function TranscriptList({ tracks, selection, onSelect, search, traceStart
106
115
  <span className="traces-row-label truncate">{label}</span>
107
116
  {detail && <span className="traces-row-detail truncate">{detail}</span>}
108
117
  <span className="traces-row-meta num">
109
- {row.span?.isError && <span className="tone-bad">error</span>}
118
+ {row.span?.isError && <span className="tone-bad">{t("common.error")}</span>}
110
119
  <span>{duration}</span>
111
120
  <span>{formatOffset(row.time - traceStart)}</span>
112
121
  </span>
@@ -132,7 +141,7 @@ export function TranscriptList({ tracks, selection, onSelect, search, traceStart
132
141
  })}
133
142
  </div>
134
143
  </div>
135
- {rows.length === 0 && <EmptyState title="No matching events" />}
144
+ {rows.length === 0 && <EmptyState title={t("trace.no-matching-events")} />}
136
145
  </div>
137
146
  );
138
- }
147
+ });
@@ -111,6 +111,32 @@
111
111
  cursor: grabbing;
112
112
  }
113
113
 
114
+ /* Scene canvas with the hover/selection boxes stacked on top. */
115
+ .traces-canvas-stack {
116
+ position: relative;
117
+ flex-shrink: 0;
118
+ overflow: hidden;
119
+ }
120
+
121
+ .traces-canvas-stack > canvas {
122
+ display: block;
123
+ }
124
+
125
+ .traces-canvas-hover,
126
+ .traces-canvas-selection {
127
+ position: absolute;
128
+ pointer-events: none;
129
+ box-sizing: border-box;
130
+ }
131
+
132
+ .traces-canvas-hover {
133
+ background: rgba(255, 255, 255, 0.16);
134
+ }
135
+
136
+ .traces-canvas-selection {
137
+ border: 1.5px solid;
138
+ }
139
+
114
140
  .traces-chevron {
115
141
  display: inline-flex;
116
142
  background: none;
@@ -1,5 +1,6 @@
1
1
  import { Check, ChevronDown, ChevronRight, Copy } from "lucide-react";
2
2
  import { useEffect, useRef, useState } from "react";
3
+ import { t } from "../i18n";
3
4
 
4
5
  export interface JsonBlockProps {
5
6
  data: unknown;
@@ -8,7 +9,7 @@ export interface JsonBlockProps {
8
9
  }
9
10
 
10
11
  /** Collapsible, copyable pretty-printed JSON. */
11
- export function JsonBlock({ data, title = "JSON", initialCollapsed = false }: JsonBlockProps) {
12
+ export function JsonBlock({ data, title = t("ui.json"), initialCollapsed = false }: JsonBlockProps) {
12
13
  const [collapsed, setCollapsed] = useState(initialCollapsed);
13
14
  const [copied, setCopied] = useState(false);
14
15
  const copyResetRef = useRef<number>(0);
@@ -44,7 +45,7 @@ export function JsonBlock({ data, title = "JSON", initialCollapsed = false }: Js
44
45
  </button>
45
46
  <button type="button" className="btn" data-variant="ghost" data-size="sm" onClick={copy}>
46
47
  {copied ? <Check size={13} /> : <Copy size={13} />}
47
- {copied ? "Copied" : "Copy"}
48
+ {copied ? t("common.copied") : t("common.copy")}
48
49
  </button>
49
50
  </div>
50
51
  {!collapsed && (
@@ -2,6 +2,7 @@ import { X } from "lucide-react";
2
2
  import type React from "react";
3
3
  import { useEffect, useRef } from "react";
4
4
  import { createPortal } from "react-dom";
5
+ import { t } from "../i18n";
5
6
  import "./modal.css";
6
7
 
7
8
  export interface ModalAction {
@@ -26,7 +27,7 @@ export interface ModalProps {
26
27
  * containers cannot clip it. Focus lands on the primary action (or the cancel
27
28
  * button when there is none) and returns to the opener on close.
28
29
  */
29
- export function Modal({ open, title, onClose, primaryAction, cancelLabel = "Cancel", children }: ModalProps) {
30
+ export function Modal({ open, title, onClose, primaryAction, cancelLabel = t("common.cancel"), children }: ModalProps) {
30
31
  const primaryRef = useRef<HTMLButtonElement>(null);
31
32
  const cancelRef = useRef<HTMLButtonElement>(null);
32
33
  const onCloseRef = useRef(onClose);
@@ -76,7 +77,7 @@ export function Modal({ open, title, onClose, primaryAction, cancelLabel = "Canc
76
77
  data-icon="true"
77
78
  data-size="sm"
78
79
  onClick={onClose}
79
- aria-label="Close"
80
+ aria-label={t("common.close")}
80
81
  >
81
82
  <X size={15} />
82
83
  </button>
@@ -1,5 +1,6 @@
1
1
  import { GitBranch } from "lucide-react";
2
2
  import { getRequestDetails } from "../api";
3
+ import { t } from "../i18n";
3
4
  import {
4
5
  formatCost,
5
6
  formatDurationMs,
@@ -22,9 +23,9 @@ import "./request-drawer.css";
22
23
 
23
24
  /** Label and tone for each request outcome; shared by the request tables. */
24
25
  export const REQUEST_STATUS: Record<RequestStatus, { label: string; tone: Tone }> = {
25
- ok: { label: "OK", tone: "ok" },
26
- aborted: { label: "Aborted", tone: "warn" },
27
- failed: { label: "Failed", tone: "bad" },
26
+ ok: { label: t("ui.status-ok"), tone: "ok" },
27
+ aborted: { label: t("common.aborted"), tone: "warn" },
28
+ failed: { label: t("common.failed"), tone: "bad" },
28
29
  };
29
30
 
30
31
  export interface RequestDrawerProps {
@@ -34,7 +35,9 @@ export interface RequestDrawerProps {
34
35
 
35
36
  /** Full detail sheet for one model request: timing, tokens, cost, ids, error, and the raw session entry. */
36
37
  export function RequestDrawer({ id, onClose }: RequestDrawerProps) {
37
- const query = useQuery(["request", id], () => getRequestDetails(id ?? 0), { enabled: id !== null });
38
+ const query = useQuery(["request", id], ({ signal }) => getRequestDetails(id ?? 0, signal), {
39
+ enabled: id !== null,
40
+ });
38
41
  // Never show the previous request's payload under a new id.
39
42
  const details = query.stale ? null : query.data;
40
43
  const status = details ? REQUEST_STATUS[requestStatus(details)] : null;
@@ -44,7 +47,7 @@ export function RequestDrawer({ id, onClose }: RequestDrawerProps) {
44
47
  open={id !== null}
45
48
  onClose={onClose}
46
49
  width={620}
47
- title={details ? <span className="mono">{details.model}</span> : "Request"}
50
+ title={details ? <span className="mono">{details.model}</span> : t("common.request")}
48
51
  subtitle={
49
52
  details ? (
50
53
  <>
@@ -68,7 +71,7 @@ export function RequestDrawer({ id, onClose }: RequestDrawerProps) {
68
71
  session: details.sessionFile,
69
72
  })}
70
73
  onClick={onClose}
71
- title="Open this request's session in the trace view"
74
+ title={t("ui.open-trace")}
72
75
  >
73
76
  <GitBranch size={13} /> Trace
74
77
  </a>
@@ -106,35 +109,35 @@ function RequestDetailsBody({ details, aborted }: { details: RequestDetails; abo
106
109
  <>
107
110
  {details.errorMessage && (
108
111
  <section className="request-drawer-error" data-tone={aborted ? "warn" : "bad"} role="note">
109
- <div className="request-drawer-error-label">{aborted ? "Aborted" : "Error"}</div>
112
+ <div className="request-drawer-error-label">{aborted ? t("common.aborted") : t("common.error")}</div>
110
113
  <pre className="request-drawer-error-text">{details.errorMessage}</pre>
111
114
  </section>
112
115
  )}
113
116
 
114
117
  <section>
115
- <div className="section-label">Timing</div>
118
+ <div className="section-label">{t("ui.section-timing")}</div>
116
119
  <KeyValues
117
120
  items={[
118
- { key: "at", label: "Started", value: formatTimestamp(details.timestamp) },
119
- { key: "duration", label: "Duration", value: formatDurationMs(details.duration) },
120
- { key: "ttft", label: "Time to first token", value: formatDurationMs(details.ttft) },
121
- { key: "tps", label: "Output tokens/s", value: formatTokensPerSecond(throughput) },
121
+ { key: "at", label: t("common.started"), value: formatTimestamp(details.timestamp) },
122
+ { key: "duration", label: t("common.duration"), value: formatDurationMs(details.duration) },
123
+ { key: "ttft", label: t("ui.time-to-first-token"), value: formatDurationMs(details.ttft) },
124
+ { key: "tps", label: t("common.output-tokens-per-second"), value: formatTokensPerSecond(throughput) },
122
125
  ]}
123
126
  />
124
127
  </section>
125
128
 
126
129
  <section>
127
- <div className="section-label">Tokens</div>
130
+ <div className="section-label">{t("common.tokens")}</div>
128
131
  <KeyValues
129
132
  items={[
130
- { key: "input", label: "Uncached input", value: formatInteger(usage.input) },
131
- { key: "cacheRead", label: "Cache read", value: formatInteger(usage.cacheRead) },
132
- { key: "cacheWrite", label: "Cache write", value: formatInteger(usage.cacheWrite) },
133
- { key: "output", label: "Output", value: formatInteger(usage.output) },
134
- { key: "total", label: "Total", value: formatInteger(usage.totalTokens) },
133
+ { key: "input", label: t("ui.uncached-input"), value: formatInteger(usage.input) },
134
+ { key: "cacheRead", label: t("common.cache-read"), value: formatInteger(usage.cacheRead) },
135
+ { key: "cacheWrite", label: t("common.cache-write"), value: formatInteger(usage.cacheWrite) },
136
+ { key: "output", label: t("common.output"), value: formatInteger(usage.output) },
137
+ { key: "total", label: t("common.total"), value: formatInteger(usage.totalTokens) },
135
138
  {
136
139
  key: "premium",
137
- label: "Premium requests",
140
+ label: t("common.premium-requests"),
138
141
  value: formatInteger(Math.round((usage.premiumRequests ?? 0) * 100) / 100),
139
142
  },
140
143
  ]}
@@ -142,11 +145,11 @@ function RequestDetailsBody({ details, aborted }: { details: RequestDetails; abo
142
145
  </section>
143
146
 
144
147
  <section>
145
- <div className="section-label">API-equivalent cost</div>
148
+ <div className="section-label">{t("ui.section-cost")}</div>
146
149
  <KeyValues
147
150
  items={[
148
151
  { key: "total", label: "Total", value: formatMessageCost(details, 4) },
149
- { key: "input", label: "Input", value: formatCost(usage.cost.input, 4) },
152
+ { key: "input", label: t("common.input"), value: formatCost(usage.cost.input, 4) },
150
153
  { key: "cacheRead", label: "Cache read", value: formatCost(usage.cost.cacheRead, 4) },
151
154
  { key: "cacheWrite", label: "Cache write", value: formatCost(usage.cost.cacheWrite, 4) },
152
155
  { key: "output", label: "Output", value: formatCost(usage.cost.output, 4) },
@@ -155,29 +158,29 @@ function RequestDetailsBody({ details, aborted }: { details: RequestDetails; abo
155
158
  </section>
156
159
 
157
160
  <section>
158
- <div className="section-label">Identity</div>
161
+ <div className="section-label">{t("ui.section-identity")}</div>
159
162
  <KeyValues
160
163
  items={[
161
- { key: "id", label: "Request id", value: details.id ?? "–" },
162
- { key: "entry", label: "Entry id", value: details.entryId },
163
- { key: "stop", label: "Stop reason", value: details.stopReason },
164
+ { key: "id", label: t("common.request-id"), value: details.id ?? "–" },
165
+ { key: "entry", label: t("common.entry-id"), value: details.entryId },
166
+ { key: "stop", label: t("ui.stop-reason"), value: details.stopReason },
164
167
  { key: "api", label: "API", value: details.api },
165
168
  {
166
169
  key: "project",
167
- label: "Project",
170
+ label: t("common.project"),
168
171
  value: <span title={details.folder}>{formatFolder(details.folder)}</span>,
169
172
  },
170
173
  ]}
171
174
  />
172
175
  <div className="request-drawer-file">
173
- <span className="kv-key">Session file</span>
176
+ <span className="kv-key">{t("ui.session-file")}</span>
174
177
  <span className="kv-value">{details.sessionFile}</span>
175
178
  </div>
176
179
  </section>
177
180
 
178
- <JsonBlock data={output} title="Output message" />
179
- <JsonBlock data={messages} title="Session entry" initialCollapsed />
180
- <JsonBlock data={row} title="Stats row" initialCollapsed />
181
+ {output != null && <JsonBlock data={output} title={t("common.output-message")} />}
182
+ <JsonBlock data={messages} title={t("common.session-entry")} initialCollapsed />
183
+ <JsonBlock data={row} title={t("ui.stats-row")} initialCollapsed />
181
184
  </>
182
185
  );
183
186
  }
@@ -1,4 +1,5 @@
1
1
  import { Search } from "lucide-react";
2
+ import { t } from "../i18n";
2
3
 
3
4
  export interface SearchInputProps {
4
5
  value: string;
@@ -8,7 +9,7 @@ export interface SearchInputProps {
8
9
  }
9
10
 
10
11
  /** Text filter with a leading search glyph. */
11
- export function SearchInput({ value, onChange, placeholder = "Filter…", width }: SearchInputProps) {
12
+ export function SearchInput({ value, onChange, placeholder = t("common.filter"), width }: SearchInputProps) {
12
13
  return (
13
14
  <label className="search">
14
15
  <Search size={14} />
@@ -1,7 +1,8 @@
1
1
  import { Inbox, RotateCw, TriangleAlert } from "lucide-react";
2
2
  import type { CSSProperties, ReactNode } from "react";
3
+ import { t } from "../i18n";
3
4
 
4
- export function EmptyState({ title = "Nothing here yet", hint }: { title?: ReactNode; hint?: ReactNode }) {
5
+ export function EmptyState({ title = t("common.nothing-here-yet"), hint }: { title?: ReactNode; hint?: ReactNode }) {
5
6
  return (
6
7
  <div className="empty">
7
8
  <Inbox size={20} />
@@ -18,7 +19,7 @@ export function ErrorState({ error, onRetry }: { error: Error | string; onRetry?
18
19
  <span className="error-state-message">{typeof error === "string" ? error : error.message}</span>
19
20
  {onRetry && (
20
21
  <button type="button" className="btn" data-size="sm" data-variant="ghost" onClick={onRetry}>
21
- <RotateCw size={13} /> Retry
22
+ <RotateCw size={13} /> {t("ui.retry")}
22
23
  </button>
23
24
  )}
24
25
  </div>
@@ -1,6 +1,7 @@
1
1
  import { ArrowDown, ArrowUp } from "lucide-react";
2
2
  import { Fragment, type ReactNode, useMemo, useState } from "react";
3
3
  import { formatInteger } from "../data/formatters";
4
+ import { t } from "../i18n";
4
5
  import { EmptyState } from "./States";
5
6
 
6
7
  export interface Column<T> {
@@ -155,7 +156,10 @@ export function Table<T>({
155
156
  {limit !== undefined && sorted.length > limit && (
156
157
  <div className="table-more">
157
158
  <span className="micro">
158
- {formatInteger(visible.length)} of {formatInteger(sorted.length)}
159
+ {t("ui.table-shown-of", {
160
+ shown: formatInteger(visible.length),
161
+ total: formatInteger(sorted.length),
162
+ })}
159
163
  </span>
160
164
  {sorted.length > shown && (
161
165
  <button
@@ -165,7 +169,7 @@ export function Table<T>({
165
169
  data-variant="ghost"
166
170
  onClick={() => setShown(n => n + limit * 4)}
167
171
  >
168
- Show more
172
+ {t("common.show-more")}
169
173
  </button>
170
174
  )}
171
175
  {shown > limit && (
@@ -176,7 +180,7 @@ export function Table<T>({
176
180
  data-variant="ghost"
177
181
  onClick={() => setShown(limit)}
178
182
  >
179
- Show fewer
183
+ {t("ui.show-fewer")}
180
184
  </button>
181
185
  )}
182
186
  </div>