@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,10 +1,12 @@
1
1
  import { X } from "lucide-react";
2
2
  import { useMemo, useState } from "react";
3
3
  import { getToolDashboardStats } from "../api";
4
+ import { t } from "../i18n";
4
5
  import { Legend, Sparkline, TimeChart, useHiddenSeries } from "../charts";
5
6
  import { buildColorLookup, OTHER_COLOR } from "../data/colors";
6
7
  import {
7
8
  formatCompact,
9
+ formatErrorRate,
8
10
  formatEstimatedCost,
9
11
  formatInteger,
10
12
  formatPercent,
@@ -43,20 +45,26 @@ export interface ToolsRouteProps {
43
45
  type CallMetric = "calls" | "errors";
44
46
 
45
47
  const METRIC_OPTIONS = [
46
- { value: "calls" as const, label: "Calls" },
47
- { value: "errors" as const, label: "Errors" },
48
+ { value: "calls" as const, label: t("tools.calls") },
49
+ { value: "errors" as const, label: t("tools.errors") },
48
50
  ];
49
51
 
50
52
  /** Tools stacked individually in the calls chart; the rest fold into "Other". */
51
53
  const TOP_TOOLS = 6;
52
54
 
53
- const ATTRIBUTION_NOTE =
54
- "Tokens and API-equivalent cost of each invoking turn, split evenly across that turn's tool calls";
55
+ const ATTRIBUTION_NOTE = t("tools.attribution-note");
55
56
 
56
- const NO_CALLS = <EmptyState title="No tool calls in this range" />;
57
+ /** Bucket granularity label for the calls chart description. */
58
+ function bucketUnit(bucketMs: number): string {
59
+ if (bucketMs < 3_600_000) return t("tools.bucket-5m");
60
+ if (bucketMs < 86_400_000) return t("tools.bucket-hour");
61
+ return t("tools.bucket-day");
62
+ }
63
+
64
+ const NO_CALLS = <EmptyState title={t("tools.no-tool-calls")} />;
57
65
 
58
66
  export function ToolsRoute({ active, range }: ToolsRouteProps) {
59
- const tools = useQuery(["tools", range], () => getToolDashboardStats(range), { enabled: active });
67
+ const tools = useQuery(["tools", range], ({ signal }) => getToolDashboardStats(range, signal), { enabled: active });
60
68
  const [metric, setMetric] = useState<CallMetric>("calls");
61
69
  const [hidden, toggleHidden] = useHiddenSeries();
62
70
  const [pickedTool, setToolFilter] = useState<string | null>(null);
@@ -71,75 +79,78 @@ export function ToolsRoute({ active, range }: ToolsRouteProps) {
71
79
 
72
80
  return (
73
81
  <div className="page">
74
- <PageHeader
75
- title="Tools"
76
- description={`Which tools zeta called in ${meta.windowLabel}, how often they failed, and what they cost.`}
77
- />
82
+ <PageHeader title={t("tools.title")} description={t("tools.description", { window: t(meta.windowKey) })} />
78
83
 
79
84
  <QueryView query={tools} skeleton={<ChartSkeleton height={112} />}>
80
85
  {() => {
81
- const t = view.totals;
86
+ const totals = view.totals;
82
87
  return (
83
88
  <div data-stale={tools.stale} className="stack" style={{ gap: 16 }}>
84
89
  <StatGrid min={190}>
85
90
  <Stat
86
- label="Tool calls"
87
- value={formatInteger(t.calls)}
88
- hint={`${formatInteger(t.errors)} failed`}
91
+ label={t("tools.tool-calls")}
92
+ value={formatInteger(totals.calls)}
93
+ hint={t("tools.failed-n", { count: formatInteger(totals.errors) })}
89
94
  spark={view.totalCalls}
90
95
  />
91
96
  <Stat
92
- label="Distinct tools"
93
- value={formatInteger(t.tools)}
94
- hint={view.rows[0] ? `Most used: ${view.rows[0].tool}` : undefined}
97
+ label={t("tools.distinct-tools")}
98
+ value={formatInteger(totals.tools)}
99
+ hint={view.rows[0] ? t("tools.most-used", { tool: view.rows[0].tool }) : undefined}
95
100
  />
96
101
  <Stat
97
- label="Error rate"
98
- title="Tool results that came back flagged as errors"
99
- value={formatPercent(t.calls > 0 ? t.errors / t.calls : 0)}
100
- hint={`${formatInteger(t.calls - t.errors)} succeeded`}
102
+ label={t("common.error-rate")}
103
+ title={t("tools.error-rate-title")}
104
+ value={formatErrorRate(totals.calls > 0 ? totals.errors / totals.calls : 0)}
105
+ hint={t("tools.succeeded-n", { count: formatInteger(totals.calls - totals.errors) })}
101
106
  spark={view.totalErrors}
102
107
  sparkColor="var(--bad)"
103
108
  />
104
109
  <Stat
105
- label="Attributed tokens"
110
+ label={t("tools.attributed-tokens")}
106
111
  title={ATTRIBUTION_NOTE}
107
- value={formatCompact(Math.round(t.tokens))}
108
- hint={`${formatCompact(Math.round(t.output))} output`}
112
+ value={formatCompact(Math.round(totals.tokens))}
113
+ hint={t("tools.tokens-output", { tokens: formatCompact(Math.round(totals.output)) })}
109
114
  />
110
115
  <Stat
111
- label="Attributed cost"
112
- title={`${ATTRIBUTION_NOTE}; API-equivalent estimate`}
113
- value={formatEstimatedCost(t.cost, t.unpriced)}
116
+ label={t("tools.attributed-cost")}
117
+ title={t("tools.attribution-estimate", { note: ATTRIBUTION_NOTE })}
118
+ value={formatEstimatedCost(totals.cost, totals.unpriced)}
114
119
  hint={
115
- t.unpriced > 0
116
- ? `${formatInteger(Math.round(t.unpriced))} unpriced requests`
117
- : "API-equivalent"
120
+ totals.unpriced > 0
121
+ ? t("tools.unpriced-requests-n", { count: formatInteger(Math.round(totals.unpriced)) })
122
+ : t("tools.api-equivalent")
118
123
  }
119
124
  />
120
125
  </StatGrid>
121
126
  <StatGrid min={150}>
122
127
  <Stat
123
128
  size="sm"
124
- label="Result text"
125
- title="Characters of tool-result text fed back into context"
126
- value={`${formatCompact(t.resultChars)} chars`}
129
+ label={t("tools.result-text")}
130
+ title={t("tools.result-chars-title")}
131
+ value={t("tools.chars", { count: formatCompact(totals.resultChars) })}
127
132
  />
128
133
  <Stat
129
134
  size="sm"
130
- label="Call arguments"
131
- title="Characters of serialized tool-call arguments"
132
- value={`${formatCompact(t.argsChars)} chars`}
135
+ label={t("tools.call-arguments")}
136
+ title={t("tools.args-chars-title")}
137
+ value={t("tools.chars", { count: formatCompact(totals.argsChars) })}
133
138
  />
134
139
  <Stat
135
140
  size="sm"
136
- label="Avg result per call"
137
- value={`${formatCompact(t.calls > 0 ? Math.round(t.resultChars / t.calls) : 0)} chars`}
141
+ label={t("tools.avg-result-per-call")}
142
+ value={t("tools.chars", {
143
+ count: formatCompact(
144
+ totals.calls > 0 ? Math.round(totals.resultChars / totals.calls) : 0,
145
+ ),
146
+ })}
138
147
  />
139
148
  <Stat
140
149
  size="sm"
141
- label="Avg arguments per call"
142
- value={`${formatCompact(t.calls > 0 ? Math.round(t.argsChars / t.calls) : 0)} chars`}
150
+ label={t("tools.avg-args-per-call")}
151
+ value={t("tools.chars", {
152
+ count: formatCompact(totals.calls > 0 ? Math.round(totals.argsChars / totals.calls) : 0),
153
+ })}
143
154
  />
144
155
  </StatGrid>
145
156
  </div>
@@ -149,8 +160,8 @@ export function ToolsRoute({ active, range }: ToolsRouteProps) {
149
160
 
150
161
  <Card
151
162
  index={1}
152
- title={metric === "calls" ? "Calls over time" : "Errors over time"}
153
- description={`Per ${meta.bucketMs < 3_600_000 ? "5 minutes" : meta.bucketMs < 86_400_000 ? "hour" : "day"}, top ${TOP_TOOLS} tools stacked`}
163
+ title={metric === "calls" ? t("tools.calls-over-time") : t("tools.errors-over-time")}
164
+ description={t("tools.chart-description", { bucket: bucketUnit(meta.bucketMs), count: TOP_TOOLS })}
154
165
  actions={<Segmented size="sm" options={METRIC_OPTIONS} value={metric} onChange={setMetric} />}
155
166
  stale={tools.stale}
156
167
  >
@@ -164,7 +175,7 @@ export function ToolsRoute({ active, range }: ToolsRouteProps) {
164
175
  hidden={hidden}
165
176
  height={260}
166
177
  formatTooltip={formatInteger}
167
- emptyLabel={metric === "errors" ? "No tool errors in this range" : undefined}
178
+ emptyLabel={metric === "errors" ? t("tools.no-tool-errors") : undefined}
168
179
  />
169
180
  <Legend
170
181
  items={chartSeries.map(s => ({
@@ -183,8 +194,8 @@ export function ToolsRoute({ active, range }: ToolsRouteProps) {
183
194
 
184
195
  <Card
185
196
  index={2}
186
- title="By tool"
187
- description={`${ATTRIBUTION_NOTE}. Select a tool to break it down by model.`}
197
+ title={t("tools.by-tool")}
198
+ description={t("tools.by-tool-description", { note: ATTRIBUTION_NOTE })}
188
199
  flush
189
200
  stale={tools.stale}
190
201
  >
@@ -206,8 +217,8 @@ export function ToolsRoute({ active, range }: ToolsRouteProps) {
206
217
 
207
218
  <Card
208
219
  index={3}
209
- title="By tool and model"
210
- description="Which models call which tools, and how often those calls fail"
220
+ title={t("tools.by-tool-and-model")}
221
+ description={t("tools.by-tool-and-model-description")}
211
222
  flush
212
223
  stale={tools.stale}
213
224
  actions={
@@ -216,9 +227,9 @@ export function ToolsRoute({ active, range }: ToolsRouteProps) {
216
227
  className="input tools-filter"
217
228
  value={toolFilter ?? ""}
218
229
  onChange={e => setToolFilter(e.target.value || null)}
219
- aria-label="Filter by tool"
230
+ aria-label={t("tools.filter-by-tool")}
220
231
  >
221
- <option value="">All tools</option>
232
+ <option value="">{t("tools.all-tools")}</option>
222
233
  {view.toolNames.map(name => (
223
234
  <option key={name} value={name}>
224
235
  {name}
@@ -232,8 +243,8 @@ export function ToolsRoute({ active, range }: ToolsRouteProps) {
232
243
  data-size="sm"
233
244
  data-variant="ghost"
234
245
  data-icon="true"
235
- title="Clear tool filter"
236
- aria-label="Clear tool filter"
246
+ title={t("tools.clear-tool-filter")}
247
+ aria-label={t("tools.clear-tool-filter")}
237
248
  onClick={() => setToolFilter(null)}
238
249
  >
239
250
  <X size={13} />
@@ -270,7 +281,7 @@ function buildToolsView(data: ToolDashboardStats | null, range: TimeRange) {
270
281
  const byTool = data?.byTool ?? [];
271
282
  const points = data?.series ?? [];
272
283
  const rows = buildToolRows(byTool).sort((a, b) => b.calls - a.calls);
273
- const colors = buildColorLookup(byTool.map(t => ({ key: t.tool, weight: t.calls })));
284
+ const colors = buildColorLookup(byTool.map(item => ({ key: item.tool, weight: item.calls })));
274
285
  const buckets = bucketAxis(
275
286
  range,
276
287
  points.map(p => p.timestamp),
@@ -287,15 +298,15 @@ function buildToolsView(data: ToolDashboardStats | null, range: TimeRange) {
287
298
  resultChars: 0,
288
299
  argsChars: 0,
289
300
  };
290
- for (const t of byTool) {
291
- totals.calls += t.calls;
292
- totals.errors += t.errors;
293
- totals.tokens += t.totalTokensShare;
294
- totals.output += t.outputTokensShare;
295
- totals.cost += t.costShare;
296
- totals.unpriced += t.unpricedRequestsShare;
297
- totals.resultChars += t.resultChars;
298
- totals.argsChars += t.argsChars;
301
+ for (const item of byTool) {
302
+ totals.calls += item.calls;
303
+ totals.errors += item.errors;
304
+ totals.tokens += item.totalTokensShare;
305
+ totals.output += item.outputTokensShare;
306
+ totals.cost += item.costShare;
307
+ totals.unpriced += item.unpricedRequestsShare;
308
+ totals.resultChars += item.resultChars;
309
+ totals.argsChars += item.argsChars;
299
310
  }
300
311
 
301
312
  // Every tool keeps its table swatch color in both metrics; zero slots become
@@ -337,7 +348,7 @@ function buildToolColumns(
337
348
  return [
338
349
  {
339
350
  key: "tool",
340
- header: "Tool",
351
+ header: t("tools.tool"),
341
352
  sort: row => row.tool,
342
353
  render: row => (
343
354
  <span className="row" style={{ gap: 8 }}>
@@ -350,8 +361,8 @@ function buildToolColumns(
350
361
  },
351
362
  {
352
363
  key: "trend",
353
- header: "Trend",
354
- title: "Calls per bucket over the range",
364
+ header: t("tools.trend"),
365
+ title: t("tools.trend-title"),
355
366
  render: row => {
356
367
  const values = trends.get(row.tool);
357
368
  return values && values.length > 1 ? (
@@ -363,7 +374,7 @@ function buildToolColumns(
363
374
  },
364
375
  {
365
376
  key: "calls",
366
- header: "Calls",
377
+ header: t("tools.calls"),
367
378
  align: "right",
368
379
  sort: row => row.calls,
369
380
  render: row => (
@@ -371,7 +382,7 @@ function buildToolColumns(
371
382
  value={row.calls}
372
383
  max={maxCalls}
373
384
  display={
374
- <span title={`${formatPercent(row.callFraction)} of all tool calls`}>
385
+ <span title={t("tools.share-of-calls", { percent: formatPercent(row.callFraction) })}>
375
386
  {formatInteger(row.calls)}
376
387
  <span className="dim tools-share">{formatPercent(row.callFraction, 0)}</span>
377
388
  </span>
@@ -381,51 +392,51 @@ function buildToolColumns(
381
392
  },
382
393
  {
383
394
  key: "errorRate",
384
- header: "Errors",
385
- title: "Calls whose result came back flagged as an error",
395
+ header: t("tools.errors"),
396
+ title: t("tools.errors-title"),
386
397
  align: "right",
387
398
  sort: row => row.errorRate,
388
399
  render: row => (
389
400
  <span className="row" style={{ gap: 8, justifyContent: "flex-end" }}>
390
401
  <span className="num dim">{formatInteger(row.errors)}</span>
391
402
  <Badge tone={row.errors > 0 ? errorRateTone(row.errorRate) : "neutral"} mono>
392
- {formatPercent(row.errorRate)}
403
+ {formatErrorRate(row.errorRate)}
393
404
  </Badge>
394
405
  </span>
395
406
  ),
396
407
  },
397
408
  {
398
409
  key: "args",
399
- header: "Args",
400
- title: "Characters of serialized tool-call arguments",
410
+ header: t("tools.args"),
411
+ title: t("tools.args-chars-title"),
401
412
  align: "right",
402
413
  sort: row => row.argsChars,
403
414
  render: row => <span className="num">{formatCompact(row.argsChars)}</span>,
404
415
  },
405
416
  {
406
417
  key: "result",
407
- header: "Result",
408
- title: "Characters of tool-result text fed back into context",
418
+ header: t("tools.result"),
419
+ title: t("tools.result-chars-title"),
409
420
  align: "right",
410
421
  sort: row => row.resultChars,
411
422
  render: row => <span className="num">{formatCompact(row.resultChars)}</span>,
412
423
  },
413
424
  {
414
425
  key: "avgResult",
415
- header: "Result / call",
416
- title: "Mean tool-result characters per call",
426
+ header: t("tools.result-per-call"),
427
+ title: t("tools.result-per-call-title"),
417
428
  align: "right",
418
429
  sort: row => row.avgResultChars,
419
430
  render: row => <span className="num muted">{formatCompact(Math.round(row.avgResultChars))}</span>,
420
431
  },
421
432
  {
422
433
  key: "tokens",
423
- header: "Attr. tokens",
434
+ header: t("tools.attr-tokens"),
424
435
  title: ATTRIBUTION_NOTE,
425
436
  align: "right",
426
437
  sort: row => row.totalTokensShare,
427
438
  render: row => (
428
- <span className="num" title={`${formatPercent(row.tokenFraction)} of attributed tokens`}>
439
+ <span className="num" title={t("tools.share-of-tokens", { percent: formatPercent(row.tokenFraction) })}>
429
440
  {formatCompact(Math.round(row.totalTokensShare))}
430
441
  <span className="dim tools-share">{formatPercent(row.tokenFraction, 0)}</span>
431
442
  </span>
@@ -433,12 +444,12 @@ function buildToolColumns(
433
444
  },
434
445
  {
435
446
  key: "cost",
436
- header: "Attr. cost",
437
- title: `${ATTRIBUTION_NOTE}; API-equivalent estimate`,
447
+ header: t("tools.attr-cost"),
448
+ title: t("tools.attribution-estimate", { note: ATTRIBUTION_NOTE }),
438
449
  align: "right",
439
450
  sort: row => row.costShare,
440
451
  render: row => (
441
- <span className="num" title={`${formatPercent(row.costFraction)} of attributed cost`}>
452
+ <span className="num" title={t("tools.share-of-cost", { percent: formatPercent(row.costFraction) })}>
442
453
  {formatEstimatedCost(row.costShare, row.unpricedRequestsShare)}
443
454
  <span className="dim tools-share">{formatPercent(row.costFraction, 0)}</span>
444
455
  </span>
@@ -446,7 +457,7 @@ function buildToolColumns(
446
457
  },
447
458
  {
448
459
  key: "lastUsed",
449
- header: "Last used",
460
+ header: t("tools.last-used"),
450
461
  align: "right",
451
462
  sort: row => row.lastUsed,
452
463
  render: row => <span className="muted">{formatRelativeTime(row.lastUsed)}</span>,
@@ -484,7 +495,7 @@ function ToolModelTable({
484
495
  return [
485
496
  {
486
497
  key: "tool",
487
- header: "Tool",
498
+ header: t("tools.tool"),
488
499
  sort: row => row.tool,
489
500
  render: row => (
490
501
  <span className="row" style={{ gap: 8 }}>
@@ -497,44 +508,47 @@ function ToolModelTable({
497
508
  },
498
509
  {
499
510
  key: "model",
500
- header: "Model",
511
+ header: t("common.model"),
501
512
  sort: row => row.model,
502
513
  render: row => (
503
- <LabelCell primary={<span className="mono">{row.model || "(unknown)"}</span>} secondary={row.provider} />
514
+ <LabelCell
515
+ primary={<span className="mono">{row.model || t("common.unknown")}</span>}
516
+ secondary={row.provider}
517
+ />
504
518
  ),
505
519
  },
506
520
  {
507
521
  key: "calls",
508
- header: "Calls",
522
+ header: t("tools.calls"),
509
523
  align: "right",
510
524
  sort: row => row.calls,
511
525
  render: row => <MeterCell value={row.calls} max={maxCalls} display={formatInteger(row.calls)} />,
512
526
  },
513
527
  {
514
528
  key: "errorRate",
515
- header: "Errors",
529
+ header: t("tools.errors"),
516
530
  align: "right",
517
531
  sort: row => row.errorRate,
518
532
  render: row => (
519
533
  <span className="row" style={{ gap: 8, justifyContent: "flex-end" }}>
520
534
  <span className="num dim">{formatInteger(row.errors)}</span>
521
535
  <Badge tone={row.errors > 0 ? errorRateTone(row.errorRate) : "neutral"} mono>
522
- {formatPercent(row.errorRate)}
536
+ {formatErrorRate(row.errorRate)}
523
537
  </Badge>
524
538
  </span>
525
539
  ),
526
540
  },
527
541
  {
528
542
  key: "result",
529
- header: "Result",
530
- title: "Characters of tool-result text fed back into context",
543
+ header: t("tools.result"),
544
+ title: t("tools.result-chars-title"),
531
545
  align: "right",
532
546
  sort: row => row.resultChars,
533
547
  render: row => <span className="num">{formatCompact(row.resultChars)}</span>,
534
548
  },
535
549
  {
536
550
  key: "tokens",
537
- header: "Attr. tokens",
551
+ header: t("tools.attr-tokens"),
538
552
  title: ATTRIBUTION_NOTE,
539
553
  align: "right",
540
554
  sort: row => row.totalTokensShare,
@@ -542,15 +556,15 @@ function ToolModelTable({
542
556
  },
543
557
  {
544
558
  key: "cost",
545
- header: "Attr. cost",
546
- title: `${ATTRIBUTION_NOTE}; API-equivalent estimate`,
559
+ header: t("tools.attr-cost"),
560
+ title: t("tools.attribution-estimate", { note: ATTRIBUTION_NOTE }),
547
561
  align: "right",
548
562
  sort: row => row.costShare,
549
563
  render: row => <span className="num">{formatEstimatedCost(row.costShare, row.unpricedRequestsShare)}</span>,
550
564
  },
551
565
  {
552
566
  key: "lastUsed",
553
- header: "Last used",
567
+ header: t("tools.last-used"),
554
568
  align: "right",
555
569
  sort: row => row.lastUsed,
556
570
  render: row => <span className="muted">{formatRelativeTime(row.lastUsed)}</span>,
@@ -566,7 +580,7 @@ function ToolModelTable({
566
580
  initialSort={{ key: "calls", dir: "desc" }}
567
581
  limit={25}
568
582
  dense
569
- empty={<EmptyState title={tool ? `No calls to ${tool} in this range` : "No tool calls in this range"} />}
583
+ empty={<EmptyState title={tool ? t("tools.no-calls-to-tool", { tool }) : t("tools.no-tool-calls")} />}
570
584
  />
571
585
  );
572
586
  }
@@ -13,6 +13,7 @@ import {
13
13
  formatRelativeTime,
14
14
  } from "../data/formatters";
15
15
  import { useQuery } from "../data/query";
16
+ import { t } from "../i18n";
16
17
  import { TraceView } from "../traces/TraceView";
17
18
  import type { SessionSummary } from "../types";
18
19
  import {
@@ -49,10 +50,11 @@ function ModelList({ models }: { models: string[] }) {
49
50
 
50
51
  export function TracesRoute({ active, session, onOpenSession }: TracesRouteProps) {
51
52
  const [filter, setFilter] = useState("");
52
- const sessions = useQuery(["sessions", SESSION_LIMIT], () => getSessions(SESSION_LIMIT), {
53
- pollMs: 30000,
54
- enabled: active && session === null,
55
- });
53
+ const sessions = useQuery(
54
+ ["sessions", SESSION_LIMIT],
55
+ ({ signal }) => getSessions(SESSION_LIMIT, undefined, signal),
56
+ { pollMs: 30000, enabled: active && session === null },
57
+ );
56
58
 
57
59
  const filtered = useMemo(() => {
58
60
  const rows = sessions.data ?? [];
@@ -72,7 +74,7 @@ export function TracesRoute({ active, session, onOpenSession }: TracesRouteProps
72
74
  () => [
73
75
  {
74
76
  key: "title",
75
- header: "Session",
77
+ header: t("trace.session"),
76
78
  width: "32%",
77
79
  render: row => (
78
80
  <LabelCell
@@ -82,10 +84,10 @@ export function TracesRoute({ active, session, onOpenSession }: TracesRouteProps
82
84
  ),
83
85
  sort: row => (row.title ?? row.file).toLowerCase(),
84
86
  },
85
- { key: "models", header: "Models", render: row => <ModelList models={row.models} /> },
87
+ { key: "models", header: t("common.models"), render: row => <ModelList models={row.models} /> },
86
88
  {
87
89
  key: "started",
88
- header: "Started",
90
+ header: t("common.started"),
89
91
  align: "right",
90
92
  render: row => (
91
93
  <span className="muted" title={new Date(row.startedAt).toLocaleString()}>
@@ -96,28 +98,28 @@ export function TracesRoute({ active, session, onOpenSession }: TracesRouteProps
96
98
  },
97
99
  {
98
100
  key: "duration",
99
- header: "Duration",
101
+ header: t("common.duration"),
100
102
  align: "right",
101
103
  render: row => <span className="num">{formatElapsed(row.endedAt - row.startedAt)}</span>,
102
104
  sort: row => row.endedAt - row.startedAt,
103
105
  },
104
106
  {
105
107
  key: "requests",
106
- header: "Requests",
108
+ header: t("common.requests"),
107
109
  align: "right",
108
110
  render: row => <span className="num">{formatInteger(row.requests)}</span>,
109
111
  sort: row => row.requests,
110
112
  },
111
113
  {
112
114
  key: "toolCalls",
113
- header: "Tools",
115
+ header: t("trace.tools"),
114
116
  align: "right",
115
117
  render: row => <span className="num">{formatInteger(row.toolCalls)}</span>,
116
118
  sort: row => row.toolCalls,
117
119
  },
118
120
  {
119
121
  key: "subagents",
120
- header: "Agents",
122
+ header: t("trace.agents"),
121
123
  align: "right",
122
124
  render: row =>
123
125
  row.subagents > 0 ? (
@@ -129,14 +131,14 @@ export function TracesRoute({ active, session, onOpenSession }: TracesRouteProps
129
131
  },
130
132
  {
131
133
  key: "tokens",
132
- header: "Tokens",
134
+ header: t("common.tokens"),
133
135
  align: "right",
134
136
  render: row => <span className="num">{formatCompact(row.totalTokens)}</span>,
135
137
  sort: row => row.totalTokens,
136
138
  },
137
139
  {
138
140
  key: "cost",
139
- header: "Cost",
141
+ header: t("common.cost"),
140
142
  align: "right",
141
143
  width: 150,
142
144
  render: row => (
@@ -159,24 +161,24 @@ export function TracesRoute({ active, session, onOpenSession }: TracesRouteProps
159
161
  const total = sessions.data?.length ?? 0;
160
162
  return (
161
163
  <div className="stack traces-page">
162
- <PageHeader
163
- title="Traces"
164
- description="Recent sessions with subagent activity folded in. Open one to inspect its timeline."
165
- />
164
+ <PageHeader title={t("trace.title")} description={t("trace.description")} />
166
165
  <Card
167
- title="Sessions"
166
+ title={t("trace.sessions")}
168
167
  description={
169
168
  sessions.data
170
169
  ? filter.trim()
171
- ? `${formatInteger(filtered.length)} of ${formatInteger(total)} most recent`
172
- : `${formatInteger(total)} most recent`
170
+ ? t("trace.most-recent-filtered", {
171
+ shown: formatInteger(filtered.length),
172
+ total: formatInteger(total),
173
+ })
174
+ : t("trace.most-recent", { count: formatInteger(total) })
173
175
  : undefined
174
176
  }
175
177
  actions={
176
178
  <SearchInput
177
179
  value={filter}
178
180
  onChange={setFilter}
179
- placeholder="Filter by title, project, model…"
181
+ placeholder={t("trace.filter-placeholder")}
180
182
  width={260}
181
183
  />
182
184
  }
@@ -188,7 +190,7 @@ export function TracesRoute({ active, session, onOpenSession }: TracesRouteProps
188
190
  query={sessions}
189
191
  skeleton={<TableSkeleton rows={10} />}
190
192
  isEmpty={rows => rows.length === 0}
191
- empty={<EmptyState title="No sessions found" hint="Run a sync to index recent activity." />}
193
+ empty={<EmptyState title={t("trace.empty-title")} hint={t("trace.empty-hint")} />}
192
194
  >
193
195
  {() => (
194
196
  <Table
@@ -198,7 +200,7 @@ export function TracesRoute({ active, session, onOpenSession }: TracesRouteProps
198
200
  onRowClick={row => onOpenSession(row.file)}
199
201
  initialSort={{ key: "started", dir: "desc" }}
200
202
  limit={50}
201
- empty={<EmptyState title="No matching sessions" hint="Try a different filter." />}
203
+ empty={<EmptyState title={t("trace.no-matching-title")} hint={t("trace.no-matching-hint")} />}
202
204
  />
203
205
  )}
204
206
  </QueryView>
@@ -460,8 +460,10 @@ kbd {
460
460
  gap: 8px;
461
461
  }
462
462
 
463
- .topbar-menu {
464
- display: none;
463
+ @media (min-width: 901px) {
464
+ .btn.topbar-menu {
465
+ display: none;
466
+ }
465
467
  }
466
468
 
467
469
  /* Sync progress hairline along the bottom edge of the topbar. */
@@ -949,6 +951,7 @@ kbd {
949
951
 
950
952
  .btn[data-variant="accent"] {
951
953
  background: var(--accent-gradient);
954
+ background-origin: border-box;
952
955
  color: #fff;
953
956
  border-color: transparent;
954
957
  }