@polderlabs/bizar-dash 3.0.0

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 (59) hide show
  1. package/dist/assets/index-B5X9g8B4.css +1 -0
  2. package/dist/assets/index-LqQuSp9d.js +388 -0
  3. package/dist/assets/index-LqQuSp9d.js.map +1 -0
  4. package/dist/index.html +18 -0
  5. package/package.json +67 -0
  6. package/src/cli.mjs +228 -0
  7. package/src/server/agents-store.mjs +190 -0
  8. package/src/server/api.mjs +913 -0
  9. package/src/server/browser.mjs +40 -0
  10. package/src/server/diagnostics-store.mjs +138 -0
  11. package/src/server/mods-loader.mjs +361 -0
  12. package/src/server/projects-store.mjs +198 -0
  13. package/src/server/providers-store.mjs +183 -0
  14. package/src/server/schedules-runner.mjs +150 -0
  15. package/src/server/schedules-store.mjs +233 -0
  16. package/src/server/search-store.mjs +120 -0
  17. package/src/server/server.mjs +388 -0
  18. package/src/server/state.mjs +357 -0
  19. package/src/server/tailscale-store.mjs +113 -0
  20. package/src/server/tasks-store.mjs +275 -0
  21. package/src/server/tui.mjs +844 -0
  22. package/src/server/watcher.mjs +81 -0
  23. package/src/web/App.tsx +316 -0
  24. package/src/web/components/Button.tsx +55 -0
  25. package/src/web/components/Card.tsx +40 -0
  26. package/src/web/components/EmptyState.tsx +30 -0
  27. package/src/web/components/Modal.tsx +137 -0
  28. package/src/web/components/SearchModal.tsx +185 -0
  29. package/src/web/components/Spinner.tsx +19 -0
  30. package/src/web/components/StatusBadge.tsx +25 -0
  31. package/src/web/components/Tag.tsx +28 -0
  32. package/src/web/components/Toast.tsx +142 -0
  33. package/src/web/components/Topbar.tsx +203 -0
  34. package/src/web/index.html +17 -0
  35. package/src/web/lib/api.ts +71 -0
  36. package/src/web/lib/markdown.tsx +59 -0
  37. package/src/web/lib/types.ts +388 -0
  38. package/src/web/lib/utils.ts +79 -0
  39. package/src/web/lib/ws.ts +132 -0
  40. package/src/web/main.tsx +12 -0
  41. package/src/web/styles/main.css +3148 -0
  42. package/src/web/views/Agents.tsx +406 -0
  43. package/src/web/views/Chat.tsx +527 -0
  44. package/src/web/views/Config.tsx +683 -0
  45. package/src/web/views/Mods.tsx +350 -0
  46. package/src/web/views/Overview.tsx +350 -0
  47. package/src/web/views/Plans.tsx +667 -0
  48. package/src/web/views/Schedules.tsx +299 -0
  49. package/src/web/views/Settings.tsx +571 -0
  50. package/src/web/views/Tasks.tsx +761 -0
  51. package/templates/mod/FORMAT.md +76 -0
  52. package/templates/mod/hello-mod/README.md +19 -0
  53. package/templates/mod/hello-mod/agents/greeter.md +8 -0
  54. package/templates/mod/hello-mod/commands/hello.md +6 -0
  55. package/templates/mod/hello-mod/mod.json +20 -0
  56. package/templates/mod/hello-mod/routes/ping.mjs +9 -0
  57. package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
  58. package/tsconfig.json +23 -0
  59. package/vite.config.ts +24 -0
@@ -0,0 +1,350 @@
1
+ // src/views/Mods.tsx — list, install, enable/disable mods + mod views.
2
+ import { useEffect, useState } from 'react';
3
+ import {
4
+ Puzzle,
5
+ Plus,
6
+ Trash2,
7
+ Power,
8
+ RefreshCw,
9
+ Folder,
10
+ FileText,
11
+ X,
12
+ ExternalLink,
13
+ Globe,
14
+ LayoutTemplate,
15
+ } from 'lucide-react';
16
+ import { Card, CardTitle, CardMeta } from '../components/Card';
17
+ import { Button } from '../components/Button';
18
+ import { EmptyState } from '../components/EmptyState';
19
+ import { Spinner } from '../components/Spinner';
20
+ import { useToast } from '../components/Toast';
21
+ import { useModal } from '../components/Modal';
22
+ import { api } from '../lib/api';
23
+ import { cn } from '../lib/utils';
24
+ import type { Mod, Settings, Snapshot } from '../lib/types';
25
+
26
+ type Props = {
27
+ snapshot: Snapshot;
28
+ settings: Settings;
29
+ activeTab: string;
30
+ setActiveTab: (id: string) => void;
31
+ refreshSnapshot: () => Promise<void>;
32
+ };
33
+
34
+ type ModView = {
35
+ id: string;
36
+ modId: string;
37
+ kind: 'iframe' | 'tab';
38
+ label: string;
39
+ description?: string;
40
+ path?: string;
41
+ };
42
+
43
+ export function Mods({ snapshot, refreshSnapshot }: Props) {
44
+ const toast = useToast();
45
+ const modal = useModal();
46
+ const [mods, setMods] = useState<Mod[]>(snapshot.mods || []);
47
+ const [loading, setLoading] = useState(!snapshot.mods);
48
+ const [selected, setSelected] = useState<string | null>(null);
49
+ const [modViews, setModViews] = useState<ModView[]>([]);
50
+ const [iframeUrl, setIframeUrl] = useState<string | null>(null);
51
+
52
+ const reload = async () => {
53
+ try {
54
+ const r = await api.get<{ mods: Mod[] }>('/mods');
55
+ setMods(r.mods || []);
56
+ const v = await api.get<{ views: ModView[] }>('/mods/views');
57
+ setModViews(v.views || []);
58
+ } catch (err) {
59
+ toast.error(`Mods load failed: ${(err as Error).message}`);
60
+ } finally {
61
+ setLoading(false);
62
+ }
63
+ };
64
+
65
+ useEffect(() => {
66
+ if (snapshot.mods?.length || snapshot.mods) {
67
+ setMods(snapshot.mods || []);
68
+ setLoading(false);
69
+ return;
70
+ }
71
+ reload();
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, [snapshot.mods]);
74
+
75
+ // Load mod views when tab becomes active
76
+ useEffect(() => {
77
+ reload();
78
+ // eslint-disable-next-line react-hooks/exhaustive-deps
79
+ }, []);
80
+
81
+ const onInstall = () => {
82
+ let pathEl: HTMLInputElement | null = null;
83
+ modal.open({
84
+ title: 'Install mod',
85
+ children: (
86
+ <div>
87
+ <p className="muted">
88
+ Provide the absolute path to a mod folder (one that contains
89
+ a <code>mod.json</code>). The folder will be copied to
90
+ {' '}<code>~/.config/bizar/mods/&lt;id&gt;/</code>.
91
+ </p>
92
+ <label className="field-label">Path</label>
93
+ <input
94
+ ref={(el) => (pathEl = el)}
95
+ className="input"
96
+ type="text"
97
+ placeholder="/path/to/my-mod"
98
+ autoFocus
99
+ />
100
+ </div>
101
+ ),
102
+ footer: (
103
+ <div className="modal-footer-actions">
104
+ <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
105
+ <Button
106
+ variant="primary"
107
+ onClick={async () => {
108
+ const path = (pathEl?.value || '').trim();
109
+ if (!path) {
110
+ toast.warning('Path is required.');
111
+ return;
112
+ }
113
+ try {
114
+ const m = await api.post<Mod>('/mods', { path });
115
+ setMods((cur) => [...cur, m]);
116
+ toast.success(`Mod "${m.id}" installed.`);
117
+ modal.close();
118
+ await refreshSnapshot();
119
+ } catch (err) {
120
+ toast.error(`Install failed: ${(err as Error).message}`);
121
+ }
122
+ }}
123
+ >
124
+ Install
125
+ </Button>
126
+ </div>
127
+ ),
128
+ });
129
+ };
130
+
131
+ const onUninstall = async (id: string) => {
132
+ if (!confirm(`Uninstall mod "${id}"? This removes the folder from ~/.config/bizar/mods/.`)) return;
133
+ try {
134
+ await api.del(`/mods/${encodeURIComponent(id)}`);
135
+ setMods((cur) => cur.filter((m) => m.id !== id));
136
+ if (selected === id) setSelected(null);
137
+ toast.success('Mod uninstalled.');
138
+ } catch (err) {
139
+ toast.error(`Uninstall failed: ${(err as Error).message}`);
140
+ }
141
+ };
142
+
143
+ const onToggleEnabled = async (mod: Mod) => {
144
+ try {
145
+ const next = await api.put<Mod>(`/mods/${encodeURIComponent(mod.id)}`, { enabled: !mod.enabled });
146
+ setMods((cur) => cur.map((m) => (m.id === mod.id ? next : m)));
147
+ toast.success(`Mod ${next.enabled ? 'enabled' : 'disabled'}.`);
148
+ } catch (err) {
149
+ toast.error(`Toggle failed: ${(err as Error).message}`);
150
+ }
151
+ };
152
+
153
+ const sel = mods.find((m) => m.id === selected) || null;
154
+
155
+ if (loading) {
156
+ return (
157
+ <div className="view-loading">
158
+ <Spinner size="lg" />
159
+ </div>
160
+ );
161
+ }
162
+
163
+ return (
164
+ <div className="view view-mods">
165
+ <header className="view-header">
166
+ <div className="view-header-text">
167
+ <h2 className="view-title">
168
+ <Puzzle size={18} /> Mods ({mods.length})
169
+ </h2>
170
+ <p className="view-subtitle">
171
+ Extensions installed in <code>~/.config/bizar/mods/</code>.
172
+ Mods can add agents, commands, routes, and views.
173
+ </p>
174
+ </div>
175
+ <div className="view-actions">
176
+ <Button variant="secondary" size="sm" onClick={reload}>
177
+ <RefreshCw size={14} /> Refresh
178
+ </Button>
179
+ <Button variant="primary" size="sm" onClick={onInstall}>
180
+ <Plus size={14} /> Install mod
181
+ </Button>
182
+ </div>
183
+ </header>
184
+
185
+ {mods.length === 0 ? (
186
+ <EmptyState
187
+ icon={<Puzzle size={32} />}
188
+ title="No mods installed"
189
+ message="Mods are folders with a mod.json. Install one to extend Bizar with custom agents, commands, and views."
190
+ />
191
+ ) : (
192
+ <div className="mods-layout">
193
+ <div className="mods-list">
194
+ {mods.map((m) => (
195
+ <div
196
+ key={m.id}
197
+ className={cn('mod-list-item', selected === m.id && 'mod-list-item-active')}
198
+ onClick={() => setSelected(m.id)}
199
+ >
200
+ <div className="mod-list-item-head">
201
+ <div>
202
+ <div className="mod-list-item-name">{m.name}</div>
203
+ <div className="mod-list-item-meta">v{m.version} · {m.type} · {m.author}</div>
204
+ </div>
205
+ <div className="mod-list-item-actions" onClick={(e) => e.stopPropagation()}>
206
+ <button
207
+ type="button"
208
+ className="icon-btn"
209
+ aria-label="Toggle enabled"
210
+ title={m.enabled ? 'Disable' : 'Enable'}
211
+ onClick={() => onToggleEnabled(m)}
212
+ >
213
+ <Power size={12} />
214
+ </button>
215
+ <button
216
+ type="button"
217
+ className="icon-btn icon-btn-danger"
218
+ aria-label="Uninstall"
219
+ title="Uninstall"
220
+ onClick={() => onUninstall(m.id)}
221
+ >
222
+ <Trash2 size={12} />
223
+ </button>
224
+ </div>
225
+ </div>
226
+ <div className="mod-list-item-desc ellipsis-2" title={m.description}>
227
+ {m.description}
228
+ </div>
229
+ <div className="mod-list-item-status">
230
+ <span className={`mod-mini-pill ${m.enabled ? 'mod-mini-pill-on' : 'mod-mini-pill-off'}`}>
231
+ {m.enabled ? 'enabled' : 'disabled'}
232
+ </span>
233
+ </div>
234
+ </div>
235
+ ))}
236
+ </div>
237
+ {sel && <ModDetails mod={sel} />}
238
+ </div>
239
+ )}
240
+
241
+ {/* Mod views section — web/index.html and registered tabs */}
242
+ {modViews.length > 0 && (
243
+ <div className="mods-views-section">
244
+ <h3 className="view-subtitle">
245
+ <Globe size={14} /> Mod views
246
+ </h3>
247
+ <div className="mods-views-grid">
248
+ {modViews.map((v) => (
249
+ <Card key={v.id} className="mod-view-card">
250
+ <div className="mod-view-card-head">
251
+ <div>
252
+ <div className="mod-view-label">
253
+ {v.kind === 'tab' ? <LayoutTemplate size={12} /> : <Globe size={12} />}
254
+ {v.label}
255
+ </div>
256
+ <div className="mod-view-mod muted">by {v.modId}</div>
257
+ {v.description && (
258
+ <div className="mod-view-desc muted ellipsis-2">{v.description}</div>
259
+ )}
260
+ </div>
261
+ <Button
262
+ variant="secondary"
263
+ size="sm"
264
+ onClick={() => {
265
+ if (v.kind === 'iframe' && v.path) {
266
+ // Open the mod's web/index.html in an iframe panel
267
+ setIframeUrl(`/api/mods/${v.modId}/web/index.html`);
268
+ } else {
269
+ // For registered tabs without web view, show placeholder
270
+ toast.info(`Tab view for "${v.label}" — full TSX loading lands in v3.1.`, 2500);
271
+ }
272
+ }}
273
+ >
274
+ <ExternalLink size={12} /> Open
275
+ </Button>
276
+ </div>
277
+ </Card>
278
+ ))}
279
+ </div>
280
+ </div>
281
+ )}
282
+
283
+ {/* Iframe panel for mod web views */}
284
+ {iframeUrl && (
285
+ <div className="mod-iframe-panel">
286
+ <div className="mod-iframe-header">
287
+ <span>Mod view — <a href={iframeUrl} target="_blank" rel="noreferrer">{iframeUrl}</a></span>
288
+ <button
289
+ type="button"
290
+ className="icon-btn"
291
+ aria-label="Close iframe"
292
+ onClick={() => setIframeUrl(null)}
293
+ >
294
+ <X size={14} />
295
+ </button>
296
+ </div>
297
+ <iframe
298
+ src={iframeUrl}
299
+ className="mod-iframe"
300
+ title="Mod view"
301
+ sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
302
+ />
303
+ </div>
304
+ )}
305
+ </div>
306
+ );
307
+ }
308
+
309
+ function ModDetails({ mod }: { mod: Mod }) {
310
+ return (
311
+ <Card className="mod-details">
312
+ <CardTitle><FileText size={14} /> Mod details — {mod.name}</CardTitle>
313
+ <CardMeta>
314
+ <code>{mod.path}</code>
315
+ </CardMeta>
316
+ <dl className="env-table">
317
+ <dt>ID</dt>
318
+ <dd className="mono">{mod.id}</dd>
319
+ <dt>Version</dt>
320
+ <dd className="mono">{mod.version}</dd>
321
+ <dt>Type</dt>
322
+ <dd className="mono">{mod.type}</dd>
323
+ <dt>Author</dt>
324
+ <dd className="mono">{mod.author || '—'}</dd>
325
+ <dt>Bizar</dt>
326
+ <dd className="mono">{mod.bizar || '*'}</dd>
327
+ <dt>Enabled</dt>
328
+ <dd>{mod.enabled ? 'yes' : 'no'}</dd>
329
+ <dt>Permissions</dt>
330
+ <dd>
331
+ {(mod.permissions || []).map((p) => (
332
+ <span key={p} className="tag">{p}</span>
333
+ ))}
334
+ {(mod.permissions || []).length === 0 && <span className="muted">(none)</span>}
335
+ </dd>
336
+ </dl>
337
+ <div className="mod-files">
338
+ <div className="muted">Files</div>
339
+ <ul>
340
+ {(mod.files || []).map((f) => (
341
+ <li key={f.path}>
342
+ <span className="mod-file-cat">{f.category}</span>
343
+ <span className="mono">{f.path}</span>
344
+ </li>
345
+ ))}
346
+ </ul>
347
+ </div>
348
+ </Card>
349
+ );
350
+ }
@@ -0,0 +1,350 @@
1
+ // src/views/Overview.tsx — system overview: project picker + counts + activity.
2
+ import { useEffect, useState } from 'react';
3
+ import {
4
+ Bot,
5
+ CheckSquare,
6
+ Folder,
7
+ LayoutDashboard,
8
+ Map,
9
+ MessageSquare,
10
+ RefreshCw,
11
+ PlayCircle,
12
+ ShieldCheck,
13
+ FileText,
14
+ Zap,
15
+ Plus,
16
+ Trash2,
17
+ Power,
18
+ Search as SearchIcon,
19
+ } from 'lucide-react';
20
+ import { Card, CardTitle, CardMeta } from '../components/Card';
21
+ import { Button } from '../components/Button';
22
+ import { EmptyState } from '../components/EmptyState';
23
+ import { Spinner } from '../components/Spinner';
24
+ import { useToast } from '../components/Toast';
25
+ import { useModal } from '../components/Modal';
26
+ import { api } from '../lib/api';
27
+ import { formatRelative, formatTime } from '../lib/utils';
28
+ import type { Overview, Settings, Snapshot, ActivityItem, ProjectRecord, Mod } from '../lib/types';
29
+
30
+ type Props = {
31
+ snapshot: Snapshot;
32
+ settings: Settings;
33
+ activeTab: string;
34
+ setActiveTab: (id: string) => void;
35
+ refreshSnapshot: () => Promise<void>;
36
+ };
37
+
38
+ export function Overview({
39
+ snapshot,
40
+ settings,
41
+ setActiveTab,
42
+ refreshSnapshot,
43
+ }: Props) {
44
+ const toast = useToast();
45
+ const modal = useModal();
46
+ const [overview, setOverview] = useState<Overview | null>(
47
+ snapshot.overview ?? null,
48
+ );
49
+ const [loading, setLoading] = useState(!snapshot.overview);
50
+ const [projects, setProjects] = useState<ProjectRecord[]>(snapshot.projects || []);
51
+ const [activeId, setActiveId] = useState<string | null>(
52
+ snapshot.activeProject?.id || null,
53
+ );
54
+ const [mods, setMods] = useState<Mod[]>(snapshot.mods || []);
55
+
56
+ useEffect(() => {
57
+ if (snapshot.overview) {
58
+ setOverview(snapshot.overview);
59
+ setLoading(false);
60
+ }
61
+ setProjects(snapshot.projects || []);
62
+ setActiveId(snapshot.activeProject?.id || null);
63
+ setMods(snapshot.mods || []);
64
+ }, [snapshot.overview, snapshot.projects, snapshot.activeProject, snapshot.mods]);
65
+
66
+ const onRefresh = async () => {
67
+ toast.info('Refreshing…', 1500);
68
+ await refreshSnapshot();
69
+ try {
70
+ const data = await api.get<{ projects: ProjectRecord[]; active: string | null }>('/projects');
71
+ setProjects(data.projects || []);
72
+ setActiveId(data.active || null);
73
+ } catch { /* ignore */ }
74
+ };
75
+
76
+ const onAddProject = () => {
77
+ let pathEl: HTMLInputElement | null = null;
78
+ let nameEl: HTMLInputElement | null = null;
79
+ modal.open({
80
+ title: 'Add project',
81
+ children: (
82
+ <div>
83
+ <label className="field-label">Path (absolute)</label>
84
+ <input
85
+ ref={(el) => (pathEl = el)}
86
+ className="input"
87
+ type="text"
88
+ placeholder="/home/user/projects/myapp"
89
+ autoFocus
90
+ />
91
+ <label className="field-label" style={{ marginTop: 12 }}>Name (optional)</label>
92
+ <input
93
+ ref={(el) => (nameEl = el)}
94
+ className="input"
95
+ type="text"
96
+ placeholder="My App"
97
+ />
98
+ </div>
99
+ ),
100
+ footer: (
101
+ <div className="modal-footer-actions">
102
+ <Button variant="ghost" onClick={() => modal.close()}>Cancel</Button>
103
+ <Button
104
+ variant="primary"
105
+ onClick={async () => {
106
+ const path = (pathEl?.value || '').trim();
107
+ const name = (nameEl?.value || '').trim() || null;
108
+ if (!path) {
109
+ toast.warning('Path is required.');
110
+ return;
111
+ }
112
+ try {
113
+ const r = await api.post<ProjectRecord>('/projects', { path, name });
114
+ setProjects((cur) => [...cur.filter((p) => p.id !== r.id), r]);
115
+ toast.success('Project added.');
116
+ modal.close();
117
+ } catch (err) {
118
+ toast.error(`Add failed: ${(err as Error).message}`);
119
+ }
120
+ }}
121
+ >
122
+ Add
123
+ </Button>
124
+ </div>
125
+ ),
126
+ });
127
+ };
128
+
129
+ const onActivate = async (id: string) => {
130
+ try {
131
+ await api.post(`/projects/${encodeURIComponent(id)}/activate`);
132
+ setActiveId(id);
133
+ toast.success('Project activated.');
134
+ await refreshSnapshot();
135
+ } catch (err) {
136
+ toast.error(`Activate failed: ${(err as Error).message}`);
137
+ }
138
+ };
139
+
140
+ const onRemove = async (id: string) => {
141
+ if (!confirm(`Remove project "${id}" from the registry?`)) return;
142
+ try {
143
+ await api.del(`/projects/${encodeURIComponent(id)}`);
144
+ setProjects((cur) => cur.filter((p) => p.id !== id));
145
+ if (activeId === id) setActiveId(null);
146
+ toast.success('Project removed.');
147
+ } catch (err) {
148
+ toast.error(`Remove failed: ${(err as Error).message}`);
149
+ }
150
+ };
151
+
152
+ if (loading || !overview) {
153
+ return (
154
+ <div className="view-loading">
155
+ <Spinner size="lg" />
156
+ <p>Loading overview…</p>
157
+ </div>
158
+ );
159
+ }
160
+
161
+ return (
162
+ <div className="view view-overview">
163
+ <header className="view-header">
164
+ <div className="view-header-text">
165
+ <h2 className="view-title">
166
+ <LayoutDashboard size={18} />
167
+ System Overview
168
+ </h2>
169
+ <p className="view-subtitle">
170
+ {projects.length} project{projects.length === 1 ? '' : 's'} ·
171
+ {' '}{overview.counts.agents} agents ·
172
+ {' '}{overview.counts.sessions} session{overview.counts.sessions === 1 ? '' : 's'}
173
+ </p>
174
+ </div>
175
+ <div className="view-actions">
176
+ <Button variant="secondary" size="sm" onClick={onAddProject}>
177
+ <Plus size={14} /> Add project
178
+ </Button>
179
+ <Button variant="secondary" size="sm" onClick={onRefresh}>
180
+ <RefreshCw size={14} /> Refresh
181
+ </Button>
182
+ </div>
183
+ </header>
184
+
185
+ <Card className="project-picker">
186
+ <CardTitle>
187
+ <Folder size={14} /> Projects
188
+ </CardTitle>
189
+ <CardMeta>
190
+ Click "Open" to switch the active project. Per-project data lives in
191
+ {' '}<code>~/.config/opencode/projects/&lt;id&gt;/</code>.
192
+ </CardMeta>
193
+ {projects.length === 0 ? (
194
+ <EmptyState
195
+ icon={<Folder size={32} />}
196
+ title="No projects yet"
197
+ message="Add a project to start tracking its tasks, plans, and schedules."
198
+ />
199
+ ) : (
200
+ <div className="project-grid">
201
+ {projects.map((p) => (
202
+ <ProjectCard
203
+ key={p.id}
204
+ project={p}
205
+ active={activeId === p.id}
206
+ onOpen={() => onActivate(p.id)}
207
+ onRemove={() => onRemove(p.id)}
208
+ />
209
+ ))}
210
+ </div>
211
+ )}
212
+ </Card>
213
+
214
+ <div className="overview-cols">
215
+ <Card>
216
+ <CardTitle>
217
+ <Zap size={14} /> Recent activity
218
+ </CardTitle>
219
+ <CardMeta>Last 30 events</CardMeta>
220
+ {overview.recentActivity.length === 0 ? (
221
+ <EmptyState
222
+ icon={<FileText size={28} />}
223
+ title="No activity yet"
224
+ message="Use the chat or invoke a Bizar command to start a feed."
225
+ />
226
+ ) : (
227
+ <ul className="activity-list">
228
+ {overview.recentActivity.slice(0, 30).map((it, idx) => (
229
+ <li key={idx} className="activity-item">
230
+ <span className="activity-ts tabular-nums">
231
+ {formatRelative(it.ts)}
232
+ </span>
233
+ <span className="activity-kind">{it.kind}</span>
234
+ <span className="activity-msg">{formatActivity(it)}</span>
235
+ </li>
236
+ ))}
237
+ </ul>
238
+ )}
239
+ </Card>
240
+
241
+ <Card>
242
+ <CardTitle>Mods</CardTitle>
243
+ <CardMeta>Extensions installed under <code>~/.config/bizar/mods/</code></CardMeta>
244
+ {mods.length === 0 ? (
245
+ <div className="muted">No mods installed.</div>
246
+ ) : (
247
+ <ul className="mod-mini-list">
248
+ {mods.map((m) => (
249
+ <li key={m.id} className="mod-mini">
250
+ <span className="mod-mini-name">{m.name}</span>
251
+ <span className="mod-mini-meta">v{m.version} · {m.type}</span>
252
+ <span className={`mod-mini-pill ${m.enabled ? 'mod-mini-pill-on' : 'mod-mini-pill-off'}`}>
253
+ {m.enabled ? 'on' : 'off'}
254
+ </span>
255
+ </li>
256
+ ))}
257
+ </ul>
258
+ )}
259
+ </Card>
260
+
261
+ <Card>
262
+ <CardTitle>Environment</CardTitle>
263
+ <CardMeta>Runtime + paths</CardMeta>
264
+ <dl className="env-table">
265
+ <dt>Node</dt>
266
+ <dd className="mono">{overview.versions.node}</dd>
267
+ <dt>Platform</dt>
268
+ <dd className="mono">{overview.versions.platform}</dd>
269
+ <dt>Project root</dt>
270
+ <dd className="mono ellipsis" title={overview.versions.projectRoot}>
271
+ {overview.versions.projectRoot}
272
+ </dd>
273
+ <dt>Bizar root</dt>
274
+ <dd className="mono ellipsis" title={overview.versions.bizarRoot}>
275
+ {overview.versions.bizarRoot}
276
+ </dd>
277
+ <dt>Generated</dt>
278
+ <dd className="mono tabular-nums">
279
+ {formatTime(overview.generatedAt)}
280
+ </dd>
281
+ </dl>
282
+ </Card>
283
+ </div>
284
+ </div>
285
+ );
286
+ }
287
+
288
+ function ProjectCard({
289
+ project,
290
+ active,
291
+ onOpen,
292
+ onRemove,
293
+ }: {
294
+ project: ProjectRecord;
295
+ active: boolean;
296
+ onOpen: () => void;
297
+ onRemove: () => void;
298
+ }) {
299
+ const statusColor = {
300
+ active: 'status-on',
301
+ inactive: 'status-neutral',
302
+ error: 'status-error',
303
+ }[project.status] || 'status-neutral';
304
+ return (
305
+ <div className={`project-card ${active ? 'project-card-active' : ''}`}>
306
+ <div className="project-card-head">
307
+ <span className={`project-card-status ${statusColor}`}>
308
+ {project.status}
309
+ </span>
310
+ <div className="project-card-name">{project.name}</div>
311
+ <button
312
+ type="button"
313
+ className="icon-btn"
314
+ aria-label="Remove project"
315
+ title="Remove"
316
+ onClick={(e) => {
317
+ e.stopPropagation();
318
+ onRemove();
319
+ }}
320
+ >
321
+ <Trash2 size={12} />
322
+ </button>
323
+ </div>
324
+ <div className="project-card-path mono ellipsis" title={project.path}>
325
+ {project.path}
326
+ </div>
327
+ <div className="project-card-meta">
328
+ {project.lastAccessed && (
329
+ <span className="muted">Last opened {formatRelative(project.lastAccessed)}</span>
330
+ )}
331
+ </div>
332
+ <div className="project-card-actions">
333
+ <Button variant={active ? 'ghost' : 'primary'} size="sm" onClick={onOpen}>
334
+ {active ? <><Power size={12} /> Active</> : <>Open</>}
335
+ </Button>
336
+ </div>
337
+ </div>
338
+ );
339
+ }
340
+
341
+ function formatActivity(it: ActivityItem): string {
342
+ if (typeof it.message === 'string') return it.message;
343
+ if (typeof it.prompt === 'string') return it.prompt;
344
+ if (typeof it.slug === 'string') {
345
+ const title = typeof it.title === 'string' ? ` title=${it.title}` : '';
346
+ return `slug=${it.slug}${title}`;
347
+ }
348
+ if (typeof it.name === 'string') return `name=${it.name}`;
349
+ return JSON.stringify(it);
350
+ }