@polderlabs/bizar 4.7.0 → 4.7.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 (75) 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-CWqPoGaT.js → mobile-BK8-ythT.js} +2 -2
  5. package/bizar-dash/dist/assets/mobile-BK8-ythT.js.map +1 -0
  6. package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.js → mobile-Chvf9u_B.js} +1 -1
  7. package/bizar-dash/dist/assets/{mobile-i4Uv9eW8.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 -1
  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/routes/backup.mjs +112 -0
  17. package/bizar-dash/src/server/routes/chat.mjs +14 -0
  18. package/bizar-dash/src/server/routes/digests.mjs +82 -0
  19. package/bizar-dash/src/server/routes-v2/events.mjs +14 -0
  20. package/bizar-dash/src/server/schedules-runner.mjs +126 -0
  21. package/bizar-dash/src/server/server.mjs +17 -0
  22. package/bizar-dash/src/web/App.tsx +8 -1
  23. package/bizar-dash/src/web/components/BackupRestore.tsx +330 -0
  24. package/bizar-dash/src/web/components/SearchModal.tsx +3 -1
  25. package/bizar-dash/src/web/components/chat/Composer.tsx +2 -0
  26. package/bizar-dash/src/web/styles/main.css +70 -8
  27. package/bizar-dash/src/web/views/Activity.tsx +11 -1
  28. package/bizar-dash/src/web/views/Agents.tsx +57 -42
  29. package/bizar-dash/src/web/views/Artifacts.tsx +38 -25
  30. package/bizar-dash/src/web/views/Chat.tsx +8 -0
  31. package/bizar-dash/src/web/views/History.tsx +4 -1
  32. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +21 -4
  33. package/bizar-dash/src/web/views/Mods.tsx +30 -17
  34. package/bizar-dash/src/web/views/Overview.tsx +8 -1
  35. package/bizar-dash/src/web/views/Providers.tsx +16 -16
  36. package/bizar-dash/src/web/views/Schedules.tsx +33 -15
  37. package/bizar-dash/src/web/views/Settings.tsx +97 -1751
  38. package/bizar-dash/src/web/views/Skills.tsx +4 -1
  39. package/bizar-dash/src/web/views/Tasks.tsx +11 -2
  40. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +8 -2
  41. package/bizar-dash/src/web/views/memory/LightragPanel.tsx +3 -0
  42. package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +12 -4
  43. package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +3 -0
  44. package/bizar-dash/src/web/views/settings/ActivitySection.tsx +205 -0
  45. package/bizar-dash/src/web/views/settings/AgentSection.tsx +294 -0
  46. package/bizar-dash/src/web/views/settings/AuthSection.tsx +159 -0
  47. package/bizar-dash/src/web/views/settings/BackupSection.tsx +16 -0
  48. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +16 -0
  49. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +105 -0
  50. package/bizar-dash/src/web/views/settings/HeadroomSection.tsx +39 -0
  51. package/bizar-dash/src/web/views/settings/MemorySection.tsx +16 -0
  52. package/bizar-dash/src/web/views/settings/NetworkSection.tsx +87 -0
  53. package/bizar-dash/src/web/views/settings/NotificationsSection.tsx +34 -0
  54. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +16 -0
  55. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +16 -0
  56. package/bizar-dash/src/web/views/settings/SystemLlmSection.tsx +81 -0
  57. package/bizar-dash/src/web/views/settings/ThemeSection.tsx +168 -0
  58. package/bizar-dash/src/web/views/settings/UpdatesSection.tsx +256 -0
  59. package/bizar-dash/tests/a11y.test.tsx +206 -0
  60. package/bizar-dash/tests/backup-restore.test.mjs +217 -0
  61. package/bizar-dash/tests/backup-restore.test.tsx +123 -0
  62. package/bizar-dash/tests/backup-store.test.mjs +300 -0
  63. package/bizar-dash/tests/cli-error-visibility.test.mjs +153 -0
  64. package/bizar-dash/tests/digest-generation.test.mjs +191 -0
  65. package/bizar-dash/tests/digest-store.test.mjs +264 -0
  66. package/bizar-dash/tests/rate-limit.test.mjs +298 -0
  67. package/cli/bin.mjs +96 -2
  68. package/cli/commands/minimax.mjs +20 -0
  69. package/cli/commands/util.mjs +154 -1
  70. package/cli/digest.mjs +149 -0
  71. package/package.json +1 -1
  72. package/bizar-dash/dist/assets/main-DAlLdW8I.css +0 -1
  73. package/bizar-dash/dist/assets/main-DGGq-iZI.js +0 -361
  74. package/bizar-dash/dist/assets/main-DGGq-iZI.js.map +0 -1
  75. package/bizar-dash/dist/assets/mobile-CWqPoGaT.js.map +0 -1
@@ -0,0 +1,87 @@
1
+ // src/web/views/settings/NetworkSection.tsx
2
+ import React from 'react';
3
+ import { Server as ServerIcon, Plug } from 'lucide-react';
4
+ import { Card, CardTitle, CardMeta } from '../../components/Card';
5
+ import { Button } from '../../components/Button';
6
+ import type { TailscaleStatus } from '../../lib/types';
7
+
8
+ type Props = {
9
+ tailscale: TailscaleStatus | null;
10
+ tailscaleDraft: { port: number; https: boolean; hostname: string };
11
+ setTailscaleDraft: React.Dispatch<React.SetStateAction<{ port: number; https: boolean; hostname: string }>>;
12
+ onTailscaleToggle: () => void;
13
+ };
14
+
15
+ export function NetworkSection({ tailscale, tailscaleDraft, setTailscaleDraft, onTailscaleToggle }: Props) {
16
+ return (
17
+ <>
18
+ {/* Service */}
19
+ <Card id="settings-service" data-section="service">
20
+ <CardTitle><ServerIcon size={14} /> Service</CardTitle>
21
+ <CardMeta>Background daemon that runs schedules.</CardMeta>
22
+ <div data-setting-id="service.enabled">
23
+ {tailscale ? (
24
+ <div className="service-card">
25
+ <p>
26
+ Status: <strong>{tailscale.settings.enabled ? 'enabled' : 'disabled'}</strong>
27
+ {' '}· Tailscale installed: <strong>{tailscale.installed ? 'yes' : 'no'}</strong>
28
+ {' '}· authenticated: <strong>{tailscale.authenticated ? 'yes' : 'no'}</strong>
29
+ </p>
30
+ <p className="muted">
31
+ Use <code>bizar service start</code> / <code>bizar service stop</code> in
32
+ your terminal to control the daemon.
33
+ </p>
34
+ </div>
35
+ ) : (
36
+ <p className="muted">Loading service status…</p>
37
+ )}
38
+ </div>
39
+ </Card>
40
+
41
+ {/* Tailscale */}
42
+ <Card id="settings-tailscale" data-section="tailscale">
43
+ <CardTitle><Plug size={14} /> Tailscale serve</CardTitle>
44
+ <CardMeta>Expose the dashboard over your Tailscale network.</CardMeta>
45
+ {tailscale ? (
46
+ <>
47
+ <p>
48
+ Installed: <strong>{tailscale.installed ? 'yes' : 'no'}</strong>{' '}
49
+ {tailscale.version && <span className="muted">({tailscale.version})</span>}
50
+ </p>
51
+ <p>
52
+ Authenticated: <strong>{tailscale.authenticated ? 'yes' : 'no'}</strong>
53
+ </p>
54
+ <p>
55
+ Serve enabled: <strong>{tailscale.settings.enabled ? 'yes' : 'no'}</strong>
56
+ </p>
57
+ <div className="task-form-row">
58
+ <div className="task-form-field">
59
+ <label className="field-label">Port</label>
60
+ <input
61
+ type="number"
62
+ className="input"
63
+ value={tailscaleDraft.port}
64
+ onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, port: Number(e.target.value) || 4321 }))}
65
+ />
66
+ </div>
67
+ <div className="task-form-field">
68
+ <label className="field-label" htmlFor="tailscale-https">Use HTTPS</label>
69
+ <input
70
+ id="tailscale-https"
71
+ type="checkbox"
72
+ checked={tailscaleDraft.https}
73
+ onChange={(e) => setTailscaleDraft((cur) => ({ ...cur, https: e.target.checked }))}
74
+ />
75
+ </div>
76
+ </div>
77
+ <Button variant="primary" onClick={onTailscaleToggle}>
78
+ {tailscale.settings.enabled ? 'Disable serve' : 'Enable serve'}
79
+ </Button>
80
+ </>
81
+ ) : (
82
+ <p className="muted">Loading Tailscale status…</p>
83
+ )}
84
+ </Card>
85
+ </>
86
+ );
87
+ }
@@ -0,0 +1,34 @@
1
+ // src/web/views/settings/NotificationsSection.tsx
2
+ import React from 'react';
3
+ import { Card, CardTitle, CardMeta } from '../../components/Card';
4
+ import type { Settings } from '../../lib/types';
5
+
6
+ type Props = {
7
+ settings: Settings;
8
+ patchNotifications: (patch: Partial<Settings['notifications']>) => void;
9
+ };
10
+
11
+ export function NotificationsSection({ settings, patchNotifications }: Props) {
12
+ return (
13
+ <Card id="settings-notifications" data-section="notifications">
14
+ <CardTitle>Notifications</CardTitle>
15
+ <CardMeta>Toast triggers inside the dashboard.</CardMeta>
16
+ <label className="checkbox-row" data-setting-id="notifications.onAgentComplete">
17
+ <input
18
+ type="checkbox"
19
+ checked={!!settings.notifications.onAgentComplete}
20
+ onChange={(e) => patchNotifications({ onAgentComplete: e.target.checked })}
21
+ />
22
+ <span>Notify when an agent invocation completes</span>
23
+ </label>
24
+ <label className="checkbox-row" data-setting-id="notifications.onPlanApproval">
25
+ <input
26
+ type="checkbox"
27
+ checked={!!settings.notifications.onPlanApproval}
28
+ onChange={(e) => patchNotifications({ onPlanApproval: e.target.checked })}
29
+ />
30
+ <span>Notify when a plan needs approval</span>
31
+ </label>
32
+ </Card>
33
+ );
34
+ }
@@ -0,0 +1,16 @@
1
+ // src/web/views/settings/ProvidersSection.tsx
2
+ // Placeholder — ProvidersPanel integration to be added in a future PR.
3
+ import React from 'react';
4
+ import { Card, CardTitle, CardMeta } from '../../components/Card';
5
+
6
+ export function ProvidersSection() {
7
+ return (
8
+ <Card id="settings-providers" data-section="providers">
9
+ <CardTitle>AI Providers</CardTitle>
10
+ <CardMeta>Configure API keys and endpoints for AI providers.</CardMeta>
11
+ <p className="muted" style={{ fontSize: 12 }}>
12
+ Provider management is coming soon.
13
+ </p>
14
+ </Card>
15
+ );
16
+ }
@@ -0,0 +1,16 @@
1
+ // src/web/views/settings/SkillsSection.tsx
2
+ // Placeholder — Skills paths + refresh to be added in a future PR.
3
+ import React from 'react';
4
+ import { Card, CardTitle, CardMeta } from '../../components/Card';
5
+
6
+ export function SkillsSection() {
7
+ return (
8
+ <Card id="settings-skills" data-section="skills">
9
+ <CardTitle>Skills</CardTitle>
10
+ <CardMeta>Manage agent skill paths and refresh available skills.</CardMeta>
11
+ <p className="muted" style={{ fontSize: 12 }}>
12
+ Skills configuration is coming soon.
13
+ </p>
14
+ </Card>
15
+ );
16
+ }
@@ -0,0 +1,81 @@
1
+ // src/web/views/settings/SystemLlmSection.tsx
2
+ import React from 'react';
3
+ import { Sparkles } from 'lucide-react';
4
+ import { Card, CardTitle, CardMeta } from '../../components/Card';
5
+ import type { Settings } from '../../lib/types';
6
+
7
+ type Props = {
8
+ settings: Settings;
9
+ patchTop: <K extends keyof Settings>(key: K, value: Settings[K]) => void;
10
+ };
11
+
12
+ export function SystemLlmSection({ settings, patchTop }: Props) {
13
+ return (
14
+ <Card id="settings-system-llm" data-section="system-llm">
15
+ <CardTitle><Sparkles size={14} /> System LLM API</CardTitle>
16
+ <CardMeta>
17
+ Configures the LLM used for automatic title generation, prompt enhancement,
18
+ summarization, and other system-level calls.
19
+ </CardMeta>
20
+
21
+ <label className="checkbox-row" data-setting-id="systemLlm.enabled">
22
+ <input
23
+ type="checkbox"
24
+ checked={!!settings.systemLlm?.enabled}
25
+ onChange={(e) => {
26
+ const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
27
+ patchTop('systemLlm', { ...cur, enabled: e.target.checked });
28
+ }}
29
+ />
30
+ <span>Enable system LLM calls</span>
31
+ </label>
32
+
33
+ <div className="field" data-setting-id="systemLlm.provider" style={{ marginTop: 'var(--space-3)' }}>
34
+ <label className="field-label" htmlFor="set-system-llm-provider">Provider</label>
35
+ <select
36
+ id="set-system-llm-provider"
37
+ className="select"
38
+ value={settings.systemLlm?.provider || 'opencode'}
39
+ onChange={(e) => {
40
+ const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
41
+ patchTop('systemLlm', { ...cur, provider: e.target.value });
42
+ }}
43
+ >
44
+ <option value="opencode">Opencode</option>
45
+ <option value="openrouter">OpenRouter</option>
46
+ <option value="minimax">MiniMax</option>
47
+ </select>
48
+ </div>
49
+
50
+ <div className="field" data-setting-id="systemLlm.model">
51
+ <label className="field-label" htmlFor="set-system-llm-model">Model</label>
52
+ <input
53
+ id="set-system-llm-model"
54
+ className="input mono"
55
+ type="text"
56
+ placeholder="opencode/deepseek-v4-flash-free"
57
+ value={settings.systemLlm?.model || 'opencode/deepseek-v4-flash-free'}
58
+ onChange={(e) => {
59
+ const cur = settings.systemLlm || { enabled: true, provider: 'opencode', model: 'opencode/deepseek-v4-flash-free' };
60
+ patchTop('systemLlm', { ...cur, model: e.target.value });
61
+ }}
62
+ />
63
+ <p className="field-help">
64
+ The API key is read from <code>auth.json</code> for the selected provider.
65
+ Leave the default model for best results.
66
+ </p>
67
+ </div>
68
+
69
+ <div style={{ marginTop: 'var(--space-4)' }}>
70
+ <h4 style={{ margin: '0 0 var(--space-2)' }}>Features using this API</h4>
71
+ <ul className="settings-feature-list" style={{ margin: 0, paddingLeft: 'var(--space-4)', lineHeight: 1.8 }}>
72
+ <li>Auto-title generation for new chat sessions</li>
73
+ <li>Auto-title generation for new tasks</li>
74
+ <li>&quot;Enhance prompt&quot; button in the task input</li>
75
+ <li>&quot;Enhance prompt&quot; button in the chat composer</li>
76
+ <li className="muted" style={{ fontSize: 12 }}>Future: summarization, name generation, and more</li>
77
+ </ul>
78
+ </div>
79
+ </Card>
80
+ );
81
+ }
@@ -0,0 +1,168 @@
1
+ // src/web/views/settings/ThemeSection.tsx
2
+ import React from 'react';
3
+ import { Palette, Sun, Moon, Monitor } from 'lucide-react';
4
+ import { Card, CardTitle, CardMeta } from '../../components/Card';
5
+ import { cn } from '../../lib/utils';
6
+ import type { Settings, ThemeName } from '../../lib/types';
7
+
8
+ type Props = {
9
+ settings: Settings;
10
+ patchTheme: (patch: Partial<Settings['theme']>) => void;
11
+ patchUi: (patch: Partial<Settings['ui']>) => void;
12
+ };
13
+
14
+ const THEMES: { id: ThemeName; label: string; Icon: typeof Sun }[] = [
15
+ { id: 'dark', label: 'Dark', Icon: Moon },
16
+ { id: 'light', label: 'Light', Icon: Sun },
17
+ { id: 'system', label: 'System', Icon: Monitor },
18
+ ];
19
+
20
+ const PRESET_THEMES = [
21
+ { name: 'Purple', accent: '#8b5cf6' },
22
+ { name: 'Blue', accent: '#3b82f6' },
23
+ { name: 'Green', accent: '#10b981' },
24
+ { name: 'Orange', accent: '#f97316' },
25
+ { name: 'Red', accent: '#ef4444' },
26
+ { name: 'Pink', accent: '#ec4899' },
27
+ { name: 'Cyan', accent: '#06b6d4' },
28
+ { name: 'Mono', accent: '#6b7280' },
29
+ ];
30
+
31
+ const FONT_FAMILIES = [
32
+ 'Inter',
33
+ 'system-ui',
34
+ 'Segoe UI',
35
+ 'Roboto',
36
+ 'JetBrains Mono',
37
+ 'SF Mono',
38
+ 'Cascadia Code',
39
+ ];
40
+
41
+ export function ThemeSection({ settings, patchTheme, patchUi }: Props) {
42
+ return (
43
+ <Card id="settings-theme" data-section="theme">
44
+ <CardTitle><Palette size={14} /> Theme</CardTitle>
45
+ <CardMeta>Mode, accent, and colors. Live preview as you tweak.</CardMeta>
46
+
47
+ {/* Accent presets */}
48
+ <div className="field" data-setting-id="theme.presets">
49
+ <label className="field-label">Accent presets</label>
50
+ <div className="theme-presets">
51
+ {PRESET_THEMES.map((t) => (
52
+ <button
53
+ key={t.name}
54
+ type="button"
55
+ className={cn(
56
+ 'theme-preset',
57
+ settings.theme.accent === t.accent && 'theme-preset-active',
58
+ )}
59
+ onClick={() => patchTheme({ accent: t.accent })}
60
+ title={t.name}
61
+ >
62
+ <span
63
+ className="theme-preset-swatch"
64
+ style={{ background: t.accent }}
65
+ />
66
+ <span className="theme-preset-name">{t.name}</span>
67
+ </button>
68
+ ))}
69
+ </div>
70
+ </div>
71
+
72
+ {/* Mode */}
73
+ <div className="field" data-setting-id="theme.mode">
74
+ <label className="field-label">Mode</label>
75
+ <div className="theme-row">
76
+ {THEMES.map(({ id, label, Icon }) => {
77
+ const active = settings.theme.mode === id;
78
+ return (
79
+ <button
80
+ key={id}
81
+ type="button"
82
+ className={cn('theme-card', active && 'theme-card-active')}
83
+ onClick={() => patchTheme({ mode: id })}
84
+ >
85
+ <Icon size={16} />
86
+ <span className="theme-card-label">{label}</span>
87
+ <span
88
+ className={cn(
89
+ 'theme-card-swatch',
90
+ `theme-card-swatch-${id}`,
91
+ )}
92
+ />
93
+ </button>
94
+ );
95
+ })}
96
+ </div>
97
+ </div>
98
+
99
+ {/* Custom colors */}
100
+ <div className="theme-colors">
101
+ {(['accent', 'success', 'warning', 'error', 'info'] as const).map((c) => (
102
+ <div key={c} className="field" data-setting-id={`theme.${c}`}>
103
+ <label className="field-label">{c.charAt(0).toUpperCase() + c.slice(1)}</label>
104
+ <div className="color-row">
105
+ <input
106
+ type="color"
107
+ className="input color-input"
108
+ value={settings.theme[c]}
109
+ onChange={(e) => patchTheme({ [c]: e.target.value })}
110
+ aria-label={`${c} color`}
111
+ />
112
+ <input
113
+ type="text"
114
+ className="input"
115
+ value={settings.theme[c]}
116
+ onChange={(e) => patchTheme({ [c]: e.target.value })}
117
+ aria-label={`${c} color hex`}
118
+ />
119
+ </div>
120
+ </div>
121
+ ))}
122
+ </div>
123
+
124
+ {/* Font */}
125
+ <div className="field" data-setting-id="theme.fontFamily">
126
+ <label className="field-label">Font family</label>
127
+ <select
128
+ className="select"
129
+ value={settings.theme.fontFamily}
130
+ onChange={(e) => patchTheme({ fontFamily: e.target.value })}
131
+ >
132
+ {FONT_FAMILIES.map((f) => (
133
+ <option key={f} value={f}>{f}</option>
134
+ ))}
135
+ </select>
136
+ </div>
137
+
138
+ <div className="field" data-setting-id="theme.fontSize">
139
+ <label className="field-label">Font size: {settings.theme.fontSize}px</label>
140
+ <input
141
+ type="range"
142
+ min={12}
143
+ max={20}
144
+ value={settings.theme.fontSize}
145
+ onChange={(e) => patchTheme({ fontSize: Number(e.target.value) })}
146
+ />
147
+ </div>
148
+
149
+ <label className="checkbox-row" data-setting-id="theme.compactMode">
150
+ <input
151
+ type="checkbox"
152
+ checked={settings.theme.compactMode}
153
+ onChange={(e) => patchTheme({ compactMode: e.target.checked })}
154
+ />
155
+ <span>Compact mode (denser UI)</span>
156
+ </label>
157
+
158
+ <label className="checkbox-row" data-setting-id="theme.animations">
159
+ <input
160
+ type="checkbox"
161
+ checked={settings.theme.animations}
162
+ onChange={(e) => patchTheme({ animations: e.target.checked })}
163
+ />
164
+ <span>Enable animations</span>
165
+ </label>
166
+ </Card>
167
+ );
168
+ }
@@ -0,0 +1,256 @@
1
+ // src/web/views/settings/UpdatesSection.tsx
2
+ import React, { useEffect, useState } from 'react';
3
+ import { Download, RefreshCw, CheckCircle, AlertCircle, AlertTriangle } from 'lucide-react';
4
+ import { Card, CardTitle, CardMeta } from '../../components/Card';
5
+ import { Button } from '../../components/Button';
6
+ import { useToast } from '../../components/Toast';
7
+ import { api } from '../../lib/api';
8
+ import { Ws } from '../../lib/ws';
9
+
10
+ type PkgStatus = {
11
+ status: 'idle' | 'starting' | 'installing' | 'done' | 'error';
12
+ error?: string;
13
+ newVersion?: string;
14
+ logs: string[];
15
+ };
16
+
17
+ type StatusState = {
18
+ current: Record<string, string | null>;
19
+ latest: Record<string, string | null> | null;
20
+ checking: boolean;
21
+ updating: boolean;
22
+ hasUpdates: boolean;
23
+ requiresRestart: boolean;
24
+ perPackage: Record<string, PkgStatus>;
25
+ error?: string;
26
+ };
27
+
28
+ const PACKAGES = [
29
+ { id: 'bizar', name: 'Bizar CLI' },
30
+ { id: 'bizar-dash', name: 'Dashboard' },
31
+ { id: 'bizar-plugin', name: 'Opencode Plugin' },
32
+ ];
33
+
34
+ export function UpdatesSection() {
35
+ const toast = useToast();
36
+ const [status, setStatus] = useState<StatusState>({
37
+ current: {},
38
+ latest: null,
39
+ checking: false,
40
+ updating: false,
41
+ hasUpdates: false,
42
+ requiresRestart: false,
43
+ perPackage: {},
44
+ });
45
+
46
+ // WebSocket subscription for live update progress
47
+ useEffect(() => {
48
+ const ws = new Ws();
49
+ ws.on((msg: Record<string, unknown>) => {
50
+ if (msg.type === 'update:progress' || msg.type === 'update:log' || msg.type === 'update:complete') {
51
+ setStatus((s) => {
52
+ if (msg.type === 'update:complete') {
53
+ return {
54
+ ...s,
55
+ updating: false,
56
+ requiresRestart: Boolean((msg as { requiresRestart?: boolean }).requiresRestart),
57
+ };
58
+ }
59
+ if (msg.type === 'update:log') {
60
+ const m = msg as { pkg: string; line: string };
61
+ const existing = s.perPackage[m.pkg] || { logs: [] };
62
+ return {
63
+ ...s,
64
+ perPackage: {
65
+ ...s.perPackage,
66
+ [m.pkg]: {
67
+ ...existing,
68
+ logs: [...(existing.logs || []).slice(-50), m.line],
69
+ },
70
+ },
71
+ };
72
+ }
73
+ // update:progress
74
+ const m = msg as { pkg: string; status: PkgStatus['status']; error?: string; newVersion?: string };
75
+ return {
76
+ ...s,
77
+ perPackage: {
78
+ ...s.perPackage,
79
+ [m.pkg]: {
80
+ ...s.perPackage[m.pkg],
81
+ status: m.status,
82
+ error: m.error,
83
+ newVersion: m.newVersion,
84
+ },
85
+ },
86
+ };
87
+ });
88
+ }
89
+ });
90
+ return () => ws.close();
91
+ }, []);
92
+
93
+ // Load current versions on mount
94
+ useEffect(() => {
95
+ api.get<{ current: Record<string, string | null> }>('/updates/status')
96
+ .then((r) => setStatus((s) => ({ ...s, current: r.current })))
97
+ .catch((e) => setStatus((s) => ({ ...s, error: e.message })));
98
+ }, []);
99
+
100
+ const check = async () => {
101
+ setStatus((s) => ({ ...s, checking: true, error: undefined }));
102
+ try {
103
+ const r = await api.get<{
104
+ current: Record<string, string | null>;
105
+ latest: Record<string, string | null>;
106
+ hasUpdates: boolean;
107
+ }>('/updates/check');
108
+ setStatus((s) => ({
109
+ ...s,
110
+ checking: false,
111
+ current: r.current,
112
+ latest: r.latest,
113
+ hasUpdates: r.hasUpdates,
114
+ }));
115
+ } catch (err) {
116
+ setStatus((s) => ({ ...s, checking: false, error: (err as Error).message }));
117
+ }
118
+ };
119
+
120
+ const applyUpdate = async () => {
121
+ if (!confirm('Update Bizar packages? The dashboard will restart automatically.')) return;
122
+ setStatus((s) => ({
123
+ ...s,
124
+ updating: true,
125
+ requiresRestart: false,
126
+ perPackage: {},
127
+ error: undefined,
128
+ }));
129
+ try {
130
+ await api.post('/updates/apply', { packages: ['bizar', 'bizar-dash', 'bizar-plugin'] });
131
+ } catch (err) {
132
+ setStatus((s) => ({ ...s, updating: false, error: (err as Error).message }));
133
+ }
134
+ };
135
+
136
+ const restart = async () => {
137
+ if (!confirm('Restart the dashboard? You will be disconnected briefly.')) return;
138
+ try {
139
+ await api.post('/restart');
140
+ toast.info('Restarting…', 3000);
141
+ setTimeout(() => window.location.reload(), 3000);
142
+ } catch {
143
+ toast.error('Restart failed');
144
+ }
145
+ };
146
+
147
+ const isBusy = status.checking || status.updating;
148
+
149
+ return (
150
+ <Card id="settings-updates" data-section="updates">
151
+ <CardTitle><Download size={14} /> Updates</CardTitle>
152
+ <CardMeta>Check installed Bizar packages and apply dashboard updates.</CardMeta>
153
+
154
+ {/* Current versions */}
155
+ <div className="updates-current">
156
+ <h4>Installed versions</h4>
157
+ <ul>
158
+ {PACKAGES.map((p) => (
159
+ <li key={p.id}>
160
+ <span>{p.name}</span>
161
+ <code className="mono">{status.current[p.id] || '—'}</code>
162
+ </li>
163
+ ))}
164
+ </ul>
165
+ </div>
166
+
167
+ {/* Latest + per-package status */}
168
+ {status.latest && (
169
+ <div className="updates-latest">
170
+ <h4>Latest available</h4>
171
+ <ul>
172
+ {PACKAGES.map((p) => {
173
+ const cur = status.current[p.id];
174
+ const lat = status.latest?.[p.id];
175
+ const isOutdated = cur && lat && cur !== lat;
176
+ return (
177
+ <li key={p.id} className={isOutdated ? 'updates-outdated' : 'updates-current-version'}>
178
+ <span>{p.name}</span>
179
+ <code className="mono">
180
+ {lat || '—'}
181
+ {isOutdated && <span className="updates-badge">update available</span>}
182
+ </code>
183
+ </li>
184
+ );
185
+ })}
186
+ </ul>
187
+ </div>
188
+ )}
189
+
190
+ {/* Per-package progress rows */}
191
+ {status.updating && (
192
+ <div className="updates-progress-rows">
193
+ {PACKAGES.map((p) => {
194
+ const pkgStatus = status.perPackage[p.id] || { status: 'idle', logs: [] as string[] };
195
+ return (
196
+ <div key={p.id} className="updates-pkg-row">
197
+ <div className="updates-pkg-row-header">
198
+ <span className="updates-pkg-name">{p.name}</span>
199
+ <div className="updates-pkg-status">
200
+ {pkgStatus.status === 'starting' && <span className="btn-spinner" />}
201
+ {pkgStatus.status === 'installing' && <span className="btn-spinner" />}
202
+ {pkgStatus.status === 'done' && <CheckCircle size={14} className="icon-success" />}
203
+ {pkgStatus.status === 'error' && <AlertCircle size={14} className="icon-error" />}
204
+ <span>{pkgStatus.status}</span>
205
+ {pkgStatus.newVersion && (
206
+ <code className="mono" style={{ fontSize: 11 }}>→ {pkgStatus.newVersion}</code>
207
+ )}
208
+ </div>
209
+ </div>
210
+ {pkgStatus.logs.length > 0 && (
211
+ <details className="updates-pkg-logs">
212
+ <summary>npm output ({pkgStatus.logs.length} lines)</summary>
213
+ <pre>{pkgStatus.logs.join('\n')}</pre>
214
+ </details>
215
+ )}
216
+ {pkgStatus.status === 'error' && pkgStatus.error && (
217
+ <div className="updates-pkg-error">
218
+ <AlertTriangle size={12} /> {pkgStatus.error}
219
+ </div>
220
+ )}
221
+ </div>
222
+ );
223
+ })}
224
+ </div>
225
+ )}
226
+
227
+ {/* Actions */}
228
+ <div className="updates-actions">
229
+ <Button onClick={check} disabled={isBusy}>
230
+ {status.checking ? <span className="btn-spinner" /> : <RefreshCw size={14} />}
231
+ Check for updates
232
+ </Button>
233
+ <Button
234
+ variant="primary"
235
+ onClick={applyUpdate}
236
+ disabled={!status.hasUpdates || status.updating}
237
+ >
238
+ {status.updating ? <span className="btn-spinner" /> : <Download size={14} />}
239
+ {status.updating ? 'Updating…' : (status.hasUpdates ? 'Update now' : 'Up to date')}
240
+ </Button>
241
+ {status.requiresRestart && (
242
+ <Button variant="danger" onClick={restart}>
243
+ <RefreshCw size={14} /> Restart Dashboard
244
+ </Button>
245
+ )}
246
+ </div>
247
+
248
+ {status.error && (
249
+ <div className="updates-error">
250
+ <AlertTriangle size={14} />
251
+ <span>{status.error}</span>
252
+ </div>
253
+ )}
254
+ </Card>
255
+ );
256
+ }