@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,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
|
+
}
|