@harbinger-ai/harbinger 0.1.1 → 0.1.3

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.
@@ -0,0 +1,618 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import { motion, AnimatePresence } from 'framer-motion';
5
+ import { SpinnerIcon, RefreshIcon, ChevronDownIcon, BellIcon } from './icons.js';
6
+ import { getSwarmStatus, getNotifications } from '../actions.js';
7
+
8
+ // ─────────────────────────────────────────────────────────────────────────────
9
+ // Utilities
10
+ // ─────────────────────────────────────────────────────────────────────────────
11
+
12
+ function formatDuration(seconds) {
13
+ if (!seconds || seconds < 0) return '0s';
14
+ if (seconds < 60) return `${seconds}s`;
15
+ const minutes = Math.floor(seconds / 60);
16
+ const secs = seconds % 60;
17
+ if (minutes < 60) return `${minutes}m ${secs}s`;
18
+ const hours = Math.floor(minutes / 60);
19
+ const mins = minutes % 60;
20
+ return `${hours}h ${mins}m`;
21
+ }
22
+
23
+ function timeAgo(timestamp) {
24
+ if (!timestamp) return '';
25
+ const seconds = Math.floor((Date.now() - new Date(timestamp).getTime()) / 1000);
26
+ if (seconds < 60) return 'just now';
27
+ const minutes = Math.floor(seconds / 60);
28
+ if (minutes < 60) return `${minutes}m ago`;
29
+ const hours = Math.floor(minutes / 60);
30
+ if (hours < 24) return `${hours}h ago`;
31
+ return `${Math.floor(hours / 24)}d ago`;
32
+ }
33
+
34
+ function getJobId(branch) {
35
+ if (!branch) return '';
36
+ return branch.replace('job/', '').slice(0, 8);
37
+ }
38
+
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+ // Agent Roster Panel (Left)
41
+ // ─────────────────────────────────────────────────────────────────────────────
42
+
43
+ function AgentRoster({ agents, runs, selectedAgent, onSelect, collapsed, onToggleCollapse }) {
44
+ const agentJobCounts = {};
45
+ for (const run of runs) {
46
+ const branch = run.branch || '';
47
+ for (const agent of agents) {
48
+ const codename = (agent.codename || agent.name || agent.id || '').toLowerCase();
49
+ if (branch.toLowerCase().includes(codename)) {
50
+ agentJobCounts[codename] = (agentJobCounts[codename] || 0) + 1;
51
+ }
52
+ }
53
+ }
54
+
55
+ const activeRuns = runs.filter(r => r.status === 'in_progress' || r.status === 'queued');
56
+ const agentActivity = {};
57
+ for (const run of activeRuns) {
58
+ const branch = (run.branch || '').toLowerCase();
59
+ for (const agent of agents) {
60
+ const codename = (agent.codename || agent.name || agent.id || '').toLowerCase();
61
+ if (branch.includes(codename)) {
62
+ agentActivity[codename] = run.status === 'in_progress' ? 'active' : 'queued';
63
+ }
64
+ }
65
+ }
66
+
67
+ return (
68
+ <div className={`shrink-0 border-r border-white/[0.06] transition-all ${collapsed ? 'w-14' : 'w-60'}`}>
69
+ <div className="flex items-center justify-between p-3 border-b border-white/[0.06]">
70
+ {!collapsed && <h2 className="text-[10px] font-mono font-medium uppercase tracking-wider text-muted-foreground">Agents</h2>}
71
+ <button
72
+ onClick={onToggleCollapse}
73
+ className="p-1 text-muted-foreground hover:text-foreground transition-colors"
74
+ >
75
+ <ChevronDownIcon size={14} className={`transition-transform ${collapsed ? '-rotate-90' : 'rotate-90'}`} />
76
+ </button>
77
+ </div>
78
+
79
+ <div className="flex flex-col gap-1 p-2 overflow-y-auto max-h-[calc(100vh-16rem)] scrollbar-thin">
80
+ {/* All filter */}
81
+ <button
82
+ onClick={() => onSelect(null)}
83
+ className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
84
+ selectedAgent === null ? 'bg-[--cyan]/10 text-[--cyan]' : 'hover:bg-white/[0.04] text-muted-foreground hover:text-foreground'
85
+ }`}
86
+ >
87
+ <div className="w-2 h-2 rounded-full bg-[--cyan] shrink-0" />
88
+ {!collapsed && <span className="text-xs font-mono truncate">All Agents</span>}
89
+ </button>
90
+
91
+ {agents.map((agent, i) => {
92
+ const codename = agent.codename || agent.name || agent.id;
93
+ const lcCodename = codename.toLowerCase();
94
+ const activity = agentActivity[lcCodename];
95
+ const isSelected = selectedAgent === agent.id;
96
+
97
+ return (
98
+ <motion.button
99
+ key={agent.id}
100
+ initial={{ opacity: 0, x: -8 }}
101
+ animate={{ opacity: 1, x: 0 }}
102
+ transition={{ duration: 0.2, delay: i * 0.03 }}
103
+ onClick={() => onSelect(agent.id)}
104
+ className={`flex items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors ${
105
+ isSelected ? 'bg-[--cyan]/10 text-[--cyan]' : 'hover:bg-white/[0.04] text-foreground/80 hover:text-foreground'
106
+ }`}
107
+ >
108
+ <div className={`w-2 h-2 rounded-full shrink-0 ${
109
+ activity === 'active' ? 'bg-green-500 animate-pulse' :
110
+ activity === 'queued' ? 'bg-yellow-500' :
111
+ 'bg-muted-foreground/40'
112
+ }`} />
113
+ {!collapsed && (
114
+ <div className="flex-1 min-w-0">
115
+ <span className="text-xs font-mono font-medium truncate block">@{codename.toUpperCase()}</span>
116
+ {agent.role && <span className="text-[10px] font-mono text-muted-foreground truncate block">{agent.role}</span>}
117
+ </div>
118
+ )}
119
+ </motion.button>
120
+ );
121
+ })}
122
+
123
+ {agents.length === 0 && !collapsed && (
124
+ <div className="text-center py-4">
125
+ <p className="text-[10px] font-mono text-muted-foreground">No agents configured</p>
126
+ </div>
127
+ )}
128
+ </div>
129
+ </div>
130
+ );
131
+ }
132
+
133
+ // ─────────────────────────────────────────────────────────────────────────────
134
+ // Mission Queue Panel (Center)
135
+ // ─────────────────────────────────────────────────────────────────────────────
136
+
137
+ function MissionCard({ run, agents, index }) {
138
+ const [expanded, setExpanded] = useState(false);
139
+ const isActive = run.status === 'in_progress';
140
+ const isQueued = run.status === 'queued';
141
+ const isFailed = run.conclusion === 'failure';
142
+ const isSuccess = run.conclusion === 'success';
143
+ const jobId = getJobId(run.branch);
144
+
145
+ // Try to match agent by branch name
146
+ const branch = (run.branch || '').toLowerCase();
147
+ const matchedAgent = agents.find(a => {
148
+ const codename = (a.codename || a.name || a.id || '').toLowerCase();
149
+ return branch.includes(codename);
150
+ });
151
+
152
+ return (
153
+ <motion.div
154
+ initial={{ opacity: 0, y: 6 }}
155
+ animate={{ opacity: 1, y: 0 }}
156
+ transition={{ duration: 0.2, delay: index * 0.03 }}
157
+ layout
158
+ className={`rounded-lg border bg-[--card] transition-all ${
159
+ isActive ? 'border-green-500/30 shadow-[0_0_15px_oklch(0.7_0.17_145/10%)]' :
160
+ isFailed ? 'border-red-500/20' :
161
+ 'border-white/[0.06] hover:border-[--cyan]/20'
162
+ }`}
163
+ >
164
+ <button
165
+ onClick={() => setExpanded(!expanded)}
166
+ className="flex items-center gap-3 w-full text-left p-3 hover:bg-white/[0.02] rounded-lg transition-colors"
167
+ >
168
+ {/* Status dot */}
169
+ <div className={`shrink-0 w-2.5 h-2.5 rounded-full ${
170
+ isActive ? 'bg-green-500 animate-pulse' :
171
+ isQueued ? 'bg-yellow-500' :
172
+ isFailed ? 'bg-red-500' :
173
+ isSuccess ? 'bg-green-500' :
174
+ 'bg-muted-foreground'
175
+ }`} />
176
+
177
+ <div className="flex-1 min-w-0">
178
+ <div className="flex items-center gap-2">
179
+ <span className="text-xs font-mono font-medium text-foreground/90 truncate">
180
+ {run.workflow_name || run.branch || 'Unknown'}
181
+ </span>
182
+ </div>
183
+ <div className="flex items-center gap-2 mt-0.5 flex-wrap">
184
+ {jobId && <span className="text-[10px] font-mono text-muted-foreground">{jobId}</span>}
185
+ {matchedAgent && (
186
+ <span className="inline-flex rounded-full bg-[--cyan]/10 border border-[--cyan]/20 px-1.5 py-0.5 text-[9px] font-mono text-[--cyan]">
187
+ @{(matchedAgent.codename || matchedAgent.name || matchedAgent.id).toUpperCase()}
188
+ </span>
189
+ )}
190
+ </div>
191
+ </div>
192
+
193
+ {/* Time */}
194
+ <span className="text-[10px] font-mono text-muted-foreground shrink-0">
195
+ {(isActive || isQueued) ? formatDuration(run.duration_seconds) : timeAgo(run.updated_at || run.started_at)}
196
+ </span>
197
+
198
+ {/* Status badge */}
199
+ {!isActive && !isQueued && (
200
+ <span className={`inline-flex rounded-full px-2 py-0.5 text-[9px] font-mono font-medium border ${
201
+ isFailed ? 'bg-red-500/10 text-red-500 border-red-500/20' :
202
+ isSuccess ? 'bg-green-500/10 text-green-500 border-green-500/20' :
203
+ 'bg-white/5 text-muted-foreground border-white/10'
204
+ }`}>
205
+ {run.conclusion || 'unknown'}
206
+ </span>
207
+ )}
208
+
209
+ <ChevronDownIcon size={14} className={`shrink-0 text-muted-foreground transition-transform ${expanded ? 'rotate-180' : ''}`} />
210
+ </button>
211
+
212
+ <AnimatePresence>
213
+ {expanded && (
214
+ <motion.div
215
+ initial={{ height: 0, opacity: 0 }}
216
+ animate={{ height: 'auto', opacity: 1 }}
217
+ exit={{ height: 0, opacity: 0 }}
218
+ transition={{ duration: 0.2 }}
219
+ className="overflow-hidden"
220
+ >
221
+ <div className="border-t border-white/[0.06] px-4 py-3 flex flex-col gap-2">
222
+ {run.branch && (
223
+ <div className="flex gap-2 text-xs items-baseline">
224
+ <span className="text-muted-foreground font-mono text-[10px] uppercase tracking-wider w-16 shrink-0">Branch</span>
225
+ <span className="font-mono text-foreground/80">{run.branch}</span>
226
+ </div>
227
+ )}
228
+ {run.started_at && (
229
+ <div className="flex gap-2 text-xs items-baseline">
230
+ <span className="text-muted-foreground font-mono text-[10px] uppercase tracking-wider w-16 shrink-0">Started</span>
231
+ <span className="font-mono text-foreground/80">{new Date(run.started_at).toLocaleString()}</span>
232
+ </div>
233
+ )}
234
+ {run.duration_seconds > 0 && (
235
+ <div className="flex gap-2 text-xs items-baseline">
236
+ <span className="text-muted-foreground font-mono text-[10px] uppercase tracking-wider w-16 shrink-0">Duration</span>
237
+ <span className="font-mono text-foreground/80">{formatDuration(run.duration_seconds)}</span>
238
+ </div>
239
+ )}
240
+ {run.html_url && (
241
+ <div className="mt-1">
242
+ <a href={run.html_url} target="_blank" rel="noopener noreferrer" className="text-[10px] font-mono text-[--cyan] hover:underline">
243
+ View on GitHub &rarr;
244
+ </a>
245
+ </div>
246
+ )}
247
+ </div>
248
+ </motion.div>
249
+ )}
250
+ </AnimatePresence>
251
+ </motion.div>
252
+ );
253
+ }
254
+
255
+ function MissionQueue({ runs, agents, selectedAgent }) {
256
+ // Filter runs by selected agent
257
+ const filteredRuns = selectedAgent
258
+ ? runs.filter(r => {
259
+ const branch = (r.branch || '').toLowerCase();
260
+ const agent = agents.find(a => a.id === selectedAgent);
261
+ if (!agent) return false;
262
+ const codename = (agent.codename || agent.name || agent.id || '').toLowerCase();
263
+ return branch.includes(codename);
264
+ })
265
+ : runs;
266
+
267
+ // Split into columns
268
+ const queued = filteredRuns.filter(r => r.status === 'queued');
269
+ const active = filteredRuns.filter(r => r.status === 'in_progress');
270
+ const completed = filteredRuns.filter(r => r.status !== 'queued' && r.status !== 'in_progress');
271
+
272
+ const columns = [
273
+ { id: 'queued', label: 'QUEUED', runs: queued, color: 'text-yellow-500', dotColor: 'bg-yellow-500' },
274
+ { id: 'active', label: 'IN PROGRESS', runs: active, color: 'text-green-500', dotColor: 'bg-green-500 animate-pulse' },
275
+ { id: 'completed', label: 'COMPLETED', runs: completed, color: 'text-muted-foreground', dotColor: 'bg-muted-foreground' },
276
+ ];
277
+
278
+ return (
279
+ <div className="flex-1 min-w-0 p-4 overflow-y-auto scrollbar-thin">
280
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4 h-full">
281
+ {columns.map(col => (
282
+ <div key={col.id} className="flex flex-col min-h-0">
283
+ <div className="flex items-center gap-2 mb-3 pb-2 border-b border-white/[0.06]">
284
+ <div className={`w-2 h-2 rounded-full ${col.dotColor}`} />
285
+ <h3 className={`text-[10px] font-mono font-medium uppercase tracking-wider ${col.color}`}>
286
+ {col.label}
287
+ </h3>
288
+ <span className="text-[10px] font-mono text-muted-foreground">({col.runs.length})</span>
289
+ </div>
290
+ <div className="flex flex-col gap-2 overflow-y-auto scrollbar-thin flex-1">
291
+ <AnimatePresence>
292
+ {col.runs.map((run, i) => (
293
+ <MissionCard key={run.run_id} run={run} agents={agents} index={i} />
294
+ ))}
295
+ </AnimatePresence>
296
+ {col.runs.length === 0 && (
297
+ <div className="flex items-center justify-center py-8 text-[10px] font-mono text-muted-foreground/50">
298
+ No missions
299
+ </div>
300
+ )}
301
+ </div>
302
+ </div>
303
+ ))}
304
+ </div>
305
+ </div>
306
+ );
307
+ }
308
+
309
+ // ─────────────────────────────────────────────────────────────────────────────
310
+ // Live Feed Panel (Right)
311
+ // ─────────────────────────────────────────────────────────────────────────────
312
+
313
+ function LiveFeed({ runs, notifications, collapsed, onToggleCollapse }) {
314
+ // Build a unified event feed from runs and notifications
315
+ const events = [];
316
+
317
+ for (const run of runs.slice(0, 20)) {
318
+ const isActive = run.status === 'in_progress';
319
+ const isQueued = run.status === 'queued';
320
+ const isFailed = run.conclusion === 'failure';
321
+ const isSuccess = run.conclusion === 'success';
322
+
323
+ events.push({
324
+ id: `run-${run.run_id}`,
325
+ time: run.updated_at || run.started_at || run.created_at,
326
+ type: isActive ? 'job_started' : isQueued ? 'job_created' : isFailed ? 'job_failed' : isSuccess ? 'job_completed' : 'job_completed',
327
+ label: isActive ? 'Running' : isQueued ? 'Queued' : isFailed ? 'Failed' : 'Completed',
328
+ description: run.workflow_name || getJobId(run.branch) || 'job',
329
+ color: isActive ? 'text-green-500' : isQueued ? 'text-yellow-500' : isFailed ? 'text-[--destructive]' : 'text-green-500',
330
+ dotColor: isActive ? 'bg-green-500 animate-pulse' : isQueued ? 'bg-yellow-500' : isFailed ? 'bg-red-500' : 'bg-green-500',
331
+ url: run.html_url,
332
+ });
333
+ }
334
+
335
+ for (const notif of (notifications || []).slice(0, 10)) {
336
+ const payload = typeof notif.payload === 'string' ? JSON.parse(notif.payload) : notif.payload;
337
+ events.push({
338
+ id: `notif-${notif.id}`,
339
+ time: notif.createdAt,
340
+ type: 'notification',
341
+ label: payload?.conclusion === 'failure' ? 'Failed' : 'PR Merged',
342
+ description: payload?.title || payload?.pr_title || 'Notification',
343
+ color: payload?.conclusion === 'failure' ? 'text-[--destructive]' : 'text-[--cyan]',
344
+ dotColor: payload?.conclusion === 'failure' ? 'bg-red-500' : 'bg-[--cyan]',
345
+ url: payload?.pr_url || payload?.html_url,
346
+ });
347
+ }
348
+
349
+ // Sort by time, newest first
350
+ events.sort((a, b) => {
351
+ const ta = a.time ? new Date(a.time).getTime() : 0;
352
+ const tb = b.time ? new Date(b.time).getTime() : 0;
353
+ return tb - ta;
354
+ });
355
+
356
+ const unique = [];
357
+ const seen = new Set();
358
+ for (const e of events) {
359
+ if (!seen.has(e.id)) {
360
+ seen.add(e.id);
361
+ unique.push(e);
362
+ }
363
+ }
364
+
365
+ return (
366
+ <div className={`shrink-0 border-l border-white/[0.06] transition-all ${collapsed ? 'w-14' : 'w-72'}`}>
367
+ <div className="flex items-center justify-between p-3 border-b border-white/[0.06]">
368
+ <button
369
+ onClick={onToggleCollapse}
370
+ className="p-1 text-muted-foreground hover:text-foreground transition-colors"
371
+ >
372
+ <ChevronDownIcon size={14} className={`transition-transform ${collapsed ? 'rotate-90' : '-rotate-90'}`} />
373
+ </button>
374
+ {!collapsed && <h2 className="text-[10px] font-mono font-medium uppercase tracking-wider text-muted-foreground">Live Feed</h2>}
375
+ </div>
376
+
377
+ {!collapsed && (
378
+ <div className="flex flex-col overflow-y-auto max-h-[calc(100vh-16rem)] scrollbar-thin">
379
+ {unique.slice(0, 30).map((event, i) => (
380
+ <motion.div
381
+ key={event.id}
382
+ initial={{ opacity: 0, x: 8 }}
383
+ animate={{ opacity: 1, x: 0 }}
384
+ transition={{ duration: 0.2, delay: i * 0.02 }}
385
+ className="flex items-start gap-2 px-3 py-2 border-b border-white/[0.03] hover:bg-white/[0.02] transition-colors"
386
+ >
387
+ <div className={`shrink-0 w-1.5 h-1.5 rounded-full mt-1.5 ${event.dotColor}`} />
388
+ <div className="flex-1 min-w-0">
389
+ <div className="flex items-center gap-1.5">
390
+ <span className={`text-[9px] font-mono font-medium ${event.color}`}>{event.label}</span>
391
+ <span className="text-[9px] font-mono text-muted-foreground">{timeAgo(event.time)}</span>
392
+ </div>
393
+ {event.url ? (
394
+ <a href={event.url} target="_blank" rel="noopener noreferrer" className="text-[10px] font-mono text-foreground/70 hover:text-[--cyan] truncate block transition-colors">
395
+ {event.description}
396
+ </a>
397
+ ) : (
398
+ <p className="text-[10px] font-mono text-foreground/70 truncate">{event.description}</p>
399
+ )}
400
+ </div>
401
+ </motion.div>
402
+ ))}
403
+
404
+ {unique.length === 0 && (
405
+ <div className="flex flex-col items-center justify-center py-8 text-center">
406
+ <BellIcon size={16} />
407
+ <p className="text-[10px] font-mono text-muted-foreground mt-2">No events yet</p>
408
+ </div>
409
+ )}
410
+ </div>
411
+ )}
412
+ </div>
413
+ );
414
+ }
415
+
416
+ // ─────────────────────────────────────────────────────────────────────────────
417
+ // Main Page
418
+ // ─────────────────────────────────────────────────────────────────────────────
419
+
420
+ export function MissionControlPage() {
421
+ const [agents, setAgents] = useState([]);
422
+ const [runs, setRuns] = useState([]);
423
+ const [notifications_, setNotifications] = useState([]);
424
+ const [loading, setLoading] = useState(true);
425
+ const [refreshing, setRefreshing] = useState(false);
426
+ const [selectedAgent, setSelectedAgent] = useState(null);
427
+ const [leftCollapsed, setLeftCollapsed] = useState(false);
428
+ const [rightCollapsed, setRightCollapsed] = useState(false);
429
+
430
+ const fetchData = useCallback(async () => {
431
+ try {
432
+ const [swarm, notifs, agentProfiles] = await Promise.all([
433
+ getSwarmStatus(1),
434
+ getNotifications(),
435
+ import('../actions.js').then(m => m.getAgentProfiles ? m.getAgentProfiles() : []).catch(() => []),
436
+ ]);
437
+ setRuns(swarm.runs || []);
438
+ setNotifications(notifs || []);
439
+ setAgents(agentProfiles || []);
440
+ } catch (err) {
441
+ console.error('Failed to fetch mission control data:', err);
442
+ } finally {
443
+ setLoading(false);
444
+ setRefreshing(false);
445
+ }
446
+ }, []);
447
+
448
+ useEffect(() => { fetchData(); }, [fetchData]);
449
+
450
+ // Auto-refresh every 10s
451
+ useEffect(() => {
452
+ const interval = setInterval(() => fetchData(), 10000);
453
+ return () => clearInterval(interval);
454
+ }, [fetchData]);
455
+
456
+ const activeCount = runs.filter(r => r.status === 'in_progress').length;
457
+ const queuedCount = runs.filter(r => r.status === 'queued').length;
458
+
459
+ if (loading) {
460
+ return (
461
+ <div className="flex flex-col gap-4 p-6">
462
+ <div className="h-8 w-48 animate-pulse rounded-lg bg-white/[0.04]" />
463
+ <div className="grid grid-cols-3 gap-4 flex-1">
464
+ {[...Array(3)].map((_, i) => (
465
+ <div key={i} className="flex flex-col gap-3">
466
+ {[...Array(4)].map((_, j) => (
467
+ <div key={j} className="h-16 animate-pulse rounded-lg bg-white/[0.04] border border-white/[0.06]" />
468
+ ))}
469
+ </div>
470
+ ))}
471
+ </div>
472
+ </div>
473
+ );
474
+ }
475
+
476
+ return (
477
+ <div className="flex flex-col h-full -m-4">
478
+ {/* Header */}
479
+ <div className="flex items-center justify-between px-4 py-3 border-b border-white/[0.06]">
480
+ <div className="flex items-center gap-3">
481
+ <h1 className="text-lg font-mono font-semibold text-[--cyan] text-glow-cyan">Mission Control</h1>
482
+ <div className="flex items-center gap-2">
483
+ {activeCount > 0 && (
484
+ <span className="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-[10px] font-mono font-medium bg-green-500/10 text-green-500 border border-green-500/20">
485
+ <span className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" />
486
+ {activeCount} active
487
+ </span>
488
+ )}
489
+ {queuedCount > 0 && (
490
+ <span className="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-[10px] font-mono font-medium bg-yellow-500/10 text-yellow-500 border border-yellow-500/20">
491
+ {queuedCount} queued
492
+ </span>
493
+ )}
494
+ <span className="inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-[10px] font-mono font-medium bg-[--cyan]/10 text-[--cyan] border border-[--cyan]/20">
495
+ {agents.length} agents
496
+ </span>
497
+ </div>
498
+ </div>
499
+ <button
500
+ onClick={() => { setRefreshing(true); fetchData(); }}
501
+ disabled={refreshing}
502
+ className="inline-flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-mono font-medium border border-white/[0.06] text-muted-foreground hover:text-foreground hover:bg-white/[0.04] disabled:opacity-50 transition-colors"
503
+ >
504
+ {refreshing ? <SpinnerIcon size={14} /> : <RefreshIcon size={14} />}
505
+ {refreshing ? 'Refreshing' : 'Refresh'}
506
+ </button>
507
+ </div>
508
+
509
+ {/* 3-panel layout - desktop */}
510
+ <div className="hidden md:flex flex-1 min-h-0">
511
+ <AgentRoster
512
+ agents={agents}
513
+ runs={runs}
514
+ selectedAgent={selectedAgent}
515
+ onSelect={setSelectedAgent}
516
+ collapsed={leftCollapsed}
517
+ onToggleCollapse={() => setLeftCollapsed(!leftCollapsed)}
518
+ />
519
+ <MissionQueue
520
+ runs={runs}
521
+ agents={agents}
522
+ selectedAgent={selectedAgent}
523
+ />
524
+ <LiveFeed
525
+ runs={runs}
526
+ notifications={notifications_}
527
+ collapsed={rightCollapsed}
528
+ onToggleCollapse={() => setRightCollapsed(!rightCollapsed)}
529
+ />
530
+ </div>
531
+
532
+ {/* Mobile layout - tabbed */}
533
+ <MobileMissionControl
534
+ agents={agents}
535
+ runs={runs}
536
+ notifications={notifications_}
537
+ selectedAgent={selectedAgent}
538
+ onSelectAgent={setSelectedAgent}
539
+ />
540
+ </div>
541
+ );
542
+ }
543
+
544
+ // ─────────────────────────────────────────────────────────────────────────────
545
+ // Mobile Layout
546
+ // ─────────────────────────────────────────────────────────────────────────────
547
+
548
+ function MobileMissionControl({ agents, runs, notifications, selectedAgent, onSelectAgent }) {
549
+ const [tab, setTab] = useState('missions');
550
+
551
+ const tabs = [
552
+ { id: 'agents', label: 'AGENTS', count: agents.length },
553
+ { id: 'missions', label: 'MISSIONS', count: runs.length },
554
+ { id: 'feed', label: 'FEED', count: null },
555
+ ];
556
+
557
+ return (
558
+ <div className="flex flex-col flex-1 md:hidden">
559
+ <div className="flex gap-1 border-b border-white/[0.06] px-2">
560
+ {tabs.map(t => (
561
+ <button
562
+ key={t.id}
563
+ onClick={() => setTab(t.id)}
564
+ className={`px-3 py-2 text-[10px] font-mono font-medium uppercase tracking-wider border-b-2 transition-colors ${
565
+ tab === t.id
566
+ ? 'border-[--cyan] text-[--cyan]'
567
+ : 'border-transparent text-muted-foreground hover:text-foreground'
568
+ }`}
569
+ >
570
+ {t.label} {t.count !== null && <span className="text-muted-foreground">({t.count})</span>}
571
+ </button>
572
+ ))}
573
+ </div>
574
+
575
+ <div className="flex-1 overflow-y-auto p-3">
576
+ {tab === 'agents' && (
577
+ <div className="flex flex-col gap-1">
578
+ <button
579
+ onClick={() => { onSelectAgent(null); setTab('missions'); }}
580
+ className={`flex items-center gap-2 rounded-md px-3 py-2 text-left transition-colors ${
581
+ selectedAgent === null ? 'bg-[--cyan]/10 text-[--cyan]' : 'hover:bg-white/[0.04]'
582
+ }`}
583
+ >
584
+ <div className="w-2 h-2 rounded-full bg-[--cyan]" />
585
+ <span className="text-xs font-mono">All Agents</span>
586
+ </button>
587
+ {agents.map(agent => {
588
+ const codename = agent.codename || agent.name || agent.id;
589
+ return (
590
+ <button
591
+ key={agent.id}
592
+ onClick={() => { onSelectAgent(agent.id); setTab('missions'); }}
593
+ className={`flex items-center gap-2 rounded-md px-3 py-2 text-left transition-colors ${
594
+ selectedAgent === agent.id ? 'bg-[--cyan]/10 text-[--cyan]' : 'hover:bg-white/[0.04]'
595
+ }`}
596
+ >
597
+ <div className="w-2 h-2 rounded-full bg-muted-foreground/40" />
598
+ <div>
599
+ <span className="text-xs font-mono font-medium block">@{codename.toUpperCase()}</span>
600
+ {agent.role && <span className="text-[10px] font-mono text-muted-foreground block">{agent.role}</span>}
601
+ </div>
602
+ </button>
603
+ );
604
+ })}
605
+ </div>
606
+ )}
607
+
608
+ {tab === 'missions' && (
609
+ <MissionQueue runs={runs} agents={agents} selectedAgent={selectedAgent} />
610
+ )}
611
+
612
+ {tab === 'feed' && (
613
+ <LiveFeed runs={runs} notifications={notifications} collapsed={false} onToggleCollapse={() => {}} />
614
+ )}
615
+ </div>
616
+ </div>
617
+ );
618
+ }