rufloui 0.3.1

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 (56) hide show
  1. package/'1' +0 -0
  2. package/.env.example +46 -0
  3. package/CHANGELOG.md +87 -0
  4. package/CLAUDE.md +287 -0
  5. package/LICENSE +21 -0
  6. package/README.md +316 -0
  7. package/Webhooks) +0 -0
  8. package/docs/plans/2026-03-11-github-webhooks.md +957 -0
  9. package/docs/screenshot-swarm-monitor.png +0 -0
  10. package/frontend +0 -0
  11. package/index.html +13 -0
  12. package/package.json +56 -0
  13. package/public/vite.svg +4 -0
  14. package/src/backend/__tests__/webhook-github.test.ts +934 -0
  15. package/src/backend/jsonl-monitor.ts +430 -0
  16. package/src/backend/server.ts +2972 -0
  17. package/src/backend/telegram-bot.ts +511 -0
  18. package/src/backend/webhook-github.ts +350 -0
  19. package/src/frontend/App.tsx +461 -0
  20. package/src/frontend/api.ts +281 -0
  21. package/src/frontend/components/ErrorBoundary.tsx +98 -0
  22. package/src/frontend/components/Layout.tsx +431 -0
  23. package/src/frontend/components/ui/Button.tsx +111 -0
  24. package/src/frontend/components/ui/Card.tsx +51 -0
  25. package/src/frontend/components/ui/StatusBadge.tsx +60 -0
  26. package/src/frontend/main.tsx +63 -0
  27. package/src/frontend/pages/AgentVizPanel.tsx +428 -0
  28. package/src/frontend/pages/AgentsPanel.tsx +445 -0
  29. package/src/frontend/pages/ConfigPanel.tsx +661 -0
  30. package/src/frontend/pages/Dashboard.tsx +482 -0
  31. package/src/frontend/pages/HiveMindPanel.tsx +355 -0
  32. package/src/frontend/pages/HooksPanel.tsx +240 -0
  33. package/src/frontend/pages/LogsPanel.tsx +261 -0
  34. package/src/frontend/pages/MemoryPanel.tsx +444 -0
  35. package/src/frontend/pages/NeuralPanel.tsx +301 -0
  36. package/src/frontend/pages/PerformancePanel.tsx +198 -0
  37. package/src/frontend/pages/SessionsPanel.tsx +428 -0
  38. package/src/frontend/pages/SetupWizard.tsx +181 -0
  39. package/src/frontend/pages/SwarmMonitorPanel.tsx +634 -0
  40. package/src/frontend/pages/SwarmPanel.tsx +322 -0
  41. package/src/frontend/pages/TasksPanel.tsx +535 -0
  42. package/src/frontend/pages/WebhooksPanel.tsx +335 -0
  43. package/src/frontend/pages/WorkflowsPanel.tsx +448 -0
  44. package/src/frontend/store.ts +185 -0
  45. package/src/frontend/styles/global.css +113 -0
  46. package/src/frontend/test-setup.ts +1 -0
  47. package/src/frontend/tour/TourContext.tsx +161 -0
  48. package/src/frontend/tour/tourSteps.ts +181 -0
  49. package/src/frontend/tour/tourStyles.css +116 -0
  50. package/src/frontend/types.ts +239 -0
  51. package/src/frontend/utils/formatTime.test.ts +83 -0
  52. package/src/frontend/utils/formatTime.ts +23 -0
  53. package/tsconfig.json +23 -0
  54. package/vite.config.ts +26 -0
  55. package/vitest.config.ts +17 -0
  56. package/{,+ +0 -0
@@ -0,0 +1,634 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { api } from '@/api'
4
+ import { useStore } from '@/store'
5
+ import type { SwarmMonitorState, SwarmAgent } from '@/types'
6
+
7
+ interface AgentActivityEvent {
8
+ agentId: string
9
+ status: 'idle' | 'working' | 'error'
10
+ currentTask?: string
11
+ currentAction?: string
12
+ lastUpdate: string
13
+ tasksCompleted: number
14
+ errors: number
15
+ }
16
+
17
+ const STATUS_COLORS: Record<string, string> = {
18
+ active: 'var(--accent-green)',
19
+ working: '#f59e0b',
20
+ healthy: 'var(--accent-green)',
21
+ idle: 'var(--accent-blue)',
22
+ error: 'var(--accent-red)',
23
+ ready: 'var(--accent-cyan)',
24
+ shutdown: 'var(--text-muted)',
25
+ inactive: 'var(--text-muted)',
26
+ }
27
+
28
+ const STATUS_BG_COLORS: Record<string, string> = {
29
+ active: 'rgba(34, 197, 94, 0.12)',
30
+ working: 'rgba(245, 158, 11, 0.15)',
31
+ healthy: 'rgba(34, 197, 94, 0.10)',
32
+ idle: 'rgba(59, 130, 246, 0.08)',
33
+ error: 'rgba(239, 68, 68, 0.15)',
34
+ ready: 'rgba(6, 182, 212, 0.10)',
35
+ shutdown: 'rgba(100, 116, 139, 0.08)',
36
+ inactive: 'rgba(100, 116, 139, 0.08)',
37
+ }
38
+
39
+ const STATUS_BORDER_COLORS: Record<string, string> = {
40
+ active: 'rgba(34, 197, 94, 0.4)',
41
+ working: 'rgba(245, 158, 11, 0.5)',
42
+ healthy: 'rgba(34, 197, 94, 0.3)',
43
+ idle: 'rgba(59, 130, 246, 0.2)',
44
+ error: 'rgba(239, 68, 68, 0.5)',
45
+ ready: 'rgba(6, 182, 212, 0.3)',
46
+ shutdown: 'var(--border)',
47
+ inactive: 'var(--border)',
48
+ }
49
+
50
+ const TYPE_COLORS: Record<string, string> = {
51
+ coordinator: '#a78bfa',
52
+ coder: '#34d399',
53
+ researcher: '#60a5fa',
54
+ tester: '#fbbf24',
55
+ reviewer: '#f87171',
56
+ architect: '#f472b6',
57
+ analyst: '#38bdf8',
58
+ optimizer: '#c084fc',
59
+ }
60
+
61
+ // ── Agent Output Modal ──────────────────────────────────────────────
62
+ function AgentOutputModal({ agent, onClose }: { agent: SwarmAgent; onClose: () => void }) {
63
+ const [lines, setLines] = useState<string[]>([])
64
+ const [loading, setLoading] = useState(true)
65
+ const scrollRef = useRef<HTMLDivElement>(null)
66
+ const autoScrollRef = useRef(true)
67
+
68
+ // Initial load
69
+ useEffect(() => {
70
+ api.swarmMonitor.agentOutput(agent.id).then(res => {
71
+ setLines(res.lines || [])
72
+ setLoading(false)
73
+ }).catch(() => setLoading(false))
74
+ }, [agent.id])
75
+
76
+ // Listen for real-time output via WS
77
+ useEffect(() => {
78
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
79
+ const ws = new WebSocket(`${protocol}//${window.location.host}/ws`)
80
+ ws.onmessage = (event) => {
81
+ try {
82
+ const msg = JSON.parse(event.data) as { type: string; payload: { agentId: string; line: string } }
83
+ if (msg.type === 'agent:output' && msg.payload.agentId === agent.id) {
84
+ setLines(prev => [...prev, msg.payload.line])
85
+ }
86
+ } catch { /* ignore */ }
87
+ }
88
+ return () => ws.close()
89
+ }, [agent.id])
90
+
91
+ // Auto-scroll to bottom
92
+ useEffect(() => {
93
+ if (autoScrollRef.current && scrollRef.current) {
94
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight
95
+ }
96
+ }, [lines])
97
+
98
+ const handleScroll = () => {
99
+ if (!scrollRef.current) return
100
+ const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
101
+ autoScrollRef.current = scrollHeight - scrollTop - clientHeight < 40
102
+ }
103
+
104
+ const typeColor = TYPE_COLORS[agent.type] || 'var(--accent-cyan)'
105
+ const statusColor = STATUS_COLORS[agent.status] || 'var(--text-muted)'
106
+
107
+ function formatLine(line: string, idx: number) {
108
+ let color = 'var(--text-secondary)'
109
+ let prefix = ''
110
+ if (line.startsWith('[Tool]')) { color = 'var(--accent-cyan)'; prefix = 'tool' }
111
+ else if (line.startsWith('[Result]')) { color = 'var(--text-muted)'; prefix = 'result' }
112
+ else if (line.startsWith('[stderr]')) { color = 'var(--accent-red)'; prefix = 'err' }
113
+ else if (line.startsWith('[Done]')) { color = 'var(--accent-green)'; prefix = 'done' }
114
+
115
+ return (
116
+ <div key={idx} style={{ display: 'flex', gap: 8, padding: '3px 0', borderBottom: '1px solid rgba(255,255,255,0.03)' }}>
117
+ <span style={{ color: 'var(--text-muted)', fontSize: 10, width: 30, textAlign: 'right', flexShrink: 0, fontFamily: 'monospace' }}>
118
+ {idx + 1}
119
+ </span>
120
+ {prefix && (
121
+ <span style={{
122
+ color, fontSize: 10, fontWeight: 600, textTransform: 'uppercase',
123
+ width: 44, flexShrink: 0,
124
+ }}>
125
+ {prefix}
126
+ </span>
127
+ )}
128
+ <span style={{ color, wordBreak: 'break-word', whiteSpace: 'pre-wrap' }}>
129
+ {prefix ? line.slice(line.indexOf(']') + 2) : line}
130
+ </span>
131
+ </div>
132
+ )
133
+ }
134
+
135
+ return createPortal(
136
+ <div
137
+ onClick={onClose}
138
+ style={{
139
+ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 1000,
140
+ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24,
141
+ }}
142
+ >
143
+ <div
144
+ onClick={e => e.stopPropagation()}
145
+ style={{
146
+ width: '85vw', maxWidth: 960, height: '80vh',
147
+ background: 'var(--bg-primary)', border: '1px solid var(--border)',
148
+ borderRadius: 12, display: 'flex', flexDirection: 'column', overflow: 'hidden',
149
+ boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
150
+ }}
151
+ >
152
+ {/* Modal header */}
153
+ <div style={{
154
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between',
155
+ padding: '14px 20px', borderBottom: '1px solid var(--border)', background: 'var(--bg-secondary)',
156
+ }}>
157
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
158
+ <div style={{
159
+ width: 12, height: 12, borderRadius: '50%', background: statusColor,
160
+ boxShadow: agent.status === 'working' ? `0 0 8px ${statusColor}` : 'none',
161
+ animation: agent.status === 'working' ? 'pulse-glow 2s ease-in-out infinite' : 'none',
162
+ }} />
163
+ <span style={{
164
+ fontSize: 12, fontWeight: 600, padding: '2px 10px', borderRadius: 10,
165
+ background: `${typeColor}22`, color: typeColor, textTransform: 'uppercase',
166
+ }}>
167
+ {agent.type}
168
+ </span>
169
+ <span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>Agent Output</span>
170
+ <span style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: 'monospace' }}>{agent.id}</span>
171
+ </div>
172
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
173
+ {(agent.status === 'working' || agent.status === 'active') && (
174
+ <span style={{
175
+ fontSize: 11, padding: '3px 10px', borderRadius: 10,
176
+ background: `${statusColor}22`, color: statusColor, fontWeight: 600,
177
+ animation: 'pulse-glow 2s ease-in-out infinite',
178
+ }}>
179
+ LIVE
180
+ </span>
181
+ )}
182
+ <button
183
+ onClick={onClose}
184
+ style={{
185
+ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 20,
186
+ cursor: 'pointer', padding: '0 4px', lineHeight: 1,
187
+ }}
188
+ >
189
+ x
190
+ </button>
191
+ </div>
192
+ </div>
193
+
194
+ {/* Current action bar */}
195
+ {agent.currentAction && (
196
+ <div style={{
197
+ padding: '8px 20px', background: 'rgba(245, 158, 11, 0.08)',
198
+ borderBottom: '1px solid var(--border)', fontSize: 12,
199
+ display: 'flex', alignItems: 'center', gap: 8,
200
+ }}>
201
+ <span style={{ color: '#f59e0b', fontWeight: 600 }}>Current:</span>
202
+ <span style={{ color: 'var(--text-secondary)', fontFamily: 'monospace' }}>{agent.currentAction}</span>
203
+ </div>
204
+ )}
205
+
206
+ {/* Output body */}
207
+ <div
208
+ ref={scrollRef}
209
+ onScroll={handleScroll}
210
+ style={{
211
+ flex: 1, overflow: 'auto', padding: '12px 20px',
212
+ fontFamily: 'monospace', fontSize: 12, lineHeight: 1.6,
213
+ }}
214
+ >
215
+ {loading ? (
216
+ <div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>Loading output...</div>
217
+ ) : lines.length === 0 ? (
218
+ <div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: 40 }}>
219
+ No output yet. Output will appear here when the agent starts working on a task.
220
+ </div>
221
+ ) : (
222
+ lines.map((line, i) => formatLine(line, i))
223
+ )}
224
+ </div>
225
+
226
+ {/* Footer stats */}
227
+ <div style={{
228
+ display: 'flex', gap: 16, padding: '8px 20px', borderTop: '1px solid var(--border)',
229
+ background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)',
230
+ }}>
231
+ <span>Lines: {lines.length}</span>
232
+ <span>Tasks completed: {agent.tasks?.completed ?? 0}</span>
233
+ <span>Errors: {agent.tasks?.failed ?? agent.errors?.count ?? 0}</span>
234
+ <span style={{ marginLeft: 'auto' }}>Click outside or press X to close</span>
235
+ </div>
236
+ </div>
237
+ </div>,
238
+ document.body,
239
+ )
240
+ }
241
+
242
+ // ── Agent Card ──────────────────────────────────────────────────────
243
+ function AgentCard({ agent, selected, onClick, onViewOutput }: {
244
+ agent: SwarmAgent; selected: boolean; onClick: () => void; onViewOutput: () => void
245
+ }) {
246
+ const color = STATUS_COLORS[agent.status] || 'var(--text-muted)'
247
+ const typeColor = TYPE_COLORS[agent.type] || 'var(--accent-cyan)'
248
+ const memPct = agent.memory ? Math.round((agent.memory.used / agent.memory.limit) * 100) : 0
249
+ const isWorking = agent.status === 'working'
250
+
251
+ return (
252
+ <div
253
+ onClick={onClick}
254
+ className={isWorking ? 'agent-working-glow' : undefined}
255
+ style={{
256
+ padding: 14,
257
+ background: selected ? 'var(--bg-hover)' : (STATUS_BG_COLORS[agent.status] || 'var(--bg-secondary)'),
258
+ border: `1px solid ${selected ? 'var(--accent-blue)' : (STATUS_BORDER_COLORS[agent.status] || 'var(--border)')}`,
259
+ borderRadius: 'var(--radius)',
260
+ cursor: 'pointer',
261
+ transition: 'all var(--transition)',
262
+ minWidth: 220,
263
+ ...(isWorking ? { boxShadow: '0 0 12px rgba(245, 158, 11, 0.25)' } : {}),
264
+ }}
265
+ >
266
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
267
+ <div style={{
268
+ width: 10, height: 10, borderRadius: '50%', background: color, flexShrink: 0,
269
+ boxShadow: agent.status === 'active' || agent.status === 'working' ? `0 0 8px ${color}` : 'none',
270
+ animation: agent.status === 'active' || agent.status === 'working' ? 'pulse-glow 2s ease-in-out infinite' : 'none',
271
+ }} />
272
+ <span style={{
273
+ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10,
274
+ background: `${typeColor}22`, color: typeColor, textTransform: 'uppercase',
275
+ }}>
276
+ {agent.type}
277
+ </span>
278
+ <span style={{ fontSize: 11, color: 'var(--text-muted)', marginLeft: 'auto' }}>
279
+ {agent.status}
280
+ </span>
281
+ </div>
282
+ {/* Current action */}
283
+ {agent.currentAction && (
284
+ <div style={{
285
+ fontSize: 11, color: '#f59e0b', fontFamily: 'monospace', marginBottom: 6,
286
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
287
+ }}>
288
+ {agent.currentAction}
289
+ </div>
290
+ )}
291
+ <div style={{ fontSize: 12, color: 'var(--text-secondary)', fontFamily: 'monospace', marginBottom: 6, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
292
+ {agent.id}
293
+ </div>
294
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
295
+ <div style={{ display: 'flex', gap: 12, fontSize: 11, color: 'var(--text-muted)' }}>
296
+ <span>Tasks: {agent.taskCount}</span>
297
+ <span>CPU: {agent.cpu ?? 0}%</span>
298
+ <span>Mem: {memPct}%</span>
299
+ </div>
300
+ <button
301
+ onClick={e => { e.stopPropagation(); onViewOutput() }}
302
+ style={{
303
+ fontSize: 10, padding: '2px 8px', background: 'rgba(255,255,255,0.08)',
304
+ border: '1px solid var(--border)', borderRadius: 4, color: 'var(--accent-cyan)',
305
+ cursor: 'pointer',
306
+ }}
307
+ >
308
+ Output
309
+ </button>
310
+ </div>
311
+ {/* Memory bar */}
312
+ <div style={{ marginTop: 6, height: 3, background: 'var(--bg-primary)', borderRadius: 2, overflow: 'hidden' }}>
313
+ <div style={{ height: '100%', width: `${memPct}%`, background: memPct > 80 ? 'var(--accent-red)' : 'var(--accent-blue)', transition: 'width 0.3s' }} />
314
+ </div>
315
+ </div>
316
+ )
317
+ }
318
+
319
+ function AgentDetail({ agent, onViewOutput }: { agent: SwarmAgent; onViewOutput: () => void }) {
320
+ const typeColor = TYPE_COLORS[agent.type] || 'var(--accent-cyan)'
321
+ const upHours = agent.uptime ? Math.floor(agent.uptime / 3600000) : 0
322
+ const upMins = agent.uptime ? Math.floor((agent.uptime % 3600000) / 60000) : 0
323
+
324
+ return (
325
+ <div style={{ padding: 16, background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
326
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 12 }}>
327
+ <div style={{
328
+ width: 14, height: 14, borderRadius: '50%',
329
+ background: STATUS_COLORS[agent.status] || 'var(--text-muted)',
330
+ }} />
331
+ <span style={{ fontSize: 16, fontWeight: 600, color: typeColor }}>{agent.type}</span>
332
+ <span style={{ fontSize: 13, color: 'var(--text-muted)' }}>({agent.status})</span>
333
+ </div>
334
+ <div style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--text-secondary)', marginBottom: 12 }}>{agent.id}</div>
335
+
336
+ {agent.currentAction && (
337
+ <div style={{
338
+ padding: '8px 10px', marginBottom: 12, borderRadius: 'var(--radius)',
339
+ background: 'rgba(245, 158, 11, 0.1)', border: '1px solid rgba(245, 158, 11, 0.3)',
340
+ fontSize: 12, fontFamily: 'monospace', color: '#f59e0b', wordBreak: 'break-word',
341
+ }}>
342
+ {agent.currentAction}
343
+ </div>
344
+ )}
345
+
346
+ <button
347
+ onClick={onViewOutput}
348
+ style={{
349
+ width: '100%', padding: '8px 0', marginBottom: 12, fontSize: 13, fontWeight: 600,
350
+ background: 'var(--accent-blue)', color: '#fff', border: 'none',
351
+ borderRadius: 'var(--radius)', cursor: 'pointer',
352
+ }}
353
+ >
354
+ View Live Output
355
+ </button>
356
+
357
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
358
+ <StatBox label="Uptime" value={`${upHours}h ${upMins}m`} />
359
+ <StatBox label="Health" value={`${Math.round((agent.health ?? 1) * 100)}%`} color={agent.health >= 0.8 ? 'var(--accent-green)' : 'var(--accent-red)'} />
360
+ <StatBox label="CPU" value={`${agent.cpu ?? 0}%`} />
361
+ <StatBox label="Memory" value={`${agent.memory?.used ?? 0}/${agent.memory?.limit ?? 512} MB`} />
362
+ <StatBox label="Avg Latency" value={`${agent.latency?.avg ?? 0}ms`} />
363
+ <StatBox label="P99 Latency" value={`${agent.latency?.p99 ?? 0}ms`} />
364
+ <StatBox label="Tasks Active" value={String(agent.tasks?.active ?? 0)} />
365
+ <StatBox label="Tasks Completed" value={String(agent.tasks?.completed ?? 0)} />
366
+ <StatBox label="Tasks Failed" value={String(agent.tasks?.failed ?? 0)} color={agent.tasks?.failed ? 'var(--accent-red)' : undefined} />
367
+ <StatBox label="Errors" value={String(agent.errors?.count ?? 0)} color={agent.errors?.count ? 'var(--accent-red)' : undefined} />
368
+ </div>
369
+
370
+ <div style={{ marginTop: 12, fontSize: 11, color: 'var(--text-muted)' }}>
371
+ Created: {new Date(agent.createdAt).toLocaleString()}
372
+ </div>
373
+ </div>
374
+ )
375
+ }
376
+
377
+ function StatBox({ label, value, color }: { label: string; value: string; color?: string }) {
378
+ return (
379
+ <div style={{ padding: 8, background: 'var(--bg-primary)', borderRadius: 'var(--radius)' }}>
380
+ <div style={{ fontSize: 10, color: 'var(--text-muted)', marginBottom: 2 }}>{label}</div>
381
+ <div style={{ fontSize: 14, fontWeight: 600, color: color || 'var(--text-primary)', fontFamily: 'monospace' }}>{value}</div>
382
+ </div>
383
+ )
384
+ }
385
+
386
+ function ProgressBar({ value, label }: { value: number; label: string }) {
387
+ return (
388
+ <div>
389
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4, fontSize: 12 }}>
390
+ <span style={{ color: 'var(--text-secondary)' }}>{label}</span>
391
+ <span style={{ color: 'var(--accent-cyan)', fontFamily: 'monospace' }}>{value}%</span>
392
+ </div>
393
+ <div style={{ height: 6, background: 'var(--bg-primary)', borderRadius: 3, overflow: 'hidden' }}>
394
+ <div style={{
395
+ height: '100%', borderRadius: 3, transition: 'width 0.5s',
396
+ width: `${value}%`,
397
+ background: value >= 80 ? 'var(--accent-green)' : value >= 40 ? 'var(--accent-blue)' : 'var(--accent-yellow)',
398
+ }} />
399
+ </div>
400
+ </div>
401
+ )
402
+ }
403
+
404
+ export default function SwarmMonitorPanel() {
405
+ const [data, setData] = useState<SwarmMonitorState | null>(null)
406
+ const [selectedAgent, setSelectedAgent] = useState<string | null>(null)
407
+ const [outputAgent, setOutputAgent] = useState<SwarmAgent | null>(null)
408
+ const [loading, setLoading] = useState(true)
409
+ const [autoRefresh, setAutoRefresh] = useState(true)
410
+ const [currentOnly, setCurrentOnly] = useState(true)
411
+ const [purging, setPurging] = useState(false)
412
+ const setSwarmMonitor = useStore(s => s.setSwarmMonitor)
413
+
414
+ const fetchData = useCallback(async () => {
415
+ try {
416
+ const snapshot = await api.swarmMonitor.snapshot(currentOnly) as SwarmMonitorState
417
+ setData(snapshot)
418
+ setSwarmMonitor(snapshot)
419
+ } catch {
420
+ /* ignore fetch errors during polling */
421
+ } finally {
422
+ setLoading(false)
423
+ }
424
+ }, [setSwarmMonitor, currentOnly])
425
+
426
+ const handlePurge = async () => {
427
+ setPurging(true)
428
+ try {
429
+ await api.swarmMonitor.purge()
430
+ await fetchData()
431
+ } catch { /* ignore */ }
432
+ setPurging(false)
433
+ }
434
+
435
+ // Listen for real-time agent:activity WebSocket events
436
+ useEffect(() => {
437
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
438
+ const ws = new WebSocket(`${protocol}//${window.location.host}/ws`)
439
+
440
+ ws.onmessage = (event) => {
441
+ try {
442
+ const msg = JSON.parse(event.data) as { type: string; payload: unknown }
443
+ if (msg.type === 'agent:activity') {
444
+ const activity = msg.payload as AgentActivityEvent
445
+ setData(prev => {
446
+ if (!prev) return prev
447
+ const updated = prev.agents.map(agent => {
448
+ if (agent.id !== activity.agentId) return agent
449
+ return {
450
+ ...agent,
451
+ status: activity.status as SwarmAgent['status'],
452
+ currentTask: activity.currentTask,
453
+ currentAction: activity.currentAction,
454
+ tasks: {
455
+ ...(agent.tasks || { active: 0, queued: 0, completed: 0, failed: 0 }),
456
+ completed: activity.tasksCompleted,
457
+ failed: activity.errors,
458
+ },
459
+ }
460
+ })
461
+ return { ...prev, agents: updated }
462
+ })
463
+ // Update output modal agent if it's the same
464
+ setOutputAgent(prev => {
465
+ if (!prev || prev.id !== activity.agentId) return prev
466
+ return {
467
+ ...prev,
468
+ status: activity.status as SwarmAgent['status'],
469
+ currentAction: activity.currentAction,
470
+ currentTask: activity.currentTask,
471
+ }
472
+ })
473
+ }
474
+ } catch { /* ignore bad messages */ }
475
+ }
476
+
477
+ return () => ws.close()
478
+ }, [])
479
+
480
+ useEffect(() => { fetchData() }, [fetchData])
481
+
482
+ useEffect(() => {
483
+ if (!autoRefresh) return
484
+ const interval = setInterval(fetchData, 10000)
485
+ return () => clearInterval(interval)
486
+ }, [autoRefresh, fetchData])
487
+
488
+ const selected = data?.agents.find(a => a.id === selectedAgent) || null
489
+
490
+ if (loading) return (
491
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '60vh', gap: 16 }}>
492
+ <div style={{
493
+ width: 40, height: 40, border: '3px solid var(--border)', borderTopColor: 'var(--accent-blue)',
494
+ borderRadius: '50%', animation: 'spin 0.8s linear infinite',
495
+ }} />
496
+ <div style={{ fontSize: 15, color: 'var(--text-secondary)', fontWeight: 500 }}>Loading swarm data...</div>
497
+ </div>
498
+ )
499
+
500
+ const noSwarm = !data || data.status === 'inactive' || data.status === 'shutdown'
501
+
502
+ return (
503
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 16, height: '100%' }}>
504
+ {/* Output modal */}
505
+ {outputAgent && <AgentOutputModal agent={outputAgent} onClose={() => setOutputAgent(null)} />}
506
+
507
+ {/* Header */}
508
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
509
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
510
+ <h2 style={{ margin: 0, fontSize: 20, color: 'var(--text-primary)' }}>Swarm Monitor</h2>
511
+ {data && (
512
+ <span style={{
513
+ fontSize: 11, fontWeight: 600, padding: '3px 10px', borderRadius: 10,
514
+ background: `${STATUS_COLORS[data.status] || 'var(--text-muted)'}22`,
515
+ color: STATUS_COLORS[data.status] || 'var(--text-muted)',
516
+ textTransform: 'uppercase',
517
+ }}>
518
+ {data.status}
519
+ </span>
520
+ )}
521
+ </div>
522
+ <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
523
+ <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-secondary)', cursor: 'pointer' }}>
524
+ <input type="checkbox" checked={currentOnly} onChange={() => setCurrentOnly(!currentOnly)} />
525
+ Current swarm only
526
+ </label>
527
+ <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-secondary)', cursor: 'pointer' }}>
528
+ <input type="checkbox" checked={autoRefresh} onChange={() => setAutoRefresh(!autoRefresh)} />
529
+ Auto-refresh
530
+ </label>
531
+ <button
532
+ onClick={handlePurge}
533
+ disabled={purging}
534
+ style={{
535
+ padding: '6px 14px', fontSize: 12, background: 'var(--accent-red)', color: '#fff',
536
+ border: 'none', borderRadius: 'var(--radius)', cursor: purging ? 'wait' : 'pointer',
537
+ opacity: purging ? 0.6 : 1,
538
+ }}
539
+ >
540
+ {purging ? 'Purging...' : 'Purge All Agents'}
541
+ </button>
542
+ <button
543
+ onClick={fetchData}
544
+ style={{
545
+ padding: '6px 14px', fontSize: 12, background: 'var(--accent-blue)', color: '#fff',
546
+ border: 'none', borderRadius: 'var(--radius)', cursor: 'pointer',
547
+ }}
548
+ >
549
+ Refresh
550
+ </button>
551
+ </div>
552
+ </div>
553
+
554
+ {noSwarm ? (
555
+ <div style={{
556
+ padding: 40, textAlign: 'center', background: 'var(--bg-secondary)',
557
+ border: '1px solid var(--border)', borderRadius: 'var(--radius)',
558
+ }}>
559
+ <div style={{ fontSize: 16, color: 'var(--text-secondary)', marginBottom: 8 }}>No Active Swarm</div>
560
+ <div style={{ fontSize: 13, color: 'var(--text-muted)' }}>
561
+ Initialize a swarm from the Swarm panel to see live agent monitoring here.
562
+ </div>
563
+ </div>
564
+ ) : (
565
+ <>
566
+ {/* Stats row */}
567
+ <div data-tour="monitor-status" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12 }}>
568
+ <div style={{ padding: 14, background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
569
+ <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Topology</div>
570
+ <div style={{ fontSize: 15, fontWeight: 600, color: 'var(--accent-cyan)' }}>{data?.topology}</div>
571
+ </div>
572
+ <div style={{ padding: 14, background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
573
+ <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Strategy</div>
574
+ <div style={{ fontSize: 15, fontWeight: 600, color: 'var(--accent-purple, #a78bfa)' }}>{data?.strategy}</div>
575
+ </div>
576
+ <div style={{ padding: 14, background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
577
+ <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Agents</div>
578
+ <div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>
579
+ {(data?.agentSummary.total || data?.agents.length) ?? 0}
580
+ <span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 400, marginLeft: 6 }}>
581
+ ({data?.agents.filter(a => a.status === 'active' || a.status === 'working').length || 0} active)
582
+ </span>
583
+ </div>
584
+ </div>
585
+ <div style={{ padding: 14, background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
586
+ <div style={{ fontSize: 11, color: 'var(--text-muted)', marginBottom: 4 }}>Consensus</div>
587
+ <div style={{ fontSize: 15, fontWeight: 600, color: 'var(--accent-yellow)' }}>{data?.coordination.consensusRounds ?? 0} rounds</div>
588
+ </div>
589
+ </div>
590
+
591
+ {/* Progress */}
592
+ {data && data.progress > 0 && (
593
+ <div style={{ padding: 14, background: 'var(--bg-secondary)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
594
+ <ProgressBar value={data.progress} label={data.objective || 'Swarm Progress'} />
595
+ </div>
596
+ )}
597
+
598
+ {/* Main content: agents grid + detail */}
599
+ <div style={{ display: 'flex', gap: 16, flex: 1, minHeight: 0 }}>
600
+ {/* Agent grid */}
601
+ <div style={{ flex: 1, overflow: 'auto' }}>
602
+ <div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
603
+ Agents ({data?.agents.length ?? 0})
604
+ </div>
605
+ <div data-tour="monitor-agents" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 10 }}>
606
+ {data?.agents.map(agent => (
607
+ <AgentCard
608
+ key={agent.id}
609
+ agent={agent}
610
+ selected={selectedAgent === agent.id}
611
+ onClick={() => setSelectedAgent(selectedAgent === agent.id ? null : agent.id)}
612
+ onViewOutput={() => setOutputAgent(agent)}
613
+ />
614
+ ))}
615
+ </div>
616
+ {(!data?.agents.length) && (
617
+ <div style={{ color: 'var(--text-muted)', fontSize: 13, textAlign: 'center', padding: 20 }}>
618
+ No agents deployed yet
619
+ </div>
620
+ )}
621
+ </div>
622
+
623
+ {/* Detail panel */}
624
+ {selected && (
625
+ <div style={{ width: 320, flexShrink: 0, overflow: 'auto' }}>
626
+ <AgentDetail agent={selected} onViewOutput={() => setOutputAgent(selected)} />
627
+ </div>
628
+ )}
629
+ </div>
630
+ </>
631
+ )}
632
+ </div>
633
+ )
634
+ }