@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.
- package/dist/assets/index-B5X9g8B4.css +1 -0
- package/dist/assets/index-LqQuSp9d.js +388 -0
- package/dist/assets/index-LqQuSp9d.js.map +1 -0
- package/dist/index.html +18 -0
- package/package.json +67 -0
- package/src/cli.mjs +228 -0
- package/src/server/agents-store.mjs +190 -0
- package/src/server/api.mjs +913 -0
- package/src/server/browser.mjs +40 -0
- package/src/server/diagnostics-store.mjs +138 -0
- package/src/server/mods-loader.mjs +361 -0
- package/src/server/projects-store.mjs +198 -0
- package/src/server/providers-store.mjs +183 -0
- package/src/server/schedules-runner.mjs +150 -0
- package/src/server/schedules-store.mjs +233 -0
- package/src/server/search-store.mjs +120 -0
- package/src/server/server.mjs +388 -0
- package/src/server/state.mjs +357 -0
- package/src/server/tailscale-store.mjs +113 -0
- package/src/server/tasks-store.mjs +275 -0
- package/src/server/tui.mjs +844 -0
- package/src/server/watcher.mjs +81 -0
- package/src/web/App.tsx +316 -0
- package/src/web/components/Button.tsx +55 -0
- package/src/web/components/Card.tsx +40 -0
- package/src/web/components/EmptyState.tsx +30 -0
- package/src/web/components/Modal.tsx +137 -0
- package/src/web/components/SearchModal.tsx +185 -0
- package/src/web/components/Spinner.tsx +19 -0
- package/src/web/components/StatusBadge.tsx +25 -0
- package/src/web/components/Tag.tsx +28 -0
- package/src/web/components/Toast.tsx +142 -0
- package/src/web/components/Topbar.tsx +203 -0
- package/src/web/index.html +17 -0
- package/src/web/lib/api.ts +71 -0
- package/src/web/lib/markdown.tsx +59 -0
- package/src/web/lib/types.ts +388 -0
- package/src/web/lib/utils.ts +79 -0
- package/src/web/lib/ws.ts +132 -0
- package/src/web/main.tsx +12 -0
- package/src/web/styles/main.css +3148 -0
- package/src/web/views/Agents.tsx +406 -0
- package/src/web/views/Chat.tsx +527 -0
- package/src/web/views/Config.tsx +683 -0
- package/src/web/views/Mods.tsx +350 -0
- package/src/web/views/Overview.tsx +350 -0
- package/src/web/views/Plans.tsx +667 -0
- package/src/web/views/Schedules.tsx +299 -0
- package/src/web/views/Settings.tsx +571 -0
- package/src/web/views/Tasks.tsx +761 -0
- package/templates/mod/FORMAT.md +76 -0
- package/templates/mod/hello-mod/README.md +19 -0
- package/templates/mod/hello-mod/agents/greeter.md +8 -0
- package/templates/mod/hello-mod/commands/hello.md +6 -0
- package/templates/mod/hello-mod/mod.json +20 -0
- package/templates/mod/hello-mod/routes/ping.mjs +9 -0
- package/templates/mod/hello-mod/views/HelloView.tsx +10 -0
- package/tsconfig.json +23 -0
- 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/<id>/</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/<id>/</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
|
+
}
|