@polderlabs/bizar 5.0.2 → 5.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 (88) hide show
  1. package/bizar-dash/dist/assets/MobileChat-TCputYzr.js +1 -0
  2. package/bizar-dash/dist/assets/MobileChat-TCputYzr.js.map +1 -0
  3. package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js +1 -0
  4. package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js.map +1 -0
  5. package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-DRDXfbBP.js} +151 -136
  6. package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +1 -0
  7. package/bizar-dash/dist/assets/main-D5Ditnrd.js +19 -0
  8. package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +1 -0
  9. package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-xFpWMd32.css} +1 -1
  10. package/bizar-dash/dist/assets/markdown-C6mXtQxD.js +1 -0
  11. package/bizar-dash/dist/assets/markdown-C6mXtQxD.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile--17fkfrl.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile--17fkfrl.js.map} +1 -1
  14. package/bizar-dash/dist/assets/mobile-j3rOZK6v.js +2 -0
  15. package/bizar-dash/dist/assets/mobile-j3rOZK6v.js.map +1 -0
  16. package/bizar-dash/dist/assets/{react-vendor-DZRUXSPQ.js → react-vendor-Dn4wqh4Z.js} +8 -8
  17. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +1 -0
  18. package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js +2 -0
  19. package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js.map +1 -0
  20. package/bizar-dash/dist/assets/vendor-CeHGtduv.js +29 -0
  21. package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +1 -0
  22. package/bizar-dash/dist/index.html +8 -6
  23. package/bizar-dash/dist/mobile.html +4 -5
  24. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  25. package/bizar-dash/src/server/api.mjs +4 -0
  26. package/bizar-dash/src/server/eval.mjs +93 -3
  27. package/bizar-dash/src/server/metrics.mjs +75 -0
  28. package/bizar-dash/src/server/otel.mjs +141 -14
  29. package/bizar-dash/src/server/plugins/registry.mjs +145 -43
  30. package/bizar-dash/src/server/routes/chat.mjs +46 -9
  31. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +120 -17
  32. package/bizar-dash/src/server/routes/plugins.mjs +131 -16
  33. package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
  34. package/bizar-dash/src/server/routes/voice.mjs +42 -6
  35. package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
  36. package/bizar-dash/src/server/server.mjs +57 -15
  37. package/bizar-dash/src/server/voice-store.mjs +27 -0
  38. package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
  39. package/bizar-dash/src/web/App.tsx +9 -0
  40. package/bizar-dash/src/web/MobileApp.tsx +18 -10
  41. package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
  42. package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
  43. package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
  44. package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
  45. package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
  46. package/bizar-dash/src/web/components/Toggle.tsx +31 -0
  47. package/bizar-dash/src/web/components/Topbar.tsx +6 -0
  48. package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
  49. package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
  50. package/bizar-dash/src/web/styles/main.css +545 -0
  51. package/bizar-dash/src/web/views/Eval.tsx +172 -0
  52. package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
  53. package/bizar-dash/src/web/views/Plugins.tsx +128 -0
  54. package/bizar-dash/src/web/views/Settings.tsx +2 -0
  55. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
  56. package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
  57. package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
  58. package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
  59. package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
  60. package/bizar-dash/tests/otel-spans.test.mjs +253 -0
  61. package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
  62. package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
  63. package/cli/bin.mjs +14 -0
  64. package/cli/commands/dash.mjs +19 -1
  65. package/cli/commands/service.mjs +7 -0
  66. package/cli/commands/tailscale.mjs +251 -0
  67. package/package.json +1 -1
  68. package/templates/eval-fixtures/citation.json +16 -0
  69. package/templates/eval-fixtures/code-review.json +16 -0
  70. package/templates/eval-fixtures/concise-output.json +16 -0
  71. package/templates/eval-fixtures/context-window.json +16 -0
  72. package/templates/eval-fixtures/error-recovery.json +16 -0
  73. package/templates/eval-fixtures/json-output.json +20 -0
  74. package/templates/eval-fixtures/multi-language.json +16 -0
  75. package/templates/eval-fixtures/safe-paths.json +16 -0
  76. package/templates/eval-fixtures/tool-call-multi-step.json +18 -0
  77. package/templates/eval-fixtures/unicode-handling.json +16 -0
  78. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +0 -1
  79. package/bizar-dash/dist/assets/index-DmpSFPJY.js +0 -9
  80. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +0 -1
  81. package/bizar-dash/dist/assets/main-C1cpttnv.js +0 -19
  82. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +0 -1
  83. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +0 -29
  84. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +0 -1
  85. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +0 -2
  86. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +0 -1
  87. package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +0 -1
  88. package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +0 -1
@@ -0,0 +1,213 @@
1
+ /**
2
+ * src/server/workers/transcription-worker.mjs
3
+ *
4
+ * v5.2 — Background transcription worker for voice notes.
5
+ *
6
+ * When a user uploads a voice note, the audio is saved immediately and a
7
+ * reference (noteId) is enqueued here. The worker drains the queue in
8
+ * FIFO order: it reads the audio from disk, calls `transcribe()` (the
9
+ * Whisper adapter), persists the result via `updateVoiceNote()`, and
10
+ * pushes a `voice:updated` event on the dashboard WebSocket so the UI
11
+ * can replace the "transcribing…" placeholder as soon as the text is
12
+ * ready.
13
+ *
14
+ * Design choices:
15
+ * - In-memory queue (per-process). No cross-instance persistence; if
16
+ * the server dies with work in the queue, those notes remain
17
+ * un-transcribed (the audio file is still on disk and can be
18
+ * re-triggered manually if needed).
19
+ * - Single-threaded drain (`processing` flag). Multiple callers
20
+ * enqueueing during a drain are captured by the while-loop's
21
+ * `queue.length > 0` check on each iteration.
22
+ * - Errors inside `processOne()` are caught and logged by
23
+ * `processQueue()`; a failure on note X does NOT stop note X+1.
24
+ * - `broadcast()` is injected at startup so the worker doesn't take
25
+ * a hard dependency on server.mjs. In tests, a no-op stub is fine.
26
+ * - `setInterval(..., 5000)` is a safety net: any work left in the
27
+ * queue (e.g. from a processQueue race during teardown) gets a
28
+ * retry tick. `.unref()` keeps the interval from blocking process
29
+ * exit — the worker never closes the server by itself.
30
+ *
31
+ * Test injection: the four setters / helpers exposed on the bottom of
32
+ * this file (`setTranscribe`, `setBroadcast`, `drainQueue`, `reset`)
33
+ * exist so tests can stub the Whisper adapter and observe the queue
34
+ * without booting a server or hitting the network. They have no effect
35
+ * in production unless called.
36
+ */
37
+
38
+ import { readFileSync, existsSync } from 'node:fs';
39
+ import { getVoiceNote, updateVoiceNote } from '../voice-store.mjs';
40
+ import { transcribe as defaultTranscribe } from '../voice-transcribe.mjs';
41
+ import { child } from '../logger.mjs';
42
+
43
+ const log = child({ module: 'transcription-worker' });
44
+
45
+ const POLL_INTERVAL_MS = 5000;
46
+
47
+ // ── Mutable worker state ────────────────────────────────────────────────────
48
+
49
+ /** @type {{ noteId: string, enqueuedAt: number }[]} */
50
+ const queue = [];
51
+ let processing = false;
52
+
53
+ /** Override hooks — defaults call through to the real Whisper adapter / no-op broadcast. */
54
+ let _transcribe = defaultTranscribe;
55
+ let _broadcast = () => {};
56
+
57
+ const idleWaiters = [];
58
+
59
+ // ── Core processing ─────────────────────────────────────────────────────────
60
+
61
+ /**
62
+ * Process one note end-to-end. Returns a status object so tests (and
63
+ * the drain loop) can distinguish "skipped for a reason" from "ok".
64
+ *
65
+ * @param {string} noteId
66
+ * @returns {Promise<{ ok?: true, transcript?: string, skipped?: string }>}
67
+ */
68
+ export async function processOne(noteId) {
69
+ const note = getVoiceNote(noteId);
70
+ if (!note) {
71
+ log.debug('note missing, skipping', { noteId });
72
+ return { skipped: 'missing' };
73
+ }
74
+ if (note.transcript) {
75
+ log.debug('already transcribed', { noteId });
76
+ return { skipped: 'already_transcribed' };
77
+ }
78
+ if (!existsSync(note.audioPath)) {
79
+ log.error('audio file missing on disk', { noteId, audioPath: note.audioPath });
80
+ return { skipped: 'audio_missing' };
81
+ }
82
+
83
+ let transcript;
84
+ try {
85
+ const audioBuffer = readFileSync(note.audioPath);
86
+ transcript = await _transcribe(audioBuffer, { mimeType: 'audio/webm' });
87
+ } catch (err) {
88
+ log.error('transcription threw', { noteId, err: err.message });
89
+ return { skipped: 'error', err: err.message };
90
+ }
91
+
92
+ if (!transcript) {
93
+ log.warn('transcription returned null', { noteId });
94
+ return { skipped: 'no_transcript' };
95
+ }
96
+
97
+ const updated = updateVoiceNote(noteId, { transcript });
98
+ if (!updated) {
99
+ log.error('updateVoiceNote returned null after successful transcription', { noteId });
100
+ return { skipped: 'update_failed' };
101
+ }
102
+
103
+ try {
104
+ _broadcast({ type: 'voice:updated', noteId, patch: { transcript } });
105
+ } catch (err) {
106
+ // Broadcast failure must not corrupt the on-disk state.
107
+ log.warn('broadcast threw', { noteId, err: err.message });
108
+ }
109
+
110
+ log.info('transcription complete', { noteId, length: transcript.length });
111
+ return { ok: true, transcript };
112
+ }
113
+
114
+ /**
115
+ * Drain the queue. Guarded by the `processing` flag so concurrent
116
+ * callers don't double-drain. Errors inside `processOne` are logged
117
+ * and swallowed, NOT re-thrown — the next item must always get a
118
+ * chance.
119
+ */
120
+ async function processQueue() {
121
+ if (processing) return;
122
+ processing = true;
123
+ try {
124
+ while (queue.length > 0) {
125
+ const { noteId } = queue.shift();
126
+ try {
127
+ await processOne(noteId);
128
+ } catch (err) {
129
+ log.error('transcription failed', { noteId, err: err.message });
130
+ }
131
+ }
132
+ } finally {
133
+ processing = false;
134
+ if (queue.length === 0) {
135
+ const toResolve = idleWaiters.splice(0);
136
+ for (const r of toResolve) r();
137
+ }
138
+ }
139
+ }
140
+
141
+ // ── Public API ──────────────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * Enqueue a note for background transcription.
145
+ *
146
+ * @param {string} noteId
147
+ */
148
+ export function enqueueTranscription(noteId) {
149
+ if (!noteId || typeof noteId !== 'string') return;
150
+ queue.push({ noteId, enqueuedAt: Date.now() });
151
+ // Drain is fire-and-forget — callers don't await per-note work.
152
+ void processQueue();
153
+ }
154
+
155
+ /**
156
+ * Start the worker. Idempotent — calling twice clears the previous
157
+ * interval and starts a fresh one.
158
+ *
159
+ * @param {{ broadcast?: (msg: unknown) => void }} [opts]
160
+ */
161
+ export function startTranscriptionWorker({ broadcast } = {}) {
162
+ if (typeof broadcast === 'function') _broadcast = broadcast;
163
+ // Replace any prior interval so tests + double-boot are safe.
164
+ if (startTranscriptionWorker._interval) {
165
+ clearInterval(startTranscriptionWorker._interval);
166
+ }
167
+ log.info('transcription worker started', { pollIntervalMs: POLL_INTERVAL_MS });
168
+ const handle = setInterval(() => void processQueue(), POLL_INTERVAL_MS);
169
+ if (typeof handle.unref === 'function') handle.unref();
170
+ startTranscriptionWorker._interval = handle;
171
+ }
172
+
173
+ // ── Test hooks (no-op in production) ────────────────────────────────────────
174
+
175
+ /** @param {(buf: Buffer|Uint8Array, opts?: object) => Promise<string|null>} fn */
176
+ export function setTranscribe(fn) {
177
+ _transcribe = typeof fn === 'function' ? fn : defaultTranscribe;
178
+ }
179
+
180
+ /** @param {(msg: unknown) => void} fn */
181
+ export function setBroadcast(fn) {
182
+ _broadcast = typeof fn === 'function' ? fn : () => {};
183
+ }
184
+
185
+ /** Number of items waiting or currently being processed. */
186
+ export function getQueueDepth() {
187
+ return queue.length + (processing ? 1 : 0);
188
+ }
189
+
190
+ /**
191
+ * Resolve once the queue is fully drained (no items + not processing).
192
+ * Returns a resolved promise immediately if already idle.
193
+ */
194
+ export function drainQueue() {
195
+ if (queue.length === 0 && !processing) return Promise.resolve();
196
+ return new Promise((resolve) => {
197
+ idleWaiters.push(resolve);
198
+ void processQueue();
199
+ });
200
+ }
201
+
202
+ /** Reset all mutable state. ONLY for tests. */
203
+ export function reset() {
204
+ queue.length = 0;
205
+ processing = false;
206
+ _transcribe = defaultTranscribe;
207
+ _broadcast = () => {};
208
+ idleWaiters.length = 0;
209
+ if (startTranscriptionWorker._interval) {
210
+ clearInterval(startTranscriptionWorker._interval);
211
+ startTranscriptionWorker._interval = null;
212
+ }
213
+ }
@@ -31,6 +31,7 @@ import { Activity } from './views/Activity';
31
31
  import { Config } from './views/Config';
32
32
  import { SettingsView } from './views/Settings';
33
33
  import { Mods } from './views/Mods';
34
+ import { Plugins } from './views/Plugins';
34
35
  import { ModView, type ModView as ModViewType } from './views/ModView';
35
36
  import { Schedules } from './views/Schedules';
36
37
  import { Skills } from './views/Skills';
@@ -39,6 +40,8 @@ import { History } from './views/History';
39
40
  import { MiniMaxUsage } from './views/MiniMaxUsage';
40
41
  import { BackgroundAgents } from './views/BackgroundAgents';
41
42
  import { Doctor } from './views/Doctor';
43
+ import { Eval } from './views/Eval';
44
+ import { EvalReport } from './views/EvalReport';
42
45
  import { SettingsNav } from './components/SettingsNav';
43
46
  import { Spinner } from './components/Spinner';
44
47
  import { Button } from './components/Button';
@@ -92,10 +95,16 @@ const VIEW_MAP: Record<string, (p: ViewProps) => React.ReactNode> = {
92
95
  skills: Skills,
93
96
  history: History,
94
97
  minimax: MiniMaxUsage,
98
+ plugins: Plugins,
95
99
  // v6.0.0 — Doctor page (system health + diagnostics surface).
96
100
  // Registered here so the tab id 'doctor' from Topbar.tsx TABS
97
101
  // resolves to a real view instead of falling back to Overview.
98
102
  doctor: Doctor,
103
+ // v5.2.0 — Eval framework web UI. `eval` is the sidebar tab
104
+ // (recent runs summary), `evalReport` is reachable from the
105
+ // Eval view's "Open Eval Report" button (full report + diff).
106
+ eval: Eval,
107
+ evalReport: EvalReport,
99
108
  };
100
109
 
101
110
  const VERSION = 'v4.5.0';
@@ -1,5 +1,5 @@
1
1
  // src/MobileApp.tsx — mobile root with state-based routing + stack navigation.
2
- import { useCallback, useEffect, useMemo, useState } from 'react';
2
+ import React, { useCallback, useEffect, useMemo, useState, Suspense } from 'react';
3
3
  import {
4
4
  Activity,
5
5
  CheckSquare,
@@ -16,7 +16,6 @@ import { MobileBottomNav, type MobileTab } from './mobile/MobileBottomNav';
16
16
  import { MobileTopbar } from './mobile/MobileTopbar';
17
17
  import { MobileActivity } from './mobile/views/MobileActivity';
18
18
  import { MobileAgents } from './mobile/views/MobileAgents';
19
- import { MobileChat } from './mobile/views/MobileChat';
20
19
  import { MobileConfig } from './mobile/views/MobileConfig';
21
20
  import { MobileHistory } from './mobile/views/MobileHistory';
22
21
  import { MobileMods } from './mobile/views/MobileMods';
@@ -25,10 +24,13 @@ import { MobileArtifactCanvas } from './mobile/views/MobileArtifactCanvas';
25
24
  import { MobileArtifacts } from './mobile/views/MobileArtifacts';
26
25
  import { MobileSchedules } from './mobile/views/MobileSchedules';
27
26
  import { MobileSearchModal } from './mobile/views/MobileSearchModal';
28
- import { MobileSettings } from './mobile/views/MobileSettings';
29
27
  import { MobileSkills } from './mobile/views/MobileSkills';
30
28
  import { MobileTasks } from './mobile/views/MobileTasks';
31
29
 
30
+ // Lazy-loaded mobile-specific variants (code-split from main bundle)
31
+ const MobileSettings = React.lazy(() => import('./mobile/MobileSettings').then(m => ({ default: m.MobileSettings })));
32
+ const MobileChat = React.lazy(() => import('./mobile/MobileChat').then(m => ({ default: m.MobileChat })));
33
+
32
34
  type MainTabId = 'activity' | 'chat' | 'tasks' | 'settings' | 'more';
33
35
 
34
36
  export type MobileView =
@@ -326,12 +328,14 @@ export function MobileApp() {
326
328
  return <MobileActivity snapshot={snapshot} onRefresh={refreshSnapshot} />;
327
329
  case 'chat':
328
330
  return (
329
- <MobileChat
330
- snapshot={snapshot}
331
- settings={settings}
332
- initialTaskId={pendingChatTaskId}
333
- onClearTaskId={() => setPendingChatTaskId(null)}
334
- />
331
+ <Suspense fallback={<div className="mobile-loading"><Spinner size="lg" /></div>}>
332
+ <MobileChat
333
+ snapshot={snapshot}
334
+ settings={settings}
335
+ initialTaskId={pendingChatTaskId}
336
+ onClearTaskId={() => setPendingChatTaskId(null)}
337
+ />
338
+ </Suspense>
335
339
  );
336
340
  case 'tasks':
337
341
  return (
@@ -345,7 +349,11 @@ export function MobileApp() {
345
349
  />
346
350
  );
347
351
  case 'settings':
348
- return <MobileSettings settings={settings} snapshot={snapshot} onRefresh={refreshSnapshot} />;
352
+ return (
353
+ <Suspense fallback={<div className="mobile-loading"><Spinner size="lg" /></div>}>
354
+ <MobileSettings settings={settings} snapshot={snapshot} onRefresh={refreshSnapshot} />
355
+ </Suspense>
356
+ );
349
357
  case 'more':
350
358
  return (
351
359
  <MobileMore
@@ -0,0 +1,157 @@
1
+ // src/components/EvalDiff.tsx — v5.2.0
2
+ //
3
+ // Side-by-side comparison of two eval runs. Categorizes each fixture
4
+ // as improved / regressed / unchanged and renders a summary block plus
5
+ // a list of changed fixtures.
6
+ //
7
+ // `runA` is the "before" run (typically an older baseline); `runB` is
8
+ // the "after" run (typically the newer run under review).
9
+
10
+ import type { ReactNode } from 'react';
11
+ import { cn } from '../lib/utils';
12
+
13
+ /** Per-fixture result row embedded in a run record. */
14
+ export type EvalFixtureResult = {
15
+ fixtureId: string;
16
+ ok: boolean;
17
+ checks?: { kind: string; ok: boolean; message?: string }[];
18
+ latencyMs?: number;
19
+ };
20
+
21
+ export type EvalRunWithResults = {
22
+ id: string;
23
+ startedAt: string;
24
+ finishedAt?: string;
25
+ suitePath?: string;
26
+ total: number;
27
+ passed: number;
28
+ failed: number;
29
+ results: EvalFixtureResult[];
30
+ };
31
+
32
+ export type EvalDiffEntry = {
33
+ fixtureId: string;
34
+ /** The fixture's pass/fail status in the "before" run, or null if absent. */
35
+ before: boolean | null;
36
+ /** The fixture's pass/fail status in the "after" run, or null if absent. */
37
+ after: boolean | null;
38
+ /** Bucket label derived from before/after. */
39
+ change: 'same' | 'improved' | 'regressed' | 'added' | 'removed';
40
+ };
41
+
42
+ export type EvalDiffProps = {
43
+ runA: EvalRunWithResults;
44
+ runB: EvalRunWithResults;
45
+ /**
46
+ * Optional override for the section heading. Defaults to
47
+ * "Regression Analysis".
48
+ */
49
+ title?: string;
50
+ };
51
+
52
+ /**
53
+ * Pure helper: compute the diff entries from two runs. Exported so
54
+ * tests can exercise the categorization logic without rendering.
55
+ */
56
+ export function computeEvalDiff(
57
+ runA: EvalRunWithResults,
58
+ runB: EvalRunWithResults,
59
+ ): EvalDiffEntry[] {
60
+ const mapA = new Map(runA.results.map((r) => [r.fixtureId, r]));
61
+ const mapB = new Map(runB.results.map((r) => [r.fixtureId, r]));
62
+ const ids = new Set<string>([...mapA.keys(), ...mapB.keys()]);
63
+
64
+ const out: EvalDiffEntry[] = [];
65
+ for (const id of ids) {
66
+ const a = mapA.get(id);
67
+ const b = mapB.get(id);
68
+ const before = a ? a.ok : null;
69
+ const after = b ? b.ok : null;
70
+
71
+ let change: EvalDiffEntry['change'];
72
+ if (a && b) {
73
+ if (before === after) change = 'same';
74
+ else if (before === false && after === true) change = 'improved';
75
+ else change = 'regressed';
76
+ } else if (b && !a) {
77
+ change = 'added';
78
+ } else {
79
+ change = 'removed';
80
+ }
81
+
82
+ out.push({ fixtureId: id, before, after, change });
83
+ }
84
+ // Stable sort: regressions first, then improvements, then others.
85
+ const order: Record<EvalDiffEntry['change'], number> = {
86
+ regressed: 0,
87
+ improved: 1,
88
+ added: 2,
89
+ removed: 3,
90
+ same: 4,
91
+ };
92
+ out.sort((x, y) => {
93
+ const d = order[x.change] - order[y.change];
94
+ if (d !== 0) return d;
95
+ return x.fixtureId.localeCompare(y.fixtureId);
96
+ });
97
+ return out;
98
+ }
99
+
100
+ export function EvalDiff({ runA, runB, title = 'Regression Analysis' }: EvalDiffProps): ReactNode {
101
+ const diff = computeEvalDiff(runA, runB);
102
+
103
+ const improved = diff.filter((d) => d.change === 'improved').length;
104
+ const regressed = diff.filter((d) => d.change === 'regressed').length;
105
+ const unchanged = diff.filter((d) => d.change === 'same').length;
106
+ const changed = diff.filter((d) => d.change !== 'same');
107
+
108
+ return (
109
+ <div className="eval-diff" data-testid="eval-diff">
110
+ <h3 className="eval-diff-title">{title}</h3>
111
+ <div className="eval-diff-subtitle muted">
112
+ Comparing <code className="mono">{runA.id}</code> →{' '}
113
+ <code className="mono">{runB.id}</code>
114
+ </div>
115
+
116
+ <div className="eval-diff-stats" aria-label="Diff summary">
117
+ <span className="eval-diff-stat improved">
118
+ <span className="eval-diff-stat-icon" aria-hidden>+</span>
119
+ {improved} improved
120
+ </span>
121
+ <span className="eval-diff-stat regressed">
122
+ <span className="eval-diff-stat-icon" aria-hidden>−</span>
123
+ {regressed} regressed
124
+ </span>
125
+ <span className="eval-diff-stat unchanged muted">
126
+ {unchanged} unchanged
127
+ </span>
128
+ </div>
129
+
130
+ {changed.length === 0 ? (
131
+ <p className="eval-diff-empty muted">No fixture-level changes between these runs.</p>
132
+ ) : (
133
+ <ul className="eval-diff-list" role="list">
134
+ {changed.map((d) => (
135
+ <li
136
+ key={d.fixtureId}
137
+ data-fixture-id={d.fixtureId}
138
+ className={cn('eval-diff-row', `is-${d.change}`)}
139
+ >
140
+ <span className="eval-diff-fixture mono">{d.fixtureId}</span>
141
+ <span className="eval-diff-arrow" aria-hidden>→</span>
142
+ <span className="eval-diff-states">
143
+ <span className={cn('eval-diff-state', d.before ? 'is-pass' : 'is-fail')}>
144
+ {d.before === null ? '—' : d.before ? 'pass' : 'fail'}
145
+ </span>
146
+ <span className="eval-diff-arrow-small" aria-hidden>→</span>
147
+ <span className={cn('eval-diff-state', d.after ? 'is-pass' : 'is-fail')}>
148
+ {d.after === null ? '—' : d.after ? 'pass' : 'fail'}
149
+ </span>
150
+ </span>
151
+ </li>
152
+ ))}
153
+ </ul>
154
+ )}
155
+ </div>
156
+ );
157
+ }
@@ -0,0 +1,78 @@
1
+ // src/components/EvalRunCard.tsx — v5.2.0
2
+ //
3
+ // Compact card summarizing a single eval run. Used in the EvalReport's
4
+ // left rail to show the recent run history. Click selects the run.
5
+ //
6
+ // Status rules (per brief):
7
+ // failed === 0 → pass
8
+ // passed/total > 0.8 → warn
9
+ // otherwise → fail
10
+
11
+ import type { ReactNode } from 'react';
12
+ import { Card, CardTitle, CardMeta } from './Card';
13
+ import { cn, formatTime } from '../lib/utils';
14
+
15
+ /**
16
+ * Summary of an eval run. The fields here are the subset returned by
17
+ * GET /api/eval/runs (the list endpoint) — `results` lives on the full
18
+ * run record returned by GET /api/eval/runs/:id and is not present here.
19
+ */
20
+ export type EvalRunSummary = {
21
+ id: string;
22
+ startedAt: string;
23
+ finishedAt?: string;
24
+ suitePath: string;
25
+ total: number;
26
+ passed: number;
27
+ failed: number;
28
+ };
29
+
30
+ export type EvalRunCardProps = {
31
+ run: EvalRunSummary;
32
+ onClick?: () => void;
33
+ /** v5.2.0 — Marks the card as the currently selected run. */
34
+ selected?: boolean;
35
+ /** Optional aria-label override; falls back to the run id. */
36
+ ariaLabel?: string;
37
+ };
38
+
39
+ function pickStatus(run: EvalRunSummary): 'pass' | 'warn' | 'fail' {
40
+ if (run.total <= 0) return 'fail';
41
+ if (run.failed === 0) return 'pass';
42
+ if (run.passed / run.total > 0.8) return 'warn';
43
+ return 'fail';
44
+ }
45
+
46
+ export function EvalRunCard({ run, onClick, selected, ariaLabel }: EvalRunCardProps): ReactNode {
47
+ const status = pickStatus(run);
48
+ // Pass-rate as a fixed-percent string. toFixed guards against locale
49
+ // issues (e.g. some locales use "," as the decimal separator).
50
+ const passRate = run.total > 0
51
+ ? ((run.passed / run.total) * 100).toFixed(1)
52
+ : '0.0';
53
+ const interactive = Boolean(onClick);
54
+
55
+ return (
56
+ <Card
57
+ onClick={onClick}
58
+ interactive={interactive}
59
+ aria-label={ariaLabel ?? `Eval run ${run.id}`}
60
+ aria-pressed={interactive ? selected : undefined}
61
+ className={cn(
62
+ 'eval-run-card',
63
+ selected && 'eval-run-card-selected',
64
+ `eval-run-${status}`,
65
+ )}
66
+ >
67
+ <div className="eval-run-head">
68
+ <CardTitle className="eval-run-title">{run.id}</CardTitle>
69
+ <span className={cn('eval-status', `is-${status}`)}>
70
+ {run.passed}/{run.total} ({passRate}%)
71
+ </span>
72
+ </div>
73
+ <CardMeta className="eval-run-meta">
74
+ {formatTime(run.startedAt)} · {run.suitePath}
75
+ </CardMeta>
76
+ </Card>
77
+ );
78
+ }
@@ -0,0 +1,64 @@
1
+ // src/web/components/PluginCard.tsx — card for one installed plugin with toggle, configure, and uninstall.
2
+
3
+ import { Card, CardTitle, CardMeta } from './Card';
4
+ import { Button } from './Button';
5
+ import { Toggle } from './Toggle';
6
+ import { PluginPermissions } from './PluginPermissions';
7
+
8
+ export type InstalledPlugin = {
9
+ id: string;
10
+ name: string;
11
+ version: string;
12
+ description?: string;
13
+ permissions?: string[];
14
+ config?: Record<string, unknown>;
15
+ methodCount?: number;
16
+ invocations?: number;
17
+ lastInvokedAt?: string | null;
18
+ enabled?: boolean;
19
+ };
20
+
21
+ export type PluginCardProps = {
22
+ plugin: InstalledPlugin;
23
+ onToggle: (id: string) => void;
24
+ onUninstall: (id: string) => void;
25
+ onConfigure: (id: string) => void;
26
+ };
27
+
28
+ export function PluginCard({ plugin, onToggle, onUninstall, onConfigure }: PluginCardProps) {
29
+ return (
30
+ <Card>
31
+ <div className="plugin-card-head">
32
+ <div>
33
+ <CardTitle>{plugin.name}</CardTitle>
34
+ <CardMeta>
35
+ v{plugin.version} · {plugin.id}
36
+ </CardMeta>
37
+ </div>
38
+ <Toggle
39
+ checked={plugin.enabled ?? true}
40
+ onChange={() => onToggle(plugin.id)}
41
+ aria-label={`Toggle ${plugin.name} enabled`}
42
+ />
43
+ </div>
44
+ {plugin.description && (
45
+ <p className="plugin-description">{plugin.description}</p>
46
+ )}
47
+ <PluginPermissions permissions={plugin.permissions ?? []} />
48
+ <div className="plugin-card-actions">
49
+ <Button size="sm" onClick={() => onConfigure(plugin.id)}>
50
+ Configure
51
+ </Button>
52
+ <Button size="sm" variant="danger" onClick={() => onUninstall(plugin.id)}>
53
+ Uninstall
54
+ </Button>
55
+ </div>
56
+ <div className="plugin-card-stats">
57
+ {plugin.invocations ?? 0} invocations
58
+ {plugin.lastInvokedAt
59
+ ? ` · last used ${plugin.lastInvokedAt}`
60
+ : ' · never used'}
61
+ </div>
62
+ </Card>
63
+ );
64
+ }
@@ -0,0 +1,36 @@
1
+ // src/web/components/PluginPermissions.tsx — renders permission chips for a plugin.
2
+
3
+ import { Globe, FileText, Settings, Terminal, Shield, type LucideIcon } from 'lucide-react';
4
+
5
+ const PERMISSION_LABELS: Record<string, { label: string; icon: LucideIcon; color: string; description: string }> = {
6
+ net: { label: 'Network access', icon: Globe, color: 'blue', description: 'Can make HTTP requests to external services' },
7
+ fs: { label: 'Filesystem', icon: FileText, color: 'yellow', description: 'Can read and write files' },
8
+ config: { label: 'Config access', icon: Settings, color: 'gray', description: 'Can read and write Bizar config' },
9
+ log: { label: 'Logging', icon: FileText, color: 'gray', description: 'Can write to the structured log' },
10
+ exec: { label: 'Shell execution', icon: Terminal, color: 'red', description: 'Can run shell commands' },
11
+ };
12
+
13
+ export type PluginPermissionsProps = {
14
+ permissions: string[];
15
+ };
16
+
17
+ export function PluginPermissions({ permissions }: PluginPermissionsProps) {
18
+ return (
19
+ <div className="plugin-permissions">
20
+ {permissions.map((perm) => {
21
+ const info = PERMISSION_LABELS[perm] ?? { label: perm, icon: Shield, color: 'gray', description: '' };
22
+ const Icon = info.icon;
23
+ return (
24
+ <div
25
+ key={perm}
26
+ className={`permission-chip is-${info.color}`}
27
+ title={info.description}
28
+ >
29
+ <Icon size={12} aria-hidden />
30
+ <span>{info.label}</span>
31
+ </div>
32
+ );
33
+ })}
34
+ </div>
35
+ );
36
+ }