groove-dev 0.8.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 (84) hide show
  1. package/CLAUDE.md +197 -0
  2. package/LICENSE +40 -0
  3. package/README.md +115 -0
  4. package/docs/GUI_DESIGN_SPEC.md +402 -0
  5. package/favicon.png +0 -0
  6. package/groove-logo-short.png +0 -0
  7. package/groove-logo.png +0 -0
  8. package/package.json +70 -0
  9. package/packages/cli/bin/groove.js +98 -0
  10. package/packages/cli/package.json +15 -0
  11. package/packages/cli/src/client.js +25 -0
  12. package/packages/cli/src/commands/agents.js +38 -0
  13. package/packages/cli/src/commands/approve.js +50 -0
  14. package/packages/cli/src/commands/config.js +35 -0
  15. package/packages/cli/src/commands/kill.js +15 -0
  16. package/packages/cli/src/commands/nuke.js +19 -0
  17. package/packages/cli/src/commands/providers.js +40 -0
  18. package/packages/cli/src/commands/rotate.js +16 -0
  19. package/packages/cli/src/commands/spawn.js +91 -0
  20. package/packages/cli/src/commands/start.js +31 -0
  21. package/packages/cli/src/commands/status.js +38 -0
  22. package/packages/cli/src/commands/stop.js +15 -0
  23. package/packages/cli/src/commands/team.js +77 -0
  24. package/packages/daemon/package.json +18 -0
  25. package/packages/daemon/src/adaptive.js +237 -0
  26. package/packages/daemon/src/api.js +533 -0
  27. package/packages/daemon/src/classifier.js +126 -0
  28. package/packages/daemon/src/credentials.js +121 -0
  29. package/packages/daemon/src/firstrun.js +93 -0
  30. package/packages/daemon/src/index.js +208 -0
  31. package/packages/daemon/src/introducer.js +238 -0
  32. package/packages/daemon/src/journalist.js +600 -0
  33. package/packages/daemon/src/lockmanager.js +58 -0
  34. package/packages/daemon/src/pm.js +108 -0
  35. package/packages/daemon/src/process.js +361 -0
  36. package/packages/daemon/src/providers/aider.js +72 -0
  37. package/packages/daemon/src/providers/base.js +38 -0
  38. package/packages/daemon/src/providers/claude-code.js +167 -0
  39. package/packages/daemon/src/providers/codex.js +68 -0
  40. package/packages/daemon/src/providers/gemini.js +62 -0
  41. package/packages/daemon/src/providers/index.js +38 -0
  42. package/packages/daemon/src/providers/ollama.js +94 -0
  43. package/packages/daemon/src/registry.js +89 -0
  44. package/packages/daemon/src/rotator.js +185 -0
  45. package/packages/daemon/src/router.js +132 -0
  46. package/packages/daemon/src/state.js +34 -0
  47. package/packages/daemon/src/supervisor.js +178 -0
  48. package/packages/daemon/src/teams.js +203 -0
  49. package/packages/daemon/src/terminal/base.js +27 -0
  50. package/packages/daemon/src/terminal/generic.js +27 -0
  51. package/packages/daemon/src/terminal/tmux.js +64 -0
  52. package/packages/daemon/src/tokentracker.js +124 -0
  53. package/packages/daemon/src/validate.js +122 -0
  54. package/packages/daemon/templates/api-builder.json +18 -0
  55. package/packages/daemon/templates/fullstack.json +18 -0
  56. package/packages/daemon/templates/monorepo.json +24 -0
  57. package/packages/gui/dist/assets/index-BO95Rm1F.js +73 -0
  58. package/packages/gui/dist/assets/index-CPzm9ZE9.css +1 -0
  59. package/packages/gui/dist/favicon.png +0 -0
  60. package/packages/gui/dist/groove-logo-short.png +0 -0
  61. package/packages/gui/dist/groove-logo.png +0 -0
  62. package/packages/gui/dist/index.html +13 -0
  63. package/packages/gui/index.html +12 -0
  64. package/packages/gui/package.json +22 -0
  65. package/packages/gui/public/favicon.png +0 -0
  66. package/packages/gui/public/groove-logo-short.png +0 -0
  67. package/packages/gui/public/groove-logo.png +0 -0
  68. package/packages/gui/src/App.jsx +215 -0
  69. package/packages/gui/src/components/AgentActions.jsx +347 -0
  70. package/packages/gui/src/components/AgentChat.jsx +479 -0
  71. package/packages/gui/src/components/AgentNode.jsx +117 -0
  72. package/packages/gui/src/components/AgentPanel.jsx +115 -0
  73. package/packages/gui/src/components/AgentStats.jsx +333 -0
  74. package/packages/gui/src/components/ApprovalQueue.jsx +156 -0
  75. package/packages/gui/src/components/EmptyState.jsx +100 -0
  76. package/packages/gui/src/components/SpawnPanel.jsx +515 -0
  77. package/packages/gui/src/components/TeamSelector.jsx +162 -0
  78. package/packages/gui/src/main.jsx +9 -0
  79. package/packages/gui/src/stores/groove.js +247 -0
  80. package/packages/gui/src/theme.css +67 -0
  81. package/packages/gui/src/views/AgentTree.jsx +148 -0
  82. package/packages/gui/src/views/CommandCenter.jsx +620 -0
  83. package/packages/gui/src/views/JournalistFeed.jsx +149 -0
  84. package/packages/gui/vite.config.js +19 -0
@@ -0,0 +1,479 @@
1
+ // GROOVE GUI — Agent Chat Tab
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import React, { useState, useRef, useEffect } from 'react';
5
+ import { useGrooveStore } from '../stores/groove';
6
+
7
+ export default function AgentChat({ agent }) {
8
+ const [input, setInput] = useState('');
9
+ const [status, setStatus] = useState(null);
10
+ const scrollRef = useRef();
11
+
12
+ const activityLog = useGrooveStore((s) => s.activityLog);
13
+ const instructAgent = useGrooveStore((s) => s.instructAgent);
14
+ const queryAgent = useGrooveStore((s) => s.queryAgent);
15
+ const showStatus = useGrooveStore((s) => s.showStatus);
16
+ const chatHistory = useGrooveStore((s) => s.chatHistory);
17
+
18
+ const activity = activityLog[agent.id] || [];
19
+ const chats = chatHistory[agent.id] || [];
20
+
21
+ const timeline = buildTimeline(chats, activity);
22
+ const isAlive = agent.status === 'running' || agent.status === 'starting';
23
+
24
+ useEffect(() => {
25
+ if (scrollRef.current) {
26
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
27
+ }
28
+ }, [timeline.length]);
29
+
30
+ async function handleSubmit() {
31
+ const text = input.trim();
32
+ if (!text || status) return;
33
+
34
+ const isQuery = text.startsWith('?');
35
+ const message = isQuery ? text.slice(1).trim() : text;
36
+ if (!message) return;
37
+
38
+ setInput('');
39
+
40
+ if (isQuery && isAlive) {
41
+ // Query — one-shot read-only question, agent keeps running
42
+ setStatus('querying...');
43
+ try {
44
+ await queryAgent(agent.id, message);
45
+ } catch { /* handled in store */ }
46
+ setStatus(null);
47
+ } else {
48
+ // Instruct — works for both alive (rotation) and dead (continuation) agents
49
+ setStatus(isAlive ? 'sending...' : 'continuing...');
50
+ try {
51
+ await instructAgent(agent.id, message);
52
+ } catch (err) {
53
+ showStatus(`failed: ${err.message}`);
54
+ }
55
+ setStatus(null);
56
+ }
57
+ }
58
+
59
+ function handleKeyDown(e) {
60
+ if (e.key === 'Enter' && !e.shiftKey) {
61
+ e.preventDefault();
62
+ e.stopPropagation();
63
+ handleSubmit();
64
+ }
65
+ }
66
+
67
+ return (
68
+ <div style={styles.container}>
69
+ {/* Timeline */}
70
+ <div ref={scrollRef} style={styles.timeline}>
71
+ {timeline.length === 0 && (
72
+ <div style={styles.hint}>
73
+ {isAlive
74
+ ? 'Type a message to instruct this agent. Prefix with ? to query without disrupting.'
75
+ : 'Agent finished. Reply to continue the conversation.'}
76
+ </div>
77
+ )}
78
+ {timeline.map((entry, i) => (
79
+ <div key={i} style={styles.entry}>
80
+ {entry.from === 'user' && (
81
+ <div style={styles.userMsg}>
82
+ <span style={styles.userLabel}>
83
+ {entry.isQuery ? '? you' : '> you'}
84
+ </span>
85
+ <div style={styles.userText}>{entry.text}</div>
86
+ </div>
87
+ )}
88
+ {entry.from === 'agent' && (
89
+ <div style={styles.agentMsg}>
90
+ <span style={styles.agentLabel}>{agent.name}</span>
91
+ <div style={styles.agentText}>
92
+ {/* Stream the latest agent message, show history instantly */}
93
+ {i === timeline.length - 1 && entry.from === 'agent' && Date.now() - entry.timestamp < 5000
94
+ ? <StreamingText text={entry.text} />
95
+ : <FormattedText text={entry.text} />
96
+ }
97
+ </div>
98
+ </div>
99
+ )}
100
+ {entry.from === 'system' && (
101
+ <div style={styles.systemMsg}>{entry.text}</div>
102
+ )}
103
+ <span style={styles.time}>
104
+ {new Date(entry.timestamp).toLocaleTimeString()}
105
+ </span>
106
+ </div>
107
+ ))}
108
+ {status && (
109
+ <div style={styles.statusMsg}>{status}</div>
110
+ )}
111
+ </div>
112
+
113
+ {/* Launch Team button — shown when planner completes */}
114
+ {agent.role === 'planner' && agent.status === 'completed' && (
115
+ <LaunchTeamButton showStatus={showStatus} />
116
+ )}
117
+
118
+ {/* Input — always enabled */}
119
+ <div style={styles.inputRow}>
120
+ <input
121
+ style={styles.input}
122
+ value={input}
123
+ onChange={(e) => setInput(e.target.value)}
124
+ onKeyDown={handleKeyDown}
125
+ placeholder={isAlive ? 'message or ?query...' : 'reply to continue...'}
126
+ disabled={!!status}
127
+ spellCheck={false}
128
+ />
129
+ <button
130
+ type="button"
131
+ onClick={handleSubmit}
132
+ disabled={!!status || !input.trim()}
133
+ style={{
134
+ ...styles.sendBtn,
135
+ opacity: (!!status || !input.trim()) ? 0.3 : 1,
136
+ }}
137
+ >
138
+ Send
139
+ </button>
140
+ </div>
141
+ </div>
142
+ );
143
+ }
144
+
145
+ // ── STREAMING TEXT — reveals text progressively for latest agent message ──
146
+
147
+ function StreamingText({ text }) {
148
+ const [revealed, setRevealed] = useState(0);
149
+ const textRef = useRef(text);
150
+
151
+ useEffect(() => {
152
+ // Reset on new text
153
+ textRef.current = text;
154
+ setRevealed(0);
155
+ }, [text]);
156
+
157
+ useEffect(() => {
158
+ if (revealed >= text.length) return;
159
+ // Reveal 2-4 chars at a time for a smooth streaming feel
160
+ const chunkSize = Math.random() > 0.7 ? 4 : 2;
161
+ const timer = setTimeout(() => {
162
+ setRevealed((r) => Math.min(r + chunkSize, text.length));
163
+ }, 12);
164
+ return () => clearTimeout(timer);
165
+ }, [revealed, text.length]);
166
+
167
+ const visibleText = text.slice(0, revealed);
168
+ const done = revealed >= text.length;
169
+
170
+ return (
171
+ <>
172
+ <FormattedText text={visibleText} />
173
+ {!done && <span style={styles.cursor}>|</span>}
174
+ </>
175
+ );
176
+ }
177
+
178
+ // ── LAUNCH TEAM BUTTON — one-click spawn from planner recommendation ──
179
+
180
+ function LaunchTeamButton({ showStatus }) {
181
+ const [team, setTeam] = useState(null);
182
+ const [launching, setLaunching] = useState(false);
183
+ const [launched, setLaunched] = useState(false);
184
+
185
+ useEffect(() => {
186
+ fetch('/api/recommended-team')
187
+ .then((r) => r.json())
188
+ .then((d) => { if (d.exists && d.agents.length > 0) setTeam(d.agents); })
189
+ .catch(() => {});
190
+ }, []);
191
+
192
+ async function handleLaunch() {
193
+ setLaunching(true);
194
+ try {
195
+ const res = await fetch('/api/recommended-team/launch', { method: 'POST' });
196
+ const data = await res.json();
197
+ if (data.launched) {
198
+ showStatus(`Launched ${data.launched} agents`);
199
+ setLaunched(true);
200
+ } else {
201
+ showStatus(`Launch failed: ${data.error || 'unknown'}`);
202
+ }
203
+ } catch (err) {
204
+ showStatus(`Launch failed: ${err.message}`);
205
+ }
206
+ setLaunching(false);
207
+ }
208
+
209
+ if (!team || launched) return null;
210
+
211
+ return (
212
+ <div style={styles.launchBox}>
213
+ <div style={styles.launchHeader}>Recommended Team ({team.length} agents)</div>
214
+ <div style={styles.launchList}>
215
+ {team.map((a, i) => (
216
+ <div key={i} style={styles.launchAgent}>
217
+ <span style={styles.launchRole}>{a.role}</span>
218
+ <span style={styles.launchPrompt}>{(a.prompt || '').slice(0, 80)}{(a.prompt || '').length > 80 ? '...' : ''}</span>
219
+ </div>
220
+ ))}
221
+ </div>
222
+ <button
223
+ type="button"
224
+ onClick={handleLaunch}
225
+ disabled={launching}
226
+ style={{ ...styles.launchBtn, opacity: launching ? 0.5 : 1 }}
227
+ >
228
+ {launching ? 'Launching...' : 'Launch Team'}
229
+ </button>
230
+ </div>
231
+ );
232
+ }
233
+
234
+ // ── FORMATTED TEXT — renders markdown-like agent output cleanly ──
235
+
236
+ function FormattedText({ text }) {
237
+ if (!text) return null;
238
+ const lines = text.split('\n');
239
+
240
+ return lines.map((line, i) => {
241
+ // Headers: ### or ## or #
242
+ if (/^#{1,3}\s/.test(line)) {
243
+ const content = line.replace(/^#{1,3}\s+/, '');
244
+ return <div key={i} style={{ fontWeight: 700, color: 'var(--text-bright)', marginTop: i > 0 ? 6 : 0, marginBottom: 2, fontSize: 11 }}>{renderInline(content)}</div>;
245
+ }
246
+
247
+ // Horizontal rules
248
+ if (/^[-*_]{3,}\s*$/.test(line)) {
249
+ return <div key={i} style={{ borderTop: '1px solid var(--border)', margin: '4px 0' }} />;
250
+ }
251
+
252
+ // List items: - or * or numbered
253
+ if (/^\s*[-*]\s/.test(line)) {
254
+ const indent = line.match(/^(\s*)/)[1].length;
255
+ const content = line.replace(/^\s*[-*]\s+/, '');
256
+ return <div key={i} style={{ paddingLeft: 8 + indent * 6, position: 'relative' }}>
257
+ <span style={{ position: 'absolute', left: indent * 6, color: 'var(--text-dim)' }}>-</span>
258
+ {renderInline(content)}
259
+ </div>;
260
+ }
261
+ if (/^\s*\d+\.\s/.test(line)) {
262
+ const indent = line.match(/^(\s*)/)[1].length;
263
+ const num = line.match(/(\d+)\./)[1];
264
+ const content = line.replace(/^\s*\d+\.\s+/, '');
265
+ return <div key={i} style={{ paddingLeft: 12 + indent * 6, position: 'relative' }}>
266
+ <span style={{ position: 'absolute', left: indent * 6, color: 'var(--text-dim)' }}>{num}.</span>
267
+ {renderInline(content)}
268
+ </div>;
269
+ }
270
+
271
+ // Empty lines
272
+ if (!line.trim()) return <div key={i} style={{ height: 4 }} />;
273
+
274
+ // Normal text
275
+ return <div key={i}>{renderInline(line)}</div>;
276
+ });
277
+ }
278
+
279
+ function renderInline(text) {
280
+ // Split on bold (**text**), code (`text`), and italic (*text*)
281
+ const parts = [];
282
+ let remaining = text;
283
+ let key = 0;
284
+
285
+ while (remaining.length > 0) {
286
+ // Bold: **text**
287
+ const boldMatch = remaining.match(/^(.*?)\*\*(.+?)\*\*(.*)/s);
288
+ if (boldMatch) {
289
+ if (boldMatch[1]) parts.push(<span key={key++}>{boldMatch[1]}</span>);
290
+ parts.push(<span key={key++} style={{ fontWeight: 700, color: 'var(--text-bright)' }}>{boldMatch[2]}</span>);
291
+ remaining = boldMatch[3];
292
+ continue;
293
+ }
294
+
295
+ // Inline code: `text`
296
+ const codeMatch = remaining.match(/^(.*?)`(.+?)`(.*)/s);
297
+ if (codeMatch) {
298
+ if (codeMatch[1]) parts.push(<span key={key++}>{codeMatch[1]}</span>);
299
+ parts.push(<span key={key++} style={{ background: 'var(--bg-base)', padding: '0 3px', borderRadius: 2, color: 'var(--accent)', fontSize: '0.95em' }}>{codeMatch[2]}</span>);
300
+ remaining = codeMatch[3];
301
+ continue;
302
+ }
303
+
304
+ // No more patterns — emit rest as plain text
305
+ parts.push(<span key={key++}>{remaining}</span>);
306
+ break;
307
+ }
308
+
309
+ return parts.length > 0 ? parts : text;
310
+ }
311
+
312
+ function parseActivityText(text) {
313
+ if (!text) return '';
314
+ // Try to parse stream-json entries and extract readable text
315
+ try {
316
+ const data = JSON.parse(text);
317
+ if (Array.isArray(data)) {
318
+ return data
319
+ .map((item) => {
320
+ if (item.type === 'text' && item.text) return item.text;
321
+ if (item.type === 'thinking' && item.thinking) return null; // skip thinking
322
+ if (item.type === 'tool_use') return null; // skip tool calls
323
+ return null;
324
+ })
325
+ .filter(Boolean)
326
+ .join('\n') || null;
327
+ }
328
+ if (data.type === 'text' && data.text) return data.text;
329
+ if (data.type === 'result' && data.result) return data.result;
330
+ return null;
331
+ } catch {
332
+ // Not JSON — return as-is if it's meaningful
333
+ if (text.length > 5) return text;
334
+ return null;
335
+ }
336
+ }
337
+
338
+ function buildTimeline(chats, activity) {
339
+ const items = [];
340
+
341
+ for (const msg of chats) {
342
+ items.push({
343
+ timestamp: msg.timestamp,
344
+ from: msg.from,
345
+ text: msg.text,
346
+ isQuery: msg.isQuery,
347
+ });
348
+ }
349
+
350
+ // Parse and add meaningful activity entries
351
+ for (const a of activity.slice(-30)) {
352
+ const parsed = parseActivityText(a.text);
353
+ if (!parsed) continue;
354
+
355
+ // Skip if we have a chat entry near this time from the agent
356
+ const hasChatNear = items.some((it) =>
357
+ Math.abs(it.timestamp - a.timestamp) < 2000 && it.from === 'agent'
358
+ );
359
+ if (!hasChatNear) {
360
+ items.push({
361
+ timestamp: a.timestamp,
362
+ from: 'agent',
363
+ text: parsed.slice(0, 500),
364
+ });
365
+ }
366
+ }
367
+
368
+ items.sort((a, b) => a.timestamp - b.timestamp);
369
+ return items;
370
+ }
371
+
372
+ const styles = {
373
+ container: {
374
+ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden',
375
+ },
376
+ timeline: {
377
+ flex: 1, overflowY: 'auto', padding: '10px 0',
378
+ },
379
+ hint: {
380
+ color: 'var(--text-dim)', fontSize: 11, padding: '20px 4px',
381
+ textAlign: 'center', lineHeight: 1.6,
382
+ },
383
+ entry: {
384
+ padding: '4px 0', position: 'relative',
385
+ },
386
+ userMsg: {
387
+ display: 'flex', flexDirection: 'column', gap: 2,
388
+ },
389
+ userLabel: {
390
+ fontSize: 10, fontWeight: 600, color: 'var(--accent)',
391
+ textTransform: 'uppercase', letterSpacing: 0.5,
392
+ },
393
+ userText: {
394
+ fontSize: 12, color: 'var(--text-bright)', lineHeight: 1.5,
395
+ padding: '4px 8px', background: 'var(--bg-surface)',
396
+ borderRadius: 2, border: '1px solid var(--border)',
397
+ whiteSpace: 'pre-wrap', wordBreak: 'break-word',
398
+ },
399
+ agentMsg: {
400
+ display: 'flex', flexDirection: 'column', gap: 2,
401
+ },
402
+ agentLabel: {
403
+ fontSize: 10, fontWeight: 600, color: 'var(--green)',
404
+ textTransform: 'uppercase', letterSpacing: 0.5,
405
+ },
406
+ agentText: {
407
+ fontSize: 12, color: 'var(--text-primary)', lineHeight: 1.5,
408
+ padding: '4px 8px', background: 'var(--bg-base)',
409
+ borderRadius: 2, whiteSpace: 'pre-wrap', wordBreak: 'break-word',
410
+ },
411
+ systemMsg: {
412
+ fontSize: 10, color: 'var(--text-dim)', fontStyle: 'italic',
413
+ padding: '2px 0',
414
+ },
415
+ time: {
416
+ position: 'absolute', top: 4, right: 0,
417
+ fontSize: 9, color: 'var(--text-muted)',
418
+ },
419
+ statusMsg: {
420
+ fontSize: 11, color: 'var(--text-dim)', fontStyle: 'italic',
421
+ padding: '6px 0',
422
+ },
423
+ inputRow: {
424
+ display: 'flex', gap: 6, padding: '8px 0 0',
425
+ borderTop: '1px solid var(--border)',
426
+ },
427
+ input: {
428
+ flex: 1, background: 'var(--bg-surface)', border: '1px solid var(--border)',
429
+ borderRadius: 2, padding: '8px 10px',
430
+ color: 'var(--text-primary)', fontSize: 12,
431
+ fontFamily: 'var(--font)', outline: 'none',
432
+ },
433
+ sendBtn: {
434
+ padding: '8px 14px',
435
+ background: 'transparent', border: '1px solid var(--accent)',
436
+ borderRadius: 2,
437
+ color: 'var(--accent)', fontSize: 11, fontWeight: 600,
438
+ fontFamily: 'var(--font)', cursor: 'pointer',
439
+ },
440
+
441
+ // Streaming cursor
442
+ cursor: {
443
+ color: 'var(--accent)', fontWeight: 400, animation: 'pulse 1s infinite',
444
+ marginLeft: 1,
445
+ },
446
+
447
+ // Launch team
448
+ launchBox: {
449
+ padding: '8px 0',
450
+ borderTop: '1px solid var(--border)',
451
+ flexShrink: 0,
452
+ },
453
+ launchHeader: {
454
+ fontSize: 10, fontWeight: 700, color: 'var(--text-bright)',
455
+ marginBottom: 6,
456
+ },
457
+ launchList: {
458
+ display: 'flex', flexDirection: 'column', gap: 3, marginBottom: 8,
459
+ },
460
+ launchAgent: {
461
+ display: 'flex', alignItems: 'baseline', gap: 6,
462
+ fontSize: 10, padding: '2px 0',
463
+ },
464
+ launchRole: {
465
+ fontWeight: 600, color: 'var(--accent)', minWidth: 60,
466
+ },
467
+ launchPrompt: {
468
+ color: 'var(--text-dim)', fontSize: 9,
469
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
470
+ flex: 1,
471
+ },
472
+ launchBtn: {
473
+ width: '100%', padding: '8px',
474
+ background: 'rgba(51, 175, 188, 0.1)', border: '1px solid var(--accent)',
475
+ color: 'var(--accent)', fontSize: 11, fontWeight: 700,
476
+ fontFamily: 'var(--font)', cursor: 'pointer',
477
+ letterSpacing: 0.5,
478
+ },
479
+ };
@@ -0,0 +1,117 @@
1
+ // GROOVE GUI — Agent Node Component (Unity/n8n inspired)
2
+ // FSL-1.1-Apache-2.0 — see LICENSE
3
+
4
+ import React from 'react';
5
+ import { Handle, Position } from '@xyflow/react';
6
+
7
+ const STATUS = {
8
+ running: { color: '#4ae168', label: 'LIVE' },
9
+ starting: { color: '#e5c07b', label: 'INIT' },
10
+ stopped: { color: '#5c6370', label: 'STOP' },
11
+ crashed: { color: '#e06c75', label: 'FAIL' },
12
+ completed: { color: '#33afbc', label: 'DONE' },
13
+ killed: { color: '#5c6370', label: 'KILL' },
14
+ };
15
+
16
+ const ROLE_COLORS = {
17
+ planner: '#c678dd',
18
+ backend: '#33afbc',
19
+ frontend: '#e5c07b',
20
+ fullstack: '#4ae168',
21
+ testing: '#61afef',
22
+ devops: '#d19a66',
23
+ docs: '#5c6370',
24
+ };
25
+
26
+ export default function AgentNode({ data }) {
27
+ const st = STATUS[data.status] || STATUS.stopped;
28
+ const alive = data.status === 'running' || data.status === 'starting';
29
+ const sel = data.selected;
30
+ const roleColor = ROLE_COLORS[data.role] || '#33afbc';
31
+ const ctx = Math.round((data.contextUsage || 0) * 100);
32
+
33
+ const tokens = data.tokensUsed > 0
34
+ ? data.tokensUsed > 999 ? `${(data.tokensUsed / 1000).toFixed(1)}k` : `${data.tokensUsed}`
35
+ : '0';
36
+
37
+ return (
38
+ <div style={{
39
+ background: '#282c34',
40
+ border: sel ? '1px solid #33afbc' : '1px solid #3e4451',
41
+ borderRadius: 8,
42
+ width: 170,
43
+ cursor: 'pointer',
44
+ fontFamily: "'JetBrains Mono', 'SF Mono', 'Fira Code', Consolas, monospace",
45
+ fontSize: 10,
46
+ overflow: 'hidden',
47
+ transition: 'border-color 0.2s',
48
+ }}>
49
+ {/* Target handle — circular port */}
50
+ <Handle type="target" position={Position.Top} style={{
51
+ background: '#282c34', border: `2px solid ${sel ? '#33afbc' : '#3e4451'}`,
52
+ width: 8, height: 8, borderRadius: '50%', top: -4,
53
+ }} />
54
+
55
+ {/* Header */}
56
+ <div style={{
57
+ padding: '8px 10px 6px',
58
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
59
+ }}>
60
+ <span style={{
61
+ color: '#e6e6e6', fontWeight: 700, fontSize: 11,
62
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1,
63
+ }}>
64
+ {data.name}
65
+ </span>
66
+ {/* Status dot */}
67
+ <div style={{
68
+ width: 6, height: 6, borderRadius: '50%', background: st.color, flexShrink: 0,
69
+ ...(alive ? { animation: 'pulse 2s infinite' } : {}),
70
+ }} />
71
+ </div>
72
+
73
+ {/* Role badge */}
74
+ <div style={{ padding: '0 10px 6px' }}>
75
+ <span style={{
76
+ fontSize: 8, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1,
77
+ color: roleColor, background: roleColor + '18', padding: '2px 6px', borderRadius: 3,
78
+ }}>
79
+ {data.role}
80
+ </span>
81
+ </div>
82
+
83
+ {/* Metrics — minimal */}
84
+ <div style={{
85
+ padding: '4px 10px 6px',
86
+ borderTop: '1px solid #2c313a',
87
+ display: 'flex', justifyContent: 'space-between', alignItems: 'center',
88
+ }}>
89
+ <span style={{ color: '#8b929e', fontSize: 9 }}>
90
+ {tokens} <span style={{ color: '#5c6370' }}>tok</span>
91
+ </span>
92
+ <span style={{ color: '#8b929e', fontSize: 9 }}>
93
+ {ctx}% <span style={{ color: '#5c6370' }}>ctx</span>
94
+ </span>
95
+ </div>
96
+
97
+ {/* Activity bar for live agents */}
98
+ {alive && (
99
+ <div style={{
100
+ height: 2, background: '#1a1e25', overflow: 'hidden',
101
+ }}>
102
+ <div style={{
103
+ width: '200%', height: '100%',
104
+ background: `linear-gradient(90deg, transparent 25%, ${st.color}44 35%, ${st.color} 50%, ${st.color}44 65%, transparent 75%)`,
105
+ animation: 'neuralFlow 2s linear infinite',
106
+ }} />
107
+ </div>
108
+ )}
109
+
110
+ {/* Source handle — circular port */}
111
+ <Handle type="source" position={Position.Bottom} style={{
112
+ background: '#282c34', border: `2px solid ${sel ? '#33afbc' : '#3e4451'}`,
113
+ width: 8, height: 8, borderRadius: '50%', bottom: -4,
114
+ }} />
115
+ </div>
116
+ );
117
+ }