@oh-my-pi/omp-stats 18.3.5 → 18.4.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 (192) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +13 -11
  3. package/build.ts +5 -49
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +2 -2
  6. package/dist/client/index.js +105 -126
  7. package/dist/types/aggregator.d.ts +25 -22
  8. package/dist/types/client/api.d.ts +11 -7
  9. package/dist/types/client/app/LiveChip.d.ts +2 -0
  10. package/dist/types/client/app/Shell.d.ts +16 -0
  11. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  12. package/dist/types/client/app/nav.d.ts +18 -0
  13. package/dist/types/client/charts/BarList.d.ts +17 -0
  14. package/dist/types/client/charts/Chart.d.ts +49 -0
  15. package/dist/types/client/charts/Legend.d.ts +16 -0
  16. package/dist/types/client/charts/ShareBar.d.ts +11 -0
  17. package/dist/types/client/charts/Sparkline.d.ts +11 -0
  18. package/dist/types/client/charts/TimeChart.d.ts +9 -0
  19. package/dist/types/client/charts/index.d.ts +7 -0
  20. package/dist/types/client/charts/types.d.ts +25 -0
  21. package/dist/types/client/charts/useWidth.d.ts +3 -0
  22. package/dist/types/client/data/colors.d.ts +25 -0
  23. package/dist/types/client/data/formatters.d.ts +9 -1
  24. package/dist/types/client/data/live.d.ts +29 -0
  25. package/dist/types/client/data/query.d.ts +35 -0
  26. package/dist/types/client/data/range.d.ts +31 -0
  27. package/dist/types/client/data/series.d.ts +28 -0
  28. package/dist/types/client/data/useHashRoute.d.ts +18 -4
  29. package/dist/types/client/data/view-models.d.ts +111 -28
  30. package/dist/types/client/index.d.ts +0 -1
  31. package/dist/types/client/routes/CostsRoute.d.ts +2 -2
  32. package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
  33. package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
  34. package/dist/types/client/routes/GainRoute.d.ts +1 -2
  35. package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
  36. package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
  37. package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
  38. package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
  39. package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
  40. package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
  41. package/dist/types/client/routes/TracesRoute.d.ts +2 -2
  42. package/dist/types/client/routes/index.d.ts +1 -1
  43. package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
  44. package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
  45. package/dist/types/client/traces/trace-colors.d.ts +18 -12
  46. package/dist/types/client/ui/Badge.d.ts +19 -0
  47. package/dist/types/client/ui/Card.d.ts +25 -0
  48. package/dist/types/client/ui/Drawer.d.ts +25 -0
  49. package/dist/types/client/ui/JsonBlock.d.ts +2 -2
  50. package/dist/types/client/ui/Modal.d.ts +23 -0
  51. package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
  52. package/dist/types/client/ui/SearchInput.d.ts +8 -0
  53. package/dist/types/client/ui/Segmented.d.ts +15 -0
  54. package/dist/types/client/ui/Stat.d.ts +36 -0
  55. package/dist/types/client/ui/States.d.ts +41 -0
  56. package/dist/types/client/ui/Table.d.ts +48 -0
  57. package/dist/types/client/ui/index.d.ts +9 -10
  58. package/dist/types/db.d.ts +90 -106
  59. package/dist/types/frustration.d.ts +70 -0
  60. package/dist/types/index.d.ts +3 -1
  61. package/dist/types/live.d.ts +30 -0
  62. package/dist/types/rollup.d.ts +115 -0
  63. package/dist/types/server.d.ts +6 -1
  64. package/dist/types/shared-types.d.ts +116 -56
  65. package/dist/types/types.d.ts +4 -0
  66. package/dist/types/user-metrics.d.ts +8 -0
  67. package/package.json +7 -13
  68. package/src/aggregator.ts +276 -211
  69. package/src/client/App.tsx +38 -80
  70. package/src/client/api.ts +65 -7
  71. package/src/client/app/LiveChip.tsx +97 -0
  72. package/src/client/app/Shell.tsx +181 -0
  73. package/src/client/app/ThemeToggle.tsx +5 -2
  74. package/src/client/app/nav.ts +78 -0
  75. package/src/client/charts/BarList.tsx +48 -0
  76. package/src/client/charts/Chart.tsx +388 -0
  77. package/src/client/charts/Legend.tsx +61 -0
  78. package/src/client/charts/ShareBar.tsx +26 -0
  79. package/src/client/charts/Sparkline.tsx +38 -0
  80. package/src/client/charts/TimeChart.tsx +22 -0
  81. package/src/client/charts/index.ts +7 -0
  82. package/src/client/charts/types.ts +27 -0
  83. package/src/client/charts/useWidth.ts +19 -0
  84. package/src/client/data/colors.ts +45 -0
  85. package/src/client/data/formatters.ts +30 -9
  86. package/src/client/data/live.tsx +85 -0
  87. package/src/client/data/query.ts +166 -0
  88. package/src/client/data/range.ts +82 -0
  89. package/src/client/data/series.ts +85 -0
  90. package/src/client/data/useHashRoute.ts +41 -83
  91. package/src/client/data/view-models.ts +347 -140
  92. package/src/client/index.tsx +3 -6
  93. package/src/client/routes/CostsRoute.tsx +394 -222
  94. package/src/client/routes/ErrorsRoute.tsx +401 -98
  95. package/src/client/routes/FrustrationRoute.tsx +924 -0
  96. package/src/client/routes/GainRoute.tsx +209 -206
  97. package/src/client/routes/ModelsRoute.tsx +540 -431
  98. package/src/client/routes/OverviewRoute.tsx +283 -311
  99. package/src/client/routes/ProjectsRoute.tsx +298 -156
  100. package/src/client/routes/ProvidersRoute.tsx +948 -416
  101. package/src/client/routes/RequestsRoute.tsx +295 -107
  102. package/src/client/routes/ToolsRoute.tsx +495 -398
  103. package/src/client/routes/TracesRoute.tsx +136 -117
  104. package/src/client/routes/costs.css +24 -0
  105. package/src/client/routes/errors.css +60 -0
  106. package/src/client/routes/frustration.css +123 -0
  107. package/src/client/routes/index.ts +1 -1
  108. package/src/client/routes/models.css +43 -0
  109. package/src/client/routes/projects.css +6 -0
  110. package/src/client/routes/providers.css +28 -0
  111. package/src/client/routes/tools.css +14 -0
  112. package/src/client/styles.css +1396 -1299
  113. package/src/client/traces/AggregatesPanel.tsx +55 -74
  114. package/src/client/traces/Minimap.tsx +6 -15
  115. package/src/client/traces/SpanDrawer.tsx +128 -194
  116. package/src/client/traces/SummaryStrip.tsx +21 -24
  117. package/src/client/traces/TimelineCanvas.tsx +27 -44
  118. package/src/client/traces/TraceView.tsx +175 -130
  119. package/src/client/traces/TranscriptList.tsx +13 -41
  120. package/src/client/traces/trace-colors.ts +60 -53
  121. package/src/client/traces/traces.css +279 -0
  122. package/src/client/ui/Badge.tsx +29 -0
  123. package/src/client/ui/Card.tsx +61 -0
  124. package/src/client/ui/Drawer.tsx +93 -0
  125. package/src/client/ui/JsonBlock.tsx +26 -44
  126. package/src/client/ui/Modal.tsx +106 -0
  127. package/src/client/ui/RequestDrawer.tsx +163 -188
  128. package/src/client/ui/SearchInput.tsx +25 -0
  129. package/src/client/ui/Segmented.tsx +61 -0
  130. package/src/client/ui/Stat.tsx +104 -0
  131. package/src/client/ui/States.tsx +81 -0
  132. package/src/client/ui/Table.tsx +229 -0
  133. package/src/client/ui/index.ts +9 -10
  134. package/src/client/ui/modal.css +54 -0
  135. package/src/client/ui/request-drawer.css +40 -0
  136. package/src/db.ts +379 -983
  137. package/src/frustration.ts +473 -0
  138. package/src/index.ts +22 -22
  139. package/src/live.ts +259 -0
  140. package/src/parser.ts +55 -66
  141. package/src/rollup.ts +951 -0
  142. package/src/server.ts +123 -40
  143. package/src/shared-types.ts +120 -56
  144. package/src/trace.ts +14 -7
  145. package/src/types.ts +4 -0
  146. package/src/user-metrics.ts +13 -0
  147. package/dist/client/styles.css +0 -1976
  148. package/dist/types/client/app/AppLayout.d.ts +0 -16
  149. package/dist/types/client/app/NavRail.d.ts +0 -7
  150. package/dist/types/client/app/RangeControl.d.ts +0 -7
  151. package/dist/types/client/app/SyncButton.d.ts +0 -14
  152. package/dist/types/client/app/TopBar.d.ts +0 -15
  153. package/dist/types/client/app/routes.d.ts +0 -12
  154. package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
  155. package/dist/types/client/components/chart-shared.d.ts +0 -178
  156. package/dist/types/client/components/models-table-shared.d.ts +0 -175
  157. package/dist/types/client/components/range-meta.d.ts +0 -21
  158. package/dist/types/client/data/charts.d.ts +0 -1
  159. package/dist/types/client/data/useResource.d.ts +0 -13
  160. package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
  161. package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
  162. package/dist/types/client/ui/DataTable.d.ts +0 -17
  163. package/dist/types/client/ui/EmptyState.d.ts +0 -7
  164. package/dist/types/client/ui/ErrorState.d.ts +0 -6
  165. package/dist/types/client/ui/MetricCluster.d.ts +0 -5
  166. package/dist/types/client/ui/Panel.d.ts +0 -7
  167. package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
  168. package/dist/types/client/ui/Skeleton.d.ts +0 -8
  169. package/dist/types/client/ui/StatusPill.d.ts +0 -7
  170. package/src/client/app/AppLayout.tsx +0 -93
  171. package/src/client/app/NavRail.tsx +0 -44
  172. package/src/client/app/RangeControl.tsx +0 -39
  173. package/src/client/app/SyncButton.tsx +0 -75
  174. package/src/client/app/TopBar.tsx +0 -73
  175. package/src/client/app/routes.ts +0 -93
  176. package/src/client/components/AgentTokenShare.tsx +0 -68
  177. package/src/client/components/chart-shared.tsx +0 -273
  178. package/src/client/components/models-table-shared.tsx +0 -255
  179. package/src/client/components/range-meta.ts +0 -73
  180. package/src/client/data/charts.ts +0 -14
  181. package/src/client/data/useResource.ts +0 -154
  182. package/src/client/routes/BehaviorRoute.tsx +0 -623
  183. package/src/client/ui/AsyncBoundary.tsx +0 -54
  184. package/src/client/ui/DataTable.tsx +0 -122
  185. package/src/client/ui/EmptyState.tsx +0 -16
  186. package/src/client/ui/ErrorState.tsx +0 -25
  187. package/src/client/ui/MetricCluster.tsx +0 -92
  188. package/src/client/ui/Panel.tsx +0 -24
  189. package/src/client/ui/SegmentedControl.tsx +0 -36
  190. package/src/client/ui/Skeleton.tsx +0 -17
  191. package/src/client/ui/StatusPill.tsx +0 -15
  192. package/tailwind.config.js +0 -40
@@ -0,0 +1,924 @@
1
+ import { Sparkles } from "lucide-react";
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
+ import {
4
+ cancelFrustrationRun,
5
+ getFrustrationDashboardStats,
6
+ getFrustrationEstimate,
7
+ startFrustrationRun,
8
+ } from "../api";
9
+ import { Chart, type ChartSeries, Legend, useHiddenSeries } from "../charts";
10
+ import { SERIES_COLORS } from "../data/colors";
11
+ import { formatCost, formatInteger, formatPercent } from "../data/formatters";
12
+ import { useQuery } from "../data/query";
13
+ import { rangeMeta } from "../data/range";
14
+ import type {
15
+ FrustrationCounts,
16
+ FrustrationEstimate,
17
+ FrustrationJobStatus,
18
+ FrustrationModelStats,
19
+ TimeRange,
20
+ } from "../types";
21
+ import {
22
+ Badge,
23
+ Card,
24
+ ChartSkeleton,
25
+ type Column,
26
+ Dot,
27
+ EmptyState,
28
+ KeyValues,
29
+ Modal,
30
+ PageHeader,
31
+ QueryView,
32
+ Segmented,
33
+ Stat,
34
+ StatGrid,
35
+ Table,
36
+ TableSkeleton,
37
+ } from "../ui";
38
+ import "./frustration.css";
39
+
40
+ export interface FrustrationRouteProps {
41
+ active: boolean;
42
+ range: TimeRange;
43
+ }
44
+
45
+ /** While a judge run is active every poll re-renders with the verdicts landed so far. */
46
+ const RUNNING_POLL_MS = 1_000;
47
+ /** Rows under this many messages are too noisy to compare; hidden unless asked for. */
48
+ const MIN_MESSAGES = 50;
49
+ /** Below this judged share a row is mostly classified by the regex fallback. */
50
+ const MIN_JUDGED_SHARE = 0.5;
51
+ /** Segmented-control value for "every model class". */
52
+ const ALL_CLASSES = "*";
53
+
54
+ export function FrustrationRoute({ active, range }: FrustrationRouteProps) {
55
+ const [running, setRunning] = useState(false);
56
+ const stats = useQuery(["frustration", range], () => getFrustrationDashboardStats(range), {
57
+ enabled: active,
58
+ pollMs: running ? RUNNING_POLL_MS : undefined,
59
+ });
60
+ const meta = rangeMeta(range);
61
+
62
+ const jobState = stats.data?.job.state;
63
+ useEffect(() => {
64
+ if (jobState !== undefined) setRunning(jobState === "running");
65
+ }, [jobState]);
66
+
67
+ const { refetch } = stats;
68
+ const handleRunStarted = useCallback(() => {
69
+ setRunning(true);
70
+ refetch();
71
+ }, [refetch]);
72
+
73
+ return (
74
+ <div className="page">
75
+ <PageHeader
76
+ title="Frustration"
77
+ description={`How often your messages sound annoyed, per model version, in ${meta.windowLabel}.`}
78
+ />
79
+
80
+ <QueryView query={stats} skeleton={<ChartSkeleton height={96} />}>
81
+ {data => (
82
+ <div data-stale={stats.stale}>
83
+ <FrustrationSummary overall={data.overall} />
84
+ </div>
85
+ )}
86
+ </QueryView>
87
+
88
+ <QueryView query={stats} skeleton={<ChartSkeleton height={120} />}>
89
+ {data => (
90
+ <JudgePanel
91
+ active={active}
92
+ range={range}
93
+ judgeAvailable={data.judgeAvailable}
94
+ job={data.job}
95
+ onRunStarted={handleRunStarted}
96
+ onRunChanged={refetch}
97
+ />
98
+ )}
99
+ </QueryView>
100
+
101
+ <QueryView
102
+ query={stats}
103
+ skeleton={
104
+ <Card index={2} title="Frustration by model version">
105
+ <ChartSkeleton height={340} />
106
+ <TableSkeleton rows={6} />
107
+ </Card>
108
+ }
109
+ >
110
+ {data => <FrustrationModelsSection models={data.byModel} stale={stats.stale} />}
111
+ </QueryView>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ /** `part / whole` as a percentage string; `–` when there is nothing to divide. */
117
+ function rate(part: number, whole: number): string {
118
+ return whole > 0 ? formatPercent(part / whole) : "–";
119
+ }
120
+
121
+ function errorMessage(err: unknown): string {
122
+ return err instanceof Error ? err.message : String(err);
123
+ }
124
+
125
+ function formatElapsed(ms: number): string {
126
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
127
+ const minutes = Math.floor(totalSeconds / 60);
128
+ const seconds = totalSeconds % 60;
129
+ return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, "0")}s` : `${seconds}s`;
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Summary
134
+ // ---------------------------------------------------------------------------
135
+
136
+ function FrustrationSummary({ overall }: { overall: FrustrationCounts }) {
137
+ const regex = overall.messages - overall.judged;
138
+ return (
139
+ <StatGrid min={170}>
140
+ <Stat
141
+ label="User messages"
142
+ title="User messages with prose in range"
143
+ value={formatInteger(overall.messages)}
144
+ />
145
+ <Stat
146
+ label="Judge coverage"
147
+ title="Messages with a cached judge verdict; the rest use regex signals"
148
+ value={rate(overall.judged, overall.messages)}
149
+ hint={`${formatInteger(overall.judged)} judged · ${formatInteger(regex)} regex`}
150
+ />
151
+ <Stat
152
+ label="Annoyed"
153
+ title="Messages annoyed at any target"
154
+ value={rate(overall.annoyed, overall.messages)}
155
+ hint={`${formatInteger(overall.annoyed)} messages`}
156
+ />
157
+ <Stat
158
+ label="At assistant"
159
+ title="Messages annoyed at the assistant"
160
+ value={rate(overall.atAssistant, overall.messages)}
161
+ hint={`${formatInteger(overall.atAssistant)} messages`}
162
+ />
163
+ <Stat
164
+ label="Angry"
165
+ title="Messages angry or hostile toward the assistant"
166
+ value={rate(overall.angry, overall.messages)}
167
+ hint={`${formatInteger(overall.angry)} messages`}
168
+ />
169
+ </StatGrid>
170
+ );
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Judge control
175
+ // ---------------------------------------------------------------------------
176
+
177
+ type EstimateState =
178
+ | { status: "loading" }
179
+ | { status: "error"; message: string }
180
+ | { status: "ready"; estimate: FrustrationEstimate; range: TimeRange };
181
+
182
+ interface JudgePanelProps {
183
+ active: boolean;
184
+ range: TimeRange;
185
+ judgeAvailable: boolean;
186
+ job: FrustrationJobStatus;
187
+ onRunStarted: () => void;
188
+ onRunChanged: () => void;
189
+ }
190
+
191
+ const UNAVAILABLE_HINT =
192
+ "Judging needs the omp host process: open the dashboard with omp stats and configure a judge model. Until then messages are classified by regex signals.";
193
+
194
+ function JudgePanel({ active, range, judgeAvailable, job, onRunStarted, onRunChanged }: JudgePanelProps) {
195
+ const [modalOpen, setModalOpen] = useState(false);
196
+ const [estimate, setEstimate] = useState<EstimateState>({ status: "loading" });
197
+ const [starting, setStarting] = useState(false);
198
+ const [startError, setStartError] = useState<string | null>(null);
199
+ const [cancelling, setCancelling] = useState(false);
200
+ const [actionError, setActionError] = useState<string | null>(null);
201
+ const estimateAbortRef = useRef<AbortController | null>(null);
202
+
203
+ const running = job.state === "running";
204
+
205
+ const closeModal = useCallback(() => {
206
+ estimateAbortRef.current?.abort();
207
+ estimateAbortRef.current = null;
208
+ setModalOpen(false);
209
+ }, []);
210
+
211
+ // A hidden route must not leave a dialog (portalled to <body>) on screen.
212
+ useEffect(() => {
213
+ if (!active) closeModal();
214
+ }, [active, closeModal]);
215
+ useEffect(() => () => estimateAbortRef.current?.abort(), []);
216
+
217
+ const openEstimate = () => {
218
+ estimateAbortRef.current?.abort();
219
+ const controller = new AbortController();
220
+ estimateAbortRef.current = controller;
221
+ const quotedRange = range;
222
+ setEstimate({ status: "loading" });
223
+ setStartError(null);
224
+ setActionError(null);
225
+ setModalOpen(true);
226
+ getFrustrationEstimate(quotedRange, controller.signal).then(
227
+ result => {
228
+ if (!controller.signal.aborted) setEstimate({ status: "ready", estimate: result, range: quotedRange });
229
+ },
230
+ (err: unknown) => {
231
+ if (!controller.signal.aborted) setEstimate({ status: "error", message: errorMessage(err) });
232
+ },
233
+ );
234
+ };
235
+
236
+ const proceed = async () => {
237
+ if (estimate.status !== "ready") return;
238
+ setStarting(true);
239
+ setStartError(null);
240
+ try {
241
+ // Judge exactly the range that was quoted, even if the picker moved meanwhile.
242
+ await startFrustrationRun(estimate.range);
243
+ closeModal();
244
+ onRunStarted();
245
+ } catch (err) {
246
+ setStartError(errorMessage(err));
247
+ } finally {
248
+ setStarting(false);
249
+ }
250
+ };
251
+
252
+ const cancelRun = async () => {
253
+ setCancelling(true);
254
+ setActionError(null);
255
+ try {
256
+ await cancelFrustrationRun();
257
+ onRunChanged();
258
+ } catch (err) {
259
+ setActionError(errorMessage(err));
260
+ } finally {
261
+ setCancelling(false);
262
+ }
263
+ };
264
+
265
+ const quote = estimate.status === "ready" ? estimate.estimate : null;
266
+ const primaryAction =
267
+ estimate.status === "loading"
268
+ ? { label: "Proceed", onClick: proceed, disabled: true }
269
+ : quote?.available
270
+ ? {
271
+ label: starting ? "Starting…" : "Proceed",
272
+ onClick: proceed,
273
+ disabled: starting || quote.messages === 0,
274
+ }
275
+ : undefined;
276
+
277
+ return (
278
+ <Card
279
+ index={1}
280
+ title={
281
+ <>
282
+ Judge classification <JobBadge job={job} judgeAvailable={judgeAvailable} />
283
+ </>
284
+ }
285
+ description="Cached LLM verdicts replace the regex heuristics; unjudged messages fall back to regex signals."
286
+ actions={
287
+ <button
288
+ type="button"
289
+ className="btn"
290
+ data-variant="accent"
291
+ data-size="sm"
292
+ onClick={openEstimate}
293
+ disabled={!judgeAvailable || running}
294
+ title={
295
+ !judgeAvailable
296
+ ? UNAVAILABLE_HINT
297
+ : running
298
+ ? "A judge run is in progress"
299
+ : "Quote the cost of judging every unjudged message in range"
300
+ }
301
+ >
302
+ <Sparkles size={13} /> Classify with judge
303
+ </button>
304
+ }
305
+ >
306
+ <div className="stack frustration-judge">
307
+ {running ? (
308
+ <JudgeProgress job={job} cancelling={cancelling} onCancel={cancelRun} />
309
+ ) : (
310
+ <JudgeResultLine job={job} />
311
+ )}
312
+ {!judgeAvailable && <p className="micro dim">{UNAVAILABLE_HINT}</p>}
313
+ {actionError && (
314
+ <p className="micro tone-bad" role="alert">
315
+ {actionError}
316
+ </p>
317
+ )}
318
+ </div>
319
+
320
+ <Modal open={modalOpen} title="Classify with judge" onClose={closeModal} primaryAction={primaryAction}>
321
+ <EstimateBody estimate={estimate} />
322
+ {startError && (
323
+ <p className="micro tone-bad" role="alert">
324
+ {startError}
325
+ </p>
326
+ )}
327
+ </Modal>
328
+ </Card>
329
+ );
330
+ }
331
+
332
+ function JobBadge({ job, judgeAvailable }: { job: FrustrationJobStatus; judgeAvailable: boolean }) {
333
+ switch (job.state) {
334
+ case "running":
335
+ return (
336
+ <Badge tone="ok">
337
+ <Dot tone="live" pulse /> Running
338
+ </Badge>
339
+ );
340
+ case "done":
341
+ return <Badge tone="ok">Last run done</Badge>;
342
+ case "cancelled":
343
+ return <Badge tone="warn">Last run cancelled</Badge>;
344
+ case "failed":
345
+ return <Badge tone="bad">Last run failed</Badge>;
346
+ case "idle":
347
+ return <Badge>{judgeAvailable ? "Not run yet" : "Regex only"}</Badge>;
348
+ }
349
+ }
350
+
351
+ function EstimateBody({ estimate }: { estimate: EstimateState }) {
352
+ if (estimate.status === "loading") return <p className="muted">Estimating cost…</p>;
353
+ if (estimate.status === "error") return <p className="tone-bad">{estimate.message}</p>;
354
+ const quote = estimate.estimate;
355
+ if (!quote.available) return <p>{quote.reason}</p>;
356
+ if (quote.messages === 0) {
357
+ return <p>Everything in this range is already judged. There is nothing left to classify.</p>;
358
+ }
359
+ return (
360
+ <>
361
+ <div className="frustration-quote">
362
+ <span className="dim micro">Estimated cost</span>
363
+ <span className="num frustration-quote-value">≈ {formatCost(quote.cost)}</span>
364
+ </div>
365
+ <KeyValues
366
+ items={[
367
+ { key: "messages", label: "Unjudged unique messages", value: formatInteger(quote.messages) },
368
+ { key: "chars", label: "Prose characters", value: formatInteger(quote.chars) },
369
+ { key: "tokens", label: "Estimated input tokens", value: formatInteger(quote.inputTokens) },
370
+ { key: "judge", label: "Judge", value: quote.judge },
371
+ ]}
372
+ />
373
+ <p className="micro dim">
374
+ Identical messages share one verdict, and verdicts are cached, so later runs only pay for new messages.
375
+ Nothing is spent until you proceed.
376
+ </p>
377
+ </>
378
+ );
379
+ }
380
+
381
+ function JudgeProgress({
382
+ job,
383
+ cancelling,
384
+ onCancel,
385
+ }: {
386
+ job: FrustrationJobStatus;
387
+ cancelling: boolean;
388
+ onCancel: () => void;
389
+ }) {
390
+ const finished = job.done + job.failed;
391
+ const share = job.total > 0 ? Math.min(1, finished / job.total) : 0;
392
+ return (
393
+ <div className="stack frustration-progress">
394
+ <div className="row frustration-progress-head">
395
+ <span className="num">
396
+ {formatInteger(job.done)}/{formatInteger(job.total)} judged
397
+ </span>
398
+ <span className="num dim">{formatPercent(share)}</span>
399
+ <span className="frustration-spacer" />
400
+ <button
401
+ type="button"
402
+ className="btn"
403
+ data-variant="danger"
404
+ data-size="sm"
405
+ onClick={onCancel}
406
+ disabled={cancelling}
407
+ >
408
+ {cancelling ? "Cancelling…" : "Cancel run"}
409
+ </button>
410
+ </div>
411
+ <div
412
+ className="meter frustration-progress-track"
413
+ role="progressbar"
414
+ aria-valuemin={0}
415
+ aria-valuemax={job.total}
416
+ aria-valuenow={finished}
417
+ >
418
+ <div className="meter-fill frustration-progress-fill" style={{ width: `${share * 100}%` }} />
419
+ </div>
420
+ <div className="row micro muted frustration-progress-meta">
421
+ <span className={job.failed > 0 ? "tone-bad" : undefined}>{formatInteger(job.failed)} failed</span>
422
+ <span>
423
+ cost so far <span className="num">{formatCost(job.cost)}</span>
424
+ </span>
425
+ {job.judge && <span className="mono">{job.judge}</span>}
426
+ {job.startedAt !== null && <span>elapsed {formatElapsed(Date.now() - job.startedAt)}</span>}
427
+ {job.startedAt !== null && job.done > 0 && (
428
+ <span className="num">{(job.done / Math.max(1, (Date.now() - job.startedAt) / 1000)).toFixed(0)}/s</span>
429
+ )}
430
+ {job.concurrency > 0 && <span className="num">{formatInteger(job.concurrency)} in flight</span>}
431
+ </div>
432
+ </div>
433
+ );
434
+ }
435
+
436
+ function JudgeResultLine({ job }: { job: FrustrationJobStatus }) {
437
+ const progress = `${formatInteger(job.done)}/${formatInteger(job.total)} messages`;
438
+ const failed = job.failed > 0 ? `, ${formatInteger(job.failed)} failed` : "";
439
+ const judge = job.judge ? ` with ${job.judge}` : "";
440
+ const elapsed =
441
+ job.startedAt !== null && job.finishedAt !== null ? ` in ${formatElapsed(job.finishedAt - job.startedAt)}` : "";
442
+ switch (job.state) {
443
+ case "idle":
444
+ return (
445
+ <p className="muted">
446
+ No judge run yet. Classify the unjudged messages to replace regex estimates with verdicts.
447
+ </p>
448
+ );
449
+ case "running":
450
+ return null;
451
+ case "done":
452
+ return (
453
+ <p className="muted">
454
+ Last run judged {progress}
455
+ {failed}
456
+ {judge}
457
+ {elapsed} for <span className="num">{formatCost(job.cost)}</span>.
458
+ </p>
459
+ );
460
+ case "cancelled":
461
+ return (
462
+ <p className="muted">
463
+ Last run cancelled after {progress}
464
+ {failed}; spent <span className="num">{formatCost(job.cost)}</span>.
465
+ </p>
466
+ );
467
+ case "failed":
468
+ return (
469
+ <p className="muted">
470
+ Last run failed after {progress}
471
+ {failed}: <span className="tone-bad">{job.error ?? "unknown error"}</span>. Spent{" "}
472
+ <span className="num">{formatCost(job.cost)}</span>.
473
+ </p>
474
+ );
475
+ }
476
+ }
477
+
478
+ // ---------------------------------------------------------------------------
479
+ // Per-model chart + table
480
+ // ---------------------------------------------------------------------------
481
+
482
+ /** Chart/filter identity of a model family: `class/family`. */
483
+ function familyKey(row: FrustrationModelStats): string {
484
+ return `${row.modelClass}/${row.family ?? "unclassified"}`;
485
+ }
486
+
487
+ function isMostlyRegex(row: FrustrationModelStats): boolean {
488
+ return row.messages > 0 && row.judged / row.messages < MIN_JUDGED_SHARE;
489
+ }
490
+
491
+ /** FNV-1a: a stable preferred palette slot per family, independent of range. */
492
+ function hashString(value: string): number {
493
+ let hash = 0x811c9dc5;
494
+ for (let i = 0; i < value.length; i++) {
495
+ hash ^= value.charCodeAt(i);
496
+ hash = Math.imul(hash, 0x01000193);
497
+ }
498
+ return hash >>> 0;
499
+ }
500
+
501
+ /**
502
+ * Deterministic family → palette color. Each family starts at its hashed slot
503
+ * and probes forward past taken slots, so families present together stay
504
+ * distinct (until the palette runs out) and keep their hue across ranges.
505
+ */
506
+ function assignFamilyColors(models: readonly FrustrationModelStats[]): Map<string, string> {
507
+ const keys = [...new Set(models.map(familyKey))].sort();
508
+ const taken = new Set<number>();
509
+ const colors = new Map<string, string>();
510
+ for (const key of keys) {
511
+ let slot = hashString(key) % SERIES_COLORS.length;
512
+ for (let probe = 0; probe < SERIES_COLORS.length && taken.has(slot); probe++) {
513
+ slot = (slot + 1) % SERIES_COLORS.length;
514
+ }
515
+ taken.add(slot);
516
+ colors.set(key, SERIES_COLORS[slot]);
517
+ }
518
+ return colors;
519
+ }
520
+
521
+ /**
522
+ * Shades of one family hue for the three stacked layers. `color-mix` keeps
523
+ * the faint layer see-through on both themes.
524
+ */
525
+ function layerColor(hue: string, layer: Layer): string {
526
+ if (layer === "angry") return `color-mix(in srgb, ${hue} 55%, black)`;
527
+ if (layer === "other") return `color-mix(in srgb, ${hue} 32%, transparent)`;
528
+ return hue;
529
+ }
530
+
531
+ /**
532
+ * Stack layers, bottom first. `annoyed ⊇ atAssistant ⊇ angry`, so stacking the
533
+ * differences draws each bar to the `annoyed` height with the subsets inside.
534
+ */
535
+ const LAYERS = [
536
+ { key: "angry", label: "Angry at assistant" },
537
+ { key: "assistant", label: "At assistant, not angry" },
538
+ { key: "other", label: "Annoyed at other targets" },
539
+ ] as const;
540
+ type Layer = (typeof LAYERS)[number]["key"];
541
+
542
+ function layerCount(row: FrustrationModelStats, layer: Layer): number {
543
+ if (layer === "angry") return row.angry;
544
+ if (layer === "assistant") return row.atAssistant - row.angry;
545
+ return row.annoyed - row.atAssistant;
546
+ }
547
+
548
+ function percent(part: number, whole: number): number {
549
+ return whole > 0 ? (part / whole) * 100 : 0;
550
+ }
551
+
552
+ interface FamilyOption {
553
+ key: string;
554
+ label: string;
555
+ messages: number;
556
+ }
557
+
558
+ function FrustrationModelsSection({ models, stale }: { models: FrustrationModelStats[]; stale: boolean }) {
559
+ const [selectedClass, setSelectedClass] = useState<string | null>(null);
560
+ const [hiddenFamilies, toggleFamily] = useHiddenSeries();
561
+ const [showSmall, setShowSmall] = useState(false);
562
+ const [hideRegex, setHideRegex] = useState(false);
563
+
564
+ const familyColors = useMemo(() => assignFamilyColors(models), [models]);
565
+
566
+ const classes = useMemo(() => {
567
+ const totals = new Map<string, number>();
568
+ for (const row of models) totals.set(row.modelClass, (totals.get(row.modelClass) ?? 0) + row.messages);
569
+ return totals;
570
+ }, [models]);
571
+
572
+ const defaultClass = useMemo(() => {
573
+ let best: string | null = null;
574
+ let bestMessages = -1;
575
+ for (const [modelClass, messages] of classes) {
576
+ if (messages > bestMessages) {
577
+ best = modelClass;
578
+ bestMessages = messages;
579
+ }
580
+ }
581
+ return best ?? ALL_CLASSES;
582
+ }, [classes]);
583
+
584
+ const activeClass =
585
+ selectedClass !== null && (selectedClass === ALL_CLASSES || classes.has(selectedClass))
586
+ ? selectedClass
587
+ : defaultClass;
588
+
589
+ const classOptions = useMemo(
590
+ () => [
591
+ { value: ALL_CLASSES, label: "All", title: "Every model class" },
592
+ ...[...classes].map(([modelClass, messages]) => ({
593
+ value: modelClass,
594
+ label: modelClass,
595
+ title: `${formatInteger(messages)} messages`,
596
+ })),
597
+ ],
598
+ [classes],
599
+ );
600
+
601
+ const inClass = useMemo(
602
+ () => (activeClass === ALL_CLASSES ? models : models.filter(row => row.modelClass === activeClass)),
603
+ [models, activeClass],
604
+ );
605
+
606
+ const families = useMemo(() => {
607
+ const byKey = new Map<string, FamilyOption>();
608
+ for (const row of inClass) {
609
+ const key = familyKey(row);
610
+ const existing = byKey.get(key);
611
+ if (existing) {
612
+ existing.messages += row.messages;
613
+ continue;
614
+ }
615
+ const family = row.family ?? "unclassified";
616
+ byKey.set(key, {
617
+ key,
618
+ label: activeClass === ALL_CLASSES ? `${row.modelClass} · ${family}` : family,
619
+ messages: row.messages,
620
+ });
621
+ }
622
+ return [...byKey.values()];
623
+ }, [inClass, activeClass]);
624
+
625
+ const visibleFamilies = useMemo(
626
+ () => inClass.filter(row => !hiddenFamilies.has(familyKey(row))),
627
+ [inClass, hiddenFamilies],
628
+ );
629
+ const smallCount = visibleFamilies.filter(row => row.messages < MIN_MESSAGES).length;
630
+ const regexCount = visibleFamilies.filter(isMostlyRegex).length;
631
+ const rows = useMemo(
632
+ () =>
633
+ visibleFamilies.filter(
634
+ row => (showSmall || row.messages >= MIN_MESSAGES) && !(hideRegex && isMostlyRegex(row)),
635
+ ),
636
+ [visibleFamilies, showSmall, hideRegex],
637
+ );
638
+
639
+ if (models.length === 0) {
640
+ return (
641
+ <Card index={2} title="Frustration by model version" stale={stale}>
642
+ <EmptyState title="No user messages in this range" hint="Try a longer range." />
643
+ </Card>
644
+ );
645
+ }
646
+
647
+ const hasRegexRows = rows.some(isMostlyRegex);
648
+
649
+ return (
650
+ <>
651
+ <Card
652
+ index={2}
653
+ title="Frustration by model version"
654
+ description="Share of user messages per model version. Each bar reaches the annoyed rate; the subsets aimed at the assistant stack inside it."
655
+ stale={stale}
656
+ >
657
+ <div className="stack frustration-chart-body">
658
+ <div className="frustration-classes">
659
+ <Segmented
660
+ size="sm"
661
+ aria-label="Model class"
662
+ options={classOptions}
663
+ value={activeClass}
664
+ onChange={setSelectedClass}
665
+ />
666
+ </div>
667
+ <div className="row frustration-filters">
668
+ <Legend
669
+ items={families.map(family => ({
670
+ key: family.key,
671
+ label: family.label,
672
+ color: familyColors.get(family.key) ?? SERIES_COLORS[0],
673
+ value: formatInteger(family.messages),
674
+ }))}
675
+ hidden={hiddenFamilies}
676
+ onToggle={toggleFamily}
677
+ />
678
+ <span className="frustration-spacer" />
679
+ <label className="check" title={`Models with fewer than ${MIN_MESSAGES} messages have noisy rates`}>
680
+ <input type="checkbox" checked={showSmall} onChange={e => setShowSmall(e.target.checked)} />
681
+ Include &lt; {MIN_MESSAGES} messages{smallCount > 0 ? ` (${smallCount})` : ""}
682
+ </label>
683
+ <label
684
+ className="check"
685
+ title={`Hide models with under ${MIN_JUDGED_SHARE * 100}% judged messages (rates from regex signals)`}
686
+ >
687
+ <input type="checkbox" checked={hideRegex} onChange={e => setHideRegex(e.target.checked)} />
688
+ Hide mostly regex{regexCount > 0 ? ` (${regexCount})` : ""}
689
+ </label>
690
+ </div>
691
+ <EncodingKey hasRegexRows={hasRegexRows} />
692
+ <FrustrationChart rows={rows} familyColors={familyColors} />
693
+ </div>
694
+ </Card>
695
+ <FrustrationTable rows={rows} familyColors={familyColors} stale={stale} />
696
+ </>
697
+ );
698
+ }
699
+
700
+ /** What the marks mean: layer shades, hatching and the trend line (hue = family). */
701
+ function EncodingKey({ hasRegexRows }: { hasRegexRows: boolean }) {
702
+ return (
703
+ <div className="legend frustration-key">
704
+ <span className="legend-item">
705
+ <span className="swatch frustration-key-swatch" data-variant="angry" />
706
+ Angry at assistant
707
+ </span>
708
+ <span className="legend-item">
709
+ <span className="swatch frustration-key-swatch" data-variant="assistant" />
710
+ At assistant
711
+ </span>
712
+ <span className="legend-item">
713
+ <span className="swatch frustration-key-swatch" data-variant="other" />
714
+ Annoyed at other targets
715
+ </span>
716
+ <span className="legend-item">
717
+ <span className="frustration-key-line" />
718
+ At assistant trend
719
+ </span>
720
+ <span
721
+ className="legend-item frustration-key-wide"
722
+ title={`Under ${MIN_JUDGED_SHARE * 100}% of these messages are judged; their rates come from regex signals`}
723
+ >
724
+ <span className="swatch frustration-key-swatch" data-variant="hatch" />
725
+ Hatched: mostly regex fallback{hasRegexRows ? " (run the judge)" : ""}
726
+ </span>
727
+ <span className="legend-item dim">Hue = model family</span>
728
+ </div>
729
+ );
730
+ }
731
+
732
+ function FrustrationChart({
733
+ rows,
734
+ familyColors,
735
+ }: {
736
+ rows: FrustrationModelStats[];
737
+ familyColors: Map<string, string>;
738
+ }) {
739
+ const series = useMemo((): ChartSeries[] => {
740
+ // Bars take their family's hue, so every (layer, family, judged|regex)
741
+ // combination is its own series with values only in that family's slots.
742
+ const groups = new Map<string, { family: string; regex: boolean; slots: number[] }>();
743
+ rows.forEach((row, i) => {
744
+ const family = familyKey(row);
745
+ const regex = isMostlyRegex(row);
746
+ const id = `${family}|${regex ? "regex" : "judged"}`;
747
+ const group = groups.get(id);
748
+ if (group) group.slots.push(i);
749
+ else groups.set(id, { family, regex, slots: [i] });
750
+ });
751
+ const out: ChartSeries[] = [];
752
+ for (const layer of LAYERS) {
753
+ for (const [id, group] of groups) {
754
+ const values: (number | null)[] = rows.map(() => null);
755
+ for (const i of group.slots) values[i] = percent(layerCount(rows[i], layer.key), rows[i].messages);
756
+ out.push({
757
+ key: `${layer.key}|${id}`,
758
+ label: layer.label,
759
+ color: layerColor(familyColors.get(group.family) ?? SERIES_COLORS[0], layer.key),
760
+ values,
761
+ pattern: group.regex ? "hatch" : undefined,
762
+ });
763
+ }
764
+ }
765
+ out.push({
766
+ key: "trend",
767
+ label: "At assistant (total)",
768
+ color: "var(--ink-1)",
769
+ values: rows.map(row => percent(row.atAssistant, row.messages)),
770
+ kind: "line",
771
+ // Repeats the two at-assistant bar layers; the tooltip already lists them.
772
+ tooltip: false,
773
+ });
774
+ return out;
775
+ }, [rows, familyColors]);
776
+
777
+ if (rows.length === 0) {
778
+ return (
779
+ <div className="frustration-chart-empty">
780
+ <EmptyState title="No model versions match the filters" />
781
+ </div>
782
+ );
783
+ }
784
+
785
+ return (
786
+ <Chart
787
+ slots={rows.length}
788
+ tickLabel={i => rows[i].label}
789
+ series={series}
790
+ height={340}
791
+ format={v => `${v}%`}
792
+ formatTooltip={v => `${v.toFixed(1)}%`}
793
+ showTotal={false}
794
+ emptyLabel="No frustrated messages for these models"
795
+ tooltipExtra={i => {
796
+ const row = rows[i];
797
+ return (
798
+ <div className="frustration-tooltip-extra">
799
+ <div className="chart-tooltip-row chart-tooltip-total">
800
+ <span className="chart-tooltip-label">Annoyed, any target</span>
801
+ <span className="chart-tooltip-value">{rate(row.annoyed, row.messages)}</span>
802
+ </div>
803
+ <div className="micro dim">
804
+ {formatInteger(row.messages)} messages · judged {formatInteger(row.judged)} (
805
+ {rate(row.judged, row.messages)}){isMostlyRegex(row) ? " · mostly regex fallback" : ""}
806
+ </div>
807
+ </div>
808
+ );
809
+ }}
810
+ />
811
+ );
812
+ }
813
+
814
+ function FrustrationTable({
815
+ rows,
816
+ familyColors,
817
+ stale,
818
+ }: {
819
+ rows: FrustrationModelStats[];
820
+ familyColors: Map<string, string>;
821
+ stale: boolean;
822
+ }) {
823
+ const columns = useMemo(
824
+ (): Column<FrustrationModelStats>[] => [
825
+ {
826
+ key: "model",
827
+ header: "Model",
828
+ sort: row => row.label,
829
+ render: row => (
830
+ <span className="row" title={row.key}>
831
+ <span
832
+ className="swatch"
833
+ style={{ background: familyColors.get(familyKey(row)) ?? SERIES_COLORS[0] }}
834
+ />
835
+ <span className="cell-primary">{row.label}</span>
836
+ </span>
837
+ ),
838
+ },
839
+ {
840
+ key: "ids",
841
+ header: "Model IDs",
842
+ wrap: true,
843
+ render: row => <span className="mono micro dim">{row.models.join(", ")}</span>,
844
+ },
845
+ {
846
+ key: "messages",
847
+ header: "Messages",
848
+ align: "right",
849
+ sort: row => row.messages,
850
+ render: row => <span className="num">{formatInteger(row.messages)}</span>,
851
+ },
852
+ {
853
+ key: "judged",
854
+ header: "Judged",
855
+ align: "right",
856
+ sort: row => (row.messages > 0 ? row.judged / row.messages : 0),
857
+ render: row =>
858
+ isMostlyRegex(row) ? (
859
+ <span
860
+ className="row frustration-judged"
861
+ title="Mostly regex fallback: run the judge for accurate rates"
862
+ >
863
+ <Badge tone="warn">regex</Badge>
864
+ <span className="num dim">{rate(row.judged, row.messages)}</span>
865
+ </span>
866
+ ) : (
867
+ <span className="num">{rate(row.judged, row.messages)}</span>
868
+ ),
869
+ },
870
+ {
871
+ key: "annoyed",
872
+ header: "Annoyed",
873
+ align: "right",
874
+ sort: row => percent(row.annoyed, row.messages),
875
+ render: row => (
876
+ <span className="num" title={`${formatInteger(row.annoyed)} messages`}>
877
+ {rate(row.annoyed, row.messages)}
878
+ </span>
879
+ ),
880
+ },
881
+ {
882
+ key: "atAssistant",
883
+ header: "At assistant",
884
+ align: "right",
885
+ sort: row => percent(row.atAssistant, row.messages),
886
+ render: row => (
887
+ <span className="num" title={`${formatInteger(row.atAssistant)} messages`}>
888
+ {rate(row.atAssistant, row.messages)}
889
+ </span>
890
+ ),
891
+ },
892
+ {
893
+ key: "angry",
894
+ header: "Angry",
895
+ align: "right",
896
+ sort: row => percent(row.angry, row.messages),
897
+ render: row => (
898
+ <span className={row.angry > 0 ? "num tone-bad" : "num"} title={`${formatInteger(row.angry)} messages`}>
899
+ {rate(row.angry, row.messages)}
900
+ </span>
901
+ ),
902
+ },
903
+ ],
904
+ [familyColors],
905
+ );
906
+
907
+ return (
908
+ <Card
909
+ index={3}
910
+ title="Model versions"
911
+ description="Rates are shares of user messages; rows follow the chart filters."
912
+ flush
913
+ stale={stale}
914
+ >
915
+ <Table
916
+ rows={rows}
917
+ rowKey={row => row.key}
918
+ columns={columns}
919
+ dense
920
+ empty={<EmptyState title="No model versions match the filters" />}
921
+ />
922
+ </Card>
923
+ );
924
+ }