@polderlabs/bizar 4.5.2 → 4.7.1

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 (123) hide show
  1. package/bizar-dash/dist/assets/main-DHZmbnxQ.js +361 -0
  2. package/bizar-dash/dist/assets/main-DHZmbnxQ.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-DX_Jh8Wc.css +1 -0
  4. package/bizar-dash/dist/assets/{mobile-lbH6szyX.js → mobile-BK8-ythT.js} +18 -18
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-BRhoDOUz.js.map → mobile-Chvf9u_B.js.map} +1 -1
  8. package/bizar-dash/dist/index.html +3 -3
  9. package/bizar-dash/dist/mobile.html +2 -2
  10. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  11. package/bizar-dash/skills/publishing/SKILL.md +146 -0
  12. package/bizar-dash/src/server/api.mjs +8 -0
  13. package/bizar-dash/src/server/backup-store.mjs +525 -0
  14. package/bizar-dash/src/server/digest-store.mjs +558 -0
  15. package/bizar-dash/src/server/lib/rate-limit.mjs +122 -0
  16. package/bizar-dash/src/server/logger.mjs +71 -0
  17. package/bizar-dash/src/server/memory-lightrag.mjs +45 -6
  18. package/bizar-dash/src/server/metrics.mjs +193 -0
  19. package/bizar-dash/src/server/routes/backup.mjs +112 -0
  20. package/bizar-dash/src/server/routes/chat.mjs +20 -3
  21. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  22. package/bizar-dash/src/server/routes/lightrag.mjs +3 -2
  23. package/bizar-dash/src/server/routes/memory.mjs +5 -4
  24. package/bizar-dash/src/server/routes/misc.mjs +2 -1
  25. package/bizar-dash/src/server/routes/overview.mjs +2 -1
  26. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  27. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  28. package/bizar-dash/src/server/server.mjs +79 -0
  29. package/bizar-dash/src/web/App.tsx +8 -1
  30. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  31. package/bizar-dash/src/web/components/SearchModal.tsx +6 -3
  32. package/bizar-dash/src/web/components/VirtualList.tsx +53 -0
  33. package/bizar-dash/src/web/components/chat/ChatThread.tsx +17 -11
  34. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  35. package/bizar-dash/src/web/hooks/useI18n.ts +13 -0
  36. package/bizar-dash/src/web/lib/i18n.ts +25 -0
  37. package/bizar-dash/src/web/locales/en.json +52 -0
  38. package/bizar-dash/src/web/main.tsx +5 -0
  39. package/bizar-dash/src/web/styles/main.css +70 -8
  40. package/bizar-dash/src/web/views/Activity.tsx +35 -18
  41. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  42. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  43. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  44. package/bizar-dash/src/web/views/History.tsx +94 -76
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  46. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  47. package/bizar-dash/src/web/views/Overview.tsx +19 -9
  48. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  49. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  50. package/bizar-dash/src/web/views/Settings.tsx +97 -1745
  51. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  52. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  53. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  54. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  55. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  56. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  57. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  58. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  59. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  60. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  61. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  62. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  63. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  64. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  65. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  66. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  67. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  68. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  69. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  70. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  71. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  72. package/bizar-dash/tests/a11y.test.tsx +206 -0
  73. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  74. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  75. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  76. package/bizar-dash/tests/cli-bugfixes.test.mjs +4 -4
  77. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  78. package/bizar-dash/tests/cli-refactor.test.mjs +184 -0
  79. package/bizar-dash/tests/components/Button.test.tsx +41 -0
  80. package/bizar-dash/tests/components/Card.test.tsx +42 -0
  81. package/bizar-dash/tests/components/Modal.test.tsx +104 -0
  82. package/bizar-dash/tests/components/Spinner.test.tsx +32 -0
  83. package/bizar-dash/tests/components/StatusBadge.test.tsx +35 -0
  84. package/bizar-dash/tests/components/Toast.test.tsx +108 -0
  85. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  86. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  87. package/bizar-dash/tests/hooks/useModal.test.tsx +84 -0
  88. package/bizar-dash/tests/hooks/useToast.test.tsx +50 -0
  89. package/bizar-dash/tests/lib/i18n.test.ts +46 -0
  90. package/bizar-dash/tests/lib/utils.test.ts +194 -0
  91. package/bizar-dash/tests/logger.test.mjs +207 -0
  92. package/bizar-dash/tests/metrics.test.mjs +183 -0
  93. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  94. package/bizar-dash/tests/server-bugfixes.test.mjs +2 -2
  95. package/bizar-dash/tests/setup.ts +7 -0
  96. package/bizar-dash/vitest.config.ts +13 -0
  97. package/cli/artifact-cli.mjs +605 -0
  98. package/cli/artifact-render.mjs +621 -0
  99. package/cli/artifact-server.mjs +847 -0
  100. package/cli/artifact.mjs +38 -2096
  101. package/cli/bg.mjs +5 -13
  102. package/cli/bin.mjs +221 -1350
  103. package/cli/commands/artifact.mjs +20 -0
  104. package/cli/commands/dash.mjs +160 -0
  105. package/cli/commands/headroom.mjs +204 -0
  106. package/cli/commands/install.mjs +169 -0
  107. package/cli/commands/memory.mjs +25 -0
  108. package/cli/commands/minimax.mjs +285 -0
  109. package/cli/commands/mod.mjs +185 -0
  110. package/cli/commands/service.mjs +65 -0
  111. package/cli/commands/usage.mjs +109 -0
  112. package/cli/commands/util.mjs +459 -0
  113. package/cli/digest.mjs +149 -0
  114. package/cli/doctor.mjs +1 -13
  115. package/cli/provision.mjs +2 -13
  116. package/cli/service-controller.mjs +1 -11
  117. package/cli/service.mjs +1 -11
  118. package/cli/utils.mjs +41 -1
  119. package/package.json +6 -1
  120. package/bizar-dash/dist/assets/main-B4OfGAwz.js +0 -361
  121. package/bizar-dash/dist/assets/main-B4OfGAwz.js.map +0 -1
  122. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  123. package/bizar-dash/dist/assets/mobile-lbH6szyX.js.map +0 -1
@@ -1,52 +1,27 @@
1
- // src/views/Settings.tsx — v3 settings: theme colors, UI layout, defaults, Tailscale, service.
2
- import React, { useEffect, useState, useCallback } from 'react';
3
- import {
4
- Sliders,
5
- Save,
6
- RefreshCw,
7
- Sun,
8
- Moon,
9
- Monitor,
10
- Info,
11
- Globe,
12
- Palette,
13
- Layout as LayoutIcon,
14
- Server as ServerIcon,
15
- RotateCcw,
16
- Plug,
17
- Download,
18
- AlertTriangle,
19
- QrCode,
20
- Smartphone,
21
- CheckCircle,
22
- AlertCircle,
23
- Shield,
24
- Copy,
25
- KeyRound,
26
- Activity,
27
- EyeOff,
28
- Eye,
29
- Trash2,
30
- Search,
31
- Sparkles,
32
- } from 'lucide-react';
33
- import { QRCodeSVG } from 'qrcode.react';
1
+ // src/views/Settings.tsx — v4 settings shell: routes to focused sub-components.
2
+ import React, { useEffect, useState } from 'react';
3
+ import { Sliders, Save, RefreshCw, RotateCcw } from 'lucide-react';
34
4
  import { Button } from '../components/Button';
35
- import { Card, CardTitle, CardMeta } from '../components/Card';
36
5
  import { useToast } from '../components/Toast';
37
6
  import { api } from '../lib/api';
38
7
  import { cn } from '../lib/utils';
39
- import { HeadroomSettingsCard } from '../components/HeadroomSettings';
40
- import { Ws } from '../lib/ws';
41
- import {
42
- applyTheme,
43
- applyThemeTokens,
44
- type Settings,
45
- type SettingsResponse,
46
- type Snapshot,
47
- type ThemeName,
48
- type TailscaleStatus,
49
- } from '../lib/types';
8
+ import { applyTheme, applyThemeTokens, type Settings, type SettingsResponse, type Snapshot, type TailscaleStatus } from '../lib/types';
9
+
10
+ import { ThemeSection } from './settings/ThemeSection';
11
+ import { UpdatesSection } from './settings/UpdatesSection';
12
+ import { GeneralSection } from './settings/GeneralSection';
13
+ import { NetworkSection } from './settings/NetworkSection';
14
+ import { NotificationsSection } from './settings/NotificationsSection';
15
+ import { AuthSection } from './settings/AuthSection';
16
+ import { AgentSection } from './settings/AgentSection';
17
+ import { SystemLlmSection } from './settings/SystemLlmSection';
18
+ import { HeadroomSection } from './settings/HeadroomSection';
19
+ import { ActivitySection } from './settings/ActivitySection';
20
+ import { EnvVarsSection } from './settings/EnvVarsSection';
21
+ import { ProvidersSection } from './settings/ProvidersSection';
22
+ import { MemorySection } from './settings/MemorySection';
23
+ import { SkillsSection } from './settings/SkillsSection';
24
+ import { BackupSection } from './settings/BackupSection';
50
25
 
51
26
  type Props = {
52
27
  snapshot: Snapshot;
@@ -56,435 +31,28 @@ type Props = {
56
31
  refreshSnapshot: () => Promise<void>;
57
32
  };
58
33
 
59
- const THEMES: { id: ThemeName; label: string; Icon: typeof Sun }[] = [
60
- { id: 'dark', label: 'Dark', Icon: Moon },
61
- { id: 'light', label: 'Light', Icon: Sun },
62
- { id: 'system', label: 'System', Icon: Monitor },
63
- ];
64
-
65
- // v3.3.1 One-click accent color presets.
66
- const PRESET_THEMES = [
67
- { name: 'Purple', accent: '#8b5cf6' },
68
- { name: 'Blue', accent: '#3b82f6' },
69
- { name: 'Green', accent: '#10b981' },
70
- { name: 'Orange', accent: '#f97316' },
71
- { name: 'Red', accent: '#ef4444' },
72
- { name: 'Pink', accent: '#ec4899' },
73
- { name: 'Cyan', accent: '#06b6d4' },
74
- { name: 'Mono', accent: '#6b7280' },
75
- ];
76
-
77
- // v5.0.0 Headroom default settings
78
- const DEFAULT_HEADROOM_SETTINGS = {
79
- enabled: true,
80
- autoInstall: true,
81
- port: 8787,
82
- host: '127.0.0.1',
83
- outputShaper: false,
84
- telemetry: false,
85
- budget: 0,
86
- backend: 'anthropic',
87
- autoStart: true,
88
- autoWrap: true,
89
- routeAllProviders: true,
90
- };
91
-
92
- const FONT_FAMILIES = [
93
- 'Inter',
94
- 'system-ui',
95
- 'Segoe UI',
96
- 'Roboto',
97
- 'JetBrains Mono',
98
- 'SF Mono',
99
- 'Cascadia Code',
100
- ];
101
-
102
- // v3.5.2 — Pair-with-mobile companion card.
103
- type PairSession = {
104
- token: string;
105
- qrPayload: string;
106
- publicUrl: string;
107
- expiresAt: number;
108
- };
109
-
110
- function formatCountdown(ms: number): string {
111
- if (ms <= 0) return 'expired';
112
- const s = Math.floor(ms / 1000);
113
- const m = Math.floor(s / 60);
114
- const r = s % 60;
115
- return `${m}:${String(r).padStart(2, '0')}`;
116
- }
117
-
118
- function PairDeviceCard() {
119
- const toast = useToast();
120
- const [pair, setPair] = useState<PairSession | null>(null);
121
- const [pairing, setPairing] = useState(false);
122
- const [pairError, setPairError] = useState<string | null>(null);
123
- const [now, setNow] = useState(Date.now());
124
-
125
- const start = useCallback(async () => {
126
- setPairing(true);
127
- setPairError(null);
128
- try {
129
- const res = await api.post<PairSession>('/pair/start');
130
- setPair(res);
131
- } catch (err) {
132
- setPairError((err as Error)?.message || 'Failed to start pairing');
133
- toast.error('Pairing failed.');
134
- } finally {
135
- setPairing(false);
136
- }
137
- }, [toast]);
138
-
139
- useEffect(() => {
140
- if (!pair) return;
141
- const t = setInterval(() => setNow(Date.now()), 1000);
142
- return () => clearInterval(t);
143
- }, [pair]);
144
-
145
- const remaining = pair ? pair.expiresAt - now : 0;
146
- const expired = pair != null && remaining <= 0;
147
-
148
- return (
149
- <Card id="settings-updates" data-section="updates">
150
- <CardTitle>
151
- <Smartphone size={14} /> Companion App
152
- </CardTitle>
153
- <CardMeta>
154
- Scan the QR with <a href="https://github.com/DrB0rk/BizarHarness" target="_blank" rel="noopener noreferrer">Bizar Companion</a> to pair.
155
- Tokens expire after 5 minutes.
156
- </CardMeta>
157
-
158
- <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start', flexWrap: 'wrap' }}>
159
- <div style={{ flex: '0 0 auto' }}>
160
- {pair && !expired ? (
161
- <div style={{ background: '#fff', padding: 12, borderRadius: 12 }}>
162
- <QRCodeSVG value={pair.qrPayload} size={192} level="M" includeMargin={false} />
163
- </div>
164
- ) : (
165
- <div
166
- style={{
167
- width: 216,
168
- height: 216,
169
- borderRadius: 12,
170
- background: 'var(--surface-2, #161b22)',
171
- display: 'flex',
172
- alignItems: 'center',
173
- justifyContent: 'center',
174
- color: 'var(--text-muted, #8b949e)',
175
- fontSize: 12,
176
- textAlign: 'center',
177
- padding: 16,
178
- border: '1px dashed var(--border, #30363d)',
179
- }}
180
- >
181
- {pair && expired ? 'QR expired' : 'No QR generated yet'}
182
- </div>
183
- )}
184
- </div>
185
-
186
- <div style={{ flex: '1 1 240px', minWidth: 220 }}>
187
- {!pair && (
188
- <Button variant="primary" onClick={start} disabled={pairing}>
189
- <QrCode size={14} /> {pairing ? 'Generating…' : 'Generate QR Code'}
190
- </Button>
191
- )}
192
-
193
- {pair && !expired && (
194
- <>
195
- <div style={{ marginBottom: 10 }}>
196
- <strong>Expires in</strong> <span className="mono">{formatCountdown(remaining)}</span>
197
- </div>
198
- <div style={{ marginBottom: 6 }}>
199
- <strong>URL:</strong> <span className="mono" style={{ wordBreak: 'break-all' }}>{pair.publicUrl}</span>
200
- </div>
201
- <div style={{ marginBottom: 12 }}>
202
- <strong>Token:</strong>{' '}
203
- <span className="mono" style={{ wordBreak: 'break-all', fontSize: 12 }}>
204
- {pair.token.slice(0, 16)}…{pair.token.slice(-6)}
205
- </span>
206
- </div>
207
- <Button variant="secondary" onClick={start} disabled={pairing}>
208
- <RefreshCw size={14} /> Regenerate
209
- </Button>
210
- </>
211
- )}
212
-
213
- {pair && expired && (
214
- <>
215
- <div style={{ marginBottom: 12, color: 'var(--error, #f85149)' }}>
216
- Token expired — generate a fresh QR to pair again.
217
- </div>
218
- <Button variant="primary" onClick={() => { setPair(null); start(); }}>
219
- <RefreshCw size={14} /> Generate new QR
220
- </Button>
221
- </>
222
- )}
223
-
224
- {pairError && (
225
- <div style={{ marginTop: 10, color: 'var(--error, #f85149)', fontSize: 12 }}>
226
- {pairError}
227
- </div>
228
- )}
229
- </div>
230
- </div>
231
- </Card>
232
- );
233
- }
234
-
235
- const LAYOUTS = [
236
- { id: 'topnav', label: 'Top nav' },
237
- { id: 'sidebar', label: 'Sidebar' },
238
- { id: 'both', label: 'Both' },
34
+ const SECTION_LINKS = [
35
+ { id: 'theme', label: 'Theme' },
36
+ { id: 'updates', label: 'Updates' },
37
+ { id: 'layout', label: 'Layout' },
38
+ { id: 'general', label: 'General' },
39
+ { id: 'network', label: 'Network' },
40
+ { id: 'notifications', label: 'Notifications' },
41
+ { id: 'auth', label: 'Auth' },
42
+ { id: 'agents', label: 'Agents' },
43
+ { id: 'dashboard', label: 'Dashboard' },
44
+ { id: 'background', label: 'Background' },
45
+ { id: 'system-llm', label: 'System LLM' },
46
+ { id: 'headroom', label: 'Headroom' },
47
+ { id: 'activity-log', label: 'Activity' },
48
+ { id: 'about', label: 'About' },
49
+ { id: 'env-vars', label: 'Env Vars' },
50
+ { id: 'providers', label: 'Providers' },
51
+ { id: 'memory', label: 'Memory' },
52
+ { id: 'skills', label: 'Skills' },
53
+ { id: 'backup', label: 'Backup' },
239
54
  ] as const;
240
55
 
241
- // v3.5.3 — Updates card with per-package selection, live progress, and auto-restart
242
- type PkgStatus = {
243
- status: 'idle' | 'starting' | 'installing' | 'done' | 'error';
244
- error?: string;
245
- newVersion?: string;
246
- logs: string[];
247
- };
248
-
249
- function UpdatesCard() {
250
- const toast = useToast();
251
- const [status, setStatus] = useState<{
252
- current: Record<string, string | null>;
253
- latest: Record<string, string | null> | null;
254
- checking: boolean;
255
- updating: boolean;
256
- hasUpdates: boolean;
257
- requiresRestart: boolean;
258
- perPackage: Record<string, PkgStatus>;
259
- error?: string;
260
- }>({
261
- current: {},
262
- latest: null,
263
- checking: false,
264
- updating: false,
265
- hasUpdates: false,
266
- requiresRestart: false,
267
- perPackage: {},
268
- });
269
-
270
- // WebSocket subscription for live update progress (auto-reconnect via Ws class)
271
- useEffect(() => {
272
- const ws = new Ws();
273
- ws.on((msg: Record<string, unknown>) => {
274
- if (msg.type === 'update:progress' || msg.type === 'update:log' || msg.type === 'update:complete') {
275
- setStatus((s) => {
276
- if (msg.type === 'update:complete') {
277
- return {
278
- ...s,
279
- updating: false,
280
- requiresRestart: Boolean((msg as { requiresRestart?: boolean }).requiresRestart),
281
- };
282
- }
283
- if (msg.type === 'update:log') {
284
- const m = msg as { pkg: string; line: string };
285
- const existing = s.perPackage[m.pkg] || { logs: [] };
286
- return {
287
- ...s,
288
- perPackage: {
289
- ...s.perPackage,
290
- [m.pkg]: {
291
- ...existing,
292
- logs: [...(existing.logs || []).slice(-50), m.line],
293
- },
294
- },
295
- };
296
- }
297
- // update:progress
298
- const m = msg as { pkg: string; status: PkgStatus['status']; error?: string; newVersion?: string };
299
- return {
300
- ...s,
301
- perPackage: {
302
- ...s.perPackage,
303
- [m.pkg]: {
304
- ...s.perPackage[m.pkg],
305
- status: m.status,
306
- error: m.error,
307
- newVersion: m.newVersion,
308
- },
309
- },
310
- };
311
- });
312
- }
313
- });
314
- return () => ws.close();
315
- }, []);
316
-
317
- // Load current versions on mount
318
- useEffect(() => {
319
- api.get<{ current: Record<string, string | null> }>('/updates/status')
320
- .then((r) => setStatus((s) => ({ ...s, current: r.current })))
321
- .catch((e) => setStatus((s) => ({ ...s, error: e.message })));
322
- }, []);
323
-
324
- const check = async () => {
325
- setStatus((s) => ({ ...s, checking: true, error: undefined }));
326
- try {
327
- const r = await api.get<{
328
- current: Record<string, string | null>;
329
- latest: Record<string, string | null>;
330
- hasUpdates: boolean;
331
- }>('/updates/check');
332
- setStatus((s) => ({
333
- ...s,
334
- checking: false,
335
- current: r.current,
336
- latest: r.latest,
337
- hasUpdates: r.hasUpdates,
338
- }));
339
- } catch (err) {
340
- setStatus((s) => ({ ...s, checking: false, error: (err as Error).message }));
341
- }
342
- };
343
-
344
- const applyUpdate = async () => {
345
- if (!confirm('Update Bizar packages? The dashboard will restart automatically.')) return;
346
- setStatus((s) => ({
347
- ...s,
348
- updating: true,
349
- requiresRestart: false,
350
- perPackage: {},
351
- error: undefined,
352
- }));
353
- try {
354
- await api.post('/updates/apply', { packages: ['bizar', 'bizar-dash', 'bizar-plugin'] });
355
- // Progress streams via WebSocket
356
- } catch (err) {
357
- setStatus((s) => ({ ...s, updating: false, error: (err as Error).message }));
358
- }
359
- };
360
-
361
- const restart = async () => {
362
- if (!confirm('Restart the dashboard? You will be disconnected briefly.')) return;
363
- try {
364
- await api.post('/restart');
365
- toast.info('Restarting…', 3000);
366
- setTimeout(() => window.location.reload(), 3000);
367
- } catch {
368
- toast.error('Restart failed');
369
- }
370
- };
371
-
372
- const packages = [
373
- { id: 'bizar', name: 'Bizar CLI' },
374
- { id: 'bizar-dash', name: 'Dashboard' },
375
- { id: 'bizar-plugin', name: 'Opencode Plugin' },
376
- ];
377
-
378
- const isBusy = status.checking || status.updating;
379
-
380
- return (
381
- <Card id="settings-updates" data-section="updates">
382
- <CardTitle><Download size={14} /> Updates</CardTitle>
383
- <CardMeta>Check installed Bizar packages and apply dashboard updates.</CardMeta>
384
- {/* Current versions */}
385
- <div className="updates-current">
386
- <h4>Installed versions</h4>
387
- <ul>
388
- {packages.map((p) => (
389
- <li key={p.id}>
390
- <span>{p.name}</span>
391
- <code className="mono">{status.current[p.id] || '—'}</code>
392
- </li>
393
- ))}
394
- </ul>
395
- </div>
396
-
397
- {/* Latest + per-package status */}
398
- {status.latest && (
399
- <div className="updates-latest">
400
- <h4>Latest available</h4>
401
- <ul>
402
- {packages.map((p) => {
403
- const cur = status.current[p.id];
404
- const lat = status.latest?.[p.id];
405
- const isOutdated = cur && lat && cur !== lat;
406
- return (
407
- <li key={p.id} className={isOutdated ? 'updates-outdated' : 'updates-current-version'}>
408
- <span>{p.name}</span>
409
- <code className="mono">
410
- {lat || '—'}
411
- {isOutdated && <span className="updates-badge">update available</span>}
412
- </code>
413
- </li>
414
- );
415
- })}
416
- </ul>
417
- </div>
418
- )}
419
-
420
- {/* Per-package progress rows (shown while updating) */}
421
- {status.updating && (
422
- <div className="updates-progress-rows">
423
- {packages.map((p) => {
424
- const pkgStatus = status.perPackage[p.id] || { status: 'idle', logs: [] as string[] };
425
- return (
426
- <div key={p.id} className="updates-pkg-row">
427
- <div className="updates-pkg-row-header">
428
- <span className="updates-pkg-name">{p.name}</span>
429
- <div className="updates-pkg-status">
430
- {pkgStatus.status === 'starting' && <span className="btn-spinner" />}
431
- {pkgStatus.status === 'installing' && <span className="btn-spinner" />}
432
- {pkgStatus.status === 'done' && <CheckCircle size={14} className="icon-success" />}
433
- {pkgStatus.status === 'error' && <AlertCircle size={14} className="icon-error" />}
434
- <span>{pkgStatus.status}</span>
435
- {pkgStatus.newVersion && (
436
- <code className="mono" style={{ fontSize: 11 }}>→ {pkgStatus.newVersion}</code>
437
- )}
438
- </div>
439
- </div>
440
- {pkgStatus.logs.length > 0 && (
441
- <details className="updates-pkg-logs">
442
- <summary>npm output ({pkgStatus.logs.length} lines)</summary>
443
- <pre>{pkgStatus.logs.join('\n')}</pre>
444
- </details>
445
- )}
446
- {pkgStatus.status === 'error' && pkgStatus.error && (
447
- <div className="updates-pkg-error">
448
- <AlertTriangle size={12} /> {pkgStatus.error}
449
- </div>
450
- )}
451
- </div>
452
- );
453
- })}
454
- </div>
455
- )}
456
-
457
- {/* Actions */}
458
- <div className="updates-actions">
459
- <Button onClick={check} disabled={isBusy}>
460
- {status.checking ? <span className="btn-spinner" /> : <RefreshCw size={14} />}
461
- Check for updates
462
- </Button>
463
- <Button
464
- variant="primary"
465
- onClick={applyUpdate}
466
- disabled={!status.hasUpdates || status.updating}
467
- >
468
- {status.updating ? <span className="btn-spinner" /> : <Download size={14} />}
469
- {status.updating ? 'Updating…' : (status.hasUpdates ? 'Update now' : 'Up to date')}
470
- </Button>
471
- {status.requiresRestart && (
472
- <Button variant="danger" onClick={restart}>
473
- <RefreshCw size={14} /> Restart Dashboard
474
- </Button>
475
- )}
476
- </div>
477
-
478
- {status.error && (
479
- <div className="updates-error">
480
- <AlertTriangle size={14} />
481
- <span>{status.error}</span>
482
- </div>
483
- )}
484
- </Card>
485
- );
486
- }
487
-
488
56
  function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
489
57
  const toast = useToast();
490
58
  const [settings, setSettings] = useState<Settings>(initial);
@@ -492,1333 +60,117 @@ function SettingsViewInner({ settings: initial, refreshSnapshot }: Props) {
492
60
  const [saving, setSaving] = useState(false);
493
61
  const [tailscale, setTailscale] = useState<TailscaleStatus | null>(null);
494
62
  const [tailscaleDraft, setTailscaleDraft] = useState({ port: 4321, https: true, hostname: '' });
495
- const [pluginOptions, setPluginOptions] = useState<Record<string, number>>({});
496
-
497
- useEffect(() => {
498
- setSettings(initial);
499
- setDirty(false);
500
- if (initial.theme) applyThemeTokens(initial.theme);
501
- }, [initial]);
502
-
503
- useEffect(() => {
504
- if (!tailscale) return;
505
- setTailscaleDraft({
506
- port: tailscale.settings.port,
507
- https: tailscale.settings.https !== false,
508
- hostname: tailscale.settings.hostname || '',
509
- });
510
- }, [tailscale]);
511
-
512
- useEffect(() => {
513
- api.get<TailscaleStatus>('/tailscale/status').then(setTailscale).catch(() => undefined);
514
- }, []);
515
-
516
- useEffect(() => {
517
- api.get<Record<string, number>>('/settings/plugin-options')
518
- .then(setPluginOptions)
519
- .catch(() => { /* not persisted yet — use defaults */ });
520
- }, []);
521
-
522
- const patchTheme = (patch: Partial<Settings['theme']>) => {
523
- setSettings((cur) => {
524
- const next = { ...cur, theme: { ...cur.theme, ...patch } };
525
- applyThemeTokens(next.theme);
526
- return next;
527
- });
528
- setDirty(true);
529
- };
530
-
531
- const patchUi = (patch: Partial<Settings['ui']>) => {
532
- setSettings((cur) => ({ ...cur, ui: { ...cur.ui, ...patch } }));
533
- setDirty(true);
534
- };
63
+ const [authStatus, setAuthStatus] = useState<{ required: boolean; loopback: boolean; peer: string } | null>(null);
535
64
 
536
- const patchTop = <K extends keyof Settings>(key: K, value: Settings[K]) => {
537
- setSettings((cur) => ({ ...cur, [key]: value }));
538
- setDirty(true);
539
- };
65
+ useEffect(() => { setSettings(initial); setDirty(false); if (initial.theme) applyThemeTokens(initial.theme); }, [initial]);
66
+ useEffect(() => { if (!tailscale) return; setTailscaleDraft({ port: tailscale.settings.port, https: tailscale.settings.https !== false, hostname: tailscale.settings.hostname || '' }); }, [tailscale]);
67
+ useEffect(() => { api.get<TailscaleStatus>('/tailscale/status').then(setTailscale).catch(() => undefined); }, []);
540
68
 
541
- const patchNotifications = (patch: Partial<Settings['notifications']>) => {
542
- setSettings((cur) => ({ ...cur, notifications: { ...cur.notifications, ...patch } }));
543
- setDirty(true);
544
- };
545
-
546
- const patchAgents = (patch: Partial<Settings['agents']>) => {
547
- setSettings((cur) => ({ ...cur, agents: { ...cur.agents, ...patch } }));
548
- setDirty(true);
549
- };
69
+ const patchTheme = (patch: Partial<Settings['theme']>) => { setSettings((cur) => { const next = { ...cur, theme: { ...cur.theme, ...patch } }; applyThemeTokens(next.theme); return next; }); setDirty(true); };
70
+ const patchUi = (patch: Partial<Settings['ui']>) => { setSettings((cur) => ({ ...cur, ui: { ...cur.ui, ...patch } })); setDirty(true); };
71
+ const patchTop = <K extends keyof Settings>(key: K, value: Settings[K]) => { setSettings((cur) => ({ ...cur, [key]: value })); setDirty(true); };
72
+ const patchNotifications = (patch: Partial<Settings['notifications']>) => { setSettings((cur) => ({ ...cur, notifications: { ...cur.notifications, ...patch } })); setDirty(true); };
73
+ const patchAgents = (patch: Partial<Settings['agents']>) => { setSettings((cur) => ({ ...cur, agents: { ...cur.agents, ...patch } })); setDirty(true); };
74
+ const patchDashboard = (patch: Partial<Settings['dashboard']>) => { setSettings((cur) => ({ ...cur, dashboard: { ...cur.dashboard, ...patch } })); setDirty(true); };
550
75
 
551
- // v3.17.0 — Settings subnav as a filter (not just scroll-to).
552
- // Clicking a subnav button shows ONLY that section; clicking "All"
553
- // restores the full layout. Anchors still work for deep-links but
554
- // the visible content is filtered, not just scrolled.
555
- //
556
- // v3.18.0 — IDs use the section NAME (not the `settings-X` element id)
557
- // because the inline-style wrap compares against the section name, not
558
- // the prefixed DOM id.
559
- const SECTION_LINKS: Array<{ id: string; label: string }> = [
560
- { id: 'theme', label: 'Theme' },
561
- { id: 'layout', label: 'Layout' },
562
- { id: 'general', label: 'General' },
563
- { id: 'service', label: 'Service' },
564
- { id: 'tailscale', label: 'Tailscale' },
565
- { id: 'notifications', label: 'Notifications' },
566
- { id: 'auth', label: 'Auth' },
567
- { id: 'agents', label: 'Agents' },
568
- { id: 'dashboard', label: 'Dashboard' },
569
- { id: 'background', label: 'Background' },
570
- { id: 'system-llm', label: 'System LLM' },
571
- { id: 'headroom', label: 'Headroom' },
572
- { id: 'updates', label: 'Updates' },
573
- { id: 'activity-log', label: 'Activity' },
574
- { id: 'about', label: 'About' },
575
- ];
576
76
  const [activeSection, setActiveSection] = useState<string | null>(() => {
577
- // Honour #settings-... deep-link on initial mount, otherwise null = All.
578
77
  if (typeof window === 'undefined') return null;
579
78
  const hash = window.location.hash.replace(/^#settings-/, '');
580
79
  return SECTION_LINKS.some((s) => s.id === hash) ? hash : null;
581
80
  });
81
+
582
82
  const onJumpSection = (id: string | null) => {
583
83
  setActiveSection(id);
584
84
  try {
585
- const url = id ? `#settings-${id}` : window.location.pathname;
586
- history.replaceState(null, '', url);
587
- if (id) {
588
- const el = document.getElementById(`settings-${id}`);
589
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
590
- } else {
591
- window.scrollTo({ top: 0, behavior: 'smooth' });
592
- }
85
+ history.replaceState(null, '', id ? `#settings-${id}` : window.location.pathname);
86
+ if (id) { const el = document.getElementById(`settings-${id}`); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
87
+ else window.scrollTo({ top: 0, behavior: 'smooth' });
593
88
  } catch { /* ignore */ }
594
89
  };
595
- // The user picks a section via the subnav; their choice is respected.
596
- // (v3.18.0 — Removed auto-scroll-based detection; with the inline-style
597
- // filter approach, hidden sections still report bounding rects, so the
598
- // detector would race the user's explicit selection and switch tabs back.)
599
90
 
600
- const patchDashboard = (patch: Partial<Settings['dashboard']>) => {
601
- setSettings((cur) => ({ ...cur, dashboard: { ...cur.dashboard, ...patch } }));
602
- setDirty(true);
91
+ const onTailscaleToggle = async () => {
92
+ try {
93
+ if (tailscale?.settings.enabled) { await api.post('/tailscale/disable'); toast.success('Tailscale serve disabled.'); }
94
+ else { await api.post('/tailscale/enable', { port: tailscaleDraft.port || 4321, https: tailscaleDraft.https, hostname: tailscaleDraft.hostname || '' }); toast.success('Tailscale serve enabled.'); }
95
+ setTailscale(await api.get<TailscaleStatus>('/tailscale/status'));
96
+ } catch (err) { toast.error(`Tailscale failed: ${(err as Error).message}`); }
603
97
  };
604
98
 
605
99
  const onSave = async () => {
606
100
  setSaving(true);
607
101
  try {
608
102
  const r = await api.put<SettingsResponse>('/settings', settings);
609
- setSettings(r.data);
610
- setDirty(false);
611
- applyTheme(r.data.theme);
612
- applyThemeTokens(r.data.theme);
613
- toast.success('Settings saved.');
614
- await refreshSnapshot();
615
- } catch (err) {
616
- toast.error(`Save failed: ${(err as Error).message}`);
617
- } finally {
618
- setSaving(false);
619
- }
103
+ setSettings(r.data); setDirty(false); applyTheme(r.data.theme); applyThemeTokens(r.data.theme);
104
+ toast.success('Settings saved.'); await refreshSnapshot();
105
+ } catch (err) { toast.error(`Save failed: ${(err as Error).message}`); }
106
+ finally { setSaving(false); }
620
107
  };
621
108
 
622
109
  const onReload = async () => {
623
110
  try {
624
111
  const r = await api.get<SettingsResponse>('/settings');
625
- setSettings(r.data);
626
- setDirty(false);
627
- applyTheme(r.data.theme);
628
- applyThemeTokens(r.data.theme);
112
+ setSettings(r.data); setDirty(false); applyTheme(r.data.theme); applyThemeTokens(r.data.theme);
629
113
  toast.info('Settings reloaded.', 1500);
630
- } catch (err) {
631
- toast.error(`Reload failed: ${(err as Error).message}`);
632
- }
114
+ } catch (err) { toast.error(`Reload failed: ${(err as Error).message}`); }
633
115
  };
634
116
 
635
117
  const onReset = async () => {
636
118
  if (!confirm('Reset all settings to defaults?')) return;
637
119
  try {
638
120
  const r = await api.post<SettingsResponse>('/settings/reset');
639
- setSettings(r.data);
640
- setDirty(false);
641
- applyTheme(r.data.theme);
642
- applyThemeTokens(r.data.theme);
643
- toast.success('Settings reset.');
644
- await refreshSnapshot();
645
- } catch (err) {
646
- toast.error(`Reset failed: ${(err as Error).message}`);
647
- }
121
+ setSettings(r.data); setDirty(false); applyTheme(r.data.theme); applyThemeTokens(r.data.theme);
122
+ toast.success('Settings reset.'); await refreshSnapshot();
123
+ } catch (err) { toast.error(`Reset failed: ${(err as Error).message}`); }
648
124
  };
649
125
 
650
- const onTailscaleToggle = async () => {
651
- try {
652
- if (tailscale?.settings.enabled) {
653
- await api.post('/tailscale/disable');
654
- toast.success('Tailscale serve disabled.');
655
- } else {
656
- await api.post('/tailscale/enable', {
657
- port: tailscaleDraft.port || 4321,
658
- https: tailscaleDraft.https,
659
- hostname: tailscaleDraft.hostname || '',
660
- });
661
- toast.success('Tailscale serve enabled.');
662
- }
663
- const r = await api.get<TailscaleStatus>('/tailscale/status');
664
- setTailscale(r);
665
- } catch (err) {
666
- toast.error(`Tailscale failed: ${(err as Error).message}`);
667
- }
668
- };
669
-
670
- const about = settings.about || {
671
- version: '3.0.4',
672
- homepage: 'https://github.com/DrB0rk/BizarHarness',
673
- license: 'MIT',
674
- };
675
-
676
- // v3.6.0 — Auth token UI state. The Settings tab is the only place
677
- // the operator can read the token back out. We default the input
678
- // to whatever's already in localStorage so a page reload doesn't
679
- // wipe the entry.
680
- const [authToken, setAuthToken] = useState<string>(api.getToken());
681
- const [authStatus, setAuthStatus] = useState<{ required: boolean; loopback: boolean; peer: string } | null>(null);
682
- const [revealedToken, setRevealedToken] = useState<string>('');
126
+ const sp = { settings, patchTheme, patchUi, patchTop, patchNotifications, patchAgents, patchDashboard };
127
+ const about = settings.about || { version: '3.0.4', homepage: 'https://github.com/DrB0rk/BizarHarness', license: 'MIT' };
683
128
 
684
- // Probe the server once on mount so we can show "auth required" vs
685
- // "auth off" in the UI. If /api/auth/status itself 401s, the token
686
- // we're using is bad — surface that distinctly.
687
- useEffect(() => {
688
- let cancelled = false;
689
- (async () => {
690
- try {
691
- const r = await api.probeAuthStatus();
692
- if (!cancelled) setAuthStatus(r);
693
- } catch {
694
- if (!cancelled) {
695
- setAuthStatus({ required: true, loopback: false, peer: '' });
696
- // Don't toast — the toast spam would be annoying on every
697
- // Settings tab open. The Copy/Regenerate buttons themselves
698
- // surface the real error if it happens there.
699
- }
700
- }
701
- })();
702
- return () => { cancelled = true; };
703
- }, []);
704
-
705
- const onCopyToken = async () => {
706
- try {
707
- const r = await api.get<{ token: string }>('/auth/reveal');
708
- setRevealedToken(r.token);
709
- setAuthToken(r.token);
710
- api.setToken(r.token);
711
- try {
712
- await navigator.clipboard.writeText(r.token);
713
- toast.success('Token copied to clipboard.');
714
- } catch {
715
- toast.success('Token revealed — copy from the field below.');
716
- }
717
- } catch (err) {
718
- toast.error(`Reveal failed: ${(err as Error).message}`);
719
- }
720
- };
721
-
722
- const onRegenerateToken = async () => {
723
- if (!confirm('Regenerate the auth token? Anything still using the old token will start getting 401 errors immediately.')) {
724
- return;
725
- }
726
- try {
727
- const r = await api.post<{ token: string }>('/auth/regenerate');
728
- setRevealedToken(r.token);
729
- setAuthToken(r.token);
730
- api.setToken(r.token);
731
- try {
732
- await navigator.clipboard.writeText(r.token);
733
- toast.success('New token generated and copied to clipboard.');
734
- } catch {
735
- toast.success('New token generated — copy from the field below. Old token is now invalid.');
736
- }
737
- } catch (err) {
738
- toast.error(`Regenerate failed: ${(err as Error).message}`);
739
- }
740
- };
741
-
742
- const onSaveToken = () => {
743
- api.setToken(authToken.trim());
744
- toast.success('Token saved. The dashboard will use it on the next request.');
745
- };
129
+ const showAll = activeSection === null;
130
+ const sectionOf = (id: string) => activeSection === id;
746
131
 
747
132
  return (
748
133
  <div className="view view-settings">
749
134
  <header className="view-header">
750
135
  <div className="view-header-text">
751
- <h2 className="view-title">
752
- <Sliders size={18} /> Settings
753
- </h2>
754
- <p className="view-subtitle">
755
- Personal preferences. Changes are saved to{' '}
756
- <code>~/.config/bizar/settings.json</code>.
757
- </p>
136
+ <h2 className="view-title"><Sliders size={18} /> Settings</h2>
137
+ <p className="view-subtitle">Personal preferences. Changes are saved to <code>~/.config/bizar/settings.json</code>.</p>
758
138
  </div>
759
139
  <div className="view-actions">
760
- <Button variant="ghost" size="sm" onClick={onReset}>
761
- <RotateCcw size={14} /> Reset
762
- </Button>
763
- <Button variant="secondary" size="sm" onClick={onReload}>
764
- <RefreshCw size={14} /> Reload
765
- </Button>
766
- <Button
767
- variant="primary"
768
- size="sm"
769
- disabled={!dirty || saving}
770
- onClick={onSave}
771
- >
772
- {saving ? <span className="btn-spinner" /> : <Save size={14} />}
773
- {saving ? 'Saving…' : 'Save'}
140
+ <Button variant="ghost" size="sm" onClick={onReset}><RotateCcw size={14} /> Reset</Button>
141
+ <Button variant="secondary" size="sm" onClick={onReload}><RefreshCw size={14} /> Reload</Button>
142
+ <Button variant="primary" size="sm" disabled={!dirty || saving} onClick={onSave}>
143
+ {saving ? <span className="btn-spinner" /> : <Save size={14} />}{saving ? 'Saving…' : 'Save'}
774
144
  </Button>
775
145
  </div>
776
146
  </header>
777
147
 
778
148
  <nav className="settings-subnav" aria-label="Settings sections">
779
- <button
780
- type="button"
781
- className={cn('settings-subnav-button', 'settings-subnav-button-all', activeSection === null && 'settings-subnav-button-active')}
782
- onClick={() => onJumpSection(null)}
783
- title="Show all settings sections"
784
- >
785
- All
786
- </button>
149
+ <button type="button" className={cn('settings-subnav-button', 'settings-subnav-button-all', showAll && 'settings-subnav-button-active')} onClick={() => onJumpSection(null)} title="Show all settings sections">All</button>
787
150
  {SECTION_LINKS.map((s) => (
788
- <button
789
- key={s.id}
790
- type="button"
791
- className={cn('settings-subnav-button', activeSection === s.id && 'settings-subnav-button-active')}
792
- onClick={() => onJumpSection(s.id)}
793
- >
794
- {s.label}
795
- </button>
151
+ <button key={s.id} type="button" className={cn('settings-subnav-button', activeSection === s.id && 'settings-subnav-button-active')} onClick={() => onJumpSection(s.id)}>{s.label}</button>
796
152
  ))}
797
153
  </nav>
798
154
 
799
- <div
800
- className={cn('settings-grid', activeSection && 'settings-grid-filtered')}
801
- data-active-section={activeSection || undefined}
802
- >
803
- {/* When a section is active, render a banner above the grid so
804
- the user knows they're seeing a filtered view. */}
805
- {activeSection && (
806
- <div className="settings-filter-banner">
807
- <span>
808
- Showing only <strong>{SECTION_LINKS.find((s) => s.id === activeSection)?.label}</strong>.
809
- </span>
810
- <button
811
- type="button"
812
- className="settings-filter-clear"
813
- onClick={() => onJumpSection(null)}
814
- >
815
- Show all sections
816
- </button>
817
- </div>
818
- )}
819
- <div data-section="theme" style={{display: (activeSection === null || activeSection === 'theme') ? 'block' : 'none'}}>
820
- <Card id="settings-theme" data-section="theme">
821
- <CardTitle><Palette size={14} /> Theme</CardTitle>
822
- <CardMeta>Mode, accent, and colors. Live preview as you tweak.</CardMeta>
823
-
824
- {/* v3.3.1 — Accent color presets */}
825
- <div className="field" data-setting-id="theme.presets">
826
- <label className="field-label">Accent presets</label>
827
- <div className="theme-presets">
828
- {PRESET_THEMES.map((t) => (
829
- <button
830
- key={t.name}
831
- type="button"
832
- className={cn(
833
- 'theme-preset',
834
- settings.theme.accent === t.accent && 'theme-preset-active',
835
- )}
836
- onClick={() => patchTheme({ accent: t.accent })}
837
- title={t.name}
838
- >
839
- <span
840
- className="theme-preset-swatch"
841
- style={{ background: t.accent }}
842
- />
843
- <span className="theme-preset-name">{t.name}</span>
844
- </button>
845
- ))}
846
- </div>
847
- </div>
848
-
849
- <div className="field" data-setting-id="theme.mode">
850
- <label className="field-label">Mode</label>
851
- <div className="theme-row">
852
- {THEMES.map(({ id, label, Icon }) => {
853
- const active = settings.theme.mode === id;
854
- return (
855
- <button
856
- key={id}
857
- type="button"
858
- className={cn('theme-card', active && 'theme-card-active')}
859
- onClick={() => patchTheme({ mode: id })}
860
- >
861
- <Icon size={16} />
862
- <span className="theme-card-label">{label}</span>
863
- <span
864
- className={cn(
865
- 'theme-card-swatch',
866
- `theme-card-swatch-${id}`,
867
- )}
868
- />
869
- </button>
870
- );
871
- })}
872
- </div>
873
- </div>
874
-
875
- <div className="theme-colors">
876
- <div className="field" data-setting-id="theme.accent">
877
- <label className="field-label">Accent</label>
878
- <div className="color-row">
879
- <input
880
- type="color"
881
- className="input color-input"
882
- value={settings.theme.accent}
883
- onChange={(e) => patchTheme({ accent: e.target.value })}
884
- aria-label="Accent color"
885
- />
886
- <input
887
- type="text"
888
- className="input"
889
- value={settings.theme.accent}
890
- onChange={(e) => patchTheme({ accent: e.target.value })}
891
- />
892
- </div>
893
- </div>
894
- <div className="field" data-setting-id="theme.success">
895
- <label className="field-label">Success</label>
896
- <div className="color-row">
897
- <input
898
- type="color"
899
- className="input color-input"
900
- value={settings.theme.success}
901
- onChange={(e) => patchTheme({ success: e.target.value })}
902
- aria-label="Success color"
903
- />
904
- <input
905
- type="text"
906
- className="input"
907
- value={settings.theme.success}
908
- onChange={(e) => patchTheme({ success: e.target.value })}
909
- />
910
- </div>
911
- </div>
912
- <div className="field" data-setting-id="theme.warning">
913
- <label className="field-label">Warning</label>
914
- <div className="color-row">
915
- <input
916
- type="color"
917
- className="input color-input"
918
- value={settings.theme.warning}
919
- onChange={(e) => patchTheme({ warning: e.target.value })}
920
- aria-label="Warning color"
921
- />
922
- <input
923
- type="text"
924
- className="input"
925
- value={settings.theme.warning}
926
- onChange={(e) => patchTheme({ warning: e.target.value })}
927
- />
928
- </div>
929
- </div>
930
- <div className="field" data-setting-id="theme.error">
931
- <label className="field-label">Error</label>
932
- <div className="color-row">
933
- <input
934
- type="color"
935
- className="input color-input"
936
- value={settings.theme.error}
937
- onChange={(e) => patchTheme({ error: e.target.value })}
938
- aria-label="Error color"
939
- />
940
- <input
941
- type="text"
942
- className="input"
943
- value={settings.theme.error}
944
- onChange={(e) => patchTheme({ error: e.target.value })}
945
- />
946
- </div>
947
- </div>
948
- <div className="field" data-setting-id="theme.info">
949
- <label className="field-label">Info</label>
950
- <div className="color-row">
951
- <input
952
- type="color"
953
- className="input color-input"
954
- value={settings.theme.info}
955
- onChange={(e) => patchTheme({ info: e.target.value })}
956
- aria-label="Info color"
957
- />
958
- <input
959
- type="text"
960
- className="input"
961
- value={settings.theme.info}
962
- onChange={(e) => patchTheme({ info: e.target.value })}
963
- />
964
- </div>
965
- </div>
966
- </div>
967
-
968
- <div className="field" data-setting-id="theme.fontFamily">
969
- <label className="field-label">Font family</label>
970
- <select
971
- className="select"
972
- value={settings.theme.fontFamily}
973
- onChange={(e) => patchTheme({ fontFamily: e.target.value })}
974
- >
975
- {FONT_FAMILIES.map((f) => (
976
- <option key={f} value={f}>{f}</option>
977
- ))}
978
- </select>
979
- </div>
980
- <div className="field" data-setting-id="theme.fontSize">
981
- <label className="field-label">Font size: {settings.theme.fontSize}px</label>
982
- <input
983
- type="range"
984
- min={12}
985
- max={20}
986
- value={settings.theme.fontSize}
987
- onChange={(e) => patchTheme({ fontSize: Number(e.target.value) })}
988
- />
989
- </div>
990
- <label className="checkbox-row" data-setting-id="theme.compactMode">
991
- <input
992
- type="checkbox"
993
- checked={settings.theme.compactMode}
994
- onChange={(e) => patchTheme({ compactMode: e.target.checked })}
995
- />
996
- <span>Compact mode (denser UI)</span>
997
- </label>
998
- <label className="checkbox-row" data-setting-id="theme.animations">
999
- <input
1000
- type="checkbox"
1001
- checked={settings.theme.animations}
1002
- onChange={(e) => patchTheme({ animations: e.target.checked })}
1003
- />
1004
- <span>Enable animations</span>
1005
- </label>
1006
- </Card>
1007
- </div>
1008
-
1009
- <div data-section="updates" style={{display: (activeSection === null || activeSection === 'updates') ? 'block' : 'none'}}>
1010
- <UpdatesCard />
1011
- </div>
1012
-
1013
- <div data-section="layout" style={{display: (activeSection === null || activeSection === 'layout') ? 'block' : 'none'}}>
1014
- <Card id="settings-layout" data-section="layout">
1015
- <CardTitle><LayoutIcon size={14} /> UI layout</CardTitle>
1016
- <CardMeta>Choose how the dashboard's navigation is presented.</CardMeta>
1017
- <div className="layout-row" data-setting-id="ui.layout">
1018
- {LAYOUTS.map((l) => (
1019
- <button
1020
- key={l.id}
1021
- type="button"
1022
- className={cn('layout-card', settings.ui.layout === l.id && 'layout-card-active')}
1023
- onClick={() => patchUi({ layout: l.id })}
1024
- >
1025
- <span className="layout-card-label">{l.label}</span>
1026
- </button>
1027
- ))}
1028
- </div>
1029
- <label className="checkbox-row" data-setting-id="ui.showHeader">
1030
- <input
1031
- type="checkbox"
1032
- checked={settings.ui.showHeader}
1033
- onChange={(e) => patchUi({ showHeader: e.target.checked })}
1034
- />
1035
- <span>Show header</span>
1036
- </label>
1037
- <label className="checkbox-row" data-setting-id="ui.showStatusBar">
1038
- <input
1039
- type="checkbox"
1040
- checked={settings.ui.showStatusBar}
1041
- onChange={(e) => patchUi({ showStatusBar: e.target.checked })}
1042
- />
1043
- <span>Show status bar</span>
1044
- </label>
1045
- <div className="field" data-setting-id="ui.defaultTab">
1046
- <label className="field-label">Default tab</label>
1047
- <select
1048
- className="select"
1049
- value={settings.ui.defaultTab}
1050
- onChange={(e) => patchUi({ defaultTab: e.target.value })}
1051
- >
1052
- <option value="overview">Overview</option>
1053
- <option value="chat">Chat</option>
1054
- <option value="agents">Agents</option>
1055
- <option value="artifacts">Plans</option>
1056
- <option value="projects">Projects</option>
1057
- <option value="tasks">Tasks</option>
1058
- <option value="config">Config</option>
1059
- <option value="settings">Settings</option>
1060
- <option value="mods">Mods</option>
1061
- <option value="schedules">Schedules</option>
1062
- </select>
1063
- </div>
1064
- </Card>
1065
- </div>
1066
-
1067
- <div data-section="general" style={{display: (activeSection === null || activeSection === 'general') ? 'block' : 'none'}}>
1068
- <Card id="settings-general" data-section="general">
1069
- <CardTitle>General</CardTitle>
1070
- <CardMeta>Default agent + model override.</CardMeta>
1071
- <div className="field" data-setting-id="defaultAgent">
1072
- <label className="field-label" htmlFor="set-default-agent">Default agent</label>
1073
- <input
1074
- id="set-default-agent"
1075
- className="input"
1076
- type="text"
1077
- placeholder="e.g. odin"
1078
- value={settings.defaultAgent || ''}
1079
- onChange={(e) => patchTop('defaultAgent', e.target.value)}
1080
- />
1081
- </div>
1082
- <div className="field" data-setting-id="defaultModel">
1083
- <label className="field-label" htmlFor="set-default-model">Model override</label>
1084
- <input
1085
- id="set-default-model"
1086
- className="input"
1087
- type="text"
1088
- placeholder="(leave empty for provider default)"
1089
- value={settings.defaultModel || ''}
1090
- onChange={(e) => patchTop('defaultModel', e.target.value)}
1091
- />
1092
- </div>
1093
- </Card>
1094
- </div>
1095
-
1096
- <div data-section="service" style={{display: (activeSection === null || activeSection === 'service') ? 'block' : 'none'}}>
1097
- <Card id="settings-service" data-section="service">
1098
- <CardTitle><ServerIcon size={14} /> Service</CardTitle>
1099
- <CardMeta>Background daemon that runs schedules.</CardMeta>
1100
- <div data-setting-id="service.enabled">
1101
- {tailscale ? (
1102
- <div className="service-card">
1103
- <p>
1104
- Status: <strong>{tailscale?.settings.enabled ? 'enabled' : 'disabled'}</strong>
1105
- {' '}· Tailscale installed: <strong>{tailscale.installed ? 'yes' : 'no'}</strong>
1106
- {' '}· authenticated: <strong>{tailscale.authenticated ? 'yes' : 'no'}</strong>
1107
- </p>
1108
- <p className="muted">
1109
- Use <code>bizar service start</code> / <code>bizar service stop</code> in
1110
- your terminal to control the daemon.
1111
- </p>
1112
- </div>
1113
- ) : (
1114
- <p className="muted">Loading service status…</p>
1115
- )}
1116
- </div>
1117
- </Card>
1118
- </div>
1119
-
1120
- <div data-section="tailscale" style={{display: (activeSection === null || activeSection === 'tailscale') ? 'block' : 'none'}}>
1121
- <Card id="settings-tailscale" data-section="tailscale">
1122
- <CardTitle><Plug size={14} /> Tailscale serve</CardTitle>
1123
- <CardMeta>Expose the dashboard over your Tailscale network.</CardMeta>
1124
- {tailscale ? (
1125
- <>
1126
- <p>
1127
- Installed: <strong>{tailscale.installed ? 'yes' : 'no'}</strong>{' '}
1128
- {tailscale.version && <span className="muted">({tailscale.version})</span>}
1129
- </p>
1130
- <p>
1131
- Authenticated: <strong>{tailscale.authenticated ? 'yes' : 'no'}</strong>
1132
- </p>
1133
- <p>
1134
- Serve enabled: <strong>{tailscale.settings.enabled ? 'yes' : 'no'}</strong>
1135
- </p>
1136
- <div className="task-form-row">
1137
- <div className="task-form-field">
1138
- <label className="field-label">Port</label>
1139
- <input
1140
- type="number"
1141
- className="input"
1142
- value={tailscaleDraft.port}
1143
- onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, port: Number(e.target.value) || 4321 }))}
1144
- />
1145
- </div>
1146
- <div className="task-form-field">
1147
- <label className="field-label">Use HTTPS</label>
1148
- <input
1149
- type="checkbox"
1150
- checked={tailscaleDraft.https}
1151
- onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, https: e.target.checked }))}
1152
- />
1153
- </div>
1154
- </div>
1155
- <Button variant="primary" onClick={onTailscaleToggle}>
1156
- {tailscale.settings.enabled ? 'Disable serve' : 'Enable serve'}
1157
- </Button>
1158
- </>
1159
- ) : (
1160
- <p className="muted">Loading Tailscale status…</p>
1161
- )}
1162
- </Card>
1163
- </div>
1164
-
1165
- <div data-section="notifications" style={{display: (activeSection === null || activeSection === 'notifications') ? 'block' : 'none'}}>
1166
- <Card id="settings-notifications" data-section="notifications">
1167
- <CardTitle>Notifications</CardTitle>
1168
- <CardMeta>Toast triggers inside the dashboard.</CardMeta>
1169
- <label className="checkbox-row" data-setting-id="notifications.onAgentComplete">
1170
- <input
1171
- type="checkbox"
1172
- checked={!!settings.notifications.onAgentComplete}
1173
- onChange={(e) => patchNotifications({ onAgentComplete: e.target.checked })}
1174
- />
1175
- <span>Notify when an agent invocation completes</span>
1176
- </label>
1177
- <label className="checkbox-row" data-setting-id="notifications.onPlanApproval">
1178
- <input
1179
- type="checkbox"
1180
- checked={!!settings.notifications.onPlanApproval}
1181
- onChange={(e) => patchNotifications({ onPlanApproval: e.target.checked })}
1182
- />
1183
- <span>Notify when a plan needs approval</span>
1184
- </label>
1185
- </Card>
1186
- </div>
1187
-
1188
- {/* v3.6.0 — Auth token management. The Settings tab is the
1189
- only place a human can read the token. The input + Save
1190
- button lets the operator paste a token they got from
1191
- server stderr or from another machine. Copy / Regenerate
1192
- buttons act via the authed /api/auth/* endpoints. */}
1193
- <div data-section="auth" style={{display: (activeSection === null || activeSection === 'auth') ? 'block' : 'none'}}>
1194
- <Card id="settings-auth" data-section="auth">
1195
- <CardTitle><Shield size={14} /> Authentication</CardTitle>
1196
- <CardMeta>
1197
- Localhost and Tailscale browser access are auto-trusted via loopback.
1198
- A bearer token is still available for non-loopback clients and
1199
- forced-auth mode.
1200
- </CardMeta>
1201
- <div className="field" data-setting-id="auth.status">
1202
- <label className="field-label">Server status</label>
1203
- <p style={{ margin: '4px 0' }}>
1204
- Auth required:{' '}
1205
- <strong>{authStatus ? (authStatus.required ? 'yes' : 'no') : 'probing…'}</strong>
1206
- </p>
1207
- <p style={{ margin: '4px 0' }}>
1208
- Connection:{' '}
1209
- <strong>
1210
- {authStatus
1211
- ? (authStatus.loopback ? 'loopback (auto-trusted)' : 'remote')
1212
- : 'probing…'}
1213
- </strong>
1214
- </p>
1215
- <p style={{ margin: '4px 0' }}>
1216
- Peer address:{' '}
1217
- {authStatus?.peer ? <code>{authStatus.peer}</code> : <span className="muted">probing…</span>}
1218
- </p>
1219
- <p className="muted" style={{ fontSize: 12, margin: '4px 0' }}>
1220
- Localhost and Tailscale browser access are auto-trusted because the
1221
- dashboard sees a loopback peer. Paste a token only for non-loopback
1222
- API clients/scripts, or if you force auth for every connection with{' '}
1223
- <code>BIZAR_DASHBOARD_REQUIRE_AUTH=1</code>.
1224
- </p>
1225
- <p className="muted" style={{ fontSize: 12, margin: '4px 0' }}>
1226
- Dashboard tokens are generated on first boot and saved to{' '}
1227
- <code>~/.config/bizar/dashboard-secret</code> (mode 0600).
1228
- </p>
1229
- {/* v3.6.2 */}
1230
- <p className="muted" style={{ fontSize: 12, margin: '4px 0' }}>
1231
- For Tailscale Serve or any reverse-proxy access, paste this token
1232
- once via the boot screen — it is saved per-origin and works for all
1233
- subsequent visits.
1234
- </p>
1235
- </div>
1236
- <div className="field" data-setting-id="auth.token">
1237
- <label className="field-label">Token (this browser)</label>
1238
- <input
1239
- type="password"
1240
- className="input mono"
1241
- value={authToken}
1242
- onChange={(e) => setAuthToken(e.target.value)}
1243
- placeholder="Paste token from server stderr or another browser"
1244
- spellCheck={false}
1245
- autoComplete="off"
1246
- />
1247
- <div className="task-form-row" style={{ marginTop: 8 }}>
1248
- <Button variant="secondary" size="sm" onClick={onSaveToken}>
1249
- <KeyRound size={14} /> Save token
1250
- </Button>
1251
- <Button variant="ghost" size="sm" onClick={onCopyToken}>
1252
- <Copy size={14} /> Reveal &amp; copy server token
1253
- </Button>
1254
- <Button variant="ghost" size="sm" onClick={onRegenerateToken}>
1255
- <RotateCcw size={14} /> Regenerate
1256
- </Button>
1257
- </div>
1258
- {revealedToken ? (
1259
- <p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
1260
- Last revealed token (one-time): <code className="mono">{revealedToken}</code>
1261
- </p>
1262
- ) : null}
1263
- <p className="muted" style={{ fontSize: 12, marginTop: 8 }}>
1264
- Regenerating invalidates the current token immediately. Anything
1265
- still using the old token will see 401 until it's updated.
1266
- </p>
1267
- </div>
1268
- </Card>
1269
- </div>
1270
-
1271
- <div data-section="agents" style={{display: (activeSection === null || activeSection === 'agents') ? 'block' : 'none'}}>
1272
- <Card id="settings-agents" data-section="agents">
1273
- <CardTitle><ServerIcon size={14} /> Agent Behavior</CardTitle>
1274
- <CardMeta>Limits and timeouts for background agent dispatch.</CardMeta>
1275
- <div className="form-row">
1276
- <label htmlFor="agents-maxParallel">
1277
- Max parallel agents
1278
- <span className="meta-badge">default: 6</span>
1279
- </label>
1280
- <input
1281
- id="agents-maxParallel"
1282
- type="number"
1283
- min={1}
1284
- max={20}
1285
- value={settings.agents?.maxParallel ?? 6}
1286
- onChange={(e) => patchAgents({ maxParallel: Math.max(1, Math.min(20, parseInt(e.target.value, 10) || 6)) })}
1287
- />
1288
- </div>
1289
- <div className="form-row">
1290
- <label htmlFor="agents-stuckThresholdMs">
1291
- Stuck threshold (ms)
1292
- <span className="meta-badge">default: 600000 (10 min)</span>
1293
- </label>
1294
- <input
1295
- id="agents-stuckThresholdMs"
1296
- type="number"
1297
- min={60000}
1298
- max={3600000}
1299
- step={60000}
1300
- value={settings.agents?.stuckThresholdMs ?? 600000}
1301
- onChange={(e) => patchAgents({ stuckThresholdMs: Math.max(60000, Math.min(3600000, parseInt(e.target.value, 10) || 600000)) })}
1302
- />
1303
- </div>
1304
- <label className="checkbox-row" data-setting-id="agents.autoRestart">
1305
- <input
1306
- type="checkbox"
1307
- checked={!!settings.agents?.autoRestart}
1308
- onChange={(e) => patchAgents({ autoRestart: e.target.checked })}
1309
- />
1310
- <span>Auto-restart stuck agents</span>
1311
- </label>
1312
- </Card>
1313
- </div>
1314
-
1315
- <div data-section="dashboard" style={{display: (activeSection === null || activeSection === 'dashboard') ? 'block' : 'none'}}>
1316
- <Card id="settings-dashboard" data-section="dashboard">
1317
- <CardTitle><Globe size={14} /> Dashboard</CardTitle>
1318
- <CardMeta>Controls how <code>bizar</code> starts up.</CardMeta>
1319
- <label className="checkbox-row" data-setting-id="dashboard.autoLaunchWeb">
1320
- <input
1321
- type="checkbox"
1322
- checked={settings.dashboard.autoLaunchWeb !== false}
1323
- onChange={(e) => patchDashboard({ autoLaunchWeb: e.target.checked })}
1324
- />
1325
- <span>Auto-launch web UI alongside TUI</span>
1326
- </label>
1327
-
1328
- <div className="field" data-setting-id="dashboard.projectsDirectory" style={{ marginTop: 'var(--space-4)' }}>
1329
- <label className="field-label" htmlFor="set-projects-directory">Projects directory</label>
1330
- <input
1331
- id="set-projects-directory"
1332
- className="input"
1333
- type="text"
1334
- placeholder="/home/user/projects"
1335
- value={settings.dashboard.projectsDirectory ?? ''}
1336
- onChange={(e) => patchDashboard({ projectsDirectory: e.target.value })}
1337
- />
1338
- <p className="field-help">
1339
- New projects created via the dashboard will land here, and existing project
1340
- directories inside this folder are auto-recognized on startup.
1341
- </p>
1342
- {settings.dashboard.projectsDirectory && (
1343
- <>
1344
- {!/^\/|^[A-Za-z]:/.test(settings.dashboard.projectsDirectory) && (
1345
- <p style={{ color: 'var(--warning)', fontSize: 11, marginTop: 4 }}>
1346
- Path should be absolute (start with / on Linux/Mac, or a drive letter on Windows).
1347
- </p>
1348
- )}
1349
- {settings.dashboard.projectsDirectory.includes('..') && (
1350
- <p style={{ color: 'var(--error)', fontSize: 11, marginTop: 4 }}>
1351
- Path traversal not allowed — this will be rejected server-side.
1352
- </p>
1353
- )}
1354
- </>
1355
- )}
1356
- </div>
1357
-
1358
- {/* allowedRoots textarea */}
1359
- <div className="field" data-setting-id="dashboard.allowedRoots" style={{ marginTop: 'var(--space-4)' }}>
1360
- <label className="field-label" htmlFor="set-allowed-roots">
1361
- Additional allowed roots <span className="muted">(advanced)</span>
1362
- </label>
1363
- <textarea
1364
- id="set-allowed-roots"
1365
- className="textarea"
1366
- rows={4}
1367
- placeholder="/workspace&#10;/srv/projects"
1368
- value={(settings.dashboard.allowedRoots ?? []).join('\n')}
1369
- onChange={(e) => {
1370
- const lines = e.target.value
1371
- .split('\n')
1372
- .map((l) => l.trim())
1373
- .filter(Boolean);
1374
- patchDashboard({ allowedRoots: lines });
1375
- }}
1376
- />
1377
- <p className="field-help">
1378
- Optional. Add filesystem roots beyond your home directory that the file
1379
- browser and project scanner can access. Each path must be inside your
1380
- home directory. One per line.
1381
- </p>
1382
- {(() => {
1383
- const rawLines = (settings.dashboard.allowedRoots ?? []).join('\n').split('\n');
1384
- const warnings: { key: string; msg: React.ReactNode }[] = [];
1385
- rawLines.forEach((line, i) => {
1386
- if (!line.trim()) return;
1387
- if (!/^\/|^[A-Za-z]:/.test(line)) {
1388
- warnings.push({
1389
- key: `noabs-${i}`,
1390
- msg: (
1391
- <p style={{ color: 'var(--warning)', fontSize: 11, marginTop: 2 }}>
1392
- Line {i + 1}: "{line}" — should be absolute (start with / or a drive letter).
1393
- </p>
1394
- ),
1395
- });
1396
- }
1397
- if (line.includes('..')) {
1398
- warnings.push({
1399
- key: `dots-${i}`,
1400
- msg: (
1401
- <p style={{ color: 'var(--error)', fontSize: 11, marginTop: 2 }}>
1402
- Line {i + 1}: "{line}" — contains '..' (server will reject this).
1403
- </p>
1404
- ),
1405
- });
1406
- }
1407
- });
1408
- return warnings.map((w) => w.msg);
1409
- })()}
1410
- </div>
1411
- </Card>
1412
- </div>
1413
-
1414
- <div data-section="background" style={{display: (activeSection === null || activeSection === 'background') ? 'block' : 'none'}}>
1415
- <Card id="settings-background" data-section="background">
1416
- <CardTitle><ServerIcon size={14} /> Background Agents</CardTitle>
1417
- <CardMeta>Tune plugin options. Changes take effect on next plugin restart.</CardMeta>
1418
-
1419
- <div className="form-row">
1420
- <label htmlFor="bg-maxConcurrent">
1421
- Max concurrent instances
1422
- <span className="meta-badge">default: 8</span>
1423
- </label>
1424
- <input
1425
- id="bg-maxConcurrent"
1426
- type="number"
1427
- min={1}
1428
- max={32}
1429
- value={pluginOptions.maxConcurrentInstances ?? 8}
1430
- onChange={(e) => setPluginOptions((cur) => ({ ...cur, maxConcurrentInstances: Math.max(1, Math.min(32, parseInt(e.target.value, 10) || 8)) }))}
1431
- />
1432
- <small className="muted">Plugin option: <code>maxConcurrentInstances</code></small>
1433
- </div>
1434
-
1435
- <div className="form-row">
1436
- <label htmlFor="bg-toolCallCap">
1437
- Tool-call cap
1438
- <span className="meta-badge">default: 500</span>
1439
- </label>
1440
- <input
1441
- id="bg-toolCallCap"
1442
- type="number"
1443
- min={1}
1444
- max={5000}
1445
- value={pluginOptions.backgroundToolCallCap ?? 500}
1446
- onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundToolCallCap: Math.max(1, Math.min(5000, parseInt(e.target.value, 10) || 500)) }))}
1447
- />
1448
- <small className="muted">Plugin option: <code>backgroundToolCallCap</code></small>
1449
- </div>
1450
-
1451
- <div className="form-row">
1452
- <label htmlFor="bg-stallTimeout">
1453
- Stall timeout (ms)
1454
- <span className="meta-badge">default: 180000</span>
1455
- </label>
1456
- <input
1457
- id="bg-stallTimeout"
1458
- type="number"
1459
- min={10000}
1460
- max={600000}
1461
- step={1000}
1462
- value={pluginOptions.backgroundStallTimeoutMs ?? 180000}
1463
- onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundStallTimeoutMs: Math.max(10000, Math.min(600000, parseInt(e.target.value, 10) || 180000)) }))}
1464
- />
1465
- <small className="muted">Plugin option: <code>backgroundStallTimeoutMs</code></small>
1466
- </div>
1467
-
1468
- <div className="form-row">
1469
- <label htmlFor="bg-thinkingLoopTimeout">
1470
- Thinking-loop timeout (ms)
1471
- <span className="meta-badge">default: 300000</span>
1472
- </label>
1473
- <input
1474
- id="bg-thinkingLoopTimeout"
1475
- type="number"
1476
- min={30000}
1477
- max={900000}
1478
- step={1000}
1479
- value={pluginOptions.backgroundThinkingLoopTimeoutMs ?? 300000}
1480
- onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundThinkingLoopTimeoutMs: Math.max(30000, Math.min(900000, parseInt(e.target.value, 10) || 300000)) }))}
1481
- />
1482
- <small className="muted">Plugin option: <code>backgroundThinkingLoopTimeoutMs</code></small>
1483
- </div>
1484
-
1485
- <div className="form-row">
1486
- <label htmlFor="bg-maxInterventions">
1487
- Max interventions
1488
- <span className="meta-badge">default: 1</span>
1489
- </label>
1490
- <input
1491
- id="bg-maxInterventions"
1492
- type="number"
1493
- min={1}
1494
- max={3}
1495
- value={pluginOptions.backgroundMaxInterventions ?? 1}
1496
- onChange={(e) => setPluginOptions((cur) => ({ ...cur, backgroundMaxInterventions: Math.max(1, Math.min(3, parseInt(e.target.value, 10) || 1)) }))}
1497
- />
1498
- <small className="muted">Plugin option: <code>backgroundMaxInterventions</code></small>
1499
- </div>
1500
-
1501
- <div className="form-row">
1502
- <Button variant="secondary" size="sm" onClick={async () => {
1503
- try {
1504
- await api.put('/settings/plugin-options', pluginOptions);
1505
- toast.success('Saved — restart opencode for changes to take effect.');
1506
- } catch (err) {
1507
- toast.error(`Save failed: ${(err as Error).message}`);
1508
- }
1509
- }}>
1510
- <Save size={14} /> Save plugin options
1511
- </Button>
1512
-
1513
- <Button variant="secondary" size="sm" onClick={async () => {
1514
- try {
1515
- const r = await fetch('/api/background/cleanup', {
1516
- method: 'POST',
1517
- headers: { 'Content-Type': 'application/json' },
1518
- body: JSON.stringify({ maxAgeDays: 7 }),
1519
- });
1520
- const result = await r.json();
1521
- toast.success(`Cleaned up ${result.deleted} old instances.`);
1522
- } catch (err) {
1523
- toast.error(`Cleanup failed: ${(err as Error).message}`);
1524
- }
1525
- }}>
1526
- Cleanup old instances (&gt;7 days)
1527
- </Button>
1528
- </div>
1529
-
1530
- <div className="form-row">
1531
- <small>Plugin options are read at startup. Save changes and run <code>bizar update</code> to apply.</small>
1532
- </div>
1533
- </Card>
1534
- </div>
155
+ <div className={cn('settings-grid', activeSection && 'settings-grid-filtered')} data-active-section={activeSection || undefined}>
156
+ {(showAll || sectionOf('theme')) && <ThemeSection {...sp} />}
157
+ {(showAll || sectionOf('updates')) && <UpdatesSection />}
158
+ {(showAll || sectionOf('layout') || sectionOf('general')) && <GeneralSection {...sp} />}
159
+ {(showAll || sectionOf('network') || sectionOf('service') || sectionOf('tailscale')) && <NetworkSection tailscale={tailscale} tailscaleDraft={tailscaleDraft} setTailscaleDraft={setTailscaleDraft} onTailscaleToggle={onTailscaleToggle} />}
160
+ {(showAll || sectionOf('notifications')) && <NotificationsSection {...sp} />}
161
+ {(showAll || sectionOf('auth')) && <AuthSection settings={settings} authStatus={authStatus} setAuthStatus={setAuthStatus} />}
162
+ {(showAll || sectionOf('agents') || sectionOf('dashboard') || sectionOf('background')) && <AgentSection {...sp} />}
163
+ {(showAll || sectionOf('system-llm')) && <SystemLlmSection {...sp} />}
164
+ {(showAll || sectionOf('headroom')) && <HeadroomSection settings={settings} setSettings={setSettings} setDirty={setDirty} />}
165
+ {(showAll || sectionOf('activity-log') || sectionOf('about')) && <ActivitySection about={about} />}
166
+ {(showAll || sectionOf('env-vars')) && <EnvVarsSection />}
167
+ {(showAll || sectionOf('providers')) && <ProvidersSection />}
168
+ {(showAll || sectionOf('memory')) && <MemorySection />}
169
+ {(showAll || sectionOf('skills')) && <SkillsSection />}
170
+ {(showAll || sectionOf('backup')) && <BackupSection />}
1535
171
  </div>
1536
-
1537
- <div data-section="system-llm" style={{display: (activeSection === null || activeSection === 'system-llm') ? 'block' : 'none'}}>
1538
- <Card id="settings-system-llm" data-section="system-llm">
1539
- <CardTitle><Sparkles size={14} /> System LLM API</CardTitle>
1540
- <CardMeta>
1541
- Configures the LLM used for automatic title generation, prompt enhancement,
1542
- summarization, and other system-level calls.
1543
- </CardMeta>
1544
-
1545
- <label className="checkbox-row" data-setting-id="systemLlm.enabled">
1546
- <input
1547
- type="checkbox"
1548
- checked={!!settings.systemLlm?.enabled}
1549
- onChange={(e) => {
1550
- const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
1551
- patchTop('systemLlm', { ...cur, enabled: e.target.checked });
1552
- }}
1553
- />
1554
- <span>Enable system LLM calls</span>
1555
- </label>
1556
-
1557
- <div className="field" data-setting-id="systemLlm.provider" style={{ marginTop: 'var(--space-3)' }}>
1558
- <label className="field-label" htmlFor="set-system-llm-provider">Provider</label>
1559
- <select
1560
- id="set-system-llm-provider"
1561
- className="select"
1562
- value={settings.systemLlm?.provider || 'opencode'}
1563
- onChange={(e) => {
1564
- const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
1565
- patchTop('systemLlm', { ...cur, provider: e.target.value });
1566
- }}
1567
- >
1568
- <option value="opencode">Opencode</option>
1569
- <option value="openrouter">OpenRouter</option>
1570
- <option value="minimax">MiniMax</option>
1571
- </select>
1572
- </div>
1573
-
1574
- <div className="field" data-setting-id="systemLlm.model">
1575
- <label className="field-label" htmlFor="set-system-llm-model">Model</label>
1576
- <input
1577
- id="set-system-llm-model"
1578
- className="input mono"
1579
- type="text"
1580
- placeholder="opencode/deepseek-v4-flash-free"
1581
- value={settings.systemLlm?.model || 'opencode/deepseek-v4-flash-free'}
1582
- onChange={(e) => {
1583
- const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
1584
- patchTop('systemLlm', { ...cur, model: e.target.value });
1585
- }}
1586
- />
1587
- <p className="field-help">
1588
- The API key is read from <code>auth.json</code> for the selected provider.
1589
- Leave the default model for best results.
1590
- </p>
1591
- </div>
1592
-
1593
- <div style={{ marginTop: 'var(--space-4)' }}>
1594
- <h4 style={{ margin: '0 0 var(--space-2)' }}>Features using this API</h4>
1595
- <ul className="settings-feature-list" style={{ margin: 0, paddingLeft: 'var(--space-4)', lineHeight: 1.8 }}>
1596
- <li>Auto-title generation for new chat sessions</li>
1597
- <li>Auto-title generation for new tasks</li>
1598
- <li>"Enhance prompt" button in the task input</li>
1599
- <li>"Enhance prompt" button in the chat composer</li>
1600
- <li className="muted" style={{ fontSize: 12 }}>Future: summarization, name generation, and more</li>
1601
- </ul>
1602
- </div>
1603
- </Card>
1604
- </div>
1605
-
1606
- <div data-section="headroom" style={{display: (activeSection === null || activeSection === 'headroom') ? 'block' : 'none'}}>
1607
- <HeadroomSettingsCard
1608
- settings={settings.headroom || DEFAULT_HEADROOM_SETTINGS}
1609
- onPatch={(patch) => {
1610
- setSettings((cur) => ({
1611
- ...cur,
1612
- headroom: { ...(cur.headroom || DEFAULT_HEADROOM_SETTINGS), ...patch },
1613
- }));
1614
- setDirty(true);
1615
- }}
1616
- />
1617
- </div>
1618
-
1619
- <PairDeviceCard />
1620
-
1621
- <div style={{display: (activeSection === null || activeSection === 'activity-log') ? 'block' : 'none'}}>
1622
- <div data-section="activity-log" style={{display: (activeSection === null || activeSection === 'activity-log') ? 'block' : 'none'}}>
1623
- <section id="settings-activity-log" data-section="activity-log" className="settings-section-wrap">
1624
- <ActivityLogCard />
1625
- </section>
1626
- </div>
1627
- </div>
1628
-
1629
- <div style={{display: (activeSection === null || activeSection === 'about') ? 'block' : 'none'}}>
1630
- <div data-section="about" style={{display: (activeSection === null || activeSection === 'about') ? 'block' : 'none'}}>
1631
- <Card id="settings-about" data-section="about">
1632
- <CardTitle><Info size={14} /> About</CardTitle>
1633
- <CardMeta>Build metadata.</CardMeta>
1634
- <dl className="about-table">
1635
- <dt>Version</dt>
1636
- <dd className="mono">{about.version}</dd>
1637
- <dt>Homepage</dt>
1638
- <dd>
1639
- <a href={about.homepage} target="_blank" rel="noopener noreferrer">
1640
- {about.homepage}
1641
- </a>
1642
- </dd>
1643
- <dt>License</dt>
1644
- <dd>{about.license}</dd>
1645
- </dl>
1646
- </Card>
1647
- </div>
1648
- </div>
1649
172
  </div>
1650
173
  );
1651
174
  }
1652
175
 
1653
- /**
1654
- * v3.15.0 — Full activity log + hidden set management.
1655
- *
1656
- * Two sources of truth:
1657
- * - GET /activity — the full unfiltered log (incl. hidden entries)
1658
- * - GET /activity/hidden — the list of keys the user chose to hide
1659
- *
1660
- * Hiding is non-destructive: hidden items still appear here so the
1661
- * user can review / restore them.
1662
- */
1663
- function ActivityLogCard() {
1664
- const toast = useToast();
1665
- const [items, setItems] = useState<Array<{ kind?: string; ts?: string; slug?: string; message?: string }>>([]);
1666
- const [hidden, setHidden] = useState<Set<string>>(new Set());
1667
- const [loading, setLoading] = useState(true);
1668
- const [filter, setFilter] = useState('');
1669
- const [showHidden, setShowHidden] = useState(true);
1670
-
1671
- const refresh = useCallback(async () => {
1672
- setLoading(true);
1673
- try {
1674
- const [log, hid] = await Promise.all([
1675
- api.get<{ items: Array<{ kind?: string; ts?: string; slug?: string; message?: string }> }>('/activity'),
1676
- api.get<{ hidden: string[] }>('/activity/hidden'),
1677
- ]);
1678
- setItems(Array.isArray(log.items) ? log.items : []);
1679
- setHidden(new Set(hid.hidden || []));
1680
- } catch (err) {
1681
- toast.error(`Load failed: ${(err as Error).message}`);
1682
- } finally {
1683
- setLoading(false);
1684
- }
1685
- }, [toast]);
1686
-
1687
- useEffect(() => { refresh(); }, [refresh]);
1688
-
1689
- const keyOf = (it: { kind?: string; ts?: string; slug?: string }, idx: number) => {
1690
- const k = `${it.kind || ''}|${it.ts || ''}|${it.slug || ''}|${idx}`;
1691
- let h = 0;
1692
- for (let i = 0; i < k.length; i++) h = ((h << 5) - h + k.charCodeAt(i)) | 0;
1693
- return Math.abs(h).toString(16).padStart(8, '0').slice(0, 16);
1694
- };
1695
-
1696
- const onRestoreAll = async () => {
1697
- try {
1698
- await api.del('/activity/hide');
1699
- setHidden(new Set());
1700
- toast.success('All hidden activity restored.');
1701
- } catch (err) {
1702
- toast.error(`Restore failed: ${(err as Error).message}`);
1703
- }
1704
- };
1705
-
1706
- const onRestoreOne = async (key: string) => {
1707
- try {
1708
- await api.del(`/activity/hide/${encodeURIComponent(key)}`);
1709
- const next = new Set(hidden);
1710
- next.delete(key);
1711
- setHidden(next);
1712
- } catch (err) {
1713
- toast.error(`Restore failed: ${(err as Error).message}`);
1714
- }
1715
- };
1716
-
1717
- const filtered = items.filter((it, idx) => {
1718
- const k = keyOf(it, idx);
1719
- if (!showHidden && hidden.has(k)) return false;
1720
- if (!filter) return true;
1721
- const q = filter.toLowerCase();
1722
- return [it.kind, it.slug, it.message].some((v) => typeof v === 'string' && v.toLowerCase().includes(q));
1723
- });
1724
-
1725
- return (
1726
- <Card id="settings-diagnostics" data-section="diagnostics">
1727
- <CardTitle>
1728
- <Activity size={14} /> Activity log
1729
- <span className="muted" style={{ fontWeight: 400, marginLeft: 8, fontSize: 12 }}>
1730
- {items.length} total · {hidden.size} hidden
1731
- </span>
1732
- <Button variant="ghost" size="sm" style={{ marginLeft: 'auto' }} onClick={refresh} title="Reload">
1733
- <RefreshCw size={12} />
1734
- </Button>
1735
- </CardTitle>
1736
- <CardMeta>
1737
- Full history from <code>~/.bizar/activity.log</code>. Hiding an item in the Overview only hides it there — the entry stays here.
1738
- </CardMeta>
1739
-
1740
- <div className="activity-log-toolbar">
1741
- <div className="activity-log-search">
1742
- <Search size={12} />
1743
- <input
1744
- type="text"
1745
- className="input"
1746
- placeholder="Filter by kind, slug, or message…"
1747
- value={filter}
1748
- onChange={(e) => setFilter(e.target.value)}
1749
- />
1750
- </div>
1751
- <label className="activity-log-toggle">
1752
- <input
1753
- type="checkbox"
1754
- checked={showHidden}
1755
- onChange={(e) => setShowHidden(e.target.checked)}
1756
- />
1757
- Show hidden
1758
- </label>
1759
- <Button
1760
- variant="ghost"
1761
- size="sm"
1762
- disabled={hidden.size === 0}
1763
- onClick={onRestoreAll}
1764
- title="Restore all hidden items to the Overview"
1765
- >
1766
- <Eye size={12} /> Restore all
1767
- </Button>
1768
- </div>
1769
-
1770
- {loading ? (
1771
- <div className="muted" style={{ padding: '12px 0', fontSize: 12 }}>Loading…</div>
1772
- ) : filtered.length === 0 ? (
1773
- <div className="muted" style={{ padding: '12px 0', fontSize: 12 }}>
1774
- {items.length === 0 ? 'No activity yet.' : 'No items match the current filter.'}
1775
- </div>
1776
- ) : (
1777
- <div className="activity-log-table-wrap">
1778
- <table className="activity-log-table">
1779
- <thead>
1780
- <tr>
1781
- <th>Kind</th>
1782
- <th>Detail</th>
1783
- <th>Time</th>
1784
- <th></th>
1785
- </tr>
1786
- </thead>
1787
- <tbody>
1788
- {filtered.slice(0, 200).map((it, idx) => {
1789
- const k = keyOf(it, idx);
1790
- const isHidden = hidden.has(k);
1791
- return (
1792
- <tr key={`${it.ts}-${idx}`} className={cn(isHidden && 'activity-log-row-hidden')}>
1793
- <td className="activity-log-kind">{it.kind || 'activity'}</td>
1794
- <td className="activity-log-detail">
1795
- {it.message || it.slug || '—'}
1796
- </td>
1797
- <td className="activity-log-time mono">
1798
- {it.ts ? new Date(it.ts).toLocaleString() : '—'}
1799
- </td>
1800
- <td>
1801
- {isHidden ? (
1802
- <Button variant="ghost" size="sm" onClick={() => onRestoreOne(k)} title="Restore to Overview">
1803
- <Eye size={12} />
1804
- </Button>
1805
- ) : (
1806
- <span className="activity-log-state-tag">shown</span>
1807
- )}
1808
- </td>
1809
- </tr>
1810
- );
1811
- })}
1812
- </tbody>
1813
- </table>
1814
- {filtered.length > 200 && (
1815
- <div className="muted" style={{ fontSize: 11, padding: '8px 0' }}>
1816
- Showing first 200 of {filtered.length}.
1817
- </div>
1818
- )}
1819
- </div>
1820
- )}
1821
- </Card>
1822
- );
1823
- }
1824
176
  export const SettingsView = React.memo(SettingsViewInner);