@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
@@ -1,6 +1,6 @@
1
1
  // src/web/components/EnvVarManager.tsx — manage BIZAR_* env vars from ~/.config/bizar/env.json
2
- import { useCallback, useEffect, useState } from 'react';
3
- import { Plus, Pencil, Trash2, Eye, EyeOff, RefreshCw, KeyRound, CheckCircle, X } from 'lucide-react';
2
+ import { useCallback, useEffect, useMemo, useState } from 'react';
3
+ import { Plus, Pencil, Trash2, Eye, EyeOff, RefreshCw, KeyRound, CheckCircle, X, Search, Download, Upload, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react';
4
4
  import { Button } from './Button';
5
5
  import { useToast } from './Toast';
6
6
  import { api } from '../lib/api';
@@ -13,6 +13,8 @@ export type EnvVarEntry = {
13
13
  source: string;
14
14
  };
15
15
 
16
+ export type EnvVarGrouped = Record<string, EnvVarEntry[]>;
17
+
16
18
  type Props = {
17
19
  onError?: (msg: string) => void;
18
20
  };
@@ -31,6 +33,14 @@ export function EnvVarManager({ onError }: Props) {
31
33
  const [showAdd, setShowAdd] = useState(false);
32
34
  const [newName, setNewName] = useState('');
33
35
  const [newValue, setNewValue] = useState('');
36
+ // Bulk operations
37
+ const [showBulk, setShowBulk] = useState(false);
38
+ const [bulkText, setBulkText] = useState('');
39
+ const [bulkImporting, setBulkImporting] = useState(false);
40
+ // Search / filter
41
+ const [searchQ, setSearchQ] = useState('');
42
+ // Group by prefix (collapsed state)
43
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
34
44
 
35
45
  const load = useCallback(async () => {
36
46
  setLoading(true);
@@ -121,21 +131,139 @@ export function EnvVarManager({ onError }: Props) {
121
131
  }
122
132
  };
123
133
 
134
+ // ─── Bulk import ───────────────────────────────────────────────────────────
135
+ const handleBulkImport = async () => {
136
+ if (!bulkText.trim()) { toast.warning('Nothing to import.'); return; }
137
+ setBulkImporting(true);
138
+ try {
139
+ const r = await api.post<{ ok: boolean; imported: number; skipped: number; errors: string[] }>('/env-vars/bulk-import', { envContent: bulkText });
140
+ toast.success(`Imported ${r.imported}, skipped ${r.skipped}.`);
141
+ if (r.errors.length > 0) {
142
+ toast.warning(`${r.errors.length} lines had errors: ${r.errors.slice(0, 3).join('; ')}${r.errors.length > 3 ? '…' : ''}`);
143
+ }
144
+ setBulkText('');
145
+ setShowBulk(false);
146
+ await load();
147
+ } catch (err) {
148
+ toast.error(`Bulk import failed: ${(err as Error).message}`);
149
+ } finally {
150
+ setBulkImporting(false);
151
+ }
152
+ };
153
+
154
+ // ─── Bulk export ───────────────────────────────────────────────────────────
155
+ const handleExport = async () => {
156
+ try {
157
+ const r = await api.get<string>('/env-vars/export');
158
+ // r is the raw text content
159
+ const blob = new Blob([r as unknown as string], { type: 'text/plain' });
160
+ const url = URL.createObjectURL(blob);
161
+ const a = document.createElement('a');
162
+ a.href = url;
163
+ a.download = '.env';
164
+ a.click();
165
+ URL.revokeObjectURL(url);
166
+ toast.success('Exported .env file.');
167
+ } catch (err) {
168
+ toast.error(`Export failed: ${(err as Error).message}`);
169
+ }
170
+ };
171
+
172
+ // ─── Group by prefix ───────────────────────────────────────────────────────
173
+ const filtered = useMemo(() => {
174
+ if (!searchQ.trim()) return vars;
175
+ const q = searchQ.toLowerCase();
176
+ return vars.filter((v) => v.name.toLowerCase().includes(q));
177
+ }, [vars, searchQ]);
178
+
179
+ const grouped = useMemo(() => {
180
+ const groups: Record<string, EnvVarEntry[]> = {};
181
+ for (const v of filtered) {
182
+ const prefix = v.name.split('_')[0] + '_';
183
+ (groups[prefix] ??= []).push(v);
184
+ }
185
+ return groups;
186
+ }, [filtered]);
187
+
188
+ const toggleGroup = (prefix: string) => {
189
+ setCollapsed((prev) => {
190
+ const next = new Set(prev);
191
+ if (next.has(prefix)) next.delete(prefix);
192
+ else next.add(prefix);
193
+ return next;
194
+ });
195
+ };
196
+
197
+ const sortedGroupKeys = useMemo(() => {
198
+ const known = ['BIZAR_', 'PROVIDER_', 'MODEL_', 'API_'];
199
+ const keys = Object.keys(grouped).sort((a, b) => {
200
+ const ai = known.indexOf(a); const bi = known.indexOf(b);
201
+ if (ai === -1 && bi === -1) return a.localeCompare(b);
202
+ if (ai === -1) return 1;
203
+ if (bi === -1) return -1;
204
+ return ai - bi;
205
+ });
206
+ return keys;
207
+ }, [grouped]);
208
+
124
209
  if (loading) {
125
210
  return <div className="muted" style={{ padding: '16px 0', fontSize: 13 }}>Loading env vars…</div>;
126
211
  }
127
212
 
213
+ const hasGroups = sortedGroupKeys.length > 1;
214
+ const groupMode = hasGroups;
215
+
128
216
  return (
129
217
  <div className="env-var-manager">
130
218
  <div className="env-var-toolbar">
131
219
  <Button variant="primary" size="sm" onClick={() => setShowAdd(true)}>
132
220
  <Plus size={12} /> New variable
133
221
  </Button>
222
+ <Button variant="secondary" size="sm" onClick={() => setShowBulk((v) => !v)} className={showBulk ? 'active' : ''}>
223
+ <Upload size={12} /> Bulk import
224
+ </Button>
225
+ <Button variant="ghost" size="sm" onClick={handleExport} title="Export as .env">
226
+ <Download size={12} /> Export
227
+ </Button>
228
+ <div className="env-var-search-wrap">
229
+ <Search size={12} className="env-var-search-icon" />
230
+ <input
231
+ className="env-var-search input"
232
+ placeholder="Filter…"
233
+ value={searchQ}
234
+ onChange={(e) => setSearchQ(e.target.value)}
235
+ />
236
+ </div>
134
237
  <Button variant="ghost" size="sm" onClick={load} title="Reload">
135
238
  <RefreshCw size={12} />
136
239
  </Button>
137
240
  </div>
138
241
 
242
+ {/* Bulk import panel */}
243
+ {showBulk && (
244
+ <div className="env-var-bulk-panel">
245
+ <div className="env-var-bulk-header">
246
+ <span>Paste <code>KEY=value</code> lines (one per line):</span>
247
+ <Button variant="ghost" size="sm" onClick={() => setShowBulk(false)}><X size={12} /></Button>
248
+ </div>
249
+ <textarea
250
+ className="input mono env-var-bulk-textarea"
251
+ placeholder={"BIZAR_API_KEY=your-key-here\nPROVIDER_OPENAI_KEY=sk-...\nMODEL_KEY=gem-..."}
252
+ value={bulkText}
253
+ onChange={(e) => setBulkText(e.target.value)}
254
+ rows={6}
255
+ />
256
+ <div className="env-var-bulk-footer">
257
+ <Button variant="primary" size="sm" onClick={handleBulkImport} disabled={bulkImporting || !bulkText.trim()}>
258
+ {bulkImporting ? 'Importing…' : 'Import'}
259
+ </Button>
260
+ <span className="muted" style={{ fontSize: 11 }}>
261
+ Existing keys are skipped unless re-created individually. Invalid lines are reported.
262
+ </span>
263
+ </div>
264
+ </div>
265
+ )}
266
+
139
267
  {vars.length === 0 && !showAdd && (
140
268
  <div className="env-var-empty">
141
269
  <KeyRound size={20} />
@@ -178,70 +306,125 @@ export function EnvVarManager({ onError }: Props) {
178
306
  </div>
179
307
  )}
180
308
 
181
- {/* Var list */}
182
- {vars.length > 0 && (
183
- <div className="env-var-list">
184
- {vars.map((v) => {
185
- const isEditing = editing === v.name;
186
- const isRevealed = revealed.has(v.name);
187
- return (
188
- <div key={v.name} className={cn('env-var-row', isEditing && 'env-var-row-editing')}>
189
- <span className="env-var-name mono">{v.name}</span>
190
- {isEditing ? (
191
- <input
192
- className="input mono"
193
- type="password"
194
- style={{ flex: 1, minWidth: 200 }}
195
- value={editValue}
196
- onChange={(e) => setEditValue(e.target.value)}
197
- onKeyDown={(e) => {
198
- if (e.key === 'Enter') handleUpdate(v.name);
199
- if (e.key === 'Escape') setEditing(null);
200
- }}
201
- autoFocus
202
- />
203
- ) : (
204
- <span className={cn('env-var-value mono', !isRevealed && 'env-var-value-masked')}>
205
- {isRevealed ? v.value : v.value}
206
- </span>
207
- )}
208
- <span className="env-var-source muted">{v.source}</span>
209
- <div className="env-var-actions">
210
- {!isEditing && (
211
- <button
212
- type="button"
213
- className="icon-btn"
214
- title={isRevealed ? 'Hide' : 'Reveal'}
215
- onClick={() => toggleReveal(v.name)}
216
- >
217
- {isRevealed ? <EyeOff size={12} /> : <Eye size={12} />}
218
- </button>
219
- )}
220
- {isEditing ? (
221
- <>
222
- <Button variant="primary" size="sm" onClick={() => handleUpdate(v.name)} disabled={saving}>
223
- Save
224
- </Button>
225
- <Button variant="ghost" size="sm" onClick={() => setEditing(null)}>
226
- Cancel
227
- </Button>
228
- </>
229
- ) : (
230
- <>
231
- <button type="button" className="icon-btn" title="Edit" onClick={() => startEdit(v)}>
232
- <Pencil size={12} />
233
- </button>
234
- <button type="button" className="icon-btn icon-btn-danger" title="Delete" onClick={() => handleDelete(v.name)}>
235
- <Trash2 size={12} />
236
- </button>
237
- </>
309
+ {/* Var list — grouped by prefix */}
310
+ {filtered.length > 0 && (
311
+ groupMode ? (
312
+ <div className="env-var-groups">
313
+ {sortedGroupKeys.map((prefix) => {
314
+ const items = grouped[prefix];
315
+ const isCollapsed = collapsed.has(prefix);
316
+ return (
317
+ <div key={prefix} className="env-var-group">
318
+ <button
319
+ type="button"
320
+ className="env-var-group-header"
321
+ onClick={() => toggleGroup(prefix)}
322
+ >
323
+ {isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
324
+ <span className="env-var-group-prefix mono">{prefix}</span>
325
+ <span className="env-var-group-count muted">({items.length})</span>
326
+ </button>
327
+ {!isCollapsed && (
328
+ <div className="env-var-list">
329
+ {items.map((v) => (
330
+ <VarRow key={v.name} v={v} editing={editing} editValue={editValue}
331
+ isRevealed={revealed.has(v.name)}
332
+ onToggleReveal={toggleReveal} onStartEdit={startEdit}
333
+ onEditValueChange={setEditValue} onHandleUpdate={handleUpdate}
334
+ onCancelEdit={() => setEditing(null)} onDelete={handleDelete}
335
+ saving={saving} />
336
+ ))}
337
+ </div>
238
338
  )}
239
339
  </div>
240
- </div>
241
- );
242
- })}
340
+ );
341
+ })}
342
+ </div>
343
+ ) : (
344
+ <div className="env-var-list">
345
+ {filtered.map((v) => (
346
+ <VarRow key={v.name} v={v} editing={editing} editValue={editValue}
347
+ isRevealed={revealed.has(v.name)}
348
+ onToggleReveal={toggleReveal} onStartEdit={startEdit}
349
+ onEditValueChange={setEditValue} onHandleUpdate={handleUpdate}
350
+ onCancelEdit={() => setEditing(null)} onDelete={handleDelete}
351
+ saving={saving} />
352
+ ))}
353
+ </div>
354
+ )
355
+ )}
356
+
357
+ {filtered.length === 0 && vars.length > 0 && (
358
+ <div className="env-var-empty">
359
+ <Search size={16} />
360
+ <span>No env vars match "{searchQ}"</span>
243
361
  </div>
244
362
  )}
245
363
  </div>
246
364
  );
247
365
  }
366
+
367
+ // ─── Individual var row (extracted for reuse in group mode) ───────────────────
368
+ type VarRowProps = {
369
+ v: EnvVarEntry;
370
+ editing: string | null;
371
+ editValue: string;
372
+ isRevealed: boolean;
373
+ onToggleReveal: (name: string) => void;
374
+ onStartEdit: (v: EnvVarEntry) => void;
375
+ onEditValueChange: (v: string) => void;
376
+ onHandleUpdate: (name: string) => void;
377
+ onCancelEdit: () => void;
378
+ onDelete: (name: string) => void;
379
+ saving: boolean;
380
+ };
381
+
382
+ function VarRow({ v, editing, editValue, isRevealed, onToggleReveal, onStartEdit, onEditValueChange, onHandleUpdate, onCancelEdit, onDelete, saving }: VarRowProps) {
383
+ const isEditing = editing === v.name;
384
+ return (
385
+ <div className={cn('env-var-row', isEditing && 'env-var-row-editing')}>
386
+ <span className="env-var-name mono">{v.name}</span>
387
+ {isEditing ? (
388
+ <input
389
+ className="input mono"
390
+ type="password"
391
+ style={{ flex: 1, minWidth: 200 }}
392
+ value={editValue}
393
+ onChange={(e) => onEditValueChange(e.target.value)}
394
+ onKeyDown={(e) => {
395
+ if (e.key === 'Enter') onHandleUpdate(v.name);
396
+ if (e.key === 'Escape') onCancelEdit();
397
+ }}
398
+ autoFocus
399
+ />
400
+ ) : (
401
+ <span className={cn('env-var-value mono', !isRevealed && 'env-var-value-masked')}>
402
+ {v.value}
403
+ </span>
404
+ )}
405
+ <span className="env-var-source muted">{v.source}</span>
406
+ <div className="env-var-actions">
407
+ {!isEditing && (
408
+ <button type="button" className="icon-btn" title={isRevealed ? 'Hide' : 'Reveal'} onClick={() => onToggleReveal(v.name)}>
409
+ {isRevealed ? <EyeOff size={12} /> : <Eye size={12} />}
410
+ </button>
411
+ )}
412
+ {isEditing ? (
413
+ <>
414
+ <Button variant="primary" size="sm" onClick={() => onHandleUpdate(v.name)} disabled={saving}>Save</Button>
415
+ <Button variant="ghost" size="sm" onClick={onCancelEdit}>Cancel</Button>
416
+ </>
417
+ ) : (
418
+ <>
419
+ <button type="button" className="icon-btn" title="Edit" onClick={() => onStartEdit(v)}>
420
+ <Pencil size={12} />
421
+ </button>
422
+ <button type="button" className="icon-btn icon-btn-danger" title="Delete" onClick={() => onDelete(v.name)}>
423
+ <Trash2 size={12} />
424
+ </button>
425
+ </>
426
+ )}
427
+ </div>
428
+ </div>
429
+ );
430
+ }
@@ -0,0 +1,71 @@
1
+ // src/components/ScheduleTemplateCard.tsx — template preview card for the Schedules Templates tab.
2
+
3
+ import { Card, CardTitle, CardMeta } from './Card';
4
+ import { Button } from './Button';
5
+
6
+ export type ScheduleTemplate = {
7
+ id: string;
8
+ name: string;
9
+ description: string;
10
+ type: 'cron' | 'interval' | 'once' | 'webhook';
11
+ schedule?: string;
12
+ intervalMs?: number;
13
+ action: {
14
+ type: 'command' | 'agent' | 'webhook';
15
+ target?: string;
16
+ prompt?: string;
17
+ method?: string;
18
+ };
19
+ tags?: string[];
20
+ source?: string;
21
+ templateFile?: string;
22
+ };
23
+
24
+ type Props = {
25
+ template: ScheduleTemplate;
26
+ onUse: (template: ScheduleTemplate) => void;
27
+ };
28
+
29
+ function formatSchedule(template: ScheduleTemplate): string {
30
+ if (template.type === 'cron' && template.schedule) {
31
+ return `cron: ${template.schedule}`;
32
+ }
33
+ if (template.type === 'interval') {
34
+ const ms = template.intervalMs || 0;
35
+ const secs = ms / 1000;
36
+ if (secs >= 3600) return `every ${secs / 3600}h`;
37
+ if (secs >= 60) return `every ${secs / 60}m`;
38
+ return `every ${secs}s`;
39
+ }
40
+ return template.type;
41
+ }
42
+
43
+ export function ScheduleTemplateCard({ template, onUse }: Props) {
44
+ return (
45
+ <Card className="schedule-template-card">
46
+ <div className="schedule-template-header">
47
+ <CardTitle>{template.name}</CardTitle>
48
+ {template.tags && template.tags.length > 0 && (
49
+ <div className="schedule-template-tags">
50
+ {template.tags.map((tag) => (
51
+ <span key={tag} className="tag">{tag}</span>
52
+ ))}
53
+ </div>
54
+ )}
55
+ </div>
56
+ <CardMeta>{template.description}</CardMeta>
57
+ <div className="schedule-template-summary">
58
+ <code>{formatSchedule(template)}</code>
59
+ {' · '}
60
+ <code>{template.action.type}</code>
61
+ </div>
62
+ <Button
63
+ variant="secondary"
64
+ size="sm"
65
+ onClick={() => onUse(template)}
66
+ >
67
+ Use this template
68
+ </Button>
69
+ </Card>
70
+ );
71
+ }
@@ -0,0 +1,101 @@
1
+ // src/components/SettingsNav.tsx — v4.9.0 full-sidebar settings navigation.
2
+ // Shown in the sidebar rail when settingsMode is active. All sections are
3
+ // visible at once; clicking one scrolls the Settings view to that section.
4
+ import { ChevronRight, Palette, Terminal, Boxes, Brain, Cpu, RefreshCw, Sparkles, Gauge, Archive, ArrowLeft, LayoutGrid, type LucideIcon } from 'lucide-react';
5
+ import { cn } from '../lib/utils';
6
+
7
+ export type SettingsSection = {
8
+ id: string;
9
+ label: string;
10
+ icon?: LucideIcon;
11
+ };
12
+
13
+ export type SettingsSectionGroup = {
14
+ label: string;
15
+ sections: SettingsSection[];
16
+ };
17
+
18
+ const SECTION_GROUPS: SettingsSectionGroup[] = [
19
+ {
20
+ label: 'General',
21
+ sections: [
22
+ { id: 'theme', label: 'Theme', icon: Palette },
23
+ { id: 'layout', label: 'Layout', icon: LayoutGrid },
24
+ { id: 'general', label: 'General', icon: LayoutGrid },
25
+ ],
26
+ },
27
+ {
28
+ label: 'Core',
29
+ sections: [
30
+ { id: 'env-vars', label: 'Env Vars', icon: Terminal },
31
+ { id: 'providers', label: 'Providers', icon: Boxes },
32
+ { id: 'memory', label: 'Memory', icon: Brain },
33
+ { id: 'system-llm', label: 'System LLM', icon: Cpu },
34
+ ],
35
+ },
36
+ {
37
+ label: 'Experience',
38
+ sections: [
39
+ { id: 'updates', label: 'Updates', icon: RefreshCw },
40
+ { id: 'skills', label: 'Skills', icon: Sparkles },
41
+ { id: 'headroom', label: 'Headroom', icon: Gauge },
42
+ ],
43
+ },
44
+ {
45
+ label: 'Data',
46
+ sections: [
47
+ { id: 'backup', label: 'Backup', icon: Archive },
48
+ ],
49
+ },
50
+ ];
51
+
52
+ export type SettingsNavProps = {
53
+ activeSection: string | null;
54
+ onSectionChange: (id: string | null) => void;
55
+ onExitSettings: () => void;
56
+ };
57
+
58
+ export function SettingsNav({ activeSection, onSectionChange, onExitSettings }: SettingsNavProps) {
59
+ return (
60
+ <div className="settings-nav-root" aria-label="Settings sections">
61
+ {/* Back button */}
62
+ <button
63
+ type="button"
64
+ className="settings-nav-back"
65
+ onClick={onExitSettings}
66
+ aria-label="Exit settings"
67
+ >
68
+ <ArrowLeft size={14} />
69
+ <span>Back</span>
70
+ </button>
71
+
72
+ <div className="settings-nav-divider" aria-hidden="true" />
73
+
74
+ {/* Section groups */}
75
+ {SECTION_GROUPS.map((group) => (
76
+ <div key={group.label} className="settings-nav-group">
77
+ <div className="settings-nav-group-label">{group.label}</div>
78
+ {group.sections.map((s) => {
79
+ const Icon = s.icon;
80
+ const active = activeSection === s.id;
81
+ return (
82
+ <button
83
+ key={s.id}
84
+ type="button"
85
+ className={cn('settings-nav-item', active && 'settings-nav-item-active')}
86
+ onClick={() => onSectionChange(active ? null : s.id)}
87
+ aria-current={active ? 'page' : undefined}
88
+ >
89
+ {Icon && <Icon size={14} className="settings-nav-item-icon" aria-hidden />}
90
+ <span className="settings-nav-item-label">{s.label}</span>
91
+ {active && (
92
+ <ChevronRight size={12} className="settings-nav-item-chevron" aria-hidden />
93
+ )}
94
+ </button>
95
+ );
96
+ })}
97
+ </div>
98
+ ))}
99
+ </div>
100
+ );
101
+ }
@@ -1,14 +1,37 @@
1
1
  // src/components/Sidebar.tsx — vertical navigation rail for sidebar/both layouts.
2
2
  import type { TabDef } from './Topbar';
3
+ import { SettingsNav } from './SettingsNav';
3
4
  import { cn } from '../lib/utils';
4
5
 
5
6
  export type SidebarProps = {
6
7
  tabs: TabDef[];
7
8
  activeTab: string;
8
9
  onTabChange: (id: string) => void;
10
+ /** v4.9.0 — When true, show the full settings sidebar nav instead of tabs. */
11
+ settingsMode?: boolean;
12
+ /** v4.9.0 — The currently highlighted settings section. */
13
+ settingsActiveSection?: string | null;
14
+ /** v4.9.0 — Called when user clicks a section in the settings sidebar. */
15
+ onSettingsSectionChange?: (id: string | null) => void;
16
+ /** v4.9.0 — Called when user clicks the back button in settings mode. */
17
+ onExitSettings?: () => void;
9
18
  };
10
19
 
11
- export function Sidebar({ tabs, activeTab, onTabChange }: SidebarProps) {
20
+ export function Sidebar({ tabs, activeTab, onTabChange, settingsMode, settingsActiveSection, onSettingsSectionChange, onExitSettings }: SidebarProps) {
21
+ // v4.9.0 — When settingsMode is active, render the full settings nav instead
22
+ // of the normal tab rail. This lets users see all sections at a glance.
23
+ if (settingsMode) {
24
+ return (
25
+ <aside className="sidebar sidebar-settings-mode" aria-label="Settings navigation">
26
+ <SettingsNav
27
+ activeSection={settingsActiveSection ?? null}
28
+ onSectionChange={onSettingsSectionChange ?? (() => {})}
29
+ onExitSettings={onExitSettings ?? (() => {})}
30
+ />
31
+ </aside>
32
+ );
33
+ }
34
+
12
35
  // Split tabs into built-in (no `isMod` flag) and mod-added entries.
13
36
  // Render a separator + small "Mods" label between them so users can
14
37
  // tell what's part of Bizar vs what an installed mod contributed.
@@ -64,3 +87,4 @@ export function Sidebar({ tabs, activeTab, onTabChange }: SidebarProps) {
64
87
  </aside>
65
88
  );
66
89
  }
90
+
@@ -3,21 +3,52 @@
3
3
  import type { ReactNode } from 'react';
4
4
  import { cn } from '../lib/utils';
5
5
 
6
+ /**
7
+ * v6.0.0 — Doctor variants. 'ok' / 'warn' / 'fail' map onto
8
+ * 'success' / 'warning' / 'error' respectively so existing CSS
9
+ * rules cover the Doctor page without a new palette entry. The
10
+ * `kind` union is widened (additive only) so every existing caller
11
+ * keeps type-checking against the same set.
12
+ */
13
+ export type StatusKind =
14
+ | 'success'
15
+ | 'warning'
16
+ | 'error'
17
+ | 'info'
18
+ | 'accent'
19
+ | 'neutral'
20
+ | 'ok'
21
+ | 'warn'
22
+ | 'fail';
23
+
6
24
  export type StatusBadgeProps = {
7
- kind?: 'success' | 'warning' | 'error' | 'info' | 'accent' | 'neutral';
25
+ kind?: StatusKind;
8
26
  children: ReactNode;
9
27
  className?: string;
10
28
  dot?: boolean;
11
29
  };
12
30
 
31
+ /**
32
+ * Map a doctor-style status to the underlying badge kind. Done at
33
+ * render time so callers don't have to translate; the CSS class is
34
+ * always one of `badge-success | badge-warning | badge-error | …`.
35
+ */
36
+ function normalizeKind(kind: StatusKind): Exclude<StatusKind, 'ok' | 'warn' | 'fail'> {
37
+ if (kind === 'ok') return 'success';
38
+ if (kind === 'warn') return 'warning';
39
+ if (kind === 'fail') return 'error';
40
+ return kind;
41
+ }
42
+
13
43
  export function StatusBadge({
14
44
  kind = 'neutral',
15
45
  children,
16
46
  className,
17
47
  dot = false,
18
48
  }: StatusBadgeProps) {
49
+ const normalized = normalizeKind(kind);
19
50
  return (
20
- <span className={cn('badge', `badge-${kind}`, className)}>
51
+ <span className={cn('badge', `badge-${normalized}`, className)} title={`status: ${kind}`}>
21
52
  {dot && <span className="badge-dot" />}
22
53
  {children}
23
54
  </span>
@@ -23,6 +23,7 @@ import {
23
23
  Radio,
24
24
  Coins,
25
25
  Brain,
26
+ Stethoscope,
26
27
  type LucideIcon,
27
28
  } from 'lucide-react';
28
29
  import { cn } from '../lib/utils';
@@ -52,6 +53,11 @@ export const TABS: TabDef[] = [
52
53
  { id: 'schedules', label: 'Schedules', icon: Clock },
53
54
  { id: 'history', label: 'History', icon: HistoryIcon },
54
55
  { id: 'minimax', label: 'Usage', icon: Coins },
56
+ // v6.0.0 — Doctor page. Lives between Overview and Settings so the
57
+ // "is everything healthy?" question is always one click from the
58
+ // home screen and from the settings surface (where an operator
59
+ // typically arrives after something looks off).
60
+ { id: 'doctor', label: 'Doctor', icon: Stethoscope },
55
61
  { id: 'settings', label: 'Settings', icon: Sliders },
56
62
  ];
57
63
 
@@ -155,6 +161,11 @@ export function Topbar({
155
161
  >
156
162
  <Icon size={14} className="tab-icon" />
157
163
  <span className="tab-label">{tab.label}</span>
164
+ {tab.id === 'settings' && active && (
165
+ <span className="settings-mode-indicator" title="Settings mode active">
166
+ <Settings2 size={12} />
167
+ </span>
168
+ )}
158
169
  </button>
159
170
  );
160
171
  })}