@linxiraos/pi-stats 1.3.2 → 1.4.1

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 (43) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -0
  3. package/dist/client/index.css +1 -1
  4. package/dist/client/index.js +91 -91
  5. package/dist/types/client/app/LanguageToggle.d.ts +7 -0
  6. package/dist/types/client/i18n/index.d.ts +11 -3
  7. package/dist/types/client/i18n/messages/common.d.ts +4 -0
  8. package/dist/types/client/i18n/messages/en.d.ts +76 -0
  9. package/dist/types/client/i18n/messages/frustration.d.ts +72 -0
  10. package/dist/types/client/i18n/messages/zh-CN.d.ts +1 -1
  11. package/dist/types/client/i18n/useLocale.d.ts +7 -0
  12. package/dist/types/db.d.ts +34 -1
  13. package/dist/types/model-identity.d.ts +29 -0
  14. package/dist/types/model-metrics.d.ts +53 -0
  15. package/dist/types/model-side.d.ts +6 -0
  16. package/dist/types/parser.d.ts +2 -1
  17. package/dist/types/shared-types.d.ts +88 -0
  18. package/dist/types/types.d.ts +47 -1
  19. package/dist/types/user-metrics.d.ts +22 -2
  20. package/package.json +4 -4
  21. package/src/aggregator.ts +1 -0
  22. package/src/client/App.tsx +4 -0
  23. package/src/client/app/LanguageToggle.tsx +30 -0
  24. package/src/client/app/Shell.tsx +12 -7
  25. package/src/client/app/ThemeToggle.tsx +11 -8
  26. package/src/client/i18n/index.ts +49 -6
  27. package/src/client/i18n/messages/common.ts +4 -0
  28. package/src/client/i18n/messages/frustration.ts +77 -0
  29. package/src/client/i18n/useLocale.ts +11 -0
  30. package/src/client/routes/FrustrationRoute.tsx +439 -4
  31. package/src/client/routes/ProvidersRoute.tsx +2 -4
  32. package/src/client/routes/ToolsRoute.tsx +7 -9
  33. package/src/client/routes/frustration.css +13 -0
  34. package/src/client/styles.css +13 -0
  35. package/src/db.ts +321 -2
  36. package/src/frustration.ts +8 -33
  37. package/src/model-identity.ts +61 -0
  38. package/src/model-metrics.ts +153 -0
  39. package/src/model-side.ts +77 -0
  40. package/src/parser.ts +104 -11
  41. package/src/shared-types.ts +93 -0
  42. package/src/types.ts +48 -1
  43. package/src/user-metrics.ts +9 -7
@@ -6,17 +6,21 @@ import {
6
6
  getFrustrationEstimate,
7
7
  startFrustrationRun,
8
8
  } from "../api";
9
- import { Chart, type ChartSeries, Legend, useHiddenSeries } from "../charts";
9
+ import { BarList, Chart, type ChartSeries, Legend, TimeChart, useHiddenSeries } from "../charts";
10
10
  import { t } from "../i18n";
11
11
  import { SERIES_COLORS } from "../data/colors";
12
12
  import { formatCost, formatInteger, formatPercent } from "../data/formatters";
13
13
  import { useQuery } from "../data/query";
14
- import { rangeMeta } from "../data/range";
14
+ import { bucketAxis, rangeMeta } from "../data/range";
15
15
  import type {
16
16
  FrustrationCounts,
17
17
  FrustrationEstimate,
18
18
  FrustrationJobStatus,
19
19
  FrustrationModelStats,
20
+ ModelSideCounts,
21
+ ModelSideModelStats,
22
+ ModelSideSessionStats,
23
+ ModelSideTimeSeriesPoint,
20
24
  TimeRange,
21
25
  } from "../types";
22
26
  import {
@@ -110,6 +114,47 @@ export function FrustrationRoute({ active, range }: FrustrationRouteProps) {
110
114
  >
111
115
  {data => <FrustrationModelsSection models={data.byModel} stale={stats.stale} />}
112
116
  </QueryView>
117
+
118
+ {/* Model side: the same page, second section. Its signals are
119
+ language-neutral lexicons computed at ingest, so it needs no
120
+ judge run and appears immediately. */}
121
+ <QueryView query={stats} skeleton={<ChartSkeleton height={96} />}>
122
+ {data => (
123
+ <div className="frustration-model-summary" data-stale={stats.stale}>
124
+ <ModelSideSummary overall={data.modelSide.overall} window={t(meta.windowKey)} />
125
+ </div>
126
+ )}
127
+ </QueryView>
128
+
129
+ <QueryView
130
+ query={stats}
131
+ skeleton={
132
+ <Card index={4} title={t("frustration.model-by-model")}>
133
+ <ChartSkeleton height={300} />
134
+ <TableSkeleton rows={5} />
135
+ </Card>
136
+ }
137
+ >
138
+ {data => (
139
+ <ModelSideModels
140
+ models={data.modelSide.byModel}
141
+ series={data.modelSide.series}
142
+ range={range}
143
+ stale={stats.stale}
144
+ />
145
+ )}
146
+ </QueryView>
147
+
148
+ <QueryView
149
+ query={stats}
150
+ skeleton={
151
+ <Card index={7} title={t("frustration.model-sessions")}>
152
+ <TableSkeleton rows={5} />
153
+ </Card>
154
+ }
155
+ >
156
+ {data => <ModelSideSessions sessions={data.modelSide.bySession} stale={stats.stale} />}
157
+ </QueryView>
113
158
  </div>
114
159
  );
115
160
  }
@@ -497,7 +542,7 @@ function JudgeResultLine({ job }: { job: FrustrationJobStatus }) {
497
542
  // ---------------------------------------------------------------------------
498
543
 
499
544
  /** Chart/filter identity of a model family: `class/family`. */
500
- function familyKey(row: FrustrationModelStats): string {
545
+ function familyKey(row: { modelClass: string; family: string | null }): string {
501
546
  return `${row.modelClass}/${row.family ?? "unclassified"}`;
502
547
  }
503
548
 
@@ -520,7 +565,7 @@ function hashString(value: string): number {
520
565
  * and probes forward past taken slots, so families present together stay
521
566
  * distinct (until the palette runs out) and keep their hue across ranges.
522
567
  */
523
- function assignFamilyColors(models: readonly FrustrationModelStats[]): Map<string, string> {
568
+ function assignFamilyColors(models: readonly { modelClass: string; family: string | null }[]): Map<string, string> {
524
569
  const keys = [...new Set(models.map(familyKey))].sort();
525
570
  const taken = new Set<number>();
526
571
  const colors = new Map<string, string>();
@@ -937,3 +982,393 @@ function FrustrationTable({
937
982
  </Card>
938
983
  );
939
984
  }
985
+
986
+ // ---------------------------------------------------------------------------
987
+ // Model side (AI breakdown)
988
+ // ---------------------------------------------------------------------------
989
+
990
+ function ModelSideSummary({ overall, window }: { overall: ModelSideCounts; window: string }) {
991
+ const mean = overall.messages > 0 ? (overall.collapse / overall.messages).toFixed(2) : "–";
992
+ return (
993
+ <>
994
+ <div className="card-titles">
995
+ <h2 className="page-title">{t("frustration.model-side-title")}</h2>
996
+ <p className="page-description">{t("frustration.model-side-description", { window })}</p>
997
+ </div>
998
+ <div className="frustration-model-notes">
999
+ <StatGrid min={170}>
1000
+ <Stat
1001
+ label={t("frustration.model-turns")}
1002
+ title={t("frustration.model-turns-title")}
1003
+ value={formatInteger(overall.messages)}
1004
+ />
1005
+ <Stat
1006
+ label={t("frustration.model-breakdown")}
1007
+ title={t("frustration.model-breakdown-title")}
1008
+ value={rate(overall.breakdown, overall.messages)}
1009
+ hint={t("frustration.turns-n", { count: formatInteger(overall.breakdown) })}
1010
+ />
1011
+ <Stat
1012
+ label={t("frustration.model-mean")}
1013
+ title={t("frustration.model-mean-title")}
1014
+ value={mean}
1015
+ hint={t("frustration.model-hint-mean", { value: mean })}
1016
+ />
1017
+ <Stat
1018
+ label={t("frustration.model-self-blame")}
1019
+ title={t("frustration.model-self-blame-title")}
1020
+ value={rate(overall.blamed, overall.messages)}
1021
+ />
1022
+ <Stat
1023
+ label={t("frustration.model-hedge")}
1024
+ title={t("frustration.model-hedge-title")}
1025
+ value={rate(overall.hedged, overall.messages)}
1026
+ />
1027
+ <Stat
1028
+ label={t("frustration.model-spiral")}
1029
+ title={t("frustration.model-spiral-title")}
1030
+ value={rate(overall.spiraled, overall.messages)}
1031
+ />
1032
+ </StatGrid>
1033
+ {overall.unscored > 0 && (
1034
+ <p className="micro dim">
1035
+ {t("frustration.model-unscored-line", { count: formatInteger(overall.unscored) })}
1036
+ </p>
1037
+ )}
1038
+ </div>
1039
+ </>
1040
+ );
1041
+ }
1042
+
1043
+ /** Share of turns carrying a signal, as the 0..100 value a chart axis wants. */
1044
+ function signalShare(part: number, whole: number): number {
1045
+ return whole > 0 ? (part / whole) * 100 : 0;
1046
+ }
1047
+
1048
+ function ModelSideModels({
1049
+ models,
1050
+ series,
1051
+ range,
1052
+ stale,
1053
+ }: {
1054
+ models: ModelSideModelStats[];
1055
+ series: ModelSideTimeSeriesPoint[];
1056
+ range: TimeRange;
1057
+ stale: boolean;
1058
+ }) {
1059
+ const meta = rangeMeta(range);
1060
+ const familyColors = useMemo(() => assignFamilyColors(models), [models]);
1061
+ const buckets = useMemo(
1062
+ () =>
1063
+ bucketAxis(
1064
+ range,
1065
+ series.map(point => point.timestamp),
1066
+ meta.bucketMs,
1067
+ ),
1068
+ [series, range, meta.bucketMs],
1069
+ );
1070
+ const byBucket = useMemo(() => new Map(series.map(point => [point.timestamp, point])), [series]);
1071
+ const chartSeries = useMemo(
1072
+ (): ChartSeries[] => [
1073
+ {
1074
+ key: "breakdown",
1075
+ label: t("frustration.model-legend-breakdown"),
1076
+ color: "var(--chart-primary)",
1077
+ values: buckets.map(bucket => {
1078
+ const point = byBucket.get(bucket);
1079
+ // A bucket without turns is a gap, not a calm 0%.
1080
+ return point && point.messages > 0 ? signalShare(point.breakdown, point.messages) : null;
1081
+ }),
1082
+ },
1083
+ ],
1084
+ [buckets, byBucket],
1085
+ );
1086
+ const ranked = useMemo(
1087
+ () =>
1088
+ [...models]
1089
+ .sort(
1090
+ (a, b) =>
1091
+ signalShare(b.breakdown, b.messages) - signalShare(a.breakdown, a.messages) ||
1092
+ b.messages - a.messages,
1093
+ )
1094
+ .slice(0, 12),
1095
+ [models],
1096
+ );
1097
+
1098
+ if (models.length === 0) {
1099
+ return (
1100
+ <Card index={4} title={t("frustration.model-by-model")} stale={stale}>
1101
+ <EmptyState title={t("frustration.model-no-turns")} hint={t("frustration.model-try-longer-range")} />
1102
+ </Card>
1103
+ );
1104
+ }
1105
+
1106
+ return (
1107
+ <>
1108
+ <Card
1109
+ index={4}
1110
+ title={t("frustration.model-by-model")}
1111
+ description={t("frustration.model-by-model-description")}
1112
+ stale={stale}
1113
+ >
1114
+ <BarList
1115
+ max={100}
1116
+ items={ranked.map(row => ({
1117
+ key: row.key,
1118
+ label: row.label,
1119
+ value: signalShare(row.breakdown, row.messages),
1120
+ display: rate(row.breakdown, row.messages),
1121
+ color: familyColors.get(familyKey(row)) ?? SERIES_COLORS[0],
1122
+ }))}
1123
+ />
1124
+ </Card>
1125
+
1126
+ <Card
1127
+ index={5}
1128
+ title={t("frustration.model-over-time")}
1129
+ description={t("frustration.model-over-time-description")}
1130
+ actions={
1131
+ <Legend
1132
+ items={chartSeries.map(entry => ({
1133
+ key: entry.key,
1134
+ label: entry.label,
1135
+ color: entry.color,
1136
+ }))}
1137
+ />
1138
+ }
1139
+ stale={stale}
1140
+ >
1141
+ <TimeChart
1142
+ buckets={buckets}
1143
+ bucketMs={meta.bucketMs}
1144
+ series={chartSeries}
1145
+ height={260}
1146
+ yMax={100}
1147
+ format={value => `${value}%`}
1148
+ formatTooltip={value => `${value.toFixed(1)}%`}
1149
+ emptyLabel={t("frustration.model-no-turns")}
1150
+ />
1151
+ </Card>
1152
+
1153
+ <ModelSideTable rows={models} familyColors={familyColors} stale={stale} />
1154
+ </>
1155
+ );
1156
+ }
1157
+
1158
+ function ModelSideTable({
1159
+ rows,
1160
+ familyColors,
1161
+ stale,
1162
+ }: {
1163
+ rows: ModelSideModelStats[];
1164
+ familyColors: Map<string, string>;
1165
+ stale: boolean;
1166
+ }) {
1167
+ const columns = useMemo(
1168
+ (): Column<ModelSideModelStats>[] => [
1169
+ {
1170
+ key: "model",
1171
+ header: t("common.model"),
1172
+ sort: row => row.label,
1173
+ render: row => (
1174
+ <span className="row" title={row.key}>
1175
+ <span
1176
+ className="swatch"
1177
+ style={{ background: familyColors.get(familyKey(row)) ?? SERIES_COLORS[0] }}
1178
+ />
1179
+ <span className="cell-primary">{row.label}</span>
1180
+ </span>
1181
+ ),
1182
+ },
1183
+ {
1184
+ key: "ids",
1185
+ header: t("frustration.column-model-ids"),
1186
+ wrap: true,
1187
+ render: row => <span className="mono micro dim">{row.models.join(", ")}</span>,
1188
+ },
1189
+ {
1190
+ key: "turns",
1191
+ header: t("frustration.column-turns"),
1192
+ align: "right",
1193
+ sort: row => row.messages,
1194
+ render: row => <span className="num">{formatInteger(row.messages)}</span>,
1195
+ },
1196
+ {
1197
+ key: "selfBlame",
1198
+ header: t("frustration.model-self-blame"),
1199
+ align: "right",
1200
+ sort: row => signalShare(row.blamed, row.messages),
1201
+ render: row => (
1202
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.blamed) })}>
1203
+ {rate(row.blamed, row.messages)}
1204
+ </span>
1205
+ ),
1206
+ },
1207
+ {
1208
+ key: "restatement",
1209
+ header: t("frustration.model-restatement"),
1210
+ title: t("frustration.model-restatement-title"),
1211
+ align: "right",
1212
+ sort: row => signalShare(row.restated, row.messages),
1213
+ render: row => (
1214
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.restated) })}>
1215
+ {rate(row.restated, row.messages)}
1216
+ </span>
1217
+ ),
1218
+ },
1219
+ {
1220
+ key: "hedge",
1221
+ header: t("frustration.model-hedge"),
1222
+ align: "right",
1223
+ sort: row => signalShare(row.hedged, row.messages),
1224
+ render: row => (
1225
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.hedged) })}>
1226
+ {rate(row.hedged, row.messages)}
1227
+ </span>
1228
+ ),
1229
+ },
1230
+ {
1231
+ key: "rambling",
1232
+ header: t("frustration.model-rambling"),
1233
+ title: t("frustration.model-rambling-title"),
1234
+ align: "right",
1235
+ sort: row => signalShare(row.verbose, row.messages),
1236
+ render: row => (
1237
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.verbose) })}>
1238
+ {rate(row.verbose, row.messages)}
1239
+ </span>
1240
+ ),
1241
+ },
1242
+ {
1243
+ key: "spiral",
1244
+ header: t("frustration.model-spiral"),
1245
+ align: "right",
1246
+ sort: row => signalShare(row.spiraled, row.messages),
1247
+ render: row => (
1248
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.spiraled) })}>
1249
+ {rate(row.spiraled, row.messages)}
1250
+ </span>
1251
+ ),
1252
+ },
1253
+ {
1254
+ key: "breakdown",
1255
+ header: t("frustration.model-breakdown"),
1256
+ align: "right",
1257
+ sort: row => signalShare(row.breakdown, row.messages),
1258
+ render: row => (
1259
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.breakdown) })}>
1260
+ {rate(row.breakdown, row.messages)}
1261
+ </span>
1262
+ ),
1263
+ },
1264
+ {
1265
+ key: "mean",
1266
+ header: t("frustration.column-mean"),
1267
+ align: "right",
1268
+ sort: row => (row.messages > 0 ? row.collapse / row.messages : 0),
1269
+ render: row => (
1270
+ <span className="num">{row.messages > 0 ? (row.collapse / row.messages).toFixed(2) : "–"}</span>
1271
+ ),
1272
+ },
1273
+ ],
1274
+ [familyColors],
1275
+ );
1276
+
1277
+ return (
1278
+ <Card
1279
+ index={6}
1280
+ title={t("frustration.model-table-title")}
1281
+ description={t("frustration.model-table-description")}
1282
+ flush
1283
+ stale={stale}
1284
+ >
1285
+ <Table
1286
+ rows={rows}
1287
+ rowKey={row => row.key}
1288
+ columns={columns}
1289
+ dense
1290
+ empty={<EmptyState title={t("frustration.model-no-turns")} />}
1291
+ />
1292
+ </Card>
1293
+ );
1294
+ }
1295
+
1296
+ function ModelSideSessions({ sessions, stale }: { sessions: ModelSideSessionStats[]; stale: boolean }) {
1297
+ const columns = useMemo(
1298
+ (): Column<ModelSideSessionStats>[] => [
1299
+ {
1300
+ key: "session",
1301
+ header: t("frustration.column-session"),
1302
+ render: row => (
1303
+ <span className="stack" title={row.sessionFile}>
1304
+ <span className="cell-primary mono micro">
1305
+ {row.sessionFile.split(/[\\/]/).pop() ?? row.sessionFile}
1306
+ </span>
1307
+ <span className="micro dim truncate">{row.folder}</span>
1308
+ </span>
1309
+ ),
1310
+ },
1311
+ {
1312
+ key: "models",
1313
+ header: t("common.model"),
1314
+ wrap: true,
1315
+ render: row => <span className="mono micro dim">{row.models.join(", ")}</span>,
1316
+ },
1317
+ {
1318
+ key: "turns",
1319
+ header: t("frustration.column-turns"),
1320
+ align: "right",
1321
+ sort: row => row.messages,
1322
+ render: row => <span className="num">{formatInteger(row.messages)}</span>,
1323
+ },
1324
+ {
1325
+ key: "breakdown",
1326
+ header: t("frustration.model-breakdown"),
1327
+ align: "right",
1328
+ sort: row => signalShare(row.breakdown, row.messages),
1329
+ render: row => (
1330
+ <span className="num" title={t("frustration.turns-n", { count: formatInteger(row.breakdown) })}>
1331
+ {rate(row.breakdown, row.messages)}
1332
+ </span>
1333
+ ),
1334
+ },
1335
+ {
1336
+ key: "mean",
1337
+ header: t("frustration.column-mean"),
1338
+ align: "right",
1339
+ sort: row => (row.messages > 0 ? row.collapse / row.messages : 0),
1340
+ render: row => (
1341
+ <span className="num">{row.messages > 0 ? (row.collapse / row.messages).toFixed(2) : "–"}</span>
1342
+ ),
1343
+ },
1344
+ {
1345
+ key: "toolErrors",
1346
+ header: t("frustration.column-tool-errors"),
1347
+ align: "right",
1348
+ sort: row => row.toolErrors,
1349
+ render: row => (
1350
+ <span className={row.toolErrors > 0 ? "num tone-bad" : "num"}>{formatInteger(row.toolErrors)}</span>
1351
+ ),
1352
+ },
1353
+ ],
1354
+ [],
1355
+ );
1356
+
1357
+ return (
1358
+ <Card
1359
+ index={7}
1360
+ title={t("frustration.model-sessions")}
1361
+ description={t("frustration.model-sessions-description")}
1362
+ flush
1363
+ stale={stale}
1364
+ >
1365
+ <Table
1366
+ rows={sessions}
1367
+ rowKey={row => row.sessionFile}
1368
+ columns={columns}
1369
+ dense
1370
+ empty={<EmptyState title={t("frustration.model-no-turns")} />}
1371
+ />
1372
+ </Card>
1373
+ );
1374
+ }
@@ -68,8 +68,6 @@ const TOKEN_MIX = [
68
68
  { key: "output", label: t("common.output"), color: "var(--chart-secondary)" },
69
69
  ] as const;
70
70
 
71
- const SNAPSHOT_HINT = t("providers.snapshot-hint");
72
-
73
71
  interface WindowRef {
74
72
  provider: string;
75
73
  windowKey: string;
@@ -608,7 +606,7 @@ function WindowInsightsTable({
608
606
  limit={12}
609
607
  selectedKey={selected ? `${selected.provider}::${selected.windowKey}` : null}
610
608
  onRowClick={i => onSelect({ provider: i.provider, windowKey: i.windowKey })}
611
- empty={<EmptyState title={t("providers.no-snapshots-in-range")} hint={SNAPSHOT_HINT} />}
609
+ empty={<EmptyState title={t("providers.no-snapshots-in-range")} hint={t("providers.snapshot-hint")} />}
612
610
  />
613
611
  );
614
612
  }
@@ -882,7 +880,7 @@ function WindowUtilizationCard({
882
880
  {insights === null ? (
883
881
  <ChartSkeleton height={260} />
884
882
  ) : selected === null ? (
885
- <EmptyState title={t("providers.no-snapshots-in-range")} hint={SNAPSHOT_HINT} />
883
+ <EmptyState title={t("providers.no-snapshots-in-range")} hint={t("providers.snapshot-hint")} />
886
884
  ) : (
887
885
  <QueryView query={accounts} skeleton={<ChartSkeleton height={260} />}>
888
886
  {() =>
@@ -52,8 +52,6 @@ const METRIC_OPTIONS = [
52
52
  /** Tools stacked individually in the calls chart; the rest fold into "Other". */
53
53
  const TOP_TOOLS = 6;
54
54
 
55
- const ATTRIBUTION_NOTE = t("tools.attribution-note");
56
-
57
55
  /** Bucket granularity label for the calls chart description. */
58
56
  function bucketUnit(bucketMs: number): string {
59
57
  if (bucketMs < 3_600_000) return t("tools.bucket-5m");
@@ -108,13 +106,13 @@ export function ToolsRoute({ active, range }: ToolsRouteProps) {
108
106
  />
109
107
  <Stat
110
108
  label={t("tools.attributed-tokens")}
111
- title={ATTRIBUTION_NOTE}
109
+ title={t("tools.attribution-note")}
112
110
  value={formatCompact(Math.round(totals.tokens))}
113
111
  hint={t("tools.tokens-output", { tokens: formatCompact(Math.round(totals.output)) })}
114
112
  />
115
113
  <Stat
116
114
  label={t("tools.attributed-cost")}
117
- title={t("tools.attribution-estimate", { note: ATTRIBUTION_NOTE })}
115
+ title={t("tools.attribution-estimate", { note: t("tools.attribution-note") })}
118
116
  value={formatEstimatedCost(totals.cost, totals.unpriced)}
119
117
  hint={
120
118
  totals.unpriced > 0
@@ -195,7 +193,7 @@ export function ToolsRoute({ active, range }: ToolsRouteProps) {
195
193
  <Card
196
194
  index={2}
197
195
  title={t("tools.by-tool")}
198
- description={t("tools.by-tool-description", { note: ATTRIBUTION_NOTE })}
196
+ description={t("tools.by-tool-description", { note: t("tools.attribution-note") })}
199
197
  flush
200
198
  stale={tools.stale}
201
199
  >
@@ -432,7 +430,7 @@ function buildToolColumns(
432
430
  {
433
431
  key: "tokens",
434
432
  header: t("tools.attr-tokens"),
435
- title: ATTRIBUTION_NOTE,
433
+ title: t("tools.attribution-note"),
436
434
  align: "right",
437
435
  sort: row => row.totalTokensShare,
438
436
  render: row => (
@@ -445,7 +443,7 @@ function buildToolColumns(
445
443
  {
446
444
  key: "cost",
447
445
  header: t("tools.attr-cost"),
448
- title: t("tools.attribution-estimate", { note: ATTRIBUTION_NOTE }),
446
+ title: t("tools.attribution-estimate", { note: t("tools.attribution-note") }),
449
447
  align: "right",
450
448
  sort: row => row.costShare,
451
449
  render: row => (
@@ -549,7 +547,7 @@ function ToolModelTable({
549
547
  {
550
548
  key: "tokens",
551
549
  header: t("tools.attr-tokens"),
552
- title: ATTRIBUTION_NOTE,
550
+ title: t("tools.attribution-note"),
553
551
  align: "right",
554
552
  sort: row => row.totalTokensShare,
555
553
  render: row => <span className="num">{formatCompact(Math.round(row.totalTokensShare))}</span>,
@@ -557,7 +555,7 @@ function ToolModelTable({
557
555
  {
558
556
  key: "cost",
559
557
  header: t("tools.attr-cost"),
560
- title: t("tools.attribution-estimate", { note: ATTRIBUTION_NOTE }),
558
+ title: t("tools.attribution-estimate", { note: t("tools.attribution-note") }),
561
559
  align: "right",
562
560
  sort: row => row.costShare,
563
561
  render: row => <span className="num">{formatEstimatedCost(row.costShare, row.unpricedRequestsShare)}</span>,
@@ -121,3 +121,16 @@
121
121
  justify-content: flex-end;
122
122
  gap: 6px;
123
123
  }
124
+
125
+ /* Model-side summary: section header above the stat tiles. */
126
+ .frustration-model-summary {
127
+ display: flex;
128
+ flex-direction: column;
129
+ gap: 20px;
130
+ }
131
+
132
+ .frustration-model-notes {
133
+ display: flex;
134
+ flex-direction: column;
135
+ gap: 8px;
136
+ }
@@ -460,6 +460,19 @@ kbd {
460
460
  gap: 8px;
461
461
  }
462
462
 
463
+ /* Language switch: icon plus the language a click installs. */
464
+ .btn .topbar-locale-label {
465
+ font-size: 12px;
466
+ font-weight: 600;
467
+ letter-spacing: 0.02em;
468
+ }
469
+
470
+ @media (max-width: 640px) {
471
+ .btn .topbar-locale-label {
472
+ display: none;
473
+ }
474
+ }
475
+
463
476
  @media (min-width: 901px) {
464
477
  .btn.topbar-menu {
465
478
  display: none;