feat(swarm): implement Swarm View remake with Operations, Archetypes, and Templates

This commit includes the new SwarmWorkspace with its 3 sub-tabs, the LeftPanel mission picker, and the comprehensive Operations Command Dashboard featuring the live interactive DAG telemetry and task assignment prep flow.
This commit is contained in:
zenchantlive 2026-02-20 22:19:38 -08:00
parent 409a7e7256
commit dfaf523029
74 changed files with 11066 additions and 2046 deletions

View file

@ -0,0 +1,110 @@
import React from 'react';
import { X, Save, ShieldAlert } from 'lucide-react';
import type { AgentArchetype } from '../../lib/types-swarm';
interface ArchetypeInspectorProps {
archetype: AgentArchetype;
onClose: () => void;
}
export function ArchetypeInspector({ archetype, onClose }: ArchetypeInspectorProps) {
if (!archetype) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="flex flex-col h-[85vh] w-full max-w-2xl overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[#0f1824] shadow-2xl animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
<div className="flex items-center gap-3">
<div
className="h-10 w-10 rounded-lg flex items-center justify-center font-bold text-lg border"
style={{ backgroundColor: `${archetype.color}15`, color: archetype.color, borderColor: `${archetype.color}30` }}
>
{archetype.name.charAt(0)}
</div>
<div>
<h2 className="text-lg font-bold text-[var(--ui-text-primary)] leading-tight">{archetype.name}</h2>
<p className="font-mono uppercase tracking-wider text-[10px] text-[var(--ui-text-muted)] mt-0.5">{archetype.id}</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-[var(--ui-text-muted)] hover:bg-white/5 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-6 custom-scrollbar">
{/* ReadOnly Warning if builtin */}
{archetype.isBuiltIn && (
<div className="flex items-start gap-3 bg-[var(--ui-accent-warning)]/10 border border-[var(--ui-accent-warning)]/20 p-3 rounded-lg text-[var(--ui-accent-warning)]">
<ShieldAlert className="w-5 h-5 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<span className="font-semibold">Built-in Archetype.</span> This is a core system role. You cannot delete it, but you can override its system prompt.
</div>
</div>
)}
{/* Metadata Section */}
<div className="space-y-4">
<div>
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">Description</label>
<input
type="text"
defaultValue={archetype.description}
readOnly
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:border-[var(--ui-accent-info)] focus:ring-1 focus:ring-[var(--ui-accent-info)]"
/>
</div>
<div>
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">Capabilities</label>
<div className="flex flex-wrap gap-2">
{archetype.capabilities.map((cap, idx) => (
<span key={idx} className="px-2 py-1 rounded-md bg-white/5 text-[11px] uppercase font-semibold text-[var(--ui-text-muted)] border border-white/10">
{cap}
</span>
))}
</div>
</div>
</div>
<div className="border-t border-[var(--ui-border-soft)] pt-6">
<div className="flex flex-col h-[300px]">
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 flex items-center justify-between">
<span>System Prompt</span>
<span className="text-[10px] text-emerald-400 normal-case tracking-normal">Syntax: Markdown</span>
</label>
<textarea
defaultValue={archetype.systemPrompt}
readOnly
className="flex-1 w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md p-4 text-sm text-[var(--ui-text-primary)] font-mono resize-none focus:outline-none focus:border-[var(--ui-accent-info)] custom-scrollbar leading-relaxed"
/>
</div>
</div>
</div>
{/* Footer Controls */}
<div className="border-t border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e] flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-semibold text-[var(--ui-text-primary)] hover:bg-white/5 rounded-md transition-colors"
>
Cancel
</button>
<button
disabled
className="flex items-center gap-2 px-4 py-2 text-sm font-bold bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
Save Changes
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import { Loader2, CheckCircle2 } from "lucide-react";
export type Phase = 'planning' | 'deployment' | 'execution' | 'debrief';
export function ConvoyStepper({ activePhase }: { activePhase: Phase }) {
const phases: Phase[] = ['planning', 'deployment', 'execution', 'debrief'];
return (
<div className="flex flex-wrap items-center gap-4 bg-muted/50 p-4 rounded-lg my-4">
{phases.map((p, i) => {
const isActive = activePhase === p;
const isPast = phases.indexOf(activePhase) > i;
return (
<div
key={p}
className={`flex items-center gap-2 ${isActive ? 'text-primary' : isPast ? 'text-muted-foreground' : 'text-muted-foreground/50'
}`}
>
{isActive && <Loader2 className="w-4 h-4 animate-spin" />}
{isPast && <CheckCircle2 className="w-4 h-4" />}
{!isActive && !isPast && <div className="w-4 h-4 rounded-full border border-current" />}
<span className="font-mono text-sm uppercase">{p}</span>
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,172 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Loader2, Plus, Rocket } from 'lucide-react';
interface LaunchSwarmDialogProps {
projectRoot: string;
onSuccess?: () => void;
}
interface Formula {
name: string;
description?: string;
}
export function LaunchSwarmDialog({ projectRoot, onSuccess }: LaunchSwarmDialogProps) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [formulas, setFormulas] = useState<Formula[]>([]);
const [selectedFormula, setSelectedFormula] = useState<string>('');
const [title, setTitle] = useState('');
const [error, setError] = useState<string | null>(null);
const fetchFormulas = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/swarm/formulas?projectRoot=${encodeURIComponent(projectRoot)}`);
const json = await res.json();
if (json.ok) {
setFormulas(json.data);
} else {
setError(json.error);
}
} catch (e) {
setError('Failed to fetch formulas');
} finally {
setLoading(false);
}
};
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
if (isOpen && formulas.length === 0) {
fetchFormulas();
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || !selectedFormula) return;
setLoading(true);
setError(null);
try {
const res = await fetch('/api/swarm/launch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectRoot,
title,
proto: selectedFormula,
}),
});
const json = await res.json();
if (json.ok) {
setOpen(false);
setTitle('');
setSelectedFormula('');
onSuccess?.();
} else {
setError(json.error);
}
} catch (e) {
setError('Failed to launch swarm');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 border-emerald-500/20 bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 hover:text-emerald-300"
>
<Rocket className="h-4 w-4" />
Launch Swarm
</Button>
</DialogTrigger>
<DialogContent className="bg-[#08111d] border-slate-800 text-slate-200 sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Launch New Swarm</DialogTitle>
<DialogDescription className="text-slate-400">
Instantiate a new molecule from a template proto.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="proto" className="text-slate-300">Formula Template</Label>
<Select value={selectedFormula} onValueChange={setSelectedFormula} disabled={loading}>
<SelectTrigger className="bg-slate-900 border-slate-700 text-slate-200">
<SelectValue placeholder="Select a proto..." />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700 text-slate-200">
{formulas.length === 0 && !loading && (
<div className="p-2 text-xs text-slate-500 text-center">No formulas found</div>
)}
{formulas.map((f) => (
<SelectItem key={f.name} value={f.name} className="focus:bg-slate-700 focus:text-slate-100">
{f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="title" className="text-slate-300">Swarm Title</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="bg-slate-900 border-slate-700 text-slate-200"
placeholder="e.g. Release v1.2"
disabled={loading}
/>
</div>
{error && (
<div className="text-xs text-rose-400 bg-rose-950/20 p-2 rounded border border-rose-900/30">
{error}
</div>
)}
</form>
<DialogFooter>
<Button
type="submit"
onClick={handleSubmit}
disabled={loading || !title || !selectedFormula}
className="bg-emerald-600 hover:bg-emerald-500 text-white"
>
{loading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Rocket className="mr-2 h-4 w-4" />}
Launch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,210 @@
import React, { useEffect, useMemo } from 'react';
import {
Background,
MarkerType,
Position,
ReactFlow,
ReactFlowProvider,
useReactFlow,
Handle,
type Edge,
type Node,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from 'dagre';
import type { BeadIssue } from '../../lib/types';
import type { AgentArchetype } from '../../lib/types-swarm';
// Custom Node for the Agent DAG
interface AgentNodeData extends Record<string, unknown> {
title: string;
status: string;
assignee: string | null;
archetype?: AgentArchetype;
isSelected?: boolean;
}
function AgentNodeCard({ data }: { data: AgentNodeData }) {
const isDone = data.status === 'closed';
const isInProgress = data.status === 'in_progress';
const isBlocked = data.status === 'blocked';
const statusColor = isDone ? 'text-emerald-400' : isBlocked ? 'text-rose-400' : isInProgress ? 'text-amber-400' : 'text-slate-400';
let borderColor = isDone ? 'border-emerald-500/30' : isBlocked ? 'border-rose-500/30' : isInProgress ? 'border-amber-500/30' : 'border-slate-500/30';
let containerClasses = `w-[260px] rounded-xl border bg-[#0a111a] p-3 shadow-xl transition-all duration-500 ${borderColor}`;
if (isInProgress) {
containerClasses += ' shadow-[0_0_20px_rgba(251,191,36,0.15)] ring-1 ring-amber-500/30';
}
if (data.isSelected) {
containerClasses = `w-[260px] rounded-xl border bg-[#0a111a] p-3 shadow-[0_0_25px_rgba(56,189,248,0.2)] transition-all duration-300 border-[var(--ui-accent-info)] ring-2 ring-[var(--ui-accent-info)]/50`;
}
const bgStr = data.archetype ? `${data.archetype.color}15` : '#ffffff05';
const colorStr = data.archetype ? data.archetype.color : '#888';
return (
<div className={containerClasses}>
<div className="flex items-start gap-3">
<div
className={`h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center font-bold text-lg border relative ${isInProgress ? 'animate-pulse duration-1000' : ''}`}
style={{ backgroundColor: bgStr, color: colorStr, borderColor: `${colorStr}40` }}
>
{data.assignee ? data.assignee.charAt(0).toUpperCase() : '?'}
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-[#0a111a] bg-current ${statusColor} ${isInProgress ? 'animate-ping' : ''}`} style={{ animationDuration: '2s' }} />
<div className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border-2 border-[#0a111a] bg-current ${statusColor}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-[10px] font-mono text-[var(--ui-text-muted)] uppercase tracking-wider mb-0.5 truncate flex items-center justify-between">
<span>{data.assignee || 'Unassigned'}</span>
{isInProgress && <span className="text-amber-500 animate-pulse text-[8px] tracking-widest">WORKING...</span>}
</div>
<div className="text-sm font-semibold text-[var(--ui-text-primary)] leading-tight line-clamp-2">
{data.title}
</div>
{data.archetype && (
<div className="text-[9px] text-[var(--ui-text-muted)] mt-1 truncate">
{data.archetype.name}
</div>
)}
</div>
</div>
{/* React Flow handles */}
<Handle type="target" position={Position.Left} className="w-4 h-4 rounded-full bg-slate-800 border-2 border-slate-600 !left-[-8px] opacity-0" />
<Handle type="source" position={Position.Right} className="w-4 h-4 rounded-full bg-slate-800 border-2 border-slate-600 !right-[-8px] opacity-0" />
</div>
);
}
const nodeTypes = {
agentNode: AgentNodeCard,
};
const layoutDagre = (nodes: Node<AgentNodeData>[], edges: Edge[]): Node<AgentNodeData>[] => {
const dagreGraph = new dagre.graphlib.Graph();
dagreGraph.setDefaultEdgeLabel(() => ({}));
dagreGraph.setGraph({ rankdir: 'LR', nodesep: 40, ranksep: 60 });
for (const node of nodes) {
dagreGraph.setNode(node.id, { width: 260, height: 110 });
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target);
}
dagre.layout(dagreGraph);
return nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id);
const newNode = { ...node };
if (nodeWithPosition) {
newNode.targetPosition = Position.Left;
newNode.sourcePosition = Position.Right;
newNode.position = {
x: nodeWithPosition.x - 260 / 2,
y: nodeWithPosition.y - 110 / 2,
};
}
return newNode;
});
};
function SpecializedAgentDagInner({ beads, archetypes, selectedId, onSelect }: { beads: BeadIssue[], archetypes: AgentArchetype[], selectedId?: string | null, onSelect?: (id: string) => void }) {
const { fitView } = useReactFlow();
const handleNodeClick = React.useCallback(
(_: React.MouseEvent, node: Node) => {
onSelect?.(node.id);
},
[onSelect],
);
const flowModel = useMemo(() => {
// Find visible beads (hide tombstone)
const visibleBeads = beads.filter(b => b.status !== 'tombstone');
const baseNodes: Node<AgentNodeData>[] = visibleBeads.map((issue) => {
const assigneeStr = issue.assignee?.toLowerCase() || '';
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
return {
id: issue.id,
type: 'agentNode',
data: {
title: issue.title,
status: issue.status,
assignee: issue.assignee,
archetype: matchedArchetype,
isSelected: issue.id === selectedId
},
position: { x: 0, y: 0 },
sourcePosition: Position.Right,
targetPosition: Position.Left,
};
});
const graphEdges: Edge[] = [];
const beadIds = new Set(visibleBeads.map(b => b.id));
visibleBeads.forEach(issue => {
issue.dependencies.forEach(dep => {
if (dep.type === 'blocks' && beadIds.has(dep.target)) {
// issue depends on dep.target (issue is blocked by dep.target)
// Edge should flow from blocker to blocked
graphEdges.push({
id: `e-${dep.target}-${issue.id}`,
source: dep.target,
target: issue.id,
type: 'smoothstep',
animated: issue.status === 'in_progress' || issue.status === 'closed',
style: { stroke: '#475569', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#475569' }
});
}
});
});
console.log('SpecializedAgentDag generated nodes:', baseNodes.length, 'edges:', graphEdges.length);
return {
nodes: layoutDagre(baseNodes, graphEdges),
edges: graphEdges,
};
}, [beads, archetypes, selectedId]);
useEffect(() => {
const timeout = setTimeout(() => {
fitView({ padding: 0.3, duration: 300 });
}, 100);
return () => clearTimeout(timeout);
}, [fitView, flowModel.nodes.length]);
return (
<ReactFlow
nodes={flowModel.nodes}
edges={flowModel.edges}
nodeTypes={nodeTypes}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={true}
onNodeClick={handleNodeClick}
fitView
>
<Background gap={24} size={1} color="rgba(255,255,255,0.02)" />
</ReactFlow>
);
}
export function SpecializedAgentDag(props: { beads: BeadIssue[], archetypes: AgentArchetype[], selectedId?: string | null, onSelect?: (id: string) => void }) {
return (
<ReactFlowProvider>
<SpecializedAgentDagInner {...props} />
</ReactFlowProvider>
);
}

View file

@ -1,47 +1,13 @@
'use client';
import type { SwarmCard as SwarmCardType, AgentRoster } from '../../lib/swarm-cards';
import type { SwarmCardData } from '../../lib/swarm-api';
import { Card } from '../../../components/ui/card';
import { Badge } from '../../../components/ui/badge';
import { AgentAvatar } from '../shared/agent-avatar';
import { cn } from '../../lib/utils';
import { Plus, Menu, Diamond, Waves, AlertTriangle } from 'lucide-react';
import { CheckCircle2, PlayCircle, Clock, AlertCircle } from 'lucide-react';
interface SwarmCardProps {
card: SwarmCardType;
onExpand?: () => void;
onMenu?: () => void;
onGraph?: () => void;
onTimeline?: () => void;
}
function formatTimeAgo(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays}d ago`;
if (diffHours > 0) return `${diffHours}h ago`;
if (diffMins > 0) return `${diffMins}m ago`;
return 'just now';
}
const HEALTH_COLORS: Record<string, string> = {
active: 'text-emerald-400',
stale: 'text-amber-400',
stuck: 'text-rose-400',
dead: 'text-red-500',
};
function AgentRosterRow({ agent }: { agent: AgentRoster }) {
return (
<div className="flex items-center gap-2 text-xs text-slate-400">
<span className="font-mono text-slate-500">{agent.name}:</span>
<span className="truncate">{agent.currentTask || 'idle'}</span>
</div>
);
card: SwarmCardData;
}
function ProgressBar({ progress }: { progress: number }) {
@ -50,118 +16,70 @@ function ProgressBar({ progress }: { progress: number }) {
return (
<div className="flex items-center gap-2">
<div className="flex-1 font-mono text-xs">
<div className="flex-1 font-mono text-xs text-slate-300">
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</div>
<span className="text-xs text-slate-400">{progress}% done</span>
<span className="text-xs text-slate-400">{progress}%</span>
</div>
);
}
function AttentionList({ items }: { items: string[] }) {
if (items.length === 0) return null;
const STATUS_COLORS: Record<string, string> = {
open: 'text-emerald-400 border-emerald-400/30',
closed: 'text-slate-400 border-slate-400/30',
in_progress: 'text-amber-400 border-amber-400/30',
};
export function SwarmCard({ card }: SwarmCardProps) {
return (
<div className="space-y-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-amber-400">
ATTENTION:
</span>
{items.slice(0, 3).map((item, i) => (
<div key={i} className="flex items-center gap-1.5 text-xs text-amber-200/80">
<AlertTriangle className="h-3 w-3 text-amber-400" />
<span className="truncate">{item}</span>
</div>
))}
</div>
);
}
export function SwarmCard({ card, onExpand, onMenu, onGraph, onTimeline }: SwarmCardProps) {
const activeAgents = card.agents.filter((a) => a.status === 'active');
const otherAgents = card.agents.filter((a) => a.status !== 'active');
return (
<Card className="rounded-xl border border-white/[0.06] bg-[#363636] px-3.5 py-3 shadow-[0_18px_38px_-18px_rgba(0,0,0,0.82),0_6px_18px_-10px_rgba(0,0,0,0.72),inset_0_1px_0_rgba(255,255,255,0.06)] transition duration-200 hover:shadow-[0_24px_52px_-16px_rgba(0,0,0,0.9),0_10px_26px_-10px_rgba(0,0,0,0.78)]">
<Card className="rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-card)] px-3.5 py-3 shadow-[0_18px_38px_-18px_rgba(0,0,0,0.82),0_6px_18px_-10px_rgba(0,0,0,0.72),inset_0_1px_0_rgba(255,255,255,0.06)] transition-shadow duration-200 hover:shadow-[0_24px_52px_-16px_rgba(0,0,0,0.9),0_10px_26px_-10px_rgba(0,0,0,0.78)]">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-slate-200">
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0 border-slate-600', HEALTH_COLORS[card.health])}
>
{card.health}
</Badge>
</div>
<span className="text-sm text-slate-400 line-clamp-1">{card.title}</span>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-slate-200">
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', STATUS_COLORS[card.status] ?? 'text-slate-400 border-slate-400/30')}
>
{card.status}
</Badge>
</div>
<button
onClick={onExpand}
className="p-1 rounded hover:bg-white/5 transition-colors"
aria-label="Expand"
>
<Plus className="h-4 w-4 text-slate-500" />
</button>
<span className="text-sm text-slate-400 line-clamp-1">{card.title}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-slate-500">
AGENTS:
</span>
<div className="flex items-center gap-1 -space-x-1">
{activeAgents.slice(0, 4).map((agent) => (
<AgentAvatar key={agent.name} name={agent.name} status={agent.status} size="sm" />
))}
{otherAgents.slice(0, 2).map((agent) => (
<AgentAvatar key={agent.name} name={agent.name} status={agent.status} size="sm" />
))}
{card.agents.length > 6 && (
<span className="text-xs text-slate-500 ml-2">+{card.agents.length - 6}</span>
)}
<ProgressBar progress={card.progressPercent} />
<div className="text-xs text-slate-500">
Epic: <span className="font-mono text-slate-400">{card.epicId}</span>
</div>
<div className="grid grid-cols-4 gap-2 text-xs">
<div className="flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
<span>{card.completedIssues}</span>
</div>
<div className="flex items-center gap-1 text-amber-400">
<PlayCircle className="h-3 w-3" />
<span>{card.activeIssues}</span>
</div>
<div className="flex items-center gap-1 text-blue-400">
<Clock className="h-3 w-3" />
<span>{card.readyIssues}</span>
</div>
<div className="flex items-center gap-1 text-rose-400">
<AlertCircle className="h-3 w-3" />
<span>{card.blockedIssues}</span>
</div>
</div>
{card.agents.filter((a) => a.currentTask).slice(0, 2).map((agent) => (
<AgentRosterRow key={agent.name} agent={agent} />
))}
<AttentionList items={card.attentionItems} />
<ProgressBar progress={card.progress} />
{card.lastActivity && (
<div className="text-xs text-slate-500 italic truncate">
Last activity {formatTimeAgo(card.lastActivity)}
{card.coordinator && (
<div className="text-xs text-slate-500">
Coordinator: <span className="text-slate-400">{card.coordinator}</span>
</div>
)}
<div className="flex items-center justify-end gap-1 pt-1 border-t border-white/[0.04]">
<button
onClick={onMenu}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Menu"
>
<Menu className="h-3.5 w-3.5 text-slate-500" />
</button>
<button
onClick={onGraph}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Graph view"
>
<Diamond className="h-3.5 w-3.5 text-slate-500" />
</button>
<button
onClick={onTimeline}
className="p-1.5 rounded hover:bg-white/5 transition-colors"
aria-label="Timeline view"
>
<Waves className="h-3.5 w-3.5 text-slate-500" />
</button>
</div>
</div>
</Card>
);

View file

@ -0,0 +1,146 @@
'use client';
import type { SwarmCardData } from '../../lib/swarm-api';
import { Card } from '../../../components/ui/card';
import { Badge } from '../../../components/ui/badge';
import { Button } from '@/components/ui/button';
import { cn } from '../../lib/utils';
import { CheckCircle2, PlayCircle, Clock, AlertCircle, UserPlus, UserMinus, Activity } from 'lucide-react';
import { AgentAvatar } from '../shared/agent-avatar';
import { useAgentPool } from '../../hooks/use-agent-pool';
interface SwarmControlCardProps {
card: SwarmCardData;
projectRoot: string;
onJoin?: () => void;
onLeave?: () => void;
isJoining?: boolean;
}
function MiniGraph({ progress }: { progress: number }) {
// A simple visual indicator of progress complexity (mocked for now, but implies graph structure)
return (
<div className="flex h-8 items-end gap-0.5 opacity-50">
{[...Array(10)].map((_, i) => {
const height = Math.max(20, Math.random() * 80);
const active = (i * 10) < progress;
return (
<div
key={i}
className={cn("w-1 rounded-t-sm transition-all", active ? "bg-emerald-500" : "bg-slate-700")}
style={{ height: `${active ? height : 20}%` }}
/>
)
})}
</div>
);
}
const STATUS_COLORS: Record<string, string> = {
open: 'text-emerald-400 border-emerald-400/30',
closed: 'text-slate-400 border-slate-400/30',
in_progress: 'text-amber-400 border-amber-400/30',
};
export function SwarmControlCard({ card, projectRoot, onJoin, onLeave, isJoining }: SwarmControlCardProps) {
const { getAgentsBySwarm } = useAgentPool(projectRoot);
const agents = getAgentsBySwarm(card.swarmId);
return (
<Card className="group relative overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-card)] p-0 shadow-lg transition-all hover:border-[var(--ui-accent-info)] hover:shadow-xl">
{/* Background Decoration */}
<div className="absolute right-0 top-0 h-32 w-32 -translate-y-16 translate-x-16 rounded-full bg-emerald-500/5 blur-3xl transition-opacity group-hover:opacity-20" />
<div className="flex flex-col h-full p-4 space-y-4">
{/* Header */}
<div className="flex items-start justify-between">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs font-bold text-emerald-500">
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[9px] px-1.5 py-0 uppercase', STATUS_COLORS[card.status] ?? 'text-slate-400 border-slate-400/30')}
>
{card.status}
</Badge>
</div>
<h4 className="text-sm font-semibold text-slate-200 line-clamp-1 group-hover:text-white transition-colors">
{card.title}
</h4>
</div>
<Activity className="h-4 w-4 text-slate-600 group-hover:text-emerald-400 transition-colors" />
</div>
{/* Visualizer */}
<div className="rounded-lg bg-black/20 p-2">
<div className="flex justify-between items-end mb-1">
<span className="text-[10px] text-slate-500 font-mono">ACTIVITY</span>
<span className="text-[10px] text-emerald-400 font-mono">{card.progressPercent}%</span>
</div>
<MiniGraph progress={card.progressPercent} />
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-2 text-xs border-t border-white/5 pt-3">
<div className="flex flex-col items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.completedIssues}</span>
</div>
<div className="flex flex-col items-center gap-1 text-amber-400">
<PlayCircle className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.activeIssues}</span>
</div>
<div className="flex flex-col items-center gap-1 text-blue-400">
<Clock className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.readyIssues}</span>
</div>
<div className="flex flex-col items-center gap-1 text-rose-400">
<AlertCircle className="h-3.5 w-3.5" />
<span className="font-mono text-[10px]">{card.blockedIssues}</span>
</div>
</div>
{/* Agent Roster & Actions */}
<div className="flex items-center justify-between mt-auto pt-2">
<div className="flex -space-x-2">
{agents.slice(0, 3).map(agent => (
<div key={agent.agent_id} className="ring-2 ring-[var(--ui-bg-card)] rounded-full z-10">
<AgentAvatar
name={agent.display_name}
status={agent.status as any}
size="sm"
/>
</div>
))}
{agents.length > 3 && (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-slate-800 text-[10px] font-bold text-slate-400 ring-2 ring-[var(--ui-bg-card)] z-0">
+{agents.length - 3}
</div>
)}
{agents.length === 0 && (
<span className="text-[10px] text-slate-500 italic pl-1">No agents</span>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="h-7 px-2 text-[10px] gap-1 border-emerald-500/20 hover:bg-emerald-500/10 hover:text-emerald-400"
onClick={(e) => {
e.stopPropagation();
onJoin?.();
}}
disabled={isJoining}
>
<UserPlus className="h-3 w-3" />
Join
</Button>
</div>
</div>
</div>
</Card>
);
}

View file

@ -1,201 +1,179 @@
'use client';
import type { SwarmCard as SwarmCardType } from '../../lib/swarm-cards';
import { useEffect, useState } from 'react';
import type { SwarmCardData, SwarmStatusFromApi } from '../../lib/swarm-api';
import { Badge } from '../../../components/ui/badge';
import { AgentAvatar } from '../shared/agent-avatar';
import { cn } from '../../lib/utils';
import { AlertTriangle, Clock, Users } from 'lucide-react';
import { CheckCircle2, PlayCircle, Clock, AlertCircle, Loader2 } from 'lucide-react';
interface SwarmDetailProps {
card: SwarmCardType;
}
const HEALTH_COLORS: Record<string, string> = {
active: 'border-emerald-500/50 text-emerald-400',
stale: 'border-amber-500/50 text-amber-400',
stuck: 'border-rose-500/50 text-rose-400',
dead: 'border-red-600/50 text-red-500',
};
const STATUS_GLOW: Record<string, string> = {
active: 'shadow-[0_0_8px_rgba(52,211,153,0.5)]',
stale: 'shadow-[0_0_8px_rgba(251,191,36,0.4)]',
stuck: 'shadow-[0_0_8px_rgba(244,63,94,0.5)]',
dead: 'shadow-[0_0_8px_rgba(220,38,38,0.6)]',
};
function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffDays > 0) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffMins > 0) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
return 'just now';
swarmId: string;
projectRoot: string;
}
function ProgressBar({ progress }: { progress: number }) {
const filled = Math.round(progress / 10);
const empty = 10 - filled;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span style={{ color: 'var(--color-text-muted)' }}>Progress</span>
<span className="font-mono" style={{ color: 'var(--color-text-secondary)' }}>
{progress}%
</span>
<span className="text-slate-400">Progress</span>
<span className="font-mono text-slate-300">{progress}%</span>
</div>
<div
className="h-1.5 rounded-full overflow-hidden"
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${progress}%`,
backgroundColor:
progress >= 80
? 'var(--color-success)'
: progress >= 50
? 'var(--color-warning)'
: 'var(--color-error)',
}}
/>
<div className="flex items-center gap-2">
<div className="flex-1 font-mono text-xs text-slate-300">
{'█'.repeat(filled)}
{'░'.repeat(empty)}
</div>
</div>
</div>
);
}
function AgentRosterSection({ agents }: { agents: SwarmCardType['agents'] }) {
const active = agents.filter((a) => a.status === 'active').length;
const stale = agents.filter((a) => a.status === 'stale').length;
const stuck = agents.filter((a) => a.status === 'stuck').length;
const dead = agents.filter((a) => a.status === 'dead').length;
export function SwarmDetail({ swarmId, projectRoot }: SwarmDetailProps) {
const [status, setStatus] = useState<SwarmStatusFromApi | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchStatus() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/swarm/status?projectRoot=${encodeURIComponent(projectRoot)}&epic=${encodeURIComponent(swarmId)}`
);
const payload = await response.json();
if (payload.ok && payload.data) {
setStatus(payload.data);
} else {
setError(payload.error?.message || 'Failed to load swarm status');
}
} catch (e) {
setError('Failed to fetch swarm status');
} finally {
setIsLoading(false);
}
}
fetchStatus();
}, [swarmId, projectRoot]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-slate-400">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading swarm...
</div>
);
}
if (error) {
return (
<div className="py-8 text-center text-rose-400">
{error}
</div>
);
}
if (!status) {
return (
<div className="py-8 text-center text-slate-400">
No swarm data found
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<Users className="h-3.5 w-3.5" style={{ color: 'var(--color-text-muted)' }} />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Agents ({agents.length})
</span>
<div className="space-y-4 p-4">
{/* Header */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-slate-200">
{swarmId}
</span>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-emerald-400 border-emerald-400/30">
swarm
</Badge>
</div>
<h3 className="text-sm font-medium text-slate-200 line-clamp-2">
{status.epic_title}
</h3>
</div>
<div className="flex flex-wrap gap-1.5">
{agents.map((agent) => (
<div
key={agent.name}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md border',
STATUS_GLOW[agent.status]
)}
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<AgentAvatar name={agent.name} status={agent.status} size="sm" />
<span className="text-xs" style={{ color: 'var(--color-text-primary)' }}>
{agent.name}
</span>
{/* Progress */}
<ProgressBar progress={status.progress_percent} />
{/* Stats Grid */}
<div className="grid grid-cols-4 gap-2 text-xs">
<div className="flex items-center gap-1 text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
<span>{status.completed.length} done</span>
</div>
<div className="flex items-center gap-1 text-amber-400">
<PlayCircle className="h-3 w-3" />
<span>{status.active_count} active</span>
</div>
<div className="flex items-center gap-1 text-blue-400">
<Clock className="h-3 w-3" />
<span>{status.ready_count} ready</span>
</div>
<div className="flex items-center gap-1 text-rose-400">
<AlertCircle className="h-3 w-3" />
<span>{status.blocked_count} blocked</span>
</div>
</div>
{/* Active Tasks */}
{status.active.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Active ({status.active.length})
</h4>
<div className="space-y-1">
{status.active.map((task) => (
<div key={task.id} className="p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<span className="font-mono text-[10px] text-amber-300">{task.id}</span>
<p className="text-xs text-slate-300 line-clamp-1">{task.title}</p>
</div>
))}
</div>
</div>
)}
{/* Ready Tasks */}
{status.ready.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Ready to Pick Up ({status.ready.length})
</h4>
<div className="space-y-1">
{status.ready.map((task) => (
<div key={task.id} className="p-2 rounded-md bg-blue-500/10 border border-blue-500/20">
<span className="font-mono text-[10px] text-blue-300">{task.id}</span>
<p className="text-xs text-slate-300 line-clamp-1">{task.title}</p>
</div>
))}
</div>
</div>
)}
{/* Blocked Tasks */}
{status.blocked.length > 0 && (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-wider text-slate-400">
Blocked ({status.blocked.length})
</h4>
<div className="space-y-1">
{status.blocked.map((task) => (
<div key={task.id} className="p-2 rounded-md bg-rose-500/10 border border-rose-500/20">
<span className="font-mono text-[10px] text-rose-300">{task.id}</span>
<p className="text-xs text-slate-300 line-clamp-1">{task.title}</p>
</div>
))}
</div>
))}
</div>
{(active > 0 || stale > 0 || stuck > 0 || dead > 0) && (
<div className="flex gap-3 text-xs" style={{ color: 'var(--color-text-muted)' }}>
{active > 0 && <span className="text-emerald-400">{active} active</span>}
{stale > 0 && <span className="text-amber-400">{stale} stale</span>}
{stuck > 0 && <span className="text-rose-400">{stuck} stuck</span>}
{dead > 0 && <span className="text-red-500">{dead} dead</span>}
</div>
)}
</div>
);
}
function AttentionSection({ items }: { items: string[] }) {
if (items.length === 0) return null;
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5">
<AlertTriangle className="h-3.5 w-3.5 text-amber-400" />
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Attention ({items.length})
</span>
</div>
<div className="space-y-1.5">
{items.map((item, i) => (
<div
key={i}
className="flex items-start gap-1.5 p-2 rounded-md"
style={{ backgroundColor: 'var(--color-bg-elevated)' }}
>
<AlertTriangle className="h-3 w-3 text-amber-400 mt-0.5 flex-shrink-0" />
<span className="text-xs" style={{ color: 'var(--color-text-secondary)' }}>
{item}
</span>
</div>
))}
</div>
</div>
);
}
function LastActivitySection({ date }: { date: Date }) {
return (
<div className="flex items-center gap-1.5 text-xs" style={{ color: 'var(--color-text-muted)' }}>
<Clock className="h-3.5 w-3.5" />
<span>Last activity {formatRelativeTime(date)}</span>
</div>
);
}
function ThreadSection() {
return (
<div className="space-y-2">
<span className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--color-text-muted)' }}>
Thread
</span>
<p className="text-text-muted text-sm italic">
Thread drawer coming (bb-ui2.31)
</p>
</div>
);
}
export function SwarmDetail({ card }: SwarmDetailProps) {
return (
<div className="space-y-4">
{/* Header */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold" style={{ color: 'var(--color-text-primary)' }}>
{card.swarmId}
</span>
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', HEALTH_COLORS[card.health])}
>
{card.health}
</Badge>
</div>
<h3 className="text-sm font-medium line-clamp-2" style={{ color: 'var(--color-text-primary)' }}>
{card.title}
</h3>
</div>
{/* Progress */}
<ProgressBar progress={card.progress} />
{/* Agent Roster */}
<AgentRosterSection agents={card.agents} />
{/* Attention Items */}
<AttentionSection items={card.attentionItems} />
{/* Last Activity */}
<LastActivitySection date={card.lastActivity} />
{/* Thread */}
<ThreadSection />
</div>
);
}

View file

@ -0,0 +1,191 @@
'use client';
import { useEffect, useState } from 'react';
import type { SwarmCardData, SwarmStatusFromApi } from '../../lib/swarm-api';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { CheckCircle2, PlayCircle, Clock, AlertCircle, Loader2, Users } from 'lucide-react';
import { AgentAvatar } from '../shared/agent-avatar';
import { useAgentPool } from '../../hooks/use-agent-pool';
interface SwarmInspectorProps {
swarmId: string;
projectRoot: string;
onClose?: () => void;
}
function ProgressBar({ progress }: { progress: number }) {
const filled = Math.round(progress / 10);
const empty = 10 - filled;
return (
<div className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-400">Progress</span>
<span className="font-mono text-slate-300">{progress}%</span>
</div>
<div className="flex items-center gap-1 font-mono text-xs text-slate-300 tracking-widest">
<span className="text-emerald-400">{'█'.repeat(filled)}</span>
<span className="text-slate-700">{'░'.repeat(empty)}</span>
</div>
</div>
);
}
export function SwarmInspector({ swarmId, projectRoot }: SwarmInspectorProps) {
const [status, setStatus] = useState<SwarmStatusFromApi | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { agents, getAgentsBySwarm } = useAgentPool(projectRoot);
useEffect(() => {
async function fetchStatus() {
setIsLoading(true);
setError(null);
try {
const response = await fetch(
`/api/swarm/status?projectRoot=${encodeURIComponent(projectRoot)}&epic=${encodeURIComponent(swarmId)}`
);
const payload = await response.json();
if (payload.ok && payload.data) {
setStatus(payload.data);
} else {
setError(payload.error?.message || 'Failed to load swarm status');
}
} catch (e) {
setError('Failed to fetch swarm status');
} finally {
setIsLoading(false);
}
}
fetchStatus();
}, [swarmId, projectRoot]);
const assignedAgents = getAgentsBySwarm(swarmId);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center text-slate-400">
<Loader2 className="h-5 w-5 animate-spin mr-2" />
Loading...
</div>
);
}
if (error || !status) {
return (
<div className="p-4 text-center text-rose-400">
{error || 'No data found'}
</div>
);
}
return (
<div className="flex flex-col h-full bg-[#08111d] text-slate-200">
{/* Header */}
<div className="p-4 border-b border-[var(--ui-border-soft)] bg-[#0d1621]">
<div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="font-mono text-[10px] text-emerald-400 border-emerald-400/30 px-1.5">
{swarmId}
</Badge>
<span className="text-[10px] uppercase tracking-wider text-slate-500">Active Operation</span>
</div>
<h3 className="text-sm font-semibold leading-snug line-clamp-2 mb-3">
{status.epic_title}
</h3>
<ProgressBar progress={status.progress_percent} />
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Agent Roster */}
<section>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-500 flex items-center gap-2">
<Users className="h-3 w-3" />
Assigned Agents
</h4>
<span className="text-[10px] bg-slate-800 px-1.5 py-0.5 rounded text-slate-400">
{assignedAgents.length}
</span>
</div>
{assignedAgents.length === 0 ? (
<div className="text-xs text-slate-500 italic p-3 border border-dashed border-slate-800 rounded-lg text-center">
No agents currently assigned.
<br/>
<span className="text-[10px]">Use "Join" on the main card.</span>
</div>
) : (
<div className="space-y-2">
{assignedAgents.map(agent => (
<div key={agent.agent_id} className="flex items-center gap-3 p-2 rounded-lg bg-slate-800/50 border border-slate-800">
<AgentAvatar
name={agent.display_name}
status={agent.status as any}
size="sm"
/>
<div>
<p className="text-xs font-medium text-slate-300">{agent.display_name}</p>
<p className="text-[10px] text-slate-500 font-mono">{agent.status}</p>
</div>
</div>
))}
</div>
)}
</section>
{/* Task Stats */}
<section className="grid grid-cols-2 gap-2">
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-emerald-400">
<CheckCircle2 className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Done</span>
</div>
<span className="text-lg font-mono">{status.completed.length}</span>
</div>
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-amber-400">
<PlayCircle className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Active</span>
</div>
<span className="text-lg font-mono">{status.active_count}</span>
</div>
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-blue-400">
<Clock className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Ready</span>
</div>
<span className="text-lg font-mono">{status.ready_count}</span>
</div>
<div className="p-2 rounded bg-[#0f1824] border border-slate-800">
<div className="flex items-center gap-2 mb-1 text-rose-400">
<AlertCircle className="h-3 w-3" />
<span className="text-[10px] font-bold uppercase">Blocked</span>
</div>
<span className="text-lg font-mono">{status.blocked_count}</span>
</div>
</section>
{/* Active Tasks List */}
{status.active.length > 0 && (
<section>
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-500 mb-3">
Currently Executing
</h4>
<div className="space-y-2">
{status.active.map((task) => (
<div key={task.id} className="p-3 rounded-lg bg-amber-950/20 border border-amber-900/30">
<div className="flex items-center justify-between mb-1">
<span className="font-mono text-[10px] text-amber-500">{task.id}</span>
<Badge variant="outline" className="text-[9px] h-4 border-amber-800 text-amber-500">IN PROGRESS</Badge>
</div>
<p className="text-xs text-slate-300 line-clamp-2">{task.title}</p>
</div>
))}
</div>
</section>
)}
</div>
</div>
);
}

View file

@ -1,10 +1,11 @@
'use client';
import { useMemo, useState } from 'react';
import type { BeadIssue } from '../../lib/types';
import type { SwarmCard as SwarmCardType } from '../../lib/swarm-cards';
import { buildSwarmCards } from '../../lib/swarm-cards';
import { SwarmCard } from './swarm-card';
import { useMemo, useState, useCallback, useEffect, useRef } from 'react';
import { useMissionList, type MissionData } from '../../hooks/use-mission-list';
import { MissionCard } from '../mission/mission-card';
import { TeamManagerDialog } from '../mission/team-manager-dialog';
import { MissionInspector } from '../mission/mission-inspector';
import { LaunchSwarmDialog } from './launch-dialog';
import {
DropdownMenu,
DropdownMenuTrigger,
@ -14,7 +15,8 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { ArrowUpDown, ChevronDown } from 'lucide-react';
import { ArrowUpDown, ChevronDown, Loader2, Rocket, LayoutGrid, Users, Shield } from 'lucide-react';
import { useAgentPool } from '../../hooks/use-agent-pool';
type SortOption = 'health' | 'activity' | 'progress' | 'name';
@ -25,76 +27,157 @@ const SORT_LABELS: Record<SortOption, string> = {
name: 'Name',
};
const INITIAL_LIMIT = 16; // 4x4 grid
const HEALTH_ORDER: Record<string, number> = {
stuck: 0,
stale: 1,
dead: 2,
active: 3,
};
function sortCards(cards: SwarmCardType[], sortBy: SortOption): SwarmCardType[] {
const sorted = [...cards];
const INITIAL_LIMIT = 16;
function sortMissions(missions: MissionData[], sortBy: SortOption): MissionData[] {
const sorted = [...missions];
switch (sortBy) {
case 'health':
return sorted.sort((a, b) => {
const orderA = HEALTH_ORDER[a.health] ?? 4;
const orderB = HEALTH_ORDER[b.health] ?? 4;
return orderA - orderB;
});
case 'activity':
return sorted.sort((a, b) => b.lastActivity.getTime() - a.lastActivity.getTime());
case 'progress':
return sorted.sort((a, b) => b.progress - a.progress);
return sorted.sort((a, b) => (b.stats.done / (b.stats.total || 1)) - (a.stats.done / (a.stats.total || 1)));
case 'activity':
return sorted; // Need last_activity in API to sort real activity
case 'health':
return sorted.sort((a, b) => b.stats.blocked - a.stats.blocked); // Most blocked first
case 'name':
return sorted.sort((a, b) => a.swarmId.localeCompare(b.swarmId));
return sorted.sort((a, b) => a.title.localeCompare(b.title));
default:
return sorted;
}
}
interface SwarmPageProps {
issues: BeadIssue[];
projectRoot: string;
selectedId?: string;
onSelect: (id: string) => void;
setRightPanel?: (content: React.ReactNode | null) => void;
}
export function SwarmPage({ issues, selectedId, onSelect }: SwarmPageProps) {
export function SwarmPage({ projectRoot, selectedId, onSelect, setRightPanel }: SwarmPageProps) {
const [sortBy, setSortBy] = useState<SortOption>('health');
const [expanded, setExpanded] = useState(false);
const [manageTeamId, setManageTeamId] = useState<string | null>(null);
const cards = useMemo(() => buildSwarmCards(issues), [issues]);
const sortedCards = useMemo(() => sortCards(cards, sortBy), [cards, sortBy]);
const visibleCards = expanded ? sortedCards : sortedCards.slice(0, INITIAL_LIMIT);
const hasMore = sortedCards.length > INITIAL_LIMIT;
// Refs to break dependency loops
const onSelectRef = useRef(onSelect);
useEffect(() => { onSelectRef.current = onSelect; }, [onSelect]);
const { missions, isLoading, error, refresh: refreshMissions } = useMissionList(projectRoot);
const { agents, refresh: refreshAgents } = useAgentPool(projectRoot);
const sortedMissions = useMemo(() => sortMissions(missions, sortBy), [missions, sortBy]);
const visibleMissions = expanded ? sortedMissions : sortedMissions.slice(0, INITIAL_LIMIT);
const hasMore = sortedMissions.length > INITIAL_LIMIT;
const busyAgents = agents.filter(a => a.status === 'working').length;
// Handle Team Manager Actions
const handleAssign = useCallback(async (agentId: string, action: 'join' | 'leave') => {
// If called from inspector, we use selectedId. If called from dialog, we use manageTeamId.
const targetMissionId = manageTeamId || selectedId;
if (!targetMissionId) return;
const endpoint = action === 'join' ? '/api/mission/assign' : '/api/mission/assign';
await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectRoot,
missionId: targetMissionId,
agentId,
action
}),
});
await Promise.all([refreshMissions(), refreshAgents()]);
}, [manageTeamId, selectedId, projectRoot, refreshMissions, refreshAgents]);
const activeMissionForInspector = missions.find(m => m.id === selectedId);
const activeMission = missions.find(m => m.id === manageTeamId);
// Sync right panel on selectedId change
useEffect(() => {
if (selectedId && setRightPanel && activeMissionForInspector) {
setRightPanel(
<MissionInspector
missionId={selectedId}
missionTitle={activeMissionForInspector.title}
projectRoot={projectRoot}
assignedAgents={activeMissionForInspector.agents}
onClose={() => onSelectRef.current('')}
onAssign={(agentId, action) => handleAssign(agentId, action)}
/>
);
} else if (!selectedId && setRightPanel) {
setRightPanel(null);
}
}, [selectedId, projectRoot, setRightPanel, activeMissionForInspector, handleAssign]); // Removed onSelect from deps
return (
<div className="p-4">
<div className="flex items-center justify-between mb-4" style={{ maxWidth: '1200px', margin: '0 auto' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--color-text-primary)' }}>
Swarm View
</h2>
<div className="h-full overflow-y-auto bg-[var(--ui-bg-app)] px-4 py-4 md:px-6 custom-scrollbar">
{/* Dashboard Stats */}
<div className="mx-auto mb-6 grid w-full max-w-[1200px] grid-cols-1 gap-4 sm:grid-cols-3">
<div className="flex items-center gap-4 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-4 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-indigo-500/10 text-indigo-500">
<Shield className="h-5 w-5" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Active Missions</p>
<p className="text-xl font-mono text-slate-200">{missions.length}</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-4 shadow-sm">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
<Users className="h-5 w-5" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Agent Fleet</p>
<p className="text-xl font-mono text-slate-200">{agents.length}</p>
</div>
</div>
<div className="flex items-center gap-4 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] p-4 shadow-sm border-l-4 border-l-emerald-500">
<div>
<p className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Operational Load</p>
<div className="flex items-center gap-2">
<span className="text-xl font-mono text-slate-200">{busyAgents}/{agents.length}</span>
<span className="text-[10px] text-slate-500">engaged</span>
</div>
</div>
</div>
</div>
{/* Toolbar */}
<div className="mx-auto mb-4 flex w-full max-w-[1200px] items-center justify-between gap-3 rounded-xl border border-[var(--ui-border-soft)] bg-[var(--ui-bg-shell)] px-3 py-2 shadow-sm">
<div className="flex items-center gap-3">
<div className="min-w-0">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--ui-text-muted)]">Command</p>
<h2 className="text-base font-semibold text-[var(--ui-text-primary)]">
Mission Control
</h2>
</div>
<div className="h-8 w-px bg-white/5 mx-2" />
<LaunchSwarmDialog projectRoot={projectRoot} onSuccess={refreshMissions} />
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="gap-2 border-white/10 bg-white/5 hover:bg-white/10"
className="gap-2 border-white/10 bg-white/5 text-[var(--ui-text-primary)] hover:bg-white/10"
>
<ArrowUpDown className="h-4 w-4" />
{SORT_LABELS[sortBy]}
<ArrowUpDown className="h-4 w-4 text-slate-500" aria-hidden="true" />
<span className="text-xs uppercase tracking-wider font-bold">{SORT_LABELS[sortBy]}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuContent align="end" className="w-40 bg-[#0d1621] border-slate-800 text-slate-300">
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-slate-500">Sort Missions</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-white/5" />
{(Object.keys(SORT_LABELS) as SortOption[]).map((option) => (
<DropdownMenuItem
key={option}
onClick={() => setSortBy(option)}
className={sortBy === option ? 'bg-accent/50' : ''}
className={sortBy === option ? 'bg-indigo-500/10 text-indigo-400' : 'focus:bg-white/5 focus:text-white'}
>
{SORT_LABELS[option]}
</DropdownMenuItem>
@ -103,47 +186,64 @@ export function SwarmPage({ issues, selectedId, onSelect }: SwarmPageProps) {
</DropdownMenu>
</div>
<div
className="grid gap-4"
style={{
gridTemplateColumns: 'repeat(4, 1fr)',
maxWidth: '1200px',
margin: '0 auto',
}}
>
{visibleCards.map((card) => (
<div
key={card.swarmId}
onClick={() => onSelect(card.swarmId)}
className={`cursor-pointer rounded-xl transition-all ${
selectedId === card.swarmId
? 'ring-2 ring-[var(--color-accent-amber)]'
: 'hover:ring-1 hover:ring-white/10'
}`}
>
<SwarmCard card={card} />
</div>
{/* Grid */}
<div className="mx-auto grid w-full max-w-[1200px] grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{visibleMissions.map((mission) => (
<MissionCard
key={mission.id}
id={mission.id}
projectRoot={projectRoot}
title={mission.title}
description={mission.description}
status={mission.status as any}
stats={mission.stats}
agents={mission.agents}
onClick={() => onSelect(mission.id)}
onDeploy={() => setManageTeamId(mission.id)}
/>
))}
</div>
{hasMore && (
<div className="flex justify-center mt-4">
<div className="mt-8 flex justify-center pb-12">
<Button
variant="outline"
onClick={() => setExpanded(true)}
className="gap-2 border-white/10 bg-white/5 hover:bg-white/10"
className="gap-2 border-white/10 bg-white/5 text-[var(--ui-text-primary)] hover:bg-white/10"
>
Show {sortedCards.length - INITIAL_LIMIT} more
<ChevronDown className="h-4 w-4" />
Show All Missions
<ChevronDown className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
)}
{sortedCards.length === 0 && (
<div className="text-center py-12" style={{ color: 'var(--color-text-muted)' }}>
No swarms found. Add agents with <code className="px-1 py-0.5 rounded bg-white/5">gt:agent</code> and <code className="px-1 py-0.5 rounded bg-white/5">swarm:*</code> labels.
{isLoading && (
<div className="py-24 flex flex-col items-center justify-center text-[var(--color-text-muted)]">
<Loader2 className="h-8 w-8 animate-spin mb-4 text-indigo-500" />
<p className="text-sm font-mono uppercase tracking-widest animate-pulse">Establishing Uplink...</p>
</div>
)}
{!isLoading && !error && missions.length === 0 && (
<div className="py-24 flex flex-col items-center justify-center text-[var(--color-text-muted)]">
<Rocket className="h-12 w-12 mb-4 opacity-20" />
<p className="text-sm mb-4">No active missions. Launch one to begin.</p>
<LaunchSwarmDialog projectRoot={projectRoot} onSuccess={refreshMissions} />
</div>
)}
{/* Dialogs */}
{activeMission && (
<TeamManagerDialog
isOpen={!!manageTeamId}
onClose={() => setManageTeamId(null)}
missionId={activeMission.id}
missionTitle={activeMission.title}
projectRoot={projectRoot}
assignedAgents={activeMission.agents}
onAssign={handleAssign}
/>
)}
</div>
);
}
}

View file

@ -1,26 +1,141 @@
"use client";
import React, { useState } from 'react';
import { SwarmLiveDag } from './swarm-live-dag';
import { ConvoyStepper } from './convoy-stepper';
import { TelemetryGrid } from './telemetry-grid';
import { ConvoyStepper, type Phase } from './convoy-stepper';
import { Network, Blocks, FileCode2, Info } from 'lucide-react';
import { cn } from '../../lib/utils';
import type { BeadIssue } from '../../lib/types';
import { useArchetypes } from '../../hooks/use-archetypes';
import { useTemplates } from '../../hooks/use-templates';
import { ArchetypeInspector } from './archetype-inspector';
import { TemplateInspector } from './template-inspector';
export function SwarmWorkspace({ selectedMissionId }: { selectedMissionId?: string }) {
export function SwarmWorkspace({ selectedMissionId, issues = [] }: { selectedMissionId?: string, issues?: BeadIssue[] }) {
const [activeTab, setActiveTab] = useState<'operations' | 'archetypes' | 'templates'>('operations');
// Inspector State
const [inspectingArchetypeId, setInspectingArchetypeId] = useState<string | null>(null);
const [inspectingTemplateId, setInspectingTemplateId] = useState<string | null>(null);
const { archetypes, isLoading: archetypesLoading } = useArchetypes();
const { templates, isLoading: templatesLoading } = useTemplates();
// Simulation State
const [isSimulating, setIsSimulating] = useState(false);
const [simPhase, setSimPhase] = useState<Phase>('planning');
const [simBeads, setSimBeads] = useState<BeadIssue[]>([]);
const handleSummon = () => {
setIsSimulating(true);
setSimPhase('planning');
setSimBeads([]);
// Mock Flow: Planning -> Graph Generation -> Deployment -> Execution
setTimeout(() => {
setSimPhase('deployment'); // Skipping Graph Generation for simplicity here
// Generate some fake beads
const mockBeads: BeadIssue[] = [
{
id: 'b-mock-1',
title: 'Analyze DB Schema',
status: 'closed',
assignee: 'Alice (Architect)',
owner: null,
description: null,
issue_type: 'task',
priority: 1,
labels: [],
dependencies: [{ type: 'parent', target: selectedMissionId || 'epic' }],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
closed_at: null, close_reason: null, closed_by_session: null, created_by: null, due_at: null, estimated_minutes: null, external_ref: null, metadata: {}
},
{
id: 'b-mock-2',
title: 'Implement API Routes',
status: 'in_progress',
assignee: 'Bob (Backend)',
owner: null,
description: null,
issue_type: 'task',
priority: 1,
labels: [],
dependencies: [
{ type: 'parent', target: selectedMissionId || 'epic' },
{ type: 'blocks', target: 'b-mock-1' } // Bob waits for Alice
],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
closed_at: null, close_reason: null, closed_by_session: null, created_by: null, due_at: null, estimated_minutes: null, external_ref: null, metadata: {}
},
{
id: 'b-mock-3',
title: 'Build UI Components',
status: 'blocked',
assignee: 'Charlie (Frontend)',
owner: null,
description: null,
issue_type: 'task',
priority: 1,
labels: [],
dependencies: [
{ type: 'parent', target: selectedMissionId || 'epic' },
{ type: 'blocks', target: 'b-mock-2' } // Charlie waits for Bob
],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
closed_at: null, close_reason: null, closed_by_session: null, created_by: null, due_at: null, estimated_minutes: null, external_ref: null, metadata: {}
}
];
setTimeout(() => {
setSimBeads(mockBeads);
setSimPhase('execution');
}, 1000);
}, 1500);
};
const displayBeads = isSimulating ? simBeads : issues;
const renderTabContent = () => {
switch (activeTab) {
case 'operations':
return selectedMissionId
? (
<div className="flex flex-col h-full gap-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<ConvoyStepper activePhase="execution" />
<div className="flex-1 min-h-0 bg-[#0f1824]/50 rounded-xl border border-[var(--ui-border-soft)] p-2 shadow-inner">
<SwarmLiveDag epicId={selectedMissionId} />
? (() => {
const epic = issues.find(i => i.id === selectedMissionId);
let epicPhase: Phase = 'planning';
if (epic?.status === 'in_progress') epicPhase = 'execution';
if (epic?.status === 'closed' || epic?.status === 'tombstone') epicPhase = 'debrief';
return (
<div className="flex flex-col h-full gap-4 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center justify-between">
<ConvoyStepper activePhase={isSimulating ? simPhase : epicPhase} />
<div className="flex gap-2">
<button
onClick={() => setIsSimulating(false)}
className="px-3 py-1.5 text-xs font-semibold bg-rose-500/10 text-rose-500 hover:bg-rose-500/20 rounded-md transition-colors"
>
Halt Swarm
</button>
<button
onClick={handleSummon}
disabled={isSimulating && simPhase !== 'debrief'}
className="px-3 py-1.5 text-xs font-bold bg-[var(--ui-accent-info)] text-white hover:bg-[var(--ui-accent-info)]/90 shadow shadow-[var(--ui-accent-info)]/20 rounded-md transition-colors disabled:opacity-50"
>
Summon Polecats
</button>
</div>
</div>
<div className="flex-1 min-h-0">
<TelemetryGrid epicId={selectedMissionId} issues={displayBeads} archetypes={archetypes} />
</div>
</div>
</div>
)
)
})()
: (
<div className="flex flex-col items-center justify-center h-full text-center space-y-4 animate-in fade-in duration-700">
<div className="p-4 bg-[var(--ui-accent-info)]/10 rounded-full">
@ -36,36 +151,122 @@ export function SwarmWorkspace({ selectedMissionId }: { selectedMissionId?: stri
);
case 'archetypes':
return (
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500 overflow-y-auto custom-scrollbar">
<h3 className="text-xl font-bold text-[var(--ui-text-primary)] mb-2">Agent Archetypes</h3>
<p className="text-[var(--ui-text-muted)] text-sm mb-6">Manage the base roles and system prompts available to your swarms.</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Placeholder Cards */}
{[1, 2, 3].map(i => (
<div key={i} className="bg-[#111f2b] p-4 rounded-lg border border-[var(--ui-border-soft)] hover:border-[var(--ui-accent-info)]/50 transition-colors">
<div className="h-10 w-10 rounded-lg bg-[var(--ui-accent-info)]/20 mb-3" />
<div className="h-4 w-24 bg-white/10 rounded mb-2" />
<div className="h-3 w-3/4 bg-white/5 rounded" />
{archetypesLoading ? (
[1, 2, 3].map(i => (
<div key={i} className="bg-[#111f2b] p-4 rounded-lg border border-[var(--ui-border-soft)] animate-pulse">
<div className="h-10 w-10 rounded-lg bg-[var(--ui-accent-info)]/20 mb-3" />
<div className="h-4 w-24 bg-white/10 rounded mb-2" />
<div className="h-3 w-3/4 bg-white/5 rounded" />
</div>
))
) : archetypes.length === 0 ? (
<div className="col-span-full text-center text-[var(--ui-text-muted)] text-sm py-8 border border-dashed border-white/10 rounded-lg">
No archetypes found. Create one in the `.beads/archetypes/` directory.
</div>
))}
) : (
archetypes.map(arc => (
<button
key={arc.id}
onClick={() => setInspectingArchetypeId(arc.id)}
className="bg-[#111f2b] p-4 rounded-xl border border-[var(--ui-border-soft)] hover:border-[var(--ui-accent-info)]/50 focus:outline-none focus:ring-2 focus:ring-[var(--ui-accent-info)] transition-colors shadow-[0_18px_28px_-22px_rgba(0,0,0,0.96)] flex flex-col text-left w-full h-full"
>
<div className="flex items-center gap-3 mb-3">
<div className="h-10 w-10 flex-shrink-0 rounded-lg flex items-center justify-center font-bold text-lg border" style={{ backgroundColor: `${arc.color}15`, color: arc.color, borderColor: `${arc.color}30` }}>
{arc.name.charAt(0)}
</div>
<div className="truncate">
<div className="font-semibold text-[15px] text-[var(--ui-text-primary)] truncate">{arc.name}</div>
<div className="text-[10px] text-[var(--ui-text-muted)] font-mono uppercase tracking-wider truncate">{arc.id}</div>
</div>
</div>
<div className="text-xs text-[var(--ui-text-muted)] line-clamp-2 mb-4 flex-1">
{arc.description}
</div>
<div className="flex flex-wrap gap-1 mt-auto">
{arc.capabilities.slice(0, 3).map((cap, idx) => (
<span key={idx} className="px-1.5 py-0.5 rounded bg-white/5 text-[9px] uppercase font-semibold text-[var(--ui-text-muted)] border border-white/10">
{cap}
</span>
))}
{arc.capabilities.length > 3 && (
<span className="px-1.5 py-0.5 rounded bg-white/5 text-[9px] uppercase font-semibold text-[var(--ui-text-muted)] border border-white/10">
+{arc.capabilities.length - 3}
</span>
)}
</div>
</button>
))
)}
</div>
</div>
);
case 'templates':
return (
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="p-6 bg-[#0f1824]/30 rounded-xl border border-[var(--ui-border-soft)] h-full animate-in fade-in slide-in-from-bottom-4 duration-500 overflow-y-auto custom-scrollbar">
<h3 className="text-xl font-bold text-[var(--ui-text-primary)] mb-2">Swarm Templates</h3>
<p className="text-[var(--ui-text-muted)] text-sm mb-6">Define predefined teams and formulas for rapid mission deployment.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1, 2].map(i => (
<div key={i} className="bg-[#111f2b] p-5 rounded-lg border border-[var(--ui-border-soft)] flex items-center gap-4 hover:border-amber-500/50 transition-colors">
<div className="h-12 w-12 rounded-full bg-amber-500/20" />
<div>
<div className="h-4 w-32 bg-white/10 rounded mb-2" />
<div className="h-3 w-48 bg-white/5 rounded" />
{templatesLoading ? (
[1, 2].map(i => (
<div key={i} className="bg-[#111f2b] p-5 rounded-xl border border-[var(--ui-border-soft)] flex items-center gap-4 animate-pulse">
<div className="h-12 w-12 rounded-full bg-amber-500/20" />
<div className="flex-1">
<div className="h-4 w-32 bg-white/10 rounded mb-2" />
<div className="h-3 w-48 bg-white/5 rounded" />
</div>
</div>
))
) : templates.length === 0 ? (
<div className="col-span-full text-center text-[var(--ui-text-muted)] text-sm py-8 border border-dashed border-white/10 rounded-lg">
No templates found. Create one in the `.beads/templates/` directory.
</div>
))}
) : (
templates.map(tpl => (
<button
key={tpl.id}
onClick={() => setInspectingTemplateId(tpl.id)}
className="bg-[#111f2b] p-5 rounded-xl border border-[var(--ui-border-soft)] flex flex-col gap-4 hover:border-amber-500/50 focus:outline-none focus:ring-2 focus:ring-amber-500/50 transition-colors shadow-[0_18px_28px_-22px_rgba(0,0,0,0.96)] text-left w-full"
>
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-3 w-full pr-2">
<div className="h-10 w-10 flex-shrink-0 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-500 font-bold">
{tpl.team.reduce((acc, curr) => acc + curr.count, 0)}
</div>
<div className="truncate">
<div className="font-semibold text-[15px] text-[var(--ui-text-primary)] truncate">{tpl.name}</div>
<div className="text-[10px] text-[var(--ui-text-muted)] font-mono uppercase tracking-wider truncate">{tpl.id}</div>
</div>
</div>
{tpl.isBuiltIn && (
<span className="flex-shrink-0 px-2 py-0.5 rounded-full bg-white/5 text-[9px] uppercase font-bold text-[var(--ui-text-muted)] border border-white/10">Default</span>
)}
</div>
<div className="text-xs text-[var(--ui-text-muted)] line-clamp-2">
{tpl.description}
</div>
<div className="mt-auto pt-3 border-t border-[var(--ui-border-soft)] w-full">
<div className="text-[10px] uppercase font-bold text-[var(--ui-text-muted)] tracking-wider mb-2">Team Composition</div>
<div className="flex flex-wrap gap-2">
{tpl.team.map((member, idx) => {
const arch = archetypes.find(a => a.id === member.archetypeId);
return (
<div key={idx} className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-[#0f1824] border border-[var(--ui-border-soft)]">
<div className="h-4 w-4 rounded text-[9px] flex items-center justify-center font-bold" style={{ backgroundColor: `${arch?.color || '#888'}20`, color: arch?.color || '#888' }}>
{arch?.name.charAt(0) || '?'}
</div>
<span className="text-[11px] text-[var(--ui-text-primary)] font-medium">{member.count}x {arch?.name || member.archetypeId}</span>
</div>
);
})}
</div>
</div>
</button>
))
)}
</div>
</div>
);
@ -121,6 +322,22 @@ export function SwarmWorkspace({ selectedMissionId }: { selectedMissionId?: stri
{renderTabContent()}
</div>
</main>
{/* Popups */}
{inspectingArchetypeId && (
<ArchetypeInspector
archetype={archetypes.find(a => a.id === inspectingArchetypeId)!}
onClose={() => setInspectingArchetypeId(null)}
/>
)}
{inspectingTemplateId && (
<TemplateInspector
template={templates.find(t => t.id === inspectingTemplateId)!}
archetypes={archetypes}
onClose={() => setInspectingTemplateId(null)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,217 @@
"use client";
import React, { useState } from 'react';
import dynamic from 'next/dynamic';
import { Loader2, AlertCircle, Bot, Zap } from 'lucide-react';
import type { BeadIssue } from '../../lib/types';
import type { AgentArchetype } from '../../lib/types-swarm';
const SpecializedAgentDagLazy = dynamic(
() => import('./specialized-agent-dag').then((m) => m.SpecializedAgentDag),
{
ssr: false,
loading: () => (
<div className="flex items-center justify-center p-8 w-full h-full min-h-[200px]">
<Loader2 className="animate-spin text-muted-foreground" />
</div>
),
}
);
interface TelemetryGridProps {
epicId: string;
issues: BeadIssue[];
archetypes: AgentArchetype[];
}
export function TelemetryGrid({ epicId, issues, archetypes }: TelemetryGridProps) {
const [selectedBeadId, setSelectedBeadId] = useState<string | null>(null);
const [isPrepping, setIsPrepping] = useState(false);
const [prepSuccess, setPrepSuccess] = useState(false);
const [selectedArchetypeForPrep, setSelectedArchetypeForPrep] = useState<string>('');
// 1. Filter beads for this epic
const beads = issues.filter(issue => {
if (issue.issue_type === 'epic') return false; // don't include epic itself in DAG
const parent = issue.dependencies.find(d => d.type === 'parent');
return parent?.target === epicId;
});
// 2. Compute "Attention Feed" (Blocked beads)
const blockedBeads = beads.filter(b => b.status === 'blocked');
// 3. Compute "Active Roster" (Unique assignees working on in_progress beads)
const activeAssignees = new Set<string>();
const rosterEntries: { assignee: string, currentTask: string, archetype?: AgentArchetype }[] = [];
beads.forEach(b => {
if (b.status === 'in_progress' && b.assignee && !activeAssignees.has(b.assignee)) {
activeAssignees.add(b.assignee);
const assigneeStr = b.assignee.toLowerCase();
const matchedArchetype = archetypes.find(a =>
assigneeStr.includes(a.id.toLowerCase()) ||
assigneeStr.includes(a.name.toLowerCase())
);
rosterEntries.push({
assignee: b.assignee,
currentTask: b.title,
archetype: matchedArchetype
});
}
});
const selectedBead = selectedBeadId ? beads.find(b => b.id === selectedBeadId) : null;
const handlePrepTask = async () => {
if (!selectedBead || !selectedArchetypeForPrep) return;
setIsPrepping(true);
setPrepSuccess(false);
try {
const res = await fetch('/api/swarm/prep', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
beadId: selectedBead.id,
archetypeId: selectedArchetypeForPrep
})
});
if (!res.ok) throw new Error('Prep failed');
setPrepSuccess(true);
setTimeout(() => setPrepSuccess(false), 3000);
// Note: The shell's useIssues typically polls or relies on SWR to update.
// In a real app we'd call mutate() here.
} catch (e) {
console.error(e);
} finally {
setIsPrepping(false);
}
};
return (
<div className="flex flex-col lg:flex-row gap-4 h-full animate-in fade-in duration-500">
{/* Left/Top: Specialized DAG */}
<div className="flex-[2] min-h-[400px] lg:min-h-0 bg-[#0f1824]/50 rounded-xl border border-[var(--ui-border-soft)] shadow-inner relative overflow-hidden flex flex-col">
<div className="absolute top-3 left-3 z-10 px-3 py-1.5 bg-background/80 backdrop-blur rounded-md border border-[var(--ui-border-soft)] flex items-center gap-2 shadow-sm pointer-events-none">
<Bot className="w-4 h-4 text-[var(--ui-accent-info)]" />
<span className="text-xs font-semibold tracking-wide uppercase text-[var(--ui-text-primary)]">Agent Flow</span>
</div>
<div className="flex-1 w-full h-full">
<SpecializedAgentDagLazy
beads={beads}
archetypes={archetypes}
selectedId={selectedBeadId}
onSelect={setSelectedBeadId}
/>
</div>
</div>
{/* Right/Bottom: Feeds */}
<div className="flex-1 flex flex-col gap-4 min-w-[300px]">
{/* Task Assignment Panel (Shows if a node is selected) */}
{selectedBead && (
<div className="flex-none bg-[#111f2b] rounded-xl border border-[var(--ui-accent-info)]/30 flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)] ring-1 ring-[var(--ui-accent-info)]/10">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<Zap className="w-4 h-4 text-[var(--ui-accent-info)]" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Task Assignment</h3>
</div>
<div className="p-4 space-y-4">
<div>
<div className="text-[10px] font-mono text-[var(--ui-text-muted)] uppercase tracking-wider mb-1">{selectedBead.id}</div>
<div className="text-sm font-semibold text-[var(--ui-text-primary)] leading-snug">{selectedBead.title}</div>
<div className="text-xs text-[var(--ui-text-muted)] mt-1">Status: <span className="font-semibold uppercase">{selectedBead.status}</span></div>
</div>
{(selectedBead.status === 'open' || selectedBead.status === 'blocked') ? (
<div className="space-y-3">
<div>
<label className="text-xs font-medium text-[var(--ui-text-muted)] mb-1.5 block">Assign Agent Archetype</label>
<select
value={selectedArchetypeForPrep}
onChange={(e) => setSelectedArchetypeForPrep(e.target.value)}
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--ui-accent-info)]"
>
<option value="" disabled>Select archetype...</option>
{archetypes.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<button
onClick={handlePrepTask}
disabled={!selectedArchetypeForPrep || isPrepping || prepSuccess}
className={`w-full py-2 text-white text-sm font-bold rounded-md disabled:opacity-50 transition-colors flex items-center justify-center ${prepSuccess ? 'bg-emerald-500' : 'bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90'}`}
>
{isPrepping ? <Loader2 className="w-4 h-4 animate-spin" /> : prepSuccess ? 'Prep Successful!' : 'Prep Task for Swarm'}
</button>
</div>
) : (
<div className="text-xs text-amber-500 bg-amber-500/10 p-2 rounded border border-amber-500/20">
Task is {selectedBead.status.replace('_', ' ')}. Only open or blocked tasks can be prepped.
</div>
)}
</div>
</div>
)}
{/* Priority Attention */}
<div className="flex-1 bg-[#111f2b] rounded-xl border border-[var(--ui-border-soft)] flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-rose-500" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Priority Attention</h3>
<span className="ml-auto bg-rose-500/10 text-rose-500 text-[10px] font-bold px-2 py-0.5 rounded-full">{blockedBeads.length} Blocked</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 custom-scrollbar">
{blockedBeads.length === 0 ? (
<div className="text-center text-xs text-[var(--ui-text-muted)] mt-6">
All clear. No blocked tasks.
</div>
) : (
blockedBeads.map(b => (
<div key={b.id} className="p-3 bg-rose-500/5 border border-rose-500/20 rounded-lg">
<div className="text-xs font-mono text-rose-500 mb-1">{b.id}</div>
<div className="text-sm text-[var(--ui-text-primary)] font-medium leading-tight">{b.title}</div>
</div>
))
)}
</div>
</div>
{/* Active Roster */}
<div className="flex-1 bg-[#111f2b] rounded-xl border border-[var(--ui-border-soft)] flex flex-col overflow-hidden shadow-[0_8px_16px_-12px_rgba(0,0,0,0.8)]">
<div className="px-4 py-3 border-b border-[var(--ui-border-soft)] bg-[#14202e] flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<h3 className="font-semibold text-sm text-[var(--ui-text-primary)]">Active Roster</h3>
<span className="ml-auto text-[10px] uppercase font-bold text-[var(--ui-text-muted)] tracking-wider">{rosterEntries.length} Deployed</span>
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-2 custom-scrollbar">
{rosterEntries.length === 0 ? (
<div className="text-center text-xs text-[var(--ui-text-muted)] mt-6">
No agents currently active.
</div>
) : (
rosterEntries.map((r, i) => (
<div key={i} className="flex gap-3 p-3 bg-[#0a111a] border border-white/5 rounded-lg items-center">
<div
className="h-8 w-8 rounded flex-shrink-0 flex items-center justify-center font-bold text-sm border"
style={{ backgroundColor: `${r.archetype?.color || '#888'}15`, color: r.archetype?.color || '#888', borderColor: `${r.archetype?.color || '#888'}30` }}
>
{r.assignee.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-bold text-[var(--ui-text-primary)] truncate">{r.assignee}</div>
<div className="text-[10px] text-[var(--ui-text-muted)] truncate">{r.currentTask}</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,138 @@
import React from 'react';
import { X, Save, Edit, Link, Network } from 'lucide-react';
import type { SwarmTemplate, AgentArchetype } from '../../lib/types-swarm';
interface TemplateInspectorProps {
template: SwarmTemplate;
archetypes: AgentArchetype[];
onClose: () => void;
}
export function TemplateInspector({ template, archetypes, onClose }: TemplateInspectorProps) {
if (!template) return null;
const totalAgents = template.team.reduce((acc, curr) => acc + curr.count, 0);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="flex flex-col h-[75vh] w-full max-w-2xl overflow-hidden rounded-xl border border-[var(--ui-border-soft)] bg-[#0f1824] shadow-2xl animate-in zoom-in-95 duration-200">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e]">
<div className="flex items-center gap-4">
<div className="h-10 w-10 flex-shrink-0 rounded-full bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-500 font-bold text-lg">
{totalAgents}
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-bold text-[var(--ui-text-primary)] leading-tight">{template.name}</h2>
{template.isBuiltIn && (
<span className="px-1.5 py-0.5 rounded-full bg-white/10 text-[9px] uppercase font-bold text-[var(--ui-text-muted)] border border-white/10">Built-in</span>
)}
</div>
<p className="font-mono uppercase tracking-wider text-[10px] text-[var(--ui-text-muted)] mt-0.5">{template.id}</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-full p-2 text-[var(--ui-text-muted)] hover:bg-white/5 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Body Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-6 custom-scrollbar">
{/* Metadata Section */}
<div>
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-1.5 block">Purpose / Description</label>
<textarea
defaultValue={template.description}
readOnly
rows={2}
className="w-full bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 text-sm text-[var(--ui-text-primary)] focus:outline-none focus:border-[var(--ui-accent-info)] focus:ring-1 focus:ring-[var(--ui-accent-info)] resize-none"
/>
</div>
{/* Team Composition Builder */}
<div className="border-t border-[var(--ui-border-soft)] pt-5">
<div className="flex items-center justify-between mb-4">
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider flex items-center gap-2">
<Network className="w-4 h-4 text-emerald-500" />
Roster Composition
</label>
<button className="text-[11px] font-semibold text-[var(--ui-accent-info)] hover:text-white bg-[var(--ui-accent-info)]/10 px-2 py-1 rounded transition-colors disabled:opacity-50">
+ Add Member
</button>
</div>
<div className="space-y-2">
{template.team.map((member, idx) => {
const arch = archetypes.find(a => a.id === member.archetypeId);
return (
<div key={idx} className="flex items-center gap-3 bg-[#111f2b] border border-[var(--ui-border-soft)] p-3 rounded-lg">
<div className="h-8 w-8 rounded text-sm flex items-center justify-center font-bold" style={{ backgroundColor: `${arch?.color || '#888'}20`, color: arch?.color || '#888' }}>
{arch?.name.charAt(0) || '?'}
</div>
<div className="flex-1">
<div className="font-semibold text-sm text-[var(--ui-text-primary)]">{arch?.name || member.archetypeId}</div>
<div className="text-[11px] text-[var(--ui-text-muted)]">{arch?.description || 'Unknown Archetype'}</div>
</div>
<div className="flex items-center gap-2 bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md p-1">
<span className="text-xs font-mono text-[var(--ui-text-muted)] px-2">Count:</span>
<input
type="number"
defaultValue={member.count}
readOnly
className="w-12 bg-transparent text-sm font-bold text-center text-[var(--ui-text-primary)] focus:outline-none"
/>
</div>
</div>
);
})}
</div>
</div>
{/* Advanced: Proto-formula */}
<div className="border-t border-[var(--ui-border-soft)] pt-5">
<label className="text-xs font-semibold text-[var(--ui-text-muted)] uppercase tracking-wider mb-2 flex items-center gap-2">
<Link className="w-4 h-4 text-amber-500" />
MOL Proto-Formula (Optional)
</label>
<div className="flex items-center gap-3">
<input
type="text"
defaultValue={template.protoFormula || ''}
placeholder="e.g. 'release' or 'bugfix'"
readOnly
className="flex-1 bg-[#0a111a] border border-[var(--ui-border-soft)] rounded-md px-3 py-2 font-mono text-sm text-amber-500 focus:outline-none focus:border-amber-500/50 focus:ring-1 focus:ring-amber-500/50"
/>
<div className="text-[11px] text-[var(--ui-text-muted)] max-w-[200px] leading-tight">
Specifies a Gastown Formula to execute (`bd mol pour`) when launching this swarm.
</div>
</div>
</div>
</div>
{/* Footer Controls */}
<div className="border-t border-[var(--ui-border-soft)] px-5 py-4 bg-[#14202e] flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-semibold text-[var(--ui-text-primary)] hover:bg-white/5 rounded-md transition-colors"
>
Close
</button>
<button
disabled
className="flex items-center gap-2 px-4 py-2 text-sm font-bold bg-[var(--ui-accent-info)] hover:bg-[var(--ui-accent-info)]/90 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Save className="w-4 h-4" />
Save Template
</button>
</div>
</div>
</div>
);
}