@polderlabs/bizar 5.0.0 → 5.0.2

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 (97) hide show
  1. package/bizar-dash/dist/assets/{icons-CFqu2M-c.js → icons-Bo0iH9EC.js} +166 -121
  2. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
  4. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
  6. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
  7. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
  8. package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js → mobile-DD-FZrTC.js} +1 -1
  9. package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js.map → mobile-DD-FZrTC.js.map} +1 -1
  10. package/bizar-dash/dist/index.html +4 -4
  11. package/bizar-dash/dist/mobile.html +3 -3
  12. package/bizar-dash/node_modules/.package-lock.json +6 -0
  13. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  14. package/bizar-dash/package-lock.json +6 -0
  15. package/bizar-dash/src/server/api.mjs +7 -0
  16. package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
  17. package/bizar-dash/src/server/memory-store.mjs +46 -0
  18. package/bizar-dash/src/server/providers-store.mjs +11 -4
  19. package/bizar-dash/src/server/routes/_shared.mjs +2 -2
  20. package/bizar-dash/src/server/routes/doctor.mjs +71 -0
  21. package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
  22. package/bizar-dash/src/server/routes/memory.mjs +3 -0
  23. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
  24. package/bizar-dash/src/server/routes/schedules.mjs +55 -0
  25. package/bizar-dash/src/server/serve-info.mjs +172 -0
  26. package/bizar-dash/src/web/App.tsx +48 -11
  27. package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
  28. package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
  29. package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
  30. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
  31. package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
  32. package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
  33. package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
  34. package/bizar-dash/src/web/components/Topbar.tsx +11 -0
  35. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
  36. package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
  37. package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
  38. package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
  39. package/bizar-dash/src/web/lib/types.ts +134 -0
  40. package/bizar-dash/src/web/styles/chat.css +8 -5
  41. package/bizar-dash/src/web/styles/main.css +77 -2
  42. package/bizar-dash/src/web/styles/settings.css +265 -0
  43. package/bizar-dash/src/web/views/Chat.tsx +15 -1
  44. package/bizar-dash/src/web/views/Doctor.tsx +317 -0
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
  46. package/bizar-dash/src/web/views/Schedules.tsx +94 -12
  47. package/bizar-dash/src/web/views/Settings.tsx +76 -46
  48. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
  49. package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
  50. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
  51. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
  52. package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
  53. package/bizar-dash/tests/autosave.test.tsx +276 -0
  54. package/bizar-dash/tests/chat-composer.test.tsx +140 -0
  55. package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
  56. package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
  57. package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
  58. package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
  59. package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
  60. package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
  61. package/bizar-dash/tests/minimax-models.test.mjs +123 -0
  62. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
  63. package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
  64. package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
  65. package/bizar-dash/tests/settings-layout.test.tsx +129 -0
  66. package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
  67. package/bizar-dash/tests/settings-nav.test.tsx +126 -0
  68. package/cli/commands/dash.mjs +5 -1
  69. package/cli/commands/minimax.mjs +5 -2
  70. package/cli/tests/minimax-cli.test.mjs +79 -0
  71. package/config/agents/frigg.md +1 -1
  72. package/config/agents/heimdall.md +1 -1
  73. package/config/agents/mimir.md +1 -1
  74. package/config/agents/quick.md +1 -1
  75. package/config/agents/semble-search.md +1 -1
  76. package/config/agents/vor.md +1 -1
  77. package/config/opencode.json +28 -21
  78. package/config/opencode.json.template +8 -1
  79. package/package.json +1 -1
  80. package/plugins/bizar/index.ts +77 -0
  81. package/plugins/bizar/src/compaction.d.mts +48 -0
  82. package/plugins/bizar/src/compaction.mjs +192 -0
  83. package/plugins/bizar/tests/compaction.test.ts +264 -0
  84. package/templates/schedules/daily-backup.json +12 -0
  85. package/templates/schedules/daily-cleanup.json +12 -0
  86. package/templates/schedules/hourly-health-check.json +12 -0
  87. package/templates/schedules/webhook-on-push.json +13 -0
  88. package/templates/schedules/weekly-digest.json +13 -0
  89. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +0 -1
  90. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +0 -16
  91. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +0 -1
  92. package/bizar-dash/dist/assets/main-ZAfGKENE.css +0 -1
  93. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +0 -2
  94. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +0 -1
  95. package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
  96. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
  97. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
@@ -413,6 +413,92 @@
413
413
  flex-wrap: wrap;
414
414
  }
415
415
 
416
+ /* Bulk import panel */
417
+ .env-var-bulk-panel {
418
+ display: flex;
419
+ flex-direction: column;
420
+ gap: var(--space-2);
421
+ padding: 12px;
422
+ background: var(--bg-elev-2);
423
+ border: 1px solid var(--accent-border);
424
+ border-radius: var(--radius);
425
+ }
426
+
427
+ .env-var-bulk-header {
428
+ display: flex;
429
+ justify-content: space-between;
430
+ align-items: center;
431
+ font-size: 12px;
432
+ }
433
+
434
+ .env-var-bulk-textarea {
435
+ resize: vertical;
436
+ min-height: 80px;
437
+ font-size: 12px;
438
+ }
439
+
440
+ .env-var-bulk-footer {
441
+ display: flex;
442
+ align-items: center;
443
+ gap: var(--space-2);
444
+ }
445
+
446
+ /* Search */
447
+ .env-var-search-wrap {
448
+ position: relative;
449
+ display: flex;
450
+ align-items: center;
451
+ }
452
+
453
+ .env-var-search-icon {
454
+ position: absolute;
455
+ left: 8px;
456
+ color: var(--text-dim);
457
+ pointer-events: none;
458
+ }
459
+
460
+ .env-var-search {
461
+ padding-left: 26px !important;
462
+ width: 160px;
463
+ font-size: 12px;
464
+ }
465
+
466
+ /* Groups */
467
+ .env-var-groups {
468
+ display: flex;
469
+ flex-direction: column;
470
+ gap: var(--space-3);
471
+ }
472
+
473
+ .env-var-group-header {
474
+ display: flex;
475
+ align-items: center;
476
+ gap: 6px;
477
+ padding: 4px 8px;
478
+ background: var(--bg-elev-1);
479
+ border: none;
480
+ border-radius: var(--radius-sm);
481
+ cursor: pointer;
482
+ font-size: 12px;
483
+ color: var(--text);
484
+ width: 100%;
485
+ text-align: left;
486
+ transition: background var(--motion-fast);
487
+ }
488
+
489
+ .env-var-group-header:hover {
490
+ background: var(--bg-elev-2);
491
+ }
492
+
493
+ .env-var-group-prefix {
494
+ font-weight: 600;
495
+ color: var(--accent);
496
+ }
497
+
498
+ .env-var-group-count {
499
+ font-size: 11px;
500
+ }
501
+
416
502
  /* Memory section */
417
503
  .memory-field-row {
418
504
  display: grid;
@@ -496,3 +582,182 @@
496
582
  grid-template-columns: 1fr;
497
583
  }
498
584
  }
585
+
586
+ /* ─── Autosave field status indicator ─── */
587
+ .autosave-field {
588
+ position: relative;
589
+ display: inline-block;
590
+ width: 100%;
591
+ }
592
+
593
+ .autosave-status {
594
+ position: absolute;
595
+ right: 8px;
596
+ top: 50%;
597
+ transform: translateY(-50%);
598
+ display: inline-flex;
599
+ align-items: center;
600
+ gap: 4px;
601
+ color: var(--success, #3fb950);
602
+ font-size: 11px;
603
+ opacity: 0;
604
+ transition: opacity 200ms ease-out;
605
+ pointer-events: none;
606
+ }
607
+
608
+ .autosave-field.saved .autosave-status,
609
+ .autosave-field.saving .autosave-status,
610
+ .autosave-field.error .autosave-status {
611
+ opacity: 1;
612
+ }
613
+
614
+ .autosave-field.error .autosave-status {
615
+ color: var(--error, #f85149);
616
+ }
617
+
618
+ .autosave-field.saving .autosave-status {
619
+ animation: autosave-save-pulse 800ms ease-out;
620
+ }
621
+
622
+ .autosave-field.saved .autosave-status {
623
+ animation: autosave-saved-fade 1500ms ease-out;
624
+ }
625
+
626
+ @keyframes autosave-save-pulse {
627
+ 0% { transform: translateY(-50%) scale(0.5); opacity: 0; }
628
+ 50% { transform: translateY(-50%) scale(1.2); opacity: 1; }
629
+ 100% { transform: translateY(-50%) scale(1); opacity: 1; }
630
+ }
631
+
632
+ @keyframes autosave-saved-fade {
633
+ 0% { transform: translateY(-50%) scale(0.5); opacity: 0; }
634
+ 30% { transform: translateY(-50%) scale(1.1); opacity: 1; }
635
+ 70% { transform: translateY(-50%) scale(1); opacity: 1; }
636
+ 100% { transform: translateY(-50%) scale(1); opacity: 0; }
637
+ }
638
+
639
+ .spinning {
640
+ animation: spin 1s linear infinite;
641
+ }
642
+
643
+ @keyframes spin {
644
+ from { transform: rotate(0deg); }
645
+ to { transform: rotate(360deg); }
646
+ }
647
+
648
+ /* ─────────────────────────────────────────────────────────────────────
649
+ v4.9.0 — Settings sidebar mode (sidebar layout only)
650
+ When settingsMode is active, the sidebar replaces the normal tab rail
651
+ with a full-width nav showing all settings sections grouped by category.
652
+ ───────────────────────────────────────────────────────────────────── */
653
+
654
+ /* Sidebar when in settings mode — full height, scrollable */
655
+ .sidebar-settings-mode {
656
+ width: var(--sidebar-width, 240px);
657
+ overflow-y: auto;
658
+ border-right: 1px solid var(--border);
659
+ background: var(--bg);
660
+ }
661
+
662
+ /* Settings nav root */
663
+ .settings-nav-root {
664
+ display: flex;
665
+ flex-direction: column;
666
+ padding: var(--space-3);
667
+ gap: 2px;
668
+ }
669
+
670
+ /* Back button */
671
+ .settings-nav-back {
672
+ display: flex;
673
+ align-items: center;
674
+ gap: 6px;
675
+ padding: 6px 8px;
676
+ border-radius: var(--radius);
677
+ border: none;
678
+ background: transparent;
679
+ color: var(--text-dim);
680
+ font-size: 13px;
681
+ font-weight: 500;
682
+ cursor: pointer;
683
+ transition: background var(--motion-fast), color var(--motion-fast);
684
+ margin-bottom: var(--space-2);
685
+ }
686
+
687
+ .settings-nav-back:hover {
688
+ background: var(--bg-elev-2);
689
+ color: var(--text);
690
+ }
691
+
692
+ .settings-nav-divider {
693
+ height: 1px;
694
+ background: var(--border);
695
+ margin: var(--space-2) 0;
696
+ }
697
+
698
+ /* Section groups */
699
+ .settings-nav-group {
700
+ margin-bottom: var(--space-3);
701
+ }
702
+
703
+ .settings-nav-group-label {
704
+ font-size: 10px;
705
+ text-transform: uppercase;
706
+ letter-spacing: 0.08em;
707
+ color: var(--text-dim);
708
+ font-weight: 600;
709
+ padding: 4px 8px 6px;
710
+ }
711
+
712
+ /* Individual nav items */
713
+ .settings-nav-item {
714
+ display: flex;
715
+ align-items: center;
716
+ gap: 8px;
717
+ padding: 6px 8px;
718
+ border-radius: var(--radius);
719
+ border: 1px solid transparent;
720
+ background: transparent;
721
+ color: var(--text-dim);
722
+ font-size: 13px;
723
+ font-weight: 500;
724
+ cursor: pointer;
725
+ text-align: left;
726
+ width: 100%;
727
+ transition: background var(--motion-fast), color var(--motion-fast), border-color var(--motion-fast);
728
+ }
729
+
730
+ .settings-nav-item:hover {
731
+ background: var(--bg-elev-2);
732
+ color: var(--text);
733
+ }
734
+
735
+ .settings-nav-item-active {
736
+ background: var(--accent-bg);
737
+ color: var(--accent-2);
738
+ border-color: var(--accent-border);
739
+ }
740
+
741
+ .settings-nav-item-active:hover {
742
+ background: var(--accent-bg);
743
+ color: var(--accent-2);
744
+ }
745
+
746
+ .settings-nav-item-icon {
747
+ flex-shrink: 0;
748
+ opacity: 0.7;
749
+ }
750
+
751
+ .settings-nav-item-active .settings-nav-item-icon {
752
+ opacity: 1;
753
+ }
754
+
755
+ .settings-nav-item-label {
756
+ flex: 1;
757
+ }
758
+
759
+ .settings-nav-item-chevron {
760
+ flex-shrink: 0;
761
+ opacity: 0.5;
762
+ }
763
+
@@ -354,7 +354,21 @@ export function Chat({
354
354
  </div>
355
355
  {chat.opencodeError && (
356
356
  <div className="chat-thread-error" role="alert">
357
- {chat.opencodeError}
357
+ <p className="chat-error-message">{chat.opencodeError}</p>
358
+ {chat.opencodeSuggestion && (
359
+ <p className="chat-error-suggestion">{chat.opencodeSuggestion}</p>
360
+ )}
361
+ {chat.activeOpencodeSessionId && (
362
+ <button
363
+ className="btn btn-sm"
364
+ type="button"
365
+ onClick={() =>
366
+ chat.loadOpencodeSession(chat.activeOpencodeSessionId!)
367
+ }
368
+ >
369
+ Retry
370
+ </button>
371
+ )}
358
372
  </div>
359
373
  )}
360
374
  </div>
@@ -0,0 +1,317 @@
1
+ // src/views/Doctor.tsx — v6.0.0 Doctor page.
2
+ //
3
+ // Full-page Doctor view: shows system health, service health, counts,
4
+ // recent errors, and action buttons. Refreshes every 30 seconds via
5
+ // the cheap `/api/doctor/health` endpoint and re-fetches the full
6
+ // snapshot on demand (initial mount, manual Refresh, or after a
7
+ // single-check POST so the operator sees the immediate result).
8
+ //
9
+ // The page deliberately leans on the existing Card component and the
10
+ // StatusBadge variants added in v6.0.0 (`ok`/`warn`/`fail`). Layout
11
+ // is a single column on narrow viewports, two columns on desktop —
12
+ // matching the rest of the dashboard's grid.
13
+
14
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
+ import {
16
+ CheckCircle2,
17
+ AlertTriangle,
18
+ AlertOctagon,
19
+ Download,
20
+ FileText,
21
+ RefreshCw,
22
+ PlayCircle,
23
+ Stethoscope,
24
+ Clock,
25
+ } from 'lucide-react';
26
+ import { Card, CardTitle, CardMeta } from '../components/Card';
27
+ import { Button } from '../components/Button';
28
+ import { DoctorPanel } from '../components/DoctorPanel';
29
+ import { StatusBadge } from '../components/StatusBadge';
30
+ import { Spinner } from '../components/Spinner';
31
+ import { useToast } from '../components/Toast';
32
+ import { api } from '../lib/api';
33
+ import { cn, formatTime } from '../lib/utils';
34
+ import type { DoctorSnapshot, DoctorCheck, Settings, Snapshot } from '../lib/types';
35
+
36
+ const REFRESH_INTERVAL_MS = 30_000;
37
+
38
+ type Props = {
39
+ snapshot: Snapshot;
40
+ settings: Settings;
41
+ activeTab: string;
42
+ setActiveTab: (id: string) => void;
43
+ refreshSnapshot: () => Promise<void>;
44
+ };
45
+
46
+ function DoctorInner({
47
+ setActiveTab,
48
+ }: Props) {
49
+ const toast = useToast();
50
+ const [data, setData] = useState<DoctorSnapshot | null>(null);
51
+ const [loading, setLoading] = useState(true);
52
+ const [refreshing, setRefreshing] = useState(false);
53
+ const [lastFetched, setLastFetched] = useState<number | null>(null);
54
+ const [autoRefresh, setAutoRefresh] = useState(true);
55
+ const cancelledRef = useRef(false);
56
+
57
+ const fetchSnapshot = useCallback(async () => {
58
+ setRefreshing(true);
59
+ try {
60
+ const r = await api.get<DoctorSnapshot>('/doctor');
61
+ if (!cancelledRef.current) {
62
+ setData(r);
63
+ setLastFetched(Date.now());
64
+ setLoading(false);
65
+ }
66
+ } catch (err) {
67
+ if (!cancelledRef.current) {
68
+ setLoading(false);
69
+ toast.error(`Doctor refresh failed: ${(err as Error).message}`);
70
+ }
71
+ } finally {
72
+ if (!cancelledRef.current) setRefreshing(false);
73
+ }
74
+ }, [toast]);
75
+
76
+ // Initial fetch on mount
77
+ useEffect(() => {
78
+ cancelledRef.current = false;
79
+ fetchSnapshot();
80
+ return () => { cancelledRef.current = true; };
81
+ }, [fetchSnapshot]);
82
+
83
+ // 30s auto-refresh via the cheap health endpoint so the page
84
+ // stays current without burning a full snapshot every tick. We
85
+ // only trigger a full snapshot fetch if status changed.
86
+ useEffect(() => {
87
+ if (!autoRefresh) return;
88
+ const tick = async () => {
89
+ try {
90
+ const r = await api.get<{ status: string; issues: DoctorCheck[] }>('/doctor/health');
91
+ setData((cur) => {
92
+ if (!cur) return cur;
93
+ if (cur.health.status !== r.status || cur.health.issues.length !== r.issues.length) {
94
+ // Status changed — re-fetch the full snapshot
95
+ fetchSnapshot();
96
+ }
97
+ return cur;
98
+ });
99
+ } catch { /* non-fatal */ }
100
+ };
101
+ const id = setInterval(tick, REFRESH_INTERVAL_MS);
102
+ return () => clearInterval(id);
103
+ }, [autoRefresh, fetchSnapshot]);
104
+
105
+ const onRunCheck = async (checkName: string) => {
106
+ try {
107
+ const r = await api.post<DoctorCheck>('/doctor/check', { checkName });
108
+ toast.info(`${r.name}: ${r.message}`, 4000);
109
+ // Re-fetch so the panel reflects the fresh result
110
+ await fetchSnapshot();
111
+ } catch (err) {
112
+ const apiErr = err as { status?: number; data?: { message?: string } };
113
+ toast.error(`Check failed: ${apiErr.data?.message ?? (err as Error).message}`);
114
+ }
115
+ };
116
+
117
+ const onExportJson = () => {
118
+ if (!data) return;
119
+ try {
120
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
121
+ const url = URL.createObjectURL(blob);
122
+ const a = document.createElement('a');
123
+ a.href = url;
124
+ a.download = `doctor-snapshot-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
125
+ document.body.appendChild(a);
126
+ a.click();
127
+ document.body.removeChild(a);
128
+ URL.revokeObjectURL(url);
129
+ } catch (err) {
130
+ toast.error(`Export failed: ${(err as Error).message}`);
131
+ }
132
+ };
133
+
134
+ const onViewLogs = () => {
135
+ // Logs live on the Settings → Activity Log surface (already
136
+ // implemented). Set the tab and let the user browse.
137
+ setActiveTab('settings');
138
+ };
139
+
140
+ const counts = data?.counts;
141
+ const countsInfo = useMemo(() => {
142
+ if (!counts) return [];
143
+ return [
144
+ { label: 'Tasks', value: counts.tasks },
145
+ { label: 'Schedules', value: counts.schedules },
146
+ { label: 'Mods', value: counts.mods },
147
+ { label: 'Providers', value: counts.providers },
148
+ { label: 'MCPs', value: counts.mcps },
149
+ { label: 'Agents', value: counts.agents },
150
+ { label: 'Projects', value: counts.projects },
151
+ { label: 'Workspaces', value: counts.workspaces },
152
+ { label: 'Voice notes', value: counts.voiceNotes },
153
+ { label: 'Eval runs', value: counts.evalRuns },
154
+ { label: 'Backups', value: counts.backups },
155
+ ];
156
+ }, [counts]);
157
+
158
+ if (loading) {
159
+ return (
160
+ <div className="view view-doctor view-loading">
161
+ <Spinner size="lg" />
162
+ <p>Running health checks…</p>
163
+ </div>
164
+ );
165
+ }
166
+
167
+ if (!data) {
168
+ return (
169
+ <div className="view view-doctor">
170
+ <Card>
171
+ <CardTitle>
172
+ <AlertOctagon size={14} /> Doctor unavailable
173
+ </CardTitle>
174
+ <CardMeta>Could not reach /api/doctor.</CardMeta>
175
+ <div className="doctor-fallback-actions">
176
+ <Button variant="primary" onClick={fetchSnapshot}>
177
+ <RefreshCw size={12} /> Retry
178
+ </Button>
179
+ </div>
180
+ </Card>
181
+ </div>
182
+ );
183
+ }
184
+
185
+ const overall = data.health.status;
186
+ const OverallIcon =
187
+ overall === 'ok' ? CheckCircle2 : overall === 'warn' ? AlertTriangle : AlertOctagon;
188
+ const overallLabel =
189
+ overall === 'ok' ? 'Healthy' : overall === 'warn' ? 'Warning' : 'Failed';
190
+
191
+ return (
192
+ <div className="view view-doctor" data-overall={overall}>
193
+ <div className="doctor-header">
194
+ <div className="doctor-header-row">
195
+ <div className="doctor-header-title">
196
+ <Stethoscope size={20} aria-hidden />
197
+ <h1>Doctor</h1>
198
+ </div>
199
+ <div className="doctor-header-status">
200
+ <span className={cn('doctor-overall-pill', `doctor-overall-${overall}`)}>
201
+ <OverallIcon size={14} aria-hidden />
202
+ <span className="doctor-overall-label">{overallLabel}</span>
203
+ <StatusBadge kind={overall} dot>
204
+ {data.health.issues.length} issue{data.health.issues.length === 1 ? '' : 's'}
205
+ </StatusBadge>
206
+ </span>
207
+ </div>
208
+ </div>
209
+ <div className="doctor-header-meta muted">
210
+ <Clock size={12} aria-hidden />
211
+ <span>
212
+ v{data.bizarVersion} · {data.platform}/{data.arch} ·{' '}
213
+ up {Math.floor(data.uptime)}s
214
+ {lastFetched ? ` · refreshed ${formatTime(new Date(lastFetched))}` : ''}
215
+ </span>
216
+ </div>
217
+ </div>
218
+
219
+ <div className="doctor-grid">
220
+ <DoctorPanel
221
+ category="system"
222
+ checks={data.checks.system}
223
+ meta={`${data.platform}/${data.arch}`}
224
+ />
225
+ <DoctorPanel
226
+ category="services"
227
+ checks={data.checks.services}
228
+ meta={
229
+ data.services.opencode?.reachable
230
+ ? `opencode on ${data.services.opencode.port ?? '?'}`
231
+ : 'opencode not connected'
232
+ }
233
+ />
234
+ <DoctorPanel
235
+ category="counts"
236
+ checks={[]}
237
+ info={countsInfo}
238
+ meta={counts?.activeProject ? `active: ${counts.activeProject}` : 'no active project'}
239
+ />
240
+ <Card className="doctor-errors-card">
241
+ <CardTitle>
242
+ <AlertOctagon size={14} aria-hidden />
243
+ <span>Recent errors (last hour)</span>
244
+ <span className="card-meta-inline muted">
245
+ {data.recentErrors.length} line{data.recentErrors.length === 1 ? '' : 's'}
246
+ </span>
247
+ </CardTitle>
248
+ {data.recentErrors.length === 0 ? (
249
+ <div className="muted doctor-errors-empty">No errors in the last hour. Nice.</div>
250
+ ) : (
251
+ <ul className="doctor-errors-list" role="list">
252
+ {data.recentErrors.map((e, idx) => (
253
+ <li key={`${e.tsMs ?? idx}-${idx}`} className="doctor-error-row">
254
+ <span className="doctor-error-ts mono">
255
+ {e.ts ? formatTime(new Date(e.ts)) : '??:??:??'}
256
+ </span>
257
+ <span className="doctor-error-line mono">{e.line}</span>
258
+ </li>
259
+ ))}
260
+ </ul>
261
+ )}
262
+ </Card>
263
+ <Card className="doctor-actions-card">
264
+ <CardTitle>
265
+ <PlayCircle size={14} aria-hidden /> Actions
266
+ </CardTitle>
267
+ <CardMeta>Run a check, view logs, or export the snapshot.</CardMeta>
268
+ <div className="doctor-actions-row">
269
+ <Button variant="primary" onClick={fetchSnapshot} disabled={refreshing}>
270
+ {refreshing ? <Spinner size="sm" /> : <RefreshCw size={12} />}
271
+ {refreshing ? 'Running…' : 'Run Health Check Now'}
272
+ </Button>
273
+ <Button variant="secondary" onClick={onViewLogs}>
274
+ <FileText size={12} /> View Full Logs
275
+ </Button>
276
+ <Button variant="secondary" onClick={onExportJson}>
277
+ <Download size={12} /> Export Diagnostics as JSON
278
+ </Button>
279
+ <Button
280
+ variant="ghost"
281
+ onClick={() => setAutoRefresh((v) => !v)}
282
+ title="Toggle auto-refresh"
283
+ >
284
+ {autoRefresh ? 'Pause auto-refresh' : 'Resume auto-refresh'}
285
+ </Button>
286
+ </div>
287
+ {data.checks.services.some((c) => c.status !== 'ok') ||
288
+ data.checks.config.some((c) => c.status !== 'ok') ? (
289
+ <div className="doctor-actions-rerun">
290
+ <span className="muted">Rerun a single check:</span>
291
+ <div className="doctor-actions-rerun-row">
292
+ {[
293
+ ...data.checks.services,
294
+ ...data.checks.config,
295
+ ]
296
+ .filter((c) => c.status !== 'ok')
297
+ .map((c) => (
298
+ <Button
299
+ key={c.name}
300
+ variant="ghost"
301
+ size="sm"
302
+ onClick={() => onRunCheck(c.name)}
303
+ title={`Run check: ${c.name}`}
304
+ >
305
+ <PlayCircle size={10} /> {c.name}
306
+ </Button>
307
+ ))}
308
+ </div>
309
+ </div>
310
+ ) : null}
311
+ </Card>
312
+ </div>
313
+ </div>
314
+ );
315
+ }
316
+
317
+ export const Doctor = React.memo(DoctorInner);
@@ -784,6 +784,7 @@ function QuotaBar({ icon, label, remainingPct, consumedPct, used, total, resetIn
784
784
  <div className="minimax-quota-head">
785
785
  <span className="minimax-quota-label">{icon} {label}</span>
786
786
  <span className="minimax-quota-remaining">{remainingPct}% remaining</span>
787
+ <span className="minimax-quota-consumed">· {consumedPct}% used</span>
787
788
  </div>
788
789
  <div className="minimax-bar">
789
790
  <div className="minimax-bar-fill" style={{ width: `${consumedPct}%` }} aria-label={`${consumedPct}% consumed`} />