@polderlabs/bizar 5.2.0 → 5.3.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 (52) hide show
  1. package/bizar-dash/dist/assets/{MobileChat-TCputYzr.js → MobileChat-OidrxXR9.js} +1 -1
  2. package/bizar-dash/dist/assets/{MobileChat-TCputYzr.js.map → MobileChat-OidrxXR9.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileSettings-BxcbL5XT.js → MobileSettings-B91gVFii.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileSettings-BxcbL5XT.js.map → MobileSettings-B91gVFii.js.map} +1 -1
  5. package/bizar-dash/dist/assets/{icons-DRDXfbBP.js → icons-Bd8piSb2.js} +18 -13
  6. package/bizar-dash/dist/assets/icons-Bd8piSb2.js.map +1 -0
  7. package/bizar-dash/dist/assets/{main-xFpWMd32.css → main-3G6mOhiK.css} +1 -1
  8. package/bizar-dash/dist/assets/main-6YUZY968.js +19 -0
  9. package/bizar-dash/dist/assets/main-6YUZY968.js.map +1 -0
  10. package/bizar-dash/dist/assets/{mobile-j3rOZK6v.js → mobile-CSsp30lr.js} +2 -2
  11. package/bizar-dash/dist/assets/{mobile-j3rOZK6v.js.map → mobile-CSsp30lr.js.map} +1 -1
  12. package/bizar-dash/dist/assets/{mobile--17fkfrl.js → mobile-DtwK7DOV.js} +1 -1
  13. package/bizar-dash/dist/assets/{mobile--17fkfrl.js.map → mobile-DtwK7DOV.js.map} +1 -1
  14. package/bizar-dash/dist/assets/{useSlashCommands-BG-DhEck.js → useSlashCommands-DEkxEvOO.js} +2 -2
  15. package/bizar-dash/dist/assets/{useSlashCommands-BG-DhEck.js.map → useSlashCommands-DEkxEvOO.js.map} +1 -1
  16. package/bizar-dash/dist/index.html +5 -5
  17. package/bizar-dash/dist/mobile.html +3 -3
  18. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/bizar-dash/src/server/eval-store.mjs +55 -0
  20. package/bizar-dash/src/server/eval.mjs +63 -0
  21. package/bizar-dash/src/server/plugins/permission-audit.mjs +109 -0
  22. package/bizar-dash/src/server/plugins/sandbox.mjs +76 -2
  23. package/bizar-dash/src/server/plugins/store.mjs +4 -0
  24. package/bizar-dash/src/server/routes/eval.mjs +42 -2
  25. package/bizar-dash/src/server/routes/plugins.mjs +44 -4
  26. package/bizar-dash/src/server/schedules-runner.mjs +61 -0
  27. package/bizar-dash/src/web/components/EvalExportButton.tsx +27 -0
  28. package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +62 -0
  29. package/bizar-dash/src/web/components/MarketplacePluginCard.tsx +51 -0
  30. package/bizar-dash/src/web/components/PluginCard.tsx +2 -2
  31. package/bizar-dash/src/web/components/PluginPermissions.tsx +1 -1
  32. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +1 -0
  33. package/bizar-dash/src/web/components/Topbar.tsx +3 -0
  34. package/bizar-dash/src/web/components/VoiceNotesPanel.tsx +28 -0
  35. package/bizar-dash/src/web/styles/main.css +139 -0
  36. package/bizar-dash/src/web/views/Doctor.tsx +3 -3
  37. package/bizar-dash/src/web/views/Eval.tsx +225 -25
  38. package/bizar-dash/src/web/views/EvalReport.tsx +7 -3
  39. package/bizar-dash/src/web/views/Marketplace.tsx +135 -0
  40. package/bizar-dash/src/web/views/Plugins.tsx +9 -3
  41. package/bizar-dash/src/web/views/Schedules.tsx +1 -1
  42. package/bizar-dash/tests/a11y/components.test.tsx +149 -0
  43. package/bizar-dash/tests/a11y/forms.test.tsx +191 -0
  44. package/bizar-dash/tests/a11y/navigation.test.tsx +112 -0
  45. package/bizar-dash/tests/components/marketplace-plugin-card.test.tsx +144 -0
  46. package/bizar-dash/tests/eval-csv.test.mjs +67 -0
  47. package/bizar-dash/tests/eval-scheduled.test.mjs +84 -0
  48. package/bizar-dash/tests/plugins-permissions.test.mjs +525 -0
  49. package/package.json +1 -1
  50. package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +0 -1
  51. package/bizar-dash/dist/assets/main-D5Ditnrd.js +0 -19
  52. package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +0 -1
@@ -0,0 +1,62 @@
1
+ // src/web/components/InstallConfirmDialog.tsx — confirmation dialog for installing a marketplace plugin.
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { AlertTriangle } from 'lucide-react';
5
+ import { useModal } from './Modal';
6
+ import { Button } from './Button';
7
+ import { PluginPermissions } from './PluginPermissions';
8
+ import type { MarketplacePlugin } from './MarketplacePluginCard';
9
+
10
+ export type InstallConfirmDialogProps = {
11
+ plugin: MarketplacePlugin;
12
+ onConfirm: () => void | Promise<void>;
13
+ onCancel: () => void;
14
+ installing: boolean;
15
+ };
16
+
17
+ export function InstallConfirmDialog({ plugin, onConfirm, onCancel, installing }: InstallConfirmDialogProps) {
18
+ const modal = useModal();
19
+ // Track the plugin id we've opened the modal for, so we don't re-open on every render.
20
+ const openedForRef = useRef<string | null>(null);
21
+
22
+ useEffect(() => {
23
+ if (!plugin) return;
24
+ // Only open if we haven't already opened for this plugin.
25
+ if (openedForRef.current === plugin.id) return;
26
+ openedForRef.current = plugin.id;
27
+
28
+ modal.open({
29
+ title: `Install ${plugin.name}?`,
30
+ width: 480,
31
+ onClose: () => {
32
+ openedForRef.current = null;
33
+ onCancel();
34
+ },
35
+ children: (
36
+ <div className="install-confirm">
37
+ <h3>{plugin.name} v{plugin.version}</h3>
38
+ {plugin.description && <p>{plugin.description}</p>}
39
+
40
+ <div className="install-warning">
41
+ <AlertTriangle size={16} />
42
+ <p>This plugin will run code on your machine. It requests these permissions:</p>
43
+ </div>
44
+
45
+ <PluginPermissions permissions={plugin.permissions ?? []} />
46
+
47
+ <div className="install-confirm-actions">
48
+ <Button onClick={onCancel} disabled={installing}>Cancel</Button>
49
+ <Button variant="primary" onClick={onConfirm} disabled={installing}>
50
+ {installing ? 'Installing...' : 'Install'}
51
+ </Button>
52
+ </div>
53
+ </div>
54
+ ),
55
+ });
56
+ // eslint-disable-next-line react-hooks/exhaustive-deps
57
+ }, [plugin, installing]);
58
+
59
+ // This component manages its own modal state via useModal.
60
+ // Render nothing in the React tree — the modal is rendered via portal.
61
+ return null;
62
+ }
@@ -0,0 +1,51 @@
1
+ // src/web/components/MarketplacePluginCard.tsx — card for a plugin listed in the marketplace.
2
+
3
+ import { Card, CardTitle, CardMeta } from './Card';
4
+ import { Button } from './Button';
5
+ import { PluginPermissions } from './PluginPermissions';
6
+
7
+ export type MarketplacePlugin = {
8
+ id: string;
9
+ name: string;
10
+ version: string;
11
+ author: string;
12
+ description?: string;
13
+ category: string;
14
+ tags?: string[];
15
+ permissions?: string[];
16
+ };
17
+
18
+ export type MarketplacePluginCardProps = {
19
+ plugin: MarketplacePlugin;
20
+ onInstall: () => void;
21
+ onView: () => void;
22
+ };
23
+
24
+ export function MarketplacePluginCard({ plugin, onInstall, onView }: MarketplacePluginCardProps) {
25
+ return (
26
+ <Card className="marketplace-plugin-card">
27
+ <div className="marketplace-plugin-head">
28
+ <div>
29
+ <CardTitle>{plugin.name}</CardTitle>
30
+ <CardMeta>v{plugin.version} · {plugin.category}</CardMeta>
31
+ </div>
32
+ <div className="marketplace-plugin-author">{plugin.author}</div>
33
+ </div>
34
+ {plugin.description && (
35
+ <p className="marketplace-plugin-description">{plugin.description}</p>
36
+ )}
37
+ {plugin.tags && plugin.tags.length > 0 && (
38
+ <div className="marketplace-plugin-tags">
39
+ {plugin.tags.map((tag) => (
40
+ <span key={tag} className="tag">{tag}</span>
41
+ ))}
42
+ </div>
43
+ )}
44
+ <PluginPermissions permissions={plugin.permissions ?? []} />
45
+ <div className="marketplace-plugin-actions">
46
+ <Button onClick={onView}>Details</Button>
47
+ <Button variant="primary" onClick={onInstall}>Install</Button>
48
+ </div>
49
+ </Card>
50
+ );
51
+ }
@@ -46,10 +46,10 @@ export function PluginCard({ plugin, onToggle, onUninstall, onConfigure }: Plugi
46
46
  )}
47
47
  <PluginPermissions permissions={plugin.permissions ?? []} />
48
48
  <div className="plugin-card-actions">
49
- <Button size="sm" onClick={() => onConfigure(plugin.id)}>
49
+ <Button size="sm" onClick={() => onConfigure(plugin.id)} aria-label={`Configure ${plugin.name}`}>
50
50
  Configure
51
51
  </Button>
52
- <Button size="sm" variant="danger" onClick={() => onUninstall(plugin.id)}>
52
+ <Button size="sm" variant="danger" onClick={() => onUninstall(plugin.id)} aria-label={`Uninstall ${plugin.name}`}>
53
53
  Uninstall
54
54
  </Button>
55
55
  </div>
@@ -16,7 +16,7 @@ export type PluginPermissionsProps = {
16
16
 
17
17
  export function PluginPermissions({ permissions }: PluginPermissionsProps) {
18
18
  return (
19
- <div className="plugin-permissions">
19
+ <div className="plugin-permissions" aria-label="Required permissions">
20
20
  {permissions.map((perm) => {
21
21
  const info = PERMISSION_LABELS[perm] ?? { label: perm, icon: Shield, color: 'gray', description: '' };
22
22
  const Icon = info.icon;
@@ -63,6 +63,7 @@ export function ScheduleTemplateCard({ template, onUse }: Props) {
63
63
  variant="secondary"
64
64
  size="sm"
65
65
  onClick={() => onUse(template)}
66
+ aria-label={`Use template: ${template.name}`}
66
67
  >
67
68
  Use this template
68
69
  </Button>
@@ -25,6 +25,7 @@ import {
25
25
  Brain,
26
26
  Stethoscope,
27
27
  ClipboardCheck,
28
+ Store,
28
29
  type LucideIcon,
29
30
  } from 'lucide-react';
30
31
  import { cn } from '../lib/utils';
@@ -51,6 +52,8 @@ export const TABS: TabDef[] = [
51
52
  { id: 'skills', label: 'Skills', icon: Sparkles },
52
53
  { id: 'memory', label: 'Memory', icon: Brain },
53
54
  { id: 'mods', label: 'Mods', icon: Puzzle },
55
+ // v5.3.0 — Plugin marketplace browse + install UI.
56
+ { id: 'marketplace', label: 'Marketplace', icon: Store },
54
57
  { id: 'schedules', label: 'Schedules', icon: Clock },
55
58
  { id: 'plugins', label: 'Plugins', icon: Puzzle },
56
59
  { id: 'history', label: 'History', icon: HistoryIcon },
@@ -120,6 +120,34 @@ export function VoiceNotesPanel({ vaultPath, refreshKey = 0 }: Props) {
120
120
 
121
121
  useEffect(() => {
122
122
  loadNotes();
123
+
124
+ // v5.2.0 — Listen for transcription worker updates
125
+ const handleMessage = (e: MessageEvent) => {
126
+ try {
127
+ const msg = JSON.parse(e.data);
128
+ if (msg.type === 'voice:updated' && msg.noteId) {
129
+ // Update the matching note's transcript in place
130
+ setNotes((prev) => prev.map((n) =>
131
+ n.id === msg.noteId
132
+ ? { ...n, transcript: msg.patch?.transcript ?? n.transcript }
133
+ : n
134
+ ));
135
+ }
136
+ } catch { /* ignore parse errors */ }
137
+ };
138
+
139
+ // Connect to WS for real-time transcript updates
140
+ const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
141
+ let ws: WebSocket | null = null;
142
+ try {
143
+ ws = new WebSocket(wsUrl);
144
+ ws.addEventListener('message', handleMessage);
145
+ } catch { /* ignore */ }
146
+
147
+ return () => {
148
+ ws?.removeEventListener('message', handleMessage);
149
+ ws?.close();
150
+ };
123
151
  }, [vaultPath, refreshKey]);
124
152
 
125
153
  const handleDelete = async (id: string) => {
@@ -9162,6 +9162,120 @@ select:focus-visible,
9162
9162
  margin-top: var(--space-2);
9163
9163
  }
9164
9164
 
9165
+ /* ─── v5.3.0 — Plugin marketplace ──────────────────────────────── */
9166
+ .marketplace-grid {
9167
+ display: grid;
9168
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
9169
+ gap: 16px;
9170
+ margin-top: 24px;
9171
+ }
9172
+
9173
+ .marketplace-plugin-card {
9174
+ display: flex;
9175
+ flex-direction: column;
9176
+ }
9177
+
9178
+ .marketplace-plugin-head {
9179
+ display: flex;
9180
+ align-items: flex-start;
9181
+ justify-content: space-between;
9182
+ gap: var(--space-3);
9183
+ }
9184
+
9185
+ .marketplace-plugin-description {
9186
+ color: var(--text-dim);
9187
+ margin: var(--space-2) 0;
9188
+ font-size: 13px;
9189
+ line-height: 1.5;
9190
+ }
9191
+
9192
+ .marketplace-plugin-tags {
9193
+ display: flex;
9194
+ flex-wrap: wrap;
9195
+ gap: 4px;
9196
+ margin: 8px 0;
9197
+ }
9198
+
9199
+ .marketplace-plugin-tags .tag {
9200
+ background: var(--surface-3, rgba(255, 255, 255, 0.05));
9201
+ color: var(--text-dim, #8b949e);
9202
+ padding: 2px 6px;
9203
+ border-radius: 8px;
9204
+ font-size: 10px;
9205
+ }
9206
+
9207
+ .marketplace-plugin-actions {
9208
+ display: flex;
9209
+ gap: var(--space-2);
9210
+ flex-wrap: wrap;
9211
+ margin-top: auto;
9212
+ padding-top: var(--space-2);
9213
+ }
9214
+
9215
+ .marketplace-plugin-author {
9216
+ font-size: 12px;
9217
+ color: var(--text-dim);
9218
+ }
9219
+
9220
+ .marketplace-search-row {
9221
+ margin-bottom: 16px;
9222
+ }
9223
+
9224
+ .category-chips {
9225
+ display: flex;
9226
+ gap: 8px;
9227
+ margin: 16px 0;
9228
+ flex-wrap: wrap;
9229
+ }
9230
+
9231
+ .category-chips button {
9232
+ padding: 6px 12px;
9233
+ border: 1px solid var(--border, #30363d);
9234
+ border-radius: 14px;
9235
+ background: transparent;
9236
+ color: var(--text-dim);
9237
+ cursor: pointer;
9238
+ font-size: 13px;
9239
+ }
9240
+
9241
+ .category-chips button:hover {
9242
+ background: var(--surface-2);
9243
+ }
9244
+
9245
+ .category-chips button.active {
9246
+ background: var(--accent, #8b5cf6);
9247
+ color: white;
9248
+ border-color: var(--accent, #8b5cf6);
9249
+ }
9250
+
9251
+ .install-confirm h3 {
9252
+ margin: 0 0 var(--space-2);
9253
+ font-size: 16px;
9254
+ }
9255
+
9256
+ .install-warning {
9257
+ display: flex;
9258
+ gap: 8px;
9259
+ background: rgba(245, 158, 11, 0.1);
9260
+ padding: 12px;
9261
+ border-radius: 6px;
9262
+ margin: 12px 0;
9263
+ color: #d29922;
9264
+ font-size: 13px;
9265
+ }
9266
+
9267
+ .install-warning p {
9268
+ margin: 0;
9269
+ color: var(--text);
9270
+ }
9271
+
9272
+ .install-confirm-actions {
9273
+ display: flex;
9274
+ gap: var(--space-2);
9275
+ justify-content: flex-end;
9276
+ margin-top: var(--space-4);
9277
+ }
9278
+
9165
9279
  /* ─── Plugin permissions ──────────────────────────────────────────── */
9166
9280
  .plugin-permissions {
9167
9281
  display: flex;
@@ -9610,3 +9724,28 @@ select:focus-visible,
9610
9724
  display: flex;
9611
9725
  flex-direction: column;
9612
9726
  }
9727
+
9728
+ /* ─── High-contrast mode (prefers-contrast: more) ──────────────────── */
9729
+ @media (prefers-contrast: more) {
9730
+ :root {
9731
+ --text-dim: var(--text);
9732
+ --text-muted: var(--text);
9733
+ }
9734
+
9735
+ [data-theme='light'] {
9736
+ --text-dim: #374151;
9737
+ --text-muted: #374151;
9738
+ }
9739
+
9740
+ :focus-visible,
9741
+ button:focus-visible,
9742
+ [role='button']:focus-visible,
9743
+ a:focus-visible,
9744
+ input:focus-visible,
9745
+ textarea:focus-visible,
9746
+ select:focus-visible,
9747
+ [tabindex]:focus-visible {
9748
+ outline-width: 3px;
9749
+ outline-style: solid;
9750
+ }
9751
+ }
@@ -208,7 +208,7 @@ function DoctorInner({
208
208
  </div>
209
209
  <div className="doctor-header-meta muted">
210
210
  <Clock size={12} aria-hidden />
211
- <span>
211
+ <span aria-live="polite" aria-atomic="true">
212
212
  v{data.bizarVersion} · {data.platform}/{data.arch} ·{' '}
213
213
  up {Math.floor(data.uptime)}s
214
214
  {lastFetched ? ` · refreshed ${formatTime(new Date(lastFetched))}` : ''}
@@ -266,7 +266,7 @@ function DoctorInner({
266
266
  </CardTitle>
267
267
  <CardMeta>Run a check, view logs, or export the snapshot.</CardMeta>
268
268
  <div className="doctor-actions-row">
269
- <Button variant="primary" onClick={fetchSnapshot} disabled={refreshing}>
269
+ <Button variant="primary" onClick={fetchSnapshot} disabled={refreshing} aria-label="Refresh diagnostics">
270
270
  {refreshing ? <Spinner size="sm" /> : <RefreshCw size={12} />}
271
271
  {refreshing ? 'Running…' : 'Run Health Check Now'}
272
272
  </Button>
@@ -279,7 +279,7 @@ function DoctorInner({
279
279
  <Button
280
280
  variant="ghost"
281
281
  onClick={() => setAutoRefresh((v) => !v)}
282
- title="Toggle auto-refresh"
282
+ aria-label={autoRefresh ? 'Pause auto-refresh' : 'Resume auto-refresh'}
283
283
  >
284
284
  {autoRefresh ? 'Pause auto-refresh' : 'Resume auto-refresh'}
285
285
  </Button>
@@ -10,7 +10,7 @@
10
10
  // overview pattern (counts at top, recent history, call to action).
11
11
 
12
12
  import React, { useCallback, useEffect, useRef, useState } from 'react';
13
- import { ClipboardCheck, ArrowRight, RefreshCw, ExternalLink } from 'lucide-react';
13
+ import { ClipboardCheck, ArrowRight, RefreshCw, ExternalLink, Clock, Plus, Trash2 } from 'lucide-react';
14
14
  import { Card, CardTitle, CardMeta } from '../components/Card';
15
15
  import { Button } from '../components/Button';
16
16
  import { EmptyState } from '../components/EmptyState';
@@ -21,6 +21,14 @@ import { api } from '../lib/api';
21
21
  import { cn } from '../lib/utils';
22
22
  import type { Settings, Snapshot } from '../lib/types';
23
23
 
24
+ type EvalSchedule = {
25
+ id: string;
26
+ name: string;
27
+ suitePath: string;
28
+ cron: string;
29
+ agent: string;
30
+ };
31
+
24
32
  type Props = {
25
33
  snapshot: Snapshot;
26
34
  settings: Settings;
@@ -31,10 +39,103 @@ type Props = {
31
39
 
32
40
  const RECENT_RUNS_LIMIT = 8;
33
41
 
42
+ function AddScheduleForm({
43
+ onAdded,
44
+ onCancel,
45
+ }: {
46
+ onAdded: (schedule: EvalSchedule) => void;
47
+ onCancel: () => void;
48
+ }) {
49
+ const toast = useToast();
50
+ const [name, setName] = useState('');
51
+ const [suitePath, setSuitePath] = useState('');
52
+ const [cron, setCron] = useState('');
53
+ const [agent, setAgent] = useState('thor');
54
+ const [submitting, setSubmitting] = useState(false);
55
+
56
+ const handleSubmit = async (e: React.FormEvent) => {
57
+ e.preventDefault();
58
+ if (!name || !suitePath || !cron) {
59
+ toast.error('name, suitePath, and cron are required');
60
+ return;
61
+ }
62
+ setSubmitting(true);
63
+ try {
64
+ const result = await api.post<EvalSchedule>('/eval/schedules', { name, suitePath, cron, agent });
65
+ onAdded(result);
66
+ toast.success('Schedule created');
67
+ } catch (err) {
68
+ toast.error(`Failed to create schedule: ${(err as Error).message}`);
69
+ } finally {
70
+ setSubmitting(false);
71
+ }
72
+ };
73
+
74
+ return (
75
+ <form className="eval-schedule-form" onSubmit={handleSubmit}>
76
+ <div className="form-row">
77
+ <label htmlFor="sched-name">Name</label>
78
+ <input
79
+ id="sched-name"
80
+ type="text"
81
+ value={name}
82
+ onChange={(e) => setName(e.target.value)}
83
+ placeholder="Daily fixture check"
84
+ required
85
+ />
86
+ </div>
87
+ <div className="form-row">
88
+ <label htmlFor="sched-suite">Suite path</label>
89
+ <input
90
+ id="sched-suite"
91
+ type="text"
92
+ value={suitePath}
93
+ onChange={(e) => setSuitePath(e.target.value)}
94
+ placeholder="./fixtures"
95
+ required
96
+ />
97
+ </div>
98
+ <div className="form-row">
99
+ <label htmlFor="sched-cron">Cron</label>
100
+ <input
101
+ id="sched-cron"
102
+ type="text"
103
+ value={cron}
104
+ onChange={(e) => setCron(e.target.value)}
105
+ placeholder="0 8 * * *"
106
+ required
107
+ />
108
+ </div>
109
+ <div className="form-row">
110
+ <label htmlFor="sched-agent">Agent</label>
111
+ <input
112
+ id="sched-agent"
113
+ type="text"
114
+ value={agent}
115
+ onChange={(e) => setAgent(e.target.value)}
116
+ placeholder="thor"
117
+ />
118
+ </div>
119
+ <div className="form-actions">
120
+ <Button type="submit" variant="primary" size="sm" disabled={submitting}>
121
+ {submitting ? 'Creating…' : 'Create schedule'}
122
+ </Button>
123
+ <Button type="button" variant="ghost" size="sm" onClick={onCancel}>
124
+ Cancel
125
+ </Button>
126
+ </div>
127
+ </form>
128
+ );
129
+ }
130
+
34
131
  function EvalInner({ setActiveTab }: Props) {
35
132
  const toast = useToast();
36
133
  const [runs, setRuns] = useState<EvalRunSummary[]>([]);
37
134
  const [loading, setLoading] = useState(true);
135
+ const [subTab, setSubTab] = useState<'runs' | 'schedules'>('runs');
136
+ const [schedules, setSchedules] = useState<EvalSchedule[]>([]);
137
+ const [loadingSchedules, setLoadingSchedules] = useState(false);
138
+ const [showAddSchedule, setShowAddSchedule] = useState(false);
38
139
  const cancelledRef = useRef(false);
39
140
 
40
141
  const load = useCallback(async () => {
@@ -51,11 +152,36 @@ function EvalInner({ setActiveTab }: Props) {
51
152
  }
52
153
  }, [toast]);
53
154
 
155
+ const loadSchedules = useCallback(async () => {
156
+ setLoadingSchedules(true);
157
+ try {
158
+ const r = await api.get<{ schedules: EvalSchedule[] }>('/eval/schedules');
159
+ if (!cancelledRef.current) setSchedules(r.schedules || []);
160
+ } catch (err) {
161
+ if (!cancelledRef.current) {
162
+ toast.error(`Failed to load eval schedules: ${(err as Error).message}`);
163
+ }
164
+ } finally {
165
+ if (!cancelledRef.current) setLoadingSchedules(false);
166
+ }
167
+ }, [toast]);
168
+
54
169
  useEffect(() => {
55
170
  cancelledRef.current = false;
56
171
  load();
172
+ if (subTab === 'schedules') loadSchedules();
57
173
  return () => { cancelledRef.current = true; };
58
- }, [load]);
174
+ }, [load, loadSchedules, subTab]);
175
+
176
+ const deleteSchedule = async (id: string) => {
177
+ try {
178
+ await api.del(`/eval/schedules/${id}`);
179
+ setSchedules((prev) => prev.filter((s) => s.id !== id));
180
+ toast.success('Schedule deleted');
181
+ } catch (err) {
182
+ toast.error(`Failed to delete schedule: ${(err as Error).message}`);
183
+ }
184
+ };
59
185
 
60
186
  const recent = runs.slice(0, RECENT_RUNS_LIMIT);
61
187
  const totals = runs.reduce(
@@ -139,30 +265,104 @@ function EvalInner({ setActiveTab }: Props) {
139
265
  </div>
140
266
 
141
267
  <div className="eval-recent-section">
142
- <h2 className="eval-section-title">Recent runs</h2>
143
- {loading && recent.length === 0 ? (
144
- <div className="eval-runs-loading">
145
- <Spinner size="sm" />
146
- <span className="muted">Loading runs…</span>
147
- </div>
148
- ) : recent.length === 0 ? (
149
- <EmptyState
150
- icon={<ClipboardCheck size={28} />}
151
- title="No eval runs yet"
152
- message={
153
- <>
154
- Run a suite via the CLI:{' '}
155
- <code className="mono">bizar eval run ./path/to/fixtures</code>.
156
- Results will appear here.
157
- </>
158
- }
159
- />
268
+ <div className="eval-sub-tabs">
269
+ <button
270
+ type="button"
271
+ className={cn('eval-sub-tab', subTab === 'runs' ? 'is-active' : '')}
272
+ onClick={() => setSubTab('runs')}
273
+ >
274
+ <ClipboardCheck size={12} aria-hidden /> Runs
275
+ </button>
276
+ <button
277
+ type="button"
278
+ className={cn('eval-sub-tab', subTab === 'schedules' ? 'is-active' : '')}
279
+ onClick={() => { setSubTab('schedules'); if (schedules.length === 0) loadSchedules(); }}
280
+ >
281
+ <Clock size={12} aria-hidden /> Schedules
282
+ </button>
283
+ </div>
284
+
285
+ {subTab === 'runs' ? (
286
+ <>
287
+ <h2 className="eval-section-title">Recent runs</h2>
288
+ {loading && recent.length === 0 ? (
289
+ <div className="eval-runs-loading">
290
+ <Spinner size="sm" />
291
+ <span className="muted">Loading runs…</span>
292
+ </div>
293
+ ) : recent.length === 0 ? (
294
+ <EmptyState
295
+ icon={<ClipboardCheck size={28} />}
296
+ title="No eval runs yet"
297
+ message={
298
+ <>
299
+ Run a suite via the CLI:{' '}
300
+ <code className="mono">bizar eval run ./path/to/fixtures</code>.
301
+ Results will appear here.
302
+ </>
303
+ }
304
+ />
305
+ ) : (
306
+ <div className="eval-runs-list">
307
+ {recent.map((r) => (
308
+ <EvalRunCard key={r.id} run={r} />
309
+ ))}
310
+ </div>
311
+ )}
312
+ </>
160
313
  ) : (
161
- <div className="eval-runs-list">
162
- {recent.map((r) => (
163
- <EvalRunCard key={r.id} run={r} />
164
- ))}
165
- </div>
314
+ <>
315
+ <h2 className="eval-section-title">Schedules</h2>
316
+ {loadingSchedules ? (
317
+ <div className="eval-runs-loading">
318
+ <Spinner size="sm" />
319
+ <span className="muted">Loading schedules…</span>
320
+ </div>
321
+ ) : schedules.length === 0 ? (
322
+ <EmptyState
323
+ icon={<Clock size={28} />}
324
+ title="No eval schedules"
325
+ message="Schedules let you run eval suites automatically on a cron. Add one below."
326
+ />
327
+ ) : (
328
+ <div className="eval-schedules-list">
329
+ {schedules.map((s) => (
330
+ <Card key={s.id} className="eval-schedule-card">
331
+ <div className="eval-schedule-row">
332
+ <div className="eval-schedule-info">
333
+ <CardTitle>{s.name}</CardTitle>
334
+ <CardMeta className="muted">
335
+ {s.cron} · {s.suitePath} · agent:{s.agent || 'thor'}
336
+ </CardMeta>
337
+ </div>
338
+ <Button
339
+ variant="ghost"
340
+ size="sm"
341
+ onClick={() => deleteSchedule(s.id)}
342
+ title="Delete schedule"
343
+ >
344
+ <Trash2 size={12} aria-hidden />
345
+ </Button>
346
+ </div>
347
+ </Card>
348
+ ))}
349
+ </div>
350
+ )}
351
+ <div className="eval-schedule-add">
352
+ <AddScheduleForm
353
+ onAdded={(newSchedule) => {
354
+ setSchedules((prev) => [...prev, newSchedule]);
355
+ setShowAddSchedule(false);
356
+ }}
357
+ onCancel={() => setShowAddSchedule(false)}
358
+ />
359
+ {!showAddSchedule && (
360
+ <Button variant="secondary" size="sm" onClick={() => setShowAddSchedule(true)}>
361
+ <Plus size={12} aria-hidden /> Add schedule
362
+ </Button>
363
+ )}
364
+ </div>
365
+ </>
166
366
  )}
167
367
  </div>
168
368
  </div>