@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,527 @@
1
+ // src/views/Chat.tsx — v3 floating chat: messages, sessions, agent selector, slash autocomplete.
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import ReactMarkdown from 'react-markdown';
4
+ import remarkGfm from 'remark-gfm';
5
+ import {
6
+ MessageSquare,
7
+ Send,
8
+ CornerDownLeft,
9
+ Bot,
10
+ Paperclip,
11
+ Pin,
12
+ Copy,
13
+ RefreshCw,
14
+ Trash2,
15
+ Server,
16
+ Wrench,
17
+ Terminal,
18
+ FileText,
19
+ Sparkles,
20
+ } from 'lucide-react';
21
+ import { Button } from '../components/Button';
22
+ import { Card, CardTitle, CardMeta } from '../components/Card';
23
+ import { EmptyState } from '../components/EmptyState';
24
+ import { Spinner } from '../components/Spinner';
25
+ import { useToast } from '../components/Toast';
26
+ import { useModal } from '../components/Modal';
27
+ import { api } from '../lib/api';
28
+ import { cn, formatTime } from '../lib/utils';
29
+ import type {
30
+ Agent,
31
+ ChatMessage,
32
+ ChatResponse,
33
+ ChatSession,
34
+ McpServer,
35
+ Mod,
36
+ Settings,
37
+ Snapshot,
38
+ } from '../lib/types';
39
+
40
+ type Props = {
41
+ snapshot: Snapshot;
42
+ settings: Settings;
43
+ activeTab: string;
44
+ setActiveTab: (id: string) => void;
45
+ refreshSnapshot: () => Promise<void>;
46
+ };
47
+
48
+ type SlashCommand = { cmd: string; desc: string; mod?: string };
49
+
50
+ const BUILTIN_COMMANDS: SlashCommand[] = [
51
+ { cmd: '/plan new <slug>', desc: 'Create a new plan' },
52
+ { cmd: '/plan list', desc: 'List plans' },
53
+ { cmd: '/plan open <slug>', desc: 'Open a plan URL' },
54
+ { cmd: '/plan status <slug> <status>', desc: 'Set plan status' },
55
+ { cmd: '/bizar', desc: 'Show Bizar menu' },
56
+ { cmd: '/audit', desc: 'Run security audit' },
57
+ { cmd: '/explain <q>', desc: 'Read-only code Q&A' },
58
+ { cmd: '/init', desc: 'Initialize .bizar/ in this project' },
59
+ { cmd: '/learn', desc: 'Extract patterns from session' },
60
+ { cmd: '/pr-review', desc: 'PR review' },
61
+ { cmd: '/help', desc: 'Show all slash commands' },
62
+ ];
63
+
64
+ export function Chat({ snapshot, settings }: Props) {
65
+ const toast = useToast();
66
+ const modal = useModal();
67
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
68
+ const [sessions, setSessions] = useState<ChatSession[]>([]);
69
+ const [loading, setLoading] = useState(true);
70
+ const [sessionId, setSessionId] = useState<string>('');
71
+ const [text, setText] = useState('');
72
+ const [sending, setSending] = useState(false);
73
+ const [agent, setAgent] = useState<string>(settings.defaultAgent || 'odin');
74
+ const [model, setModel] = useState<string>(settings.defaultModel || '');
75
+ const [attachments, setAttachments] = useState<string[]>([]);
76
+ const [suggestions, setSuggestions] = useState<SlashCommand[]>([]);
77
+ const [pinned, setPinned] = useState<Set<number>>(new Set());
78
+ const [sessionPanelOpen, setSessionPanelOpen] = useState(true);
79
+ const listRef = useRef<HTMLDivElement>(null);
80
+ const inputRef = useRef<HTMLTextAreaElement>(null);
81
+ const fileInputRef = useRef<HTMLInputElement>(null);
82
+
83
+ const allCommands: SlashCommand[] = [
84
+ ...BUILTIN_COMMANDS,
85
+ ...(snapshot.mods || []).flatMap((m: Mod) =>
86
+ (m.entry?.command ? [{ cmd: `/${m.id}`, desc: m.description || m.name, mod: m.id }] : []),
87
+ ),
88
+ ];
89
+
90
+ const loadChat = async (sid?: string) => {
91
+ try {
92
+ const url = sid ? `/chat?session=${encodeURIComponent(sid)}` : '/chat?limit=200';
93
+ const data = await api.get<ChatResponse>(url);
94
+ setMessages(data.messages || []);
95
+ setSessions(data.sessions || []);
96
+ } catch (err) {
97
+ toast.error(`Chat load failed: ${(err as Error).message}`);
98
+ } finally {
99
+ setLoading(false);
100
+ }
101
+ };
102
+
103
+ useEffect(() => {
104
+ loadChat();
105
+ // eslint-disable-next-line react-hooks/exhaustive-deps
106
+ }, []);
107
+
108
+ // Auto-scroll on new messages
109
+ useEffect(() => {
110
+ if (listRef.current) {
111
+ listRef.current.scrollTop = listRef.current.scrollHeight;
112
+ }
113
+ }, [messages]);
114
+
115
+ // Slash-command suggestions
116
+ useEffect(() => {
117
+ if (text.startsWith('/') && !text.includes(' ')) {
118
+ const q = text.toLowerCase();
119
+ setSuggestions(
120
+ allCommands.filter((c) => c.cmd.toLowerCase().startsWith(q)).slice(0, 6),
121
+ );
122
+ } else {
123
+ setSuggestions([]);
124
+ }
125
+ }, [text, allCommands.length]);
126
+
127
+ const onSend = async () => {
128
+ const message = text.trim();
129
+ if (!message || sending) return;
130
+ setSending(true);
131
+ const optimistic: ChatMessage = {
132
+ role: 'user',
133
+ content: message,
134
+ agent,
135
+ ts: new Date().toISOString(),
136
+ };
137
+ setMessages((cur) => [...cur, optimistic]);
138
+ setText('');
139
+ setSuggestions([]);
140
+ try {
141
+ await api.post('/chat', { message, agent, model, attachments });
142
+ toast.success('Message accepted.');
143
+ } catch (err) {
144
+ setMessages((cur) => cur.filter((m) => m !== optimistic));
145
+ toast.error(`Send failed: ${(err as Error).message}`);
146
+ } finally {
147
+ setSending(false);
148
+ inputRef.current?.focus();
149
+ }
150
+ };
151
+
152
+ const onRegenerate = async (messageId: string | number) => {
153
+ try {
154
+ await api.post('/chat/regenerate', { sessionId, messageId });
155
+ toast.success('Regenerating…', 1500);
156
+ // Re-fetch messages so the new one appears
157
+ await loadChat(sessionId || undefined);
158
+ } catch (err) {
159
+ toast.error(`Regenerate failed: ${(err as Error).message}`);
160
+ }
161
+ };
162
+
163
+ // Wrapper that captures the messageId from the bubble
164
+ const makeRegenerateHandler = (messageId: string) => () => onRegenerate(messageId);
165
+
166
+ const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
167
+ if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
168
+ e.preventDefault();
169
+ onSend();
170
+ } else if (e.key === 'Enter' && !e.shiftKey) {
171
+ e.preventDefault();
172
+ onSend();
173
+ } else if (e.key === 'Tab' && suggestions.length) {
174
+ e.preventDefault();
175
+ const first = suggestions[0];
176
+ const base = first.cmd.split(' ')[0];
177
+ setText(`${base} `);
178
+ setSuggestions([]);
179
+ }
180
+ };
181
+
182
+ const onAttach = () => fileInputRef.current?.click();
183
+
184
+ const onFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
185
+ const files = e.target.files;
186
+ if (!files) return;
187
+ const next: string[] = [];
188
+ for (let i = 0; i < files.length; i++) {
189
+ // Use webkitRelativePath or just name
190
+ next.push(files[i].name);
191
+ }
192
+ setAttachments((cur) => [...cur, ...next]);
193
+ e.target.value = '';
194
+ };
195
+
196
+ const copyMessage = (m: ChatMessage) => {
197
+ const text = m.content || m.message || '';
198
+ navigator.clipboard?.writeText(text).then(
199
+ () => toast.success('Copied.', 1200),
200
+ () => toast.error('Copy failed.'),
201
+ );
202
+ };
203
+
204
+ const deleteMessage = (idx: number) => {
205
+ if (!confirm('Delete this message?')) return;
206
+ setMessages((cur) => cur.filter((_, i) => i !== idx));
207
+ };
208
+
209
+ const togglePin = (idx: number) => {
210
+ setPinned((cur) => {
211
+ const next = new Set(cur);
212
+ if (next.has(idx)) next.delete(idx);
213
+ else next.add(idx);
214
+ return next;
215
+ });
216
+ };
217
+
218
+ // Newest at the bottom for chat feel
219
+ const ordered = [...messages].reverse();
220
+ const orderedWithPinned = [
221
+ ...ordered.filter((_, i) => pinned.has(messages.length - 1 - i)),
222
+ ...ordered.filter((_, i) => !pinned.has(messages.length - 1 - i)),
223
+ ];
224
+
225
+ return (
226
+ <div className="view view-chat">
227
+ <header className="view-header chat-header">
228
+ <div className="view-header-text">
229
+ <h2 className="view-title">
230
+ <MessageSquare size={18} /> Chat
231
+ </h2>
232
+ <p className="view-subtitle">
233
+ {snapshot.activeProject ? (
234
+ <>Active project: <strong>{snapshot.activeProject.name}</strong></>
235
+ ) : (
236
+ 'No active project — pick one in Overview to scope chat sessions.'
237
+ )}
238
+ </p>
239
+ </div>
240
+ <div className="view-actions">
241
+ <Button
242
+ variant="ghost"
243
+ size="sm"
244
+ onClick={() => setSessionPanelOpen((v) => !v)}
245
+ title="Toggle session panel"
246
+ >
247
+ Sessions ({sessions.length})
248
+ </Button>
249
+ <Button variant="secondary" size="sm" onClick={() => loadChat(sessionId || undefined)}>
250
+ <RefreshCw size={14} /> Refresh
251
+ </Button>
252
+ </div>
253
+ </header>
254
+
255
+ <div className="chat-layout">
256
+ {sessionPanelOpen && (
257
+ <aside className="chat-sessions">
258
+ <div className="chat-sessions-head">
259
+ <span className="muted">Sessions</span>
260
+ <button
261
+ type="button"
262
+ className="icon-btn"
263
+ aria-label="New session"
264
+ title="New session"
265
+ onClick={() => {
266
+ setSessionId('');
267
+ setMessages([]);
268
+ }}
269
+ >
270
+ <Sparkles size={12} />
271
+ </button>
272
+ </div>
273
+ <ul className="chat-sessions-list">
274
+ {sessions.length === 0 && (
275
+ <li className="muted">No sessions yet.</li>
276
+ )}
277
+ {sessions.map((s) => (
278
+ <li
279
+ key={s.id}
280
+ className={cn('chat-session-item', sessionId === s.id && 'active')}
281
+ onClick={() => {
282
+ setSessionId(s.id);
283
+ loadChat(s.id);
284
+ }}
285
+ >
286
+ <span className="chat-session-id">{s.id}</span>
287
+ <span className="chat-session-meta">{new Date(s.mtime).toLocaleDateString()}</span>
288
+ </li>
289
+ ))}
290
+ </ul>
291
+ </aside>
292
+ )}
293
+
294
+ <div className="chat-main">
295
+ <div className="chat-list" ref={listRef}>
296
+ {loading ? (
297
+ <div className="view-loading"><Spinner /></div>
298
+ ) : messages.length === 0 ? (
299
+ <EmptyState
300
+ icon={<MessageSquare size={28} />}
301
+ title="No messages yet"
302
+ message="Type something below to start. Press / for commands, Tab to autocomplete, ⌘/Ctrl+Enter to send."
303
+ />
304
+ ) : (
305
+ orderedWithPinned.map((m, idx) => {
306
+ const originalIdx = messages.length - 1 - idx;
307
+ return (
308
+ <ChatBubble
309
+ key={`${originalIdx}-${m.ts ?? ''}`}
310
+ message={m}
311
+ pinned={pinned.has(originalIdx)}
312
+ onCopy={() => copyMessage(m)}
313
+ onDelete={() => deleteMessage(originalIdx)}
314
+ onTogglePin={() => togglePin(originalIdx)}
315
+ onRegenerate={makeRegenerateHandler(String(m.ts) || String(originalIdx))}
316
+ />
317
+ );
318
+ })
319
+ )}
320
+ </div>
321
+
322
+ <div className="chat-composer">
323
+ {suggestions.length > 0 && (
324
+ <div className="chat-suggestions">
325
+ {suggestions.map((s) => (
326
+ <button
327
+ type="button"
328
+ key={s.cmd}
329
+ className="chat-suggestion"
330
+ onClick={() => {
331
+ const base = s.cmd.split(' ')[0];
332
+ setText(`${base} `);
333
+ inputRef.current?.focus();
334
+ }}
335
+ >
336
+ <span className="mono">{s.cmd}</span>
337
+ <span className="chat-suggestion-desc">{s.desc}</span>
338
+ </button>
339
+ ))}
340
+ </div>
341
+ )}
342
+ {attachments.length > 0 && (
343
+ <div className="chat-attachments">
344
+ {attachments.map((a, i) => (
345
+ <span key={i} className="chat-attachment-tag">
346
+ <Paperclip size={10} /> {a}
347
+ <button
348
+ type="button"
349
+ className="icon-btn"
350
+ onClick={() => setAttachments((cur) => cur.filter((_, j) => j !== i))}
351
+ >
352
+ ×
353
+ </button>
354
+ </span>
355
+ ))}
356
+ </div>
357
+ )}
358
+ <div className="chat-composer-row">
359
+ <select
360
+ className="select select-sm"
361
+ value={agent}
362
+ onChange={(e) => setAgent(e.target.value)}
363
+ title="Agent"
364
+ >
365
+ {(snapshot.agents || []).map((a) => (
366
+ <option key={a.name} value={a.name}>
367
+ @{a.name}
368
+ </option>
369
+ ))}
370
+ <option value="">(no agent)</option>
371
+ </select>
372
+ <input
373
+ className="input input-sm"
374
+ type="text"
375
+ placeholder="model (optional)"
376
+ value={model}
377
+ onChange={(e) => setModel(e.target.value)}
378
+ style={{ width: 140 }}
379
+ />
380
+ <button
381
+ type="button"
382
+ className="icon-btn"
383
+ aria-label="Attach files"
384
+ title="Attach"
385
+ onClick={onAttach}
386
+ >
387
+ <Paperclip size={14} />
388
+ </button>
389
+ <input
390
+ ref={fileInputRef}
391
+ type="file"
392
+ multiple
393
+ style={{ display: 'none' }}
394
+ onChange={onFiles}
395
+ />
396
+ <textarea
397
+ ref={inputRef}
398
+ className="chat-input"
399
+ placeholder="Send a message… (Enter to send, Shift+Enter for newline, / for commands)"
400
+ rows={2}
401
+ value={text}
402
+ onChange={(e) => setText(e.target.value)}
403
+ onKeyDown={onKeyDown}
404
+ disabled={sending}
405
+ />
406
+ <Button variant="primary" onClick={onSend} disabled={sending || !text.trim()}>
407
+ {sending ? <Spinner size="sm" /> : <Send size={14} />}
408
+ <span>Send</span>
409
+ <CornerDownLeft size={12} className="hint-key" />
410
+ </Button>
411
+ </div>
412
+ </div>
413
+ </div>
414
+
415
+ <aside className="chat-info">
416
+ <Card>
417
+ <CardTitle><MessageSquare size={14} /> Session</CardTitle>
418
+ <CardMeta>
419
+ {sessionId ? `id: ${sessionId}` : 'Live (unsaved)'}
420
+ </CardMeta>
421
+ <dl className="env-table">
422
+ <dt>Messages</dt>
423
+ <dd className="mono">{messages.length}</dd>
424
+ <dt>Pinned</dt>
425
+ <dd className="mono">{pinned.size}</dd>
426
+ <dt>Agent</dt>
427
+ <dd className="mono">{agent || '—'}</dd>
428
+ <dt>Model</dt>
429
+ <dd className="mono ellipsis" title={model}>{model || '—'}</dd>
430
+ </dl>
431
+ </Card>
432
+ <Card>
433
+ <CardTitle><Bot size={14} /> Agents in this project</CardTitle>
434
+ <CardMeta>{(snapshot.agents || []).length} configured</CardMeta>
435
+ <ul className="mod-mini-list">
436
+ {(snapshot.agents || []).slice(0, 8).map((a) => (
437
+ <li key={a.name} className="mod-mini" onClick={() => setAgent(a.name)}>
438
+ <span className="mod-mini-name">@{a.name}</span>
439
+ <span className="mod-mini-meta">{a.model || a.mode || ''}</span>
440
+ </li>
441
+ ))}
442
+ </ul>
443
+ </Card>
444
+ <Card>
445
+ <CardTitle><Server size={14} /> Active MCPs</CardTitle>
446
+ <CardMeta>{(snapshot.mcps || []).length} configured</CardMeta>
447
+ <ul className="mod-mini-list">
448
+ {(snapshot.mcps || []).map((m) => (
449
+ <li key={m.id} className="mod-mini">
450
+ <span className="mod-mini-name">{m.id}</span>
451
+ <span className="mod-mini-meta">{m.command}</span>
452
+ <span className={`mod-mini-pill ${m.enabled ? 'mod-mini-pill-on' : 'mod-mini-pill-off'}`}>
453
+ {m.enabled ? 'on' : 'off'}
454
+ </span>
455
+ </li>
456
+ ))}
457
+ {(snapshot.mcps || []).length === 0 && (
458
+ <li className="muted">No MCPs configured.</li>
459
+ )}
460
+ </ul>
461
+ </Card>
462
+ <Card>
463
+ <CardTitle><Terminal size={14} /> Slash commands</CardTitle>
464
+ <CardMeta>{allCommands.length} available</CardMeta>
465
+ <ul className="slash-mini-list">
466
+ {allCommands.slice(0, 10).map((c) => (
467
+ <li key={c.cmd}>
468
+ <code>{c.cmd}</code>
469
+ <span className="muted">{c.desc}</span>
470
+ </li>
471
+ ))}
472
+ </ul>
473
+ </Card>
474
+ </aside>
475
+ </div>
476
+ </div>
477
+ );
478
+ }
479
+
480
+ function ChatBubble({
481
+ message,
482
+ pinned,
483
+ onCopy,
484
+ onDelete,
485
+ onTogglePin,
486
+ onRegenerate,
487
+ }: {
488
+ message: ChatMessage;
489
+ pinned: boolean;
490
+ onCopy: () => void;
491
+ onDelete: () => void;
492
+ onTogglePin: () => void;
493
+ onRegenerate: () => void;
494
+ }) {
495
+ const role = (message.role || 'assistant').toLowerCase();
496
+ const ts = message.ts ? formatTime(message.ts) : '';
497
+ const content = message.content || message.message || '';
498
+ return (
499
+ <div className={cn('chat-msg', `chat-msg-${role}`, pinned && 'chat-msg-pinned')}>
500
+ <div className="chat-msg-meta">
501
+ <span className="chat-msg-role">{role}</span>
502
+ {message.agent && <span className="chat-msg-agent">@{message.agent}</span>}
503
+ {ts && <span className="chat-msg-ts tabular-nums">{ts}</span>}
504
+ {pinned && <span className="chat-msg-pin"><Pin size={10} /> pinned</span>}
505
+ <div className="chat-msg-actions">
506
+ <button type="button" className="icon-btn" aria-label="Copy" title="Copy" onClick={onCopy}>
507
+ <Copy size={12} />
508
+ </button>
509
+ {role !== 'user' && (
510
+ <button type="button" className="icon-btn" aria-label="Regenerate" title="Regenerate" onClick={onRegenerate}>
511
+ <RefreshCw size={12} />
512
+ </button>
513
+ )}
514
+ <button type="button" className="icon-btn" aria-label="Pin" title="Pin" onClick={onTogglePin}>
515
+ <Pin size={12} />
516
+ </button>
517
+ <button type="button" className="icon-btn icon-btn-danger" aria-label="Delete" title="Delete" onClick={onDelete}>
518
+ <Trash2 size={12} />
519
+ </button>
520
+ </div>
521
+ </div>
522
+ <div className="chat-msg-body markdown-body">
523
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
524
+ </div>
525
+ </div>
526
+ );
527
+ }