@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
@@ -5,9 +5,17 @@
5
5
  // in use, a section for usage/cost placeholders (filled in by the
6
6
  // MiniMax usage module when present), and rename / delete / export
7
7
  // actions wired to the parent.
8
+ //
9
+ // v5.0.0 — bug #4 fix: the panel now renders a structured error
10
+ // section when the opencode session load fails. The chat-thread
11
+ // header (rendered by Chat.tsx) also shows the error inline; the
12
+ // info-panel error makes it impossible to miss when the right
13
+ // sidebar is in view, and exposes the server's `suggestion` and a
14
+ // Retry button so the operator can act without scrolling.
8
15
 
9
16
  import { useEffect, useState } from 'react';
10
17
  import {
18
+ AlertTriangle,
11
19
  MessageSquare,
12
20
  Bot,
13
21
  Server,
@@ -16,6 +24,7 @@ import {
16
24
  Trash2,
17
25
  Download,
18
26
  CircleDollarSign,
27
+ RotateCw,
19
28
  } from 'lucide-react';
20
29
  import type { ChatMessage } from '../../lib/types';
21
30
 
@@ -36,6 +45,23 @@ interface Props {
36
45
  onExport?: () => void;
37
46
  /** Per-action busy flags — disable buttons while pending. */
38
47
  busy?: { rename?: boolean; delete?: boolean };
48
+ /**
49
+ * v5.0.0 — bug #4: structured error envelope from `loadOpencodeSession`.
50
+ * When present, the panel renders an error section above the Session
51
+ * section so the operator can see what went wrong without scrolling.
52
+ */
53
+ error?: {
54
+ code: string;
55
+ message: string;
56
+ suggestion?: string | null;
57
+ canRetry?: boolean;
58
+ } | null;
59
+ /**
60
+ * v5.0.0 — bug #4: retry handler invoked when the operator clicks
61
+ * the Retry button on the error section. Wired to
62
+ * `chat.retryOpencodeSession` by Chat.tsx.
63
+ */
64
+ onRetry?: () => void;
39
65
  }
40
66
 
41
67
  interface UsageRow {
@@ -86,6 +112,8 @@ export function ChatInfoPanel({
86
112
  onDelete,
87
113
  onExport,
88
114
  busy,
115
+ error,
116
+ onRetry,
89
117
  }: Props) {
90
118
  // Best-effort: pull usage summary from the MiniMax usage endpoint
91
119
  // when the session is opencode. Falls back silently if the module
@@ -129,6 +157,42 @@ export function ChatInfoPanel({
129
157
 
130
158
  return (
131
159
  <aside className="chat-info">
160
+ {error && (
161
+ <div
162
+ className="chat-info-section chat-info-error"
163
+ role="alert"
164
+ aria-live="polite"
165
+ >
166
+ <h4>
167
+ <AlertTriangle
168
+ size={11}
169
+ style={{ marginRight: 4, verticalAlign: -1 }}
170
+ aria-hidden
171
+ />{' '}
172
+ Couldn't load session
173
+ </h4>
174
+ <p className="chat-info-value chat-ellipsis" title={error.message}>
175
+ {error.message}
176
+ </p>
177
+ <div className="chat-info-mono">
178
+ code · {error.code}
179
+ </div>
180
+ {error.suggestion ? (
181
+ <p className="chat-info-suggestion">{error.suggestion}</p>
182
+ ) : null}
183
+ {error.canRetry !== false && onRetry ? (
184
+ <button
185
+ type="button"
186
+ className="btn btn-ghost btn-sm chat-info-retry"
187
+ onClick={onRetry}
188
+ title="Retry loading the session"
189
+ >
190
+ <RotateCw size={12} aria-hidden /> Retry
191
+ </button>
192
+ ) : null}
193
+ </div>
194
+ )}
195
+
132
196
  <div className="chat-info-section">
133
197
  <div className="chat-info-section-head">
134
198
  <h4>
@@ -41,7 +41,7 @@ export function Composer({
41
41
  }: Props) {
42
42
  const inputRef = useRef<HTMLTextAreaElement>(null);
43
43
  const fileInputRef = useRef<HTMLInputElement>(null);
44
- useAutoGrowTextarea(inputRef, text, 200);
44
+ useAutoGrowTextarea(inputRef, text, 160);
45
45
 
46
46
  const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
47
47
  if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
@@ -5,6 +5,13 @@
5
5
  // - bizar (GET /api/chat)
6
6
  // - opencode (GET /api/opencode-sessions/:id/messages + SSE)
7
7
  //
8
+ // v5.0.0 — bug #4 fix: `loadOpencodeSession` failure path now extracts
9
+ // structured `code`, `cause`, `status`, and `suggestion` from the
10
+ // server's ApiError.data envelope and exposes them via a new
11
+ // `opencodeErrorInfo` field. `ChatInfoPanel` consumes that field to
12
+ // render a structured "Couldn't load session" panel with the server's
13
+ // `suggestion` verbatim, plus a Retry button.
14
+ //
8
15
  // Changes from v3.22:
9
16
  // * SSE has automatic reconnect-with-backoff (1s → 30s, doubled on
10
17
  // each error up to a cap). Closes cleanly on unmount and on
@@ -59,6 +66,31 @@ export interface ChatBusyState {
59
66
  delete: boolean;
60
67
  }
61
68
 
69
+ /**
70
+ * Structured error envelope surfaced by `loadOpencodeSession` when
71
+ * the upstream `/api/opencode-sessions/:id/messages` call fails.
72
+ *
73
+ * - `code` mirrors the server's `error` field (`plugin_offline`,
74
+ * `directory_unknown`, `opencode_error`, `bad_request`).
75
+ * - `cause` is the server's structured cause (`network`, `timeout`,
76
+ * `unknown`, or a raw ECONNREFUSED-style string).
77
+ * - `suggestion` is the operator-friendly hint from the server
78
+ * (e.g. "Run `bizar doctor` for diagnostics").
79
+ * - `canRetry` is `true` whenever the failure is transient (network
80
+ * error, upstream 502) — i.e. the user can fix it by trying again
81
+ * after starting the plugin / waiting for opencode to come back.
82
+ * False for hard failures (bad_request).
83
+ */
84
+ export interface OpencodeErrorInfo {
85
+ type: 'session_load_failed';
86
+ code: string;
87
+ message: string;
88
+ cause?: string;
89
+ status?: number;
90
+ suggestion?: string | null;
91
+ canRetry: boolean;
92
+ }
93
+
62
94
  /** Shape of one opencode SSE envelope after unwrapping. */
63
95
  interface OpencodeSseEnvelope {
64
96
  type: string;
@@ -95,6 +127,14 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
95
127
  const [activeSource, setActiveSource] = useState<'bizar' | 'opencode' | null>(null);
96
128
  const [activeOpencodeSessionId, setActiveOpencodeSessionId] = useState<string | null>(null);
97
129
  const [opencodeError, setOpencodeError] = useState<string | null>(null);
130
+ const [opencodeSuggestion, setOpencodeSuggestion] = useState<string | null>(null);
131
+ /**
132
+ * Structured error info for the opencode session load path. New in
133
+ * v5.0.0 — `ChatInfoPanel` consumes this to render its own error
134
+ * section (in addition to the chat-thread-header error already
135
+ * rendered by Chat.tsx). `null` when no error.
136
+ */
137
+ const [opencodeErrorInfo, setOpencodeErrorInfo] = useState<OpencodeErrorInfo | null>(null);
98
138
 
99
139
  // ── v3.22 — session-state, unread, tree per session ────────────────────────
100
140
  const [sessionStates, setSessionStates] = useState<Record<string, SessionDisplayState>>({});
@@ -300,6 +340,8 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
300
340
  sseAutoReconnectRef.current = true;
301
341
  sseReconnectAttemptRef.current = 0;
302
342
  setOpencodeError(null);
343
+ setOpencodeSuggestion(null);
344
+ setOpencodeErrorInfo(null);
303
345
 
304
346
  const connect = () => {
305
347
  if (!sseAutoReconnectRef.current) return;
@@ -527,6 +569,8 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
527
569
  // Successful (re)connect — reset backoff.
528
570
  sseReconnectAttemptRef.current = 0;
529
571
  setOpencodeError(null);
572
+ setOpencodeSuggestion(null);
573
+ setOpencodeErrorInfo(null);
530
574
  };
531
575
  };
532
576
 
@@ -554,6 +598,8 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
554
598
  setOpencodeMessages([]);
555
599
  setSeenOpencodeMessages(new Set());
556
600
  setOpencodeError(null);
601
+ setOpencodeSuggestion(null);
602
+ setOpencodeErrorInfo(null);
557
603
  setActiveOpencodeSessionId(null);
558
604
  setActiveSource('bizar');
559
605
  }, []);
@@ -576,6 +622,8 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
576
622
  }
577
623
 
578
624
  setOpencodeError(null);
625
+ setOpencodeSuggestion(null);
626
+ setOpencodeErrorInfo(null);
579
627
  setLoading(true);
580
628
 
581
629
  try {
@@ -585,7 +633,62 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
585
633
  setOpencodeMessages(data.messages || []);
586
634
  } catch (err) {
587
635
  const msg = (err as Error).message;
588
- toastRef.current?.error(`Opencode session load failed: ${msg}`);
636
+ // v5.0.0 bug #4: surface the server's structured error
637
+ // envelope so the UI can render a useful suggestion instead
638
+ // of the raw upstream error string. The server returns
639
+ // `{error, message, cause?, status?, suggestion?}` for every
640
+ // non-2xx response (see serve-info.mjs + routes/opencode-
641
+ // session-detail.mjs).
642
+ let info: OpencodeErrorInfo | null = null;
643
+ if (err instanceof ApiError && err.data && typeof err.data === 'object') {
644
+ const d = err.data as {
645
+ error?: string;
646
+ message?: string;
647
+ cause?: string;
648
+ status?: number;
649
+ suggestion?: string;
650
+ };
651
+ // canRetry: 503/502 are transient (plugin offline /
652
+ // upstream hiccup); the operator can fix the plugin and
653
+ // try again. 400 is a hard bad_request — retrying with the
654
+ // same payload won't help.
655
+ const code = d.error || 'unknown';
656
+ const canRetry =
657
+ code !== 'bad_request' &&
658
+ code !== 'too_many_subscribers' &&
659
+ code !== 'unauthorized';
660
+ info = {
661
+ type: 'session_load_failed',
662
+ code,
663
+ message: d.message || msg,
664
+ cause: d.cause,
665
+ status: d.status,
666
+ suggestion: d.suggestion ?? null,
667
+ canRetry,
668
+ };
669
+ } else if (err instanceof ApiError) {
670
+ info = {
671
+ type: 'session_load_failed',
672
+ code: 'unknown',
673
+ message: msg,
674
+ suggestion: null,
675
+ canRetry: true,
676
+ };
677
+ }
678
+ // Legacy fields — kept so the existing Chat.tsx thread-header
679
+ // error display continues to work unchanged.
680
+ setOpencodeError(
681
+ info
682
+ ? `Opencode session load failed (${info.code}): ${info.message}`
683
+ : `Opencode session load failed: ${msg}`,
684
+ );
685
+ setOpencodeSuggestion(info?.suggestion ?? null);
686
+ setOpencodeErrorInfo(info);
687
+ toastRef.current?.error(
688
+ info
689
+ ? `Opencode session load failed: ${info.message}`
690
+ : `Opencode session load failed: ${msg}`,
691
+ );
589
692
  setOpencodeMessages([]);
590
693
  } finally {
591
694
  setLoading(false);
@@ -1039,6 +1142,20 @@ export function useChat(snapshot: Snapshot, settings: Settings, initialTaskId?:
1039
1142
  activeSource,
1040
1143
  activeOpencodeSessionId,
1041
1144
  opencodeError,
1145
+ opencodeSuggestion,
1146
+ /**
1147
+ * v5.0.0 — bug #4: structured error info consumed by
1148
+ * `ChatInfoPanel`. `null` when no error is active.
1149
+ */
1150
+ opencodeErrorInfo,
1151
+ /**
1152
+ * v5.0.0 — bug #4: retry helper exposed for `ChatInfoPanel`'s
1153
+ * Retry button. Calls `loadOpencodeSession` on the currently
1154
+ * active opencode session (or no-op if none is active).
1155
+ */
1156
+ retryOpencodeSession: () => {
1157
+ if (activeOpencodeSessionId) void loadOpencodeSession(activeOpencodeSessionId);
1158
+ },
1042
1159
  // ── v3.22 — per-session display state ──────────────────────────────────
1043
1160
  sessionStates,
1044
1161
  busy,
@@ -0,0 +1,107 @@
1
+ // src/web/hooks/useAutosave.ts
2
+ // Debounced autosave hook for settings fields.
3
+
4
+ import { useState, useEffect, useRef, useCallback } from 'react';
5
+
6
+ export type AutosaveStatus = 'idle' | 'saving' | 'saved' | 'error';
7
+
8
+ export function useAutosave<T>(
9
+ initialValue: T,
10
+ saveFn: (value: T) => Promise<void>,
11
+ {
12
+ delay = 800,
13
+ onSaved,
14
+ onError,
15
+ }: { delay?: number; onSaved?: () => void; onError?: (e: Error) => void } = {},
16
+ ) {
17
+ const [value, setValue] = useState<T>(initialValue);
18
+ const [status, setStatus] = useState<AutosaveStatus>('idle');
19
+ const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
20
+ const pendingValueRef = useRef<T | null>(null);
21
+ const lastSavedValue = useRef<T>(initialValue);
22
+ // Tracks whether a debounce timeout is currently pending (set when timeout is scheduled, cleared when it fires)
23
+ const hasPendingDebounceRef = useRef(false);
24
+
25
+ const save = useCallback(
26
+ async (v: T) => {
27
+ setStatus('saving');
28
+ try {
29
+ await saveFn(v);
30
+ lastSavedValue.current = v;
31
+ pendingValueRef.current = null;
32
+ setStatus('saved');
33
+ onSaved?.();
34
+ setTimeout(() => setStatus('idle'), 2000);
35
+ } catch (err) {
36
+ setStatus('error');
37
+ onError?.(err as Error);
38
+ }
39
+ },
40
+ [saveFn, onSaved, onError],
41
+ );
42
+
43
+ const setValueAndSave = useCallback(
44
+ (v: T) => {
45
+ setValue(v);
46
+ pendingValueRef.current = v;
47
+ hasPendingDebounceRef.current = true;
48
+ if (timeoutRef.current) clearTimeout(timeoutRef.current);
49
+ timeoutRef.current = setTimeout(() => {
50
+ timeoutRef.current = null;
51
+ hasPendingDebounceRef.current = false;
52
+ const toSave = pendingValueRef.current;
53
+ pendingValueRef.current = null;
54
+ if (toSave !== null) save(toSave);
55
+ }, delay);
56
+ },
57
+ [delay, save],
58
+ );
59
+
60
+ /**
61
+ * Clear any pending debounce without saving.
62
+ * Use this when you want the debounce to handle the save naturally.
63
+ */
64
+ const clearDebounce = useCallback(() => {
65
+ if (timeoutRef.current) {
66
+ clearTimeout(timeoutRef.current);
67
+ timeoutRef.current = null;
68
+ hasPendingDebounceRef.current = false;
69
+ }
70
+ }, []);
71
+
72
+ /**
73
+ * Flush any pending save immediately (called on unmount or explicit flush).
74
+ */
75
+ const flush = useCallback(async () => {
76
+ if (timeoutRef.current) {
77
+ clearTimeout(timeoutRef.current);
78
+ timeoutRef.current = null;
79
+ hasPendingDebounceRef.current = false;
80
+ const toSave = pendingValueRef.current;
81
+ pendingValueRef.current = null;
82
+ if (toSave !== null) await save(toSave);
83
+ }
84
+ }, [save]);
85
+
86
+ // Cleanup on unmount: fire pending save immediately
87
+ useEffect(() => {
88
+ return () => {
89
+ if (timeoutRef.current) {
90
+ clearTimeout(timeoutRef.current);
91
+ timeoutRef.current = null;
92
+ const toSave = pendingValueRef.current;
93
+ pendingValueRef.current = null;
94
+ // Fire-and-forget: call saveFn directly to persist without awaiting
95
+ if (toSave !== null) {
96
+ setStatus('saving');
97
+ saveFn(toSave)
98
+ .then(() => { lastSavedValue.current = toSave; setStatus('saved'); onSaved?.(); })
99
+ .catch((err: Error) => { setStatus('error'); onError?.(err); })
100
+ .finally(() => { setTimeout(() => setStatus('idle'), 2000); });
101
+ }
102
+ }
103
+ };
104
+ }, [saveFn, onSaved, onError]);
105
+
106
+ return { value, setValue: setValueAndSave, status, save, flush, clearDebounce, hasPendingDebounceRef, pendingValueRef };
107
+ }
@@ -535,6 +535,140 @@ export type TailscaleStatus = {
535
535
  settings: { enabled: boolean; port: number; https: boolean; hostname: string };
536
536
  };
537
537
 
538
+ // ────────────────────────────────────────────────────────────────────────
539
+ // v6.0.0 — Doctor page types
540
+ // ────────────────────────────────────────────────────────────────────────
541
+
542
+ export type DoctorStatus = 'ok' | 'warn' | 'fail';
543
+
544
+ export type DoctorCheck = {
545
+ name: string;
546
+ status: DoctorStatus;
547
+ message: string;
548
+ };
549
+
550
+ export type DoctorChecks = {
551
+ system: DoctorCheck[];
552
+ config: DoctorCheck[];
553
+ services: DoctorCheck[];
554
+ };
555
+
556
+ export type DoctorHealth = {
557
+ ts: string;
558
+ status: DoctorStatus;
559
+ issues: DoctorCheck[];
560
+ groups: DoctorChecks;
561
+ };
562
+
563
+ export type DoctorCounts = {
564
+ tasks: number;
565
+ schedules: number;
566
+ mods: number;
567
+ providers: number;
568
+ mcps: number;
569
+ agents: number;
570
+ projects: number;
571
+ activeProject: string | null;
572
+ workspaces: number;
573
+ voiceNotes: number;
574
+ evalRuns: number;
575
+ backups: number;
576
+ };
577
+
578
+ export type DoctorService = {
579
+ running: boolean;
580
+ port?: number;
581
+ host?: string;
582
+ pid?: number;
583
+ uptime?: number;
584
+ error?: string;
585
+ baseUrl?: string | null;
586
+ reachable?: boolean;
587
+ path?: string | null;
588
+ logPath?: string;
589
+ logExists?: boolean;
590
+ };
591
+
592
+ export type DoctorServices = {
593
+ dashboard: DoctorService;
594
+ headroom: DoctorService;
595
+ lightrag: DoctorService;
596
+ opencode: DoctorService;
597
+ };
598
+
599
+ export type DoctorRecentError = {
600
+ line: string;
601
+ ts: string | null;
602
+ tsMs: number | null;
603
+ };
604
+
605
+ export type DoctorOpencode = {
606
+ configExists: boolean;
607
+ configPath: string;
608
+ agentsDir: string;
609
+ agentFiles: string[];
610
+ dashboardConnected: boolean;
611
+ };
612
+
613
+ export type DoctorDisk = {
614
+ path: string;
615
+ exists: boolean;
616
+ sizeBytes: number;
617
+ };
618
+
619
+ export type DoctorSnapshot = {
620
+ timestamp: string;
621
+ bizarVersion: string;
622
+ nodeVersion: string;
623
+ platform: string;
624
+ arch: string;
625
+ uptime: number;
626
+ memory: NodeJS.MemoryUsage;
627
+ disk: DoctorDisk;
628
+ services: DoctorServices;
629
+ counts: DoctorCounts;
630
+ recentErrors: DoctorRecentError[];
631
+ configHealth: DoctorCheck[];
632
+ opencode: DoctorOpencode;
633
+ checks: DoctorChecks;
634
+ health: { status: DoctorStatus; issues: DoctorCheck[] };
635
+ };
636
+
637
+ /**
638
+ * The categories the Doctor page renders. Order is the render order.
639
+ * Each maps 1:1 to either a `DoctorChecks` group or a derived slice.
640
+ */
641
+ export type DoctorCategory =
642
+ | 'system'
643
+ | 'services'
644
+ | 'counts'
645
+ | 'errors'
646
+ | 'actions';
647
+
648
+ export type DoctorCheckOrInfo =
649
+ | DoctorCheck
650
+ | { kind: 'count'; label: string; value: number | string };
651
+
652
+ export type DoctorPanelProps = {
653
+ category: DoctorCategory;
654
+ /** Title shown in the card header. Optional — derived from category if omitted. */
655
+ title?: string;
656
+ /** Lucide icon for the header. Optional — derived from category if omitted. */
657
+ icon?: React.ComponentType<{ size?: number; 'aria-hidden'?: boolean }>;
658
+ /** Per-line entries. Each carries an implicit `ok | warn | fail` status pill. */
659
+ checks: DoctorCheck[];
660
+ /**
661
+ * Plain key/value rows that aren't checks (e.g. counts). Rendered
662
+ * as a compact two-column list below the check rows when present.
663
+ */
664
+ info?: Array<{ label: string; value: string | number }>;
665
+ /** Meta line shown beneath the title. */
666
+ meta?: string;
667
+ /** Optional custom body — bypasses the default check-list render. */
668
+ children?: React.ReactNode;
669
+ className?: string;
670
+ };
671
+
538
672
  export type SearchResult = {
539
673
  type: string;
540
674
  score: number;
@@ -892,13 +892,15 @@
892
892
  ============================================================ */
893
893
  .chat-composer-wrap {
894
894
  position: relative;
895
- padding: var(--space-3) var(--space-8) var(--space-5);
895
+ padding: 8px 16px 8px;
896
896
  border-top: 1px solid var(--border);
897
897
  background: var(--bg);
898
898
  }
899
899
  .chat-composer-pill {
900
- display: flex; align-items: center; gap: var(--space-2);
900
+ display: flex; align-items: flex-end; gap: var(--space-2);
901
901
  width: 100%;
902
+ min-height: 48px;
903
+ max-height: 200px;
902
904
  padding: 8px 8px 8px 16px;
903
905
  background: var(--bg-1);
904
906
  border: 1px solid var(--border);
@@ -1480,9 +1482,9 @@
1480
1482
  .chat-composer-textarea {
1481
1483
  flex: 1; min-width: 0; width: 100%; resize: none;
1482
1484
  background: transparent; border: none; outline: none;
1483
- color: var(--text); font: inherit; font-size: 0.9375rem;
1484
- line-height: 1.5; padding: 0.5rem 0.5rem;
1485
- max-height: 200px; overflow-y: auto;
1485
+ color: var(--text); font: inherit; font-size: 14px;
1486
+ line-height: 1.4; padding: 8px 12px;
1487
+ min-height: 24px; max-height: 160px; overflow-y: auto;
1486
1488
  }
1487
1489
  .chat-composer-textarea::placeholder { color: var(--text-muted); }
1488
1490
  .chat-attach-btn {
@@ -1494,6 +1496,7 @@
1494
1496
  .chat-attach-btn:hover { background: var(--bg-2); color: var(--text); }
1495
1497
  .chat-send-btn {
1496
1498
  flex-shrink: 0; display: flex; align-items: center; justify-content: center;
1499
+ align-self: flex-end;
1497
1500
  width: 32px; height: 32px; padding: 0; background: var(--accent);
1498
1501
  color: white; border: none; border-radius: var(--radius-sm); cursor: pointer;
1499
1502
  }
@@ -349,11 +349,25 @@ pre,
349
349
  width: 100%;
350
350
  display: flex;
351
351
  flex-direction: column;
352
- gap: var(--space-5);
352
+ gap: var(--space-6);
353
+ padding: var(--space-6) var(--space-6) 0;
353
354
  animation: view-in var(--motion-slow) var(--ease);
354
355
  min-height: 0;
355
356
  }
356
357
 
358
+ /* v4.x.x — Responsive page-level padding for .view */
359
+ @media (max-width: 1000px) {
360
+ .view {
361
+ padding: var(--space-4) var(--space-4) 0;
362
+ }
363
+ }
364
+
365
+ @media (max-width: 600px) {
366
+ .view {
367
+ padding: var(--space-3) var(--space-3) 0;
368
+ }
369
+ }
370
+
357
371
  @keyframes view-in {
358
372
  from {
359
373
  opacity: 0;
@@ -727,6 +741,35 @@ pre,
727
741
  flex-wrap: wrap;
728
742
  }
729
743
 
744
+ /* v4.x.x — Page-level header spacing. Used by views that render a
745
+ title row above the content grid. Adds breathing room below the
746
+ view title and a bottom border to visually separate from content. */
747
+ .page-header {
748
+ display: flex;
749
+ align-items: center;
750
+ justify-content: space-between;
751
+ gap: var(--space-4);
752
+ flex-wrap: wrap;
753
+ margin-bottom: var(--space-6);
754
+ padding-bottom: var(--space-4);
755
+ border-bottom: 1px solid var(--border);
756
+ }
757
+
758
+ .page-header h1,
759
+ .page-header h2 {
760
+ margin: 0;
761
+ font-weight: 600;
762
+ font-size: 22px;
763
+ letter-spacing: -0.01em;
764
+ color: var(--text-strong);
765
+ }
766
+
767
+ /* v4.x.x — Extra breathing room between a view-header and the content
768
+ below it when the header has no bottom border. */
769
+ .view-header {
770
+ margin-bottom: var(--space-6);
771
+ }
772
+
730
773
  /* ─── Cards ────────────────────────────────────────────────────────── */
731
774
 
732
775
  .card {
@@ -782,6 +825,16 @@ pre,
782
825
  color: var(--text-dim);
783
826
  }
784
827
 
828
+ /* v4.x.x — Card body used when a card is split into header + body */
829
+ .card-body {
830
+ padding: var(--space-4) var(--space-5);
831
+ }
832
+
833
+ /* v4.x.x — Extra top margin between sibling cards */
834
+ .card + .card {
835
+ margin-top: var(--space-4);
836
+ }
837
+
785
838
  /* ─── Buttons ──────────────────────────────────────────────────────── */
786
839
 
787
840
  .btn {
@@ -7391,7 +7444,7 @@ select:focus-visible,
7391
7444
  display: flex;
7392
7445
  flex-direction: column;
7393
7446
  gap: var(--space-4);
7394
- padding: var(--space-4) var(--space-5);
7447
+ padding: var(--space-6) var(--space-6) 0;
7395
7448
  max-width: 1100px;
7396
7449
  margin: 0 auto;
7397
7450
  }
@@ -8990,3 +9043,25 @@ select:focus-visible,
8990
9043
  gap: 0.5rem;
8991
9044
  margin-top: 1rem;
8992
9045
  }
9046
+
9047
+ /* v4.9.0 — Settings mode visual indicators */
9048
+
9049
+ .settings-mode-banner {
9050
+ display: flex;
9051
+ align-items: center;
9052
+ justify-content: space-between;
9053
+ padding: 12px 20px;
9054
+ background: linear-gradient(to right, rgba(139, 92, 246, 0.1), transparent);
9055
+ border: 1px solid var(--accent, #8b5cf6);
9056
+ border-radius: 8px;
9057
+ margin-bottom: 24px;
9058
+ font-size: 14px;
9059
+ color: var(--text);
9060
+ }
9061
+
9062
+ .settings-mode-indicator {
9063
+ display: inline-flex;
9064
+ align-items: center;
9065
+ color: var(--accent, #8b5cf6);
9066
+ margin-left: 6px;
9067
+ }