Files
claude-code/web/src/components/visualizations/s09-agent-teams.tsx
T
CrazyBoyMandCrazyBoyM c6a27ef1d7 feat: build an AI agent from 0 to 1 -- 11 progressive sessions
- 11 sessions from basic agent loop to autonomous teams
- Python MVP implementations for each session
- Mental-model-first docs in en/zh/ja
- Interactive web platform with step-through visualizations
- Incremental architecture: each session adds one mechanism
2026-02-21 17:02:43 +08:00

394 lines
14 KiB
TypeScript

"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSvgPalette } from "@/hooks/useDarkMode";
// -- Layout constants --
const SVG_W = 560;
const SVG_H = 340;
const AGENT_R = 40;
// Agent positions: inverted triangle (Lead top-center, Coder bottom-left, Reviewer bottom-right)
const AGENTS = [
{ id: "lead", label: "Lead", cx: SVG_W / 2, cy: 70, inbox: "lead.jsonl" },
{ id: "coder", label: "Coder", cx: 140, cy: 230, inbox: "coder.jsonl" },
{ id: "reviewer", label: "Reviewer", cx: SVG_W - 140, cy: 230, inbox: "reviewer.jsonl" },
] as const;
// Inbox tray dimensions, positioned below each agent circle
const TRAY_W = 72;
const TRAY_H = 22;
const TRAY_OFFSET_Y = AGENT_R + 14;
// Message block dimensions
const MSG_W = 60;
const MSG_H = 20;
function agentById(id: string) {
return AGENTS.find((a) => a.id === id)!;
}
function trayCenter(id: string) {
const a = agentById(id);
return { x: a.cx, y: a.cy + TRAY_OFFSET_Y + TRAY_H / 2 };
}
// Step configuration
const STEPS = [
{ title: "The Team", desc: "Teams use a leader-worker pattern. Each teammate has a file-based mailbox inbox." },
{ title: "Lead Assigns Work", desc: "Communication is async: write a message to the recipient's .jsonl inbox file." },
{ title: "Read Inbox", desc: "Teammates poll their inbox before each LLM call. New messages become context." },
{ title: "Independent Work", desc: "Each teammate runs its own agent loop independently." },
{ title: "Pass Result", desc: "Results flow through the same mailbox mechanism. All communication is via files." },
{ title: "Feedback Loop", desc: "The mailbox pattern supports any communication topology: linear, broadcast, round-robin." },
{ title: "File-Based Coordination", desc: "No shared memory, no locks. All coordination through append-only files. Simple, robust, debuggable." },
];
// Helper: determine which agent glows at each step
function agentGlows(agentId: string, step: number): boolean {
if (step === 1 && agentId === "lead") return true;
if (step === 2 && agentId === "coder") return true;
if (step === 3 && agentId === "coder") return true;
if (step === 4 && agentId === "coder") return true;
if (step === 5 && agentId === "reviewer") return true;
return false;
}
// Helper: determine which inbox tray has a message sitting in it
function trayHasMessage(agentId: string, step: number): boolean {
if (step === 2 && agentId === "coder") return true;
if (step === 4 && agentId === "reviewer") return false;
if (step === 5 && agentId === "reviewer") return true;
return false;
}
// Animated message that travels from one point to another
function TravelingMessage({
fromX,
fromY,
toX,
toY,
label,
delay = 0,
}: {
fromX: number;
fromY: number;
toX: number;
toY: number;
label: string;
delay?: number;
}) {
return (
<motion.g
initial={{ opacity: 0 }}
animate={{
opacity: [0, 1, 1, 0.8],
x: [fromX - MSG_W / 2, fromX - MSG_W / 2, toX - MSG_W / 2, toX - MSG_W / 2],
y: [fromY - MSG_H / 2, fromY - MSG_H / 2, toY - MSG_H / 2, toY - MSG_H / 2],
}}
transition={{
duration: 1.4,
delay,
times: [0, 0.1, 0.7, 1],
ease: "easeInOut",
}}
>
<rect width={MSG_W} height={MSG_H} rx={4} fill="#f59e0b" />
<text
x={MSG_W / 2}
y={MSG_H / 2 + 1}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={8}
fontWeight={600}
>
{label}
</text>
</motion.g>
);
}
// Faded trace line between two agents
function TraceLine({ from, to, strokeColor }: { from: string; to: string; strokeColor: string }) {
const f = trayCenter(from);
const t = trayCenter(to);
return (
<motion.line
x1={f.x}
y1={f.y}
x2={t.x}
y2={t.y}
stroke={strokeColor}
strokeWidth={1.5}
strokeDasharray="6 4"
initial={{ opacity: 0 }}
animate={{ opacity: 0.4 }}
transition={{ duration: 0.6 }}
/>
);
}
export default function AgentTeams({ title }: { title?: string }) {
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
const step = vis.currentStep;
const palette = useSvgPalette();
return (
<section className="space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Agent Team Mailboxes"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900 min-h-[500px]">
<div className="flex flex-col lg:flex-row gap-4">
{/* SVG visualization */}
<div className="flex-1">
<svg viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="w-full">
<defs>
<filter id="agent-glow">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Step 6: trace lines */}
{step === 6 && (
<>
<TraceLine from="lead" to="coder" strokeColor={palette.edgeStroke} />
<TraceLine from="coder" to="reviewer" strokeColor={palette.edgeStroke} />
<TraceLine from="reviewer" to="lead" strokeColor={palette.edgeStroke} />
</>
)}
{/* Agent nodes */}
{AGENTS.map((agent) => {
const glowing = agentGlows(agent.id, step);
const pulsing = step === 3 && agent.id === "coder";
return (
<g key={agent.id}>
{/* Agent circle */}
<motion.circle
cx={agent.cx}
cy={agent.cy}
r={AGENT_R}
fill={glowing ? "#3b82f6" : palette.edgeStroke}
stroke={glowing ? "#60a5fa" : palette.labelFill}
strokeWidth={2}
animate={{
scale: pulsing ? [1, 1.08, 1] : 1,
fill: glowing ? "#3b82f6" : palette.edgeStroke,
}}
transition={
pulsing
? { duration: 0.8, repeat: Infinity, ease: "easeInOut" }
: { duration: 0.4 }
}
filter={glowing ? "url(#agent-glow)" : undefined}
/>
{/* Agent label */}
<text
x={agent.cx}
y={agent.cy + 1}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={12}
fontWeight={700}
>
{agent.label}
</text>
{/* Inbox tray (file icon style) */}
<rect
x={agent.cx - TRAY_W / 2}
y={agent.cy + TRAY_OFFSET_Y}
width={TRAY_W}
height={TRAY_H}
rx={3}
fill={trayHasMessage(agent.id, step) ? "#fef3c7" : palette.nodeFill}
stroke={trayHasMessage(agent.id, step) ? "#f59e0b" : palette.nodeStroke}
strokeWidth={1}
/>
<text
x={agent.cx}
y={agent.cy + TRAY_OFFSET_Y + TRAY_H / 2 + 1}
textAnchor="middle"
dominantBaseline="middle"
fontSize={8}
fontFamily="monospace"
fill={palette.labelFill}
>
{agent.inbox}
</text>
</g>
);
})}
{/* Step 0: team config card */}
{step === 0 && (
<motion.g
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<rect x={12} y={12} width={100} height={44} rx={4} fill="#f0f9ff" stroke="#bae6fd" strokeWidth={1} />
<text x={20} y={28} fontSize={7} fontFamily="monospace" fill="#0284c7" fontWeight={600}>
team.config
</text>
<text x={20} y={40} fontSize={6} fontFamily="monospace" fill="#0369a1">
workers: [coder, reviewer]
</text>
</motion.g>
)}
{/* Step 1: message from Lead to Coder inbox */}
<AnimatePresence>
{step === 1 && (
<TravelingMessage
key="msg-lead-coder"
fromX={agentById("lead").cx}
fromY={agentById("lead").cy + AGENT_R}
toX={agentById("coder").cx}
toY={agentById("coder").cy + TRAY_OFFSET_Y + TRAY_H / 2}
label="task:login"
/>
)}
</AnimatePresence>
{/* Step 2: message from Coder inbox to Coder circle */}
<AnimatePresence>
{step === 2 && (
<TravelingMessage
key="msg-inbox-coder"
fromX={agentById("coder").cx}
fromY={agentById("coder").cy + TRAY_OFFSET_Y + TRAY_H / 2}
toX={agentById("coder").cx}
toY={agentById("coder").cy}
label="task:login"
/>
)}
</AnimatePresence>
{/* Step 3: Coder working, result appears */}
<AnimatePresence>
{step === 3 && (
<motion.g
key="result-msg"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.8, duration: 0.4 }}
>
<rect
x={agentById("coder").cx + AGENT_R + 8}
y={agentById("coder").cy - MSG_H / 2}
width={MSG_W + 10}
height={MSG_H}
rx={4}
fill="#10b981"
/>
<text
x={agentById("coder").cx + AGENT_R + 8 + (MSG_W + 10) / 2}
y={agentById("coder").cy + 1}
textAnchor="middle"
dominantBaseline="middle"
fill="white"
fontSize={8}
fontWeight={600}
>
result:done
</text>
</motion.g>
)}
</AnimatePresence>
{/* Step 4: Coder result message travels to Reviewer inbox */}
<AnimatePresence>
{step === 4 && (
<TravelingMessage
key="msg-coder-reviewer"
fromX={agentById("coder").cx + AGENT_R + 8 + (MSG_W + 10) / 2}
fromY={agentById("coder").cy}
toX={agentById("reviewer").cx}
toY={agentById("reviewer").cy + TRAY_OFFSET_Y + TRAY_H / 2}
label="result:done"
/>
)}
</AnimatePresence>
{/* Step 5: Reviewer reads inbox, sends feedback to Lead */}
<AnimatePresence>
{step === 5 && (
<>
<TravelingMessage
key="msg-reviewer-read"
fromX={agentById("reviewer").cx}
fromY={agentById("reviewer").cy + TRAY_OFFSET_Y + TRAY_H / 2}
toX={agentById("reviewer").cx}
toY={agentById("reviewer").cy}
label="result:done"
delay={0}
/>
<TravelingMessage
key="msg-reviewer-lead"
fromX={agentById("reviewer").cx}
fromY={agentById("reviewer").cy}
toX={agentById("lead").cx}
toY={agentById("lead").cy + TRAY_OFFSET_Y + TRAY_H / 2}
label="feedback"
delay={1.0}
/>
</>
)}
</AnimatePresence>
{/* Step 6: filesystem tree */}
{step === 6 && (
<motion.g
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6 }}
>
<rect x={SVG_W / 2 - 110} y={SVG_H - 80} width={220} height={68} rx={6} fill={palette.bgSubtle} stroke={palette.nodeStroke} strokeWidth={1} />
<text x={SVG_W / 2 - 96} y={SVG_H - 60} fontSize={8} fontFamily="monospace" fill={palette.labelFill}>
.claude/teams/project/
</text>
<text x={SVG_W / 2 - 82} y={SVG_H - 48} fontSize={8} fontFamily="monospace" fill="#60a5fa">
lead.jsonl
</text>
<text x={SVG_W / 2 - 82} y={SVG_H - 36} fontSize={8} fontFamily="monospace" fill="#60a5fa">
coder.jsonl
</text>
<text x={SVG_W / 2 - 82} y={SVG_H - 24} fontSize={8} fontFamily="monospace" fill="#60a5fa">
reviewer.jsonl
</text>
</motion.g>
)}
</svg>
</div>
</div>
{/* Step controls */}
<div className="mt-4">
<StepControls
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={STEPS[step].title}
stepDescription={STEPS[step].desc}
/>
</div>
</div>
</section>
);
}