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:
parent
409a7e7256
commit
dfaf523029
74 changed files with 11066 additions and 2046 deletions
110
src/components/swarm/archetype-inspector.tsx
Normal file
110
src/components/swarm/archetype-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/swarm/convoy-stepper.tsx
Normal file
31
src/components/swarm/convoy-stepper.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
src/components/swarm/launch-dialog.tsx
Normal file
172
src/components/swarm/launch-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
210
src/components/swarm/specialized-agent-dag.tsx
Normal file
210
src/components/swarm/specialized-agent-dag.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
146
src/components/swarm/swarm-control-card.tsx
Normal file
146
src/components/swarm/swarm-control-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
191
src/components/swarm/swarm-inspector.tsx
Normal file
191
src/components/swarm/swarm-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
217
src/components/swarm/telemetry-grid.tsx
Normal file
217
src/components/swarm/telemetry-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
src/components/swarm/template-inspector.tsx
Normal file
138
src/components/swarm/template-inspector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue