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
This commit is contained in:
CrazyBoyM
2026-02-21 17:02:43 +08:00
committed by CrazyBoyM
commit c6a27ef1d7
156 changed files with 28059 additions and 0 deletions
@@ -0,0 +1,228 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import { LAYERS } from "@/lib/constants";
import versionsData from "@/data/generated/versions.json";
const CLASS_DESCRIPTIONS: Record<string, string> = {
TodoManager: "Visible task planning with constraints",
SkillLoader: "Dynamic knowledge injection from SKILL.md files",
ContextManager: "Three-layer context compression pipeline",
Task: "File-based persistent task with dependencies",
TaskManager: "File-based persistent task CRUD with dependencies",
BackgroundTask: "Single background execution unit",
BackgroundManager: "Non-blocking thread execution + notification queue",
TeammateManager: "Multi-agent team lifecycle and coordination",
Teammate: "Individual agent identity and state tracking",
SharedBoard: "Cross-agent shared state coordination",
};
interface ArchDiagramProps {
version: string;
}
function getLayerColor(versionId: string): string {
const layer = LAYERS.find((l) => (l.versions as readonly string[]).includes(versionId));
return layer?.color ?? "#71717a";
}
function getLayerColorClasses(versionId: string): {
border: string;
bg: string;
} {
const v =
versionsData.versions.find((v) => v.id === versionId) as { layer?: string } | undefined;
const layer = v?.layer;
switch (layer) {
case "tools":
return {
border: "border-blue-500",
bg: "bg-blue-500/10",
};
case "planning":
return {
border: "border-emerald-500",
bg: "bg-emerald-500/10",
};
case "memory":
return {
border: "border-purple-500",
bg: "bg-purple-500/10",
};
case "concurrency":
return {
border: "border-amber-500",
bg: "bg-amber-500/10",
};
case "collaboration":
return {
border: "border-red-500",
bg: "bg-red-500/10",
};
default:
return {
border: "border-zinc-500",
bg: "bg-zinc-500/10",
};
}
}
function collectClassesUpTo(
targetId: string
): { name: string; introducedIn: string }[] {
const { versions, diffs } = versionsData;
const order = versions.map((v) => v.id);
const targetIdx = order.indexOf(targetId);
if (targetIdx < 0) return [];
const result: { name: string; introducedIn: string }[] = [];
const seen = new Set<string>();
for (let i = 0; i <= targetIdx; i++) {
const v = versions[i];
if (!v.classes) continue;
for (const cls of v.classes) {
if (!seen.has(cls.name)) {
seen.add(cls.name);
result.push({ name: cls.name, introducedIn: v.id });
}
}
}
return result;
}
function getNewClassNames(version: string): Set<string> {
const diff = versionsData.diffs.find((d) => d.to === version);
if (!diff) {
const v = versionsData.versions.find((ver) => ver.id === version);
return new Set(v?.classes?.map((c) => c.name) ?? []);
}
return new Set(diff.newClasses ?? []);
}
export function ArchDiagram({ version }: ArchDiagramProps) {
const allClasses = collectClassesUpTo(version);
const newClassNames = getNewClassNames(version);
const versionData = versionsData.versions.find((v) => v.id === version);
const tools = versionData?.tools ?? [];
const reversed = [...allClasses].reverse();
return (
<div className="space-y-3">
{reversed.map((cls, i) => {
const isNew = newClassNames.has(cls.name);
const colorClasses = getLayerColorClasses(cls.introducedIn);
return (
<div key={cls.name}>
{i > 0 && (
<div className="flex justify-center py-1">
<motion.svg
width="24"
height="20"
viewBox="0 0 24 20"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: i * 0.08 + 0.05 }}
>
<motion.line
x1={12}
y1={0}
x2={12}
y2={14}
stroke="var(--color-text-secondary)"
strokeWidth={1.5}
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, delay: i * 0.08 }}
/>
<motion.polygon
points="7,12 12,19 17,12"
fill="var(--color-text-secondary)"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: i * 0.08 + 0.2 }}
/>
</motion.svg>
</div>
)}
<motion.div
key={cls.name}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.08, duration: 0.3 }}
className={cn(
"rounded-lg border-2 px-4 py-3 transition-colors",
isNew
? cn(colorClasses.border, colorClasses.bg)
: "border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/50"
)}
>
<div className="flex items-center justify-between">
<div>
<span
className={cn(
"font-mono text-sm font-semibold",
isNew
? "text-zinc-900 dark:text-white"
: "text-zinc-400 dark:text-zinc-500"
)}
>
{cls.name}
</span>
<p
className={cn(
"mt-0.5 text-xs",
isNew
? "text-zinc-600 dark:text-zinc-300"
: "text-zinc-400 dark:text-zinc-500"
)}
>
{CLASS_DESCRIPTIONS[cls.name] || ""}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-400 dark:text-zinc-500">
{cls.introducedIn}
</span>
{isNew && (
<span className="rounded-full bg-zinc-900 px-2 py-0.5 text-[10px] font-bold uppercase text-white dark:bg-white dark:text-zinc-900">
NEW
</span>
)}
</div>
</div>
</motion.div>
</div>
);
})}
{allClasses.length === 0 && (
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-center text-sm text-zinc-400 dark:border-zinc-600">
No classes in this version (functions only)
</div>
)}
{tools.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: reversed.length * 0.08 + 0.1 }}
className="flex flex-wrap gap-1.5 pt-2"
>
{tools.map((tool) => (
<span
key={tool}
className="rounded-md bg-zinc-100 px-2 py-1 font-mono text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
>
{tool}
</span>
))}
</motion.div>
)}
</div>
);
}
@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslations, useLocale } from "@/lib/i18n";
import { ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
import s01Annotations from "@/data/annotations/s01.json";
import s02Annotations from "@/data/annotations/s02.json";
import s03Annotations from "@/data/annotations/s03.json";
import s04Annotations from "@/data/annotations/s04.json";
import s05Annotations from "@/data/annotations/s05.json";
import s06Annotations from "@/data/annotations/s06.json";
import s07Annotations from "@/data/annotations/s07.json";
import s08Annotations from "@/data/annotations/s08.json";
import s09Annotations from "@/data/annotations/s09.json";
import s10Annotations from "@/data/annotations/s10.json";
import s11Annotations from "@/data/annotations/s11.json";
interface Decision {
id: string;
title: string;
description: string;
alternatives: string;
zh?: { title: string; description: string };
ja?: { title: string; description: string };
}
interface AnnotationFile {
version: string;
decisions: Decision[];
}
const ANNOTATIONS: Record<string, AnnotationFile> = {
s01: s01Annotations as AnnotationFile,
s02: s02Annotations as AnnotationFile,
s03: s03Annotations as AnnotationFile,
s04: s04Annotations as AnnotationFile,
s05: s05Annotations as AnnotationFile,
s06: s06Annotations as AnnotationFile,
s07: s07Annotations as AnnotationFile,
s08: s08Annotations as AnnotationFile,
s09: s09Annotations as AnnotationFile,
s10: s10Annotations as AnnotationFile,
s11: s11Annotations as AnnotationFile,
};
interface DesignDecisionsProps {
version: string;
}
function DecisionCard({
decision,
locale,
}: {
decision: Decision;
locale: string;
}) {
const [open, setOpen] = useState(false);
const t = useTranslations("version");
const localized =
locale !== "en" ? (decision as unknown as Record<string, unknown>)[locale] as { title?: string; description?: string } | undefined : undefined;
const title = localized?.title || decision.title;
const description = localized?.description || decision.description;
return (
<div className="rounded-lg border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900">
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center justify-between px-4 py-3 text-left"
>
<span className="pr-4 text-sm font-semibold text-zinc-900 dark:text-white">
{title}
</span>
<ChevronDown
size={16}
className={cn(
"shrink-0 text-zinc-400 transition-transform duration-200",
open && "rotate-180"
)}
/>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="border-t border-zinc-100 px-4 py-3 dark:border-zinc-800">
<p className="text-sm leading-relaxed text-zinc-600 dark:text-zinc-300">
{description}
</p>
{decision.alternatives && (
<div className="mt-3">
<h4 className="text-xs font-medium uppercase tracking-wide text-zinc-400 dark:text-zinc-500">
{t("alternatives")}
</h4>
<p className="mt-1 text-sm leading-relaxed text-zinc-500 dark:text-zinc-400">
{decision.alternatives}
</p>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export function DesignDecisions({ version }: DesignDecisionsProps) {
const t = useTranslations("version");
const locale = useLocale();
const annotations = ANNOTATIONS[version];
if (!annotations || annotations.decisions.length === 0) {
return null;
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">{t("design_decisions")}</h2>
<div className="space-y-2">
{annotations.decisions.map((decision, i) => (
<motion.div
key={decision.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
>
<DecisionCard decision={decision} locale={locale} />
</motion.div>
))}
</div>
</div>
);
}
@@ -0,0 +1,243 @@
"use client";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";
import { useTranslations } from "@/lib/i18n";
import { getFlowForVersion } from "@/data/execution-flows";
import type { FlowNode, FlowEdge } from "@/types/agent-data";
const NODE_WIDTH = 140;
const NODE_HEIGHT = 40;
const DIAMOND_SIZE = 50;
const LAYER_COLORS: Record<string, string> = {
start: "#3B82F6",
process: "#10B981",
decision: "#F59E0B",
subprocess: "#8B5CF6",
end: "#EF4444",
};
function getNodeCenter(node: FlowNode): { cx: number; cy: number } {
return { cx: node.x, cy: node.y };
}
function getEdgePath(from: FlowNode, to: FlowNode): string {
const { cx: x1, cy: y1 } = getNodeCenter(from);
const { cx: x2, cy: y2 } = getNodeCenter(to);
const halfH = from.type === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
const halfHTo = to.type === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
if (Math.abs(x1 - x2) < 10) {
const startY = y1 + halfH;
const endY = y2 - halfHTo;
return `M ${x1} ${startY} L ${x2} ${endY}`;
}
const startY = y1 + halfH;
const endY = y2 - halfHTo;
const midY = (startY + endY) / 2;
return `M ${x1} ${startY} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${endY}`;
}
function NodeShape({ node }: { node: FlowNode }) {
const color = LAYER_COLORS[node.type];
const lines = node.label.split("\n");
if (node.type === "decision") {
const half = DIAMOND_SIZE / 2;
return (
<g>
<polygon
points={`${node.x},${node.y - half} ${node.x + half},${node.y} ${node.x},${node.y + half} ${node.x - half},${node.y}`}
fill="none"
stroke={color}
strokeWidth={2}
/>
{lines.map((line, i) => (
<text
key={i}
x={node.x}
y={node.y + (i - (lines.length - 1) / 2) * 12}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontFamily="monospace"
fill="currentColor"
>
{line}
</text>
))}
</g>
);
}
if (node.type === "start" || node.type === "end") {
return (
<g>
<rect
x={node.x - NODE_WIDTH / 2}
y={node.y - NODE_HEIGHT / 2}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx={NODE_HEIGHT / 2}
fill="none"
stroke={color}
strokeWidth={2}
/>
<text
x={node.x}
y={node.y}
textAnchor="middle"
dominantBaseline="central"
fontSize={12}
fontWeight={600}
fontFamily="monospace"
fill="currentColor"
>
{node.label}
</text>
</g>
);
}
const isSubprocess = node.type === "subprocess";
return (
<g>
<rect
x={node.x - NODE_WIDTH / 2}
y={node.y - NODE_HEIGHT / 2}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx={4}
fill="none"
stroke={color}
strokeWidth={2}
strokeDasharray={isSubprocess ? "6 3" : undefined}
/>
{lines.map((line, i) => (
<text
key={i}
x={node.x}
y={node.y + (i - (lines.length - 1) / 2) * 13}
textAnchor="middle"
dominantBaseline="central"
fontSize={11}
fontFamily="monospace"
fill="currentColor"
>
{line}
</text>
))}
</g>
);
}
function EdgePath({
edge,
nodes,
index,
}: {
edge: FlowEdge;
nodes: FlowNode[];
index: number;
}) {
const from = nodes.find((n) => n.id === edge.from);
const to = nodes.find((n) => n.id === edge.to);
if (!from || !to) return null;
const d = getEdgePath(from, to);
const midX = (from.x + to.x) / 2;
const midY = (from.y + to.y) / 2;
return (
<g>
<motion.path
d={d}
fill="none"
stroke="var(--color-text-secondary)"
strokeWidth={1.5}
markerEnd="url(#arrowhead)"
initial={{ pathLength: 0, opacity: 0 }}
animate={{ pathLength: 1, opacity: 1 }}
transition={{ duration: 0.5, delay: index * 0.12 }}
/>
{edge.label && (
<motion.text
x={midX + 8}
y={midY - 4}
fontSize={10}
fill="var(--color-text-secondary)"
fontFamily="monospace"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.12 + 0.3 }}
>
{edge.label}
</motion.text>
)}
</g>
);
}
interface ExecutionFlowProps {
version: string;
}
export function ExecutionFlow({ version }: ExecutionFlowProps) {
const t = useTranslations("version");
const [flow, setFlow] = useState<ReturnType<typeof getFlowForVersion>>(null);
useEffect(() => {
setFlow(getFlowForVersion(version));
}, [version]);
if (!flow) return null;
const maxY = Math.max(...flow.nodes.map((n) => n.y)) + 50;
return (
<section>
<h2 className="mb-4 text-xl font-semibold">{t("execution_flow")}</h2>
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
<svg
viewBox={`0 0 600 ${maxY}`}
className="mx-auto w-full max-w-[600px]"
style={{ minHeight: 300 }}
>
<defs>
<marker
id="arrowhead"
markerWidth={8}
markerHeight={6}
refX={8}
refY={3}
orient="auto"
>
<polygon
points="0 0, 8 3, 0 6"
fill="var(--color-text-secondary)"
/>
</marker>
</defs>
{flow.edges.map((edge, i) => (
<EdgePath key={`${edge.from}-${edge.to}`} edge={edge} nodes={flow.nodes} index={i} />
))}
{flow.nodes.map((node, i) => (
<motion.g
key={node.id}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.06, duration: 0.3 }}
>
<NodeShape node={node} />
</motion.g>
))}
</svg>
</div>
</section>
);
}
@@ -0,0 +1,70 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
const FLOW_STEPS = [
{ role: "user", label: "user", color: "bg-blue-500" },
{ role: "assistant", label: "assistant", color: "bg-zinc-600" },
{ role: "tool_call", label: "tool_call", color: "bg-amber-500" },
{ role: "tool_result", label: "tool_result", color: "bg-emerald-500" },
{ role: "assistant", label: "assistant", color: "bg-zinc-600" },
{ role: "tool_call", label: "tool_call", color: "bg-amber-500" },
{ role: "tool_result", label: "tool_result", color: "bg-emerald-500" },
{ role: "assistant", label: "assistant (final)", color: "bg-zinc-600" },
];
export function MessageFlow() {
const [count, setCount] = useState(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
intervalRef.current = setInterval(() => {
setCount((prev) => {
if (prev >= FLOW_STEPS.length) {
setTimeout(() => setCount(0), 1500);
return prev;
}
return prev + 1;
});
}, 800);
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, []);
return (
<div className="overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
<div className="mb-3 flex items-center gap-2">
<span className="font-mono text-xs text-[var(--color-text-secondary)]">
messages[]
</span>
<span className="ml-auto rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-xs tabular-nums dark:bg-zinc-800">
len={count}
</span>
</div>
<div className="flex gap-1.5 overflow-x-auto pb-1">
<AnimatePresence>
{FLOW_STEPS.slice(0, count).map((step, i) => (
<motion.div
key={i}
initial={{ opacity: 0, scale: 0.7, width: 0 }}
animate={{ opacity: 1, scale: 1, width: "auto" }}
transition={{ duration: 0.25 }}
className={`flex shrink-0 items-center rounded-md px-2.5 py-1.5 ${step.color}`}
>
<span className="whitespace-nowrap font-mono text-[10px] font-medium text-white">
{step.label}
</span>
</motion.div>
))}
</AnimatePresence>
{count === 0 && (
<div className="flex h-7 items-center text-xs text-[var(--color-text-secondary)]">
[]
</div>
)}
</div>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useMemo } from "react";
interface SourceViewerProps {
source: string;
filename: string;
}
function highlightLine(line: string): React.ReactNode[] {
const trimmed = line.trimStart();
if (trimmed.startsWith("#")) {
return [
<span key={0} className="text-zinc-400 italic">
{line}
</span>,
];
}
if (trimmed.startsWith("@")) {
return [
<span key={0} className="text-amber-400">
{line}
</span>,
];
}
if (trimmed.startsWith('"""') || trimmed.startsWith("'''")) {
return [
<span key={0} className="text-emerald-500">
{line}
</span>,
];
}
const keywordSet = new Set([
"def", "class", "import", "from", "return", "if", "elif", "else",
"while", "for", "in", "not", "and", "or", "is", "None", "True",
"False", "try", "except", "raise", "with", "as", "yield", "break",
"continue", "pass", "global", "lambda", "async", "await",
]);
const parts = line.split(
/(\b(?:def|class|import|from|return|if|elif|else|while|for|in|not|and|or|is|None|True|False|try|except|raise|with|as|yield|break|continue|pass|global|lambda|async|await|self)\b|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|f"(?:[^"\\]|\\.)*"|f'(?:[^'\\]|\\.)*'|#.*$|\b\d+(?:\.\d+)?\b)/
);
return parts.map((part, idx) => {
if (!part) return null;
if (keywordSet.has(part)) {
return <span key={idx} className="text-blue-400 font-medium">{part}</span>;
}
if (part === "self") {
return <span key={idx} className="text-purple-400">{part}</span>;
}
if (part.startsWith("#")) {
return <span key={idx} className="text-zinc-400 italic">{part}</span>;
}
if (
(part.startsWith('"') && part.endsWith('"')) ||
(part.startsWith("'") && part.endsWith("'")) ||
(part.startsWith('f"') && part.endsWith('"')) ||
(part.startsWith("f'") && part.endsWith("'"))
) {
return <span key={idx} className="text-emerald-500">{part}</span>;
}
if (/^\d+(?:\.\d+)?$/.test(part)) {
return <span key={idx} className="text-orange-400">{part}</span>;
}
return <span key={idx}>{part}</span>;
});
}
export function SourceViewer({ source, filename }: SourceViewerProps) {
const lines = useMemo(() => source.split("\n"), [source]);
return (
<div className="rounded-lg border border-zinc-200 dark:border-zinc-700">
<div className="flex items-center gap-2 border-b border-zinc-200 px-4 py-2 dark:border-zinc-700">
<div className="flex gap-1.5">
<span className="h-3 w-3 rounded-full bg-red-400" />
<span className="h-3 w-3 rounded-full bg-yellow-400" />
<span className="h-3 w-3 rounded-full bg-green-400" />
</div>
<span className="font-mono text-xs text-zinc-400">{filename}</span>
</div>
<div className="overflow-x-auto bg-zinc-950">
<pre className="p-2 text-[10px] leading-4 sm:p-4 sm:text-xs sm:leading-5">
<code>
{lines.map((line, i) => (
<div key={i} className="flex">
<span className="mr-2 inline-block w-6 shrink-0 select-none text-right text-zinc-600 sm:mr-4 sm:w-8">
{i + 1}
</span>
<span className="text-zinc-200">
{highlightLine(line)}
</span>
</div>
))}
</code>
</pre>
</div>
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
"use client";
import { useState, useMemo } from "react";
import { diffLines, Change } from "diff";
import { cn } from "@/lib/utils";
interface CodeDiffProps {
oldSource: string;
newSource: string;
oldLabel: string;
newLabel: string;
}
export function CodeDiff({ oldSource, newSource, oldLabel, newLabel }: CodeDiffProps) {
const [viewMode, setViewMode] = useState<"unified" | "split">("unified");
const changes = useMemo(() => diffLines(oldSource, newSource), [oldSource, newSource]);
return (
<div>
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 truncate text-sm text-zinc-500 dark:text-zinc-400">
<span className="font-medium text-zinc-700 dark:text-zinc-300">{oldLabel}</span>
{" -> "}
<span className="font-medium text-zinc-700 dark:text-zinc-300">{newLabel}</span>
</div>
<div className="flex shrink-0 rounded-lg border border-zinc-200 dark:border-zinc-700">
<button
onClick={() => setViewMode("unified")}
className={cn(
"min-h-[36px] px-3 text-xs font-medium transition-colors",
viewMode === "unified"
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
: "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400"
)}
>
Unified
</button>
<button
onClick={() => setViewMode("split")}
className={cn(
"min-h-[36px] px-3 text-xs font-medium transition-colors sm:inline-flex hidden",
viewMode === "split"
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
: "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400"
)}
>
Split
</button>
</div>
</div>
{viewMode === "unified" ? (
<UnifiedView changes={changes} />
) : (
<SplitView changes={changes} />
)}
</div>
);
}
function UnifiedView({ changes }: { changes: Change[] }) {
let oldLine = 1;
let newLine = 1;
const rows: { oldNum: number | null; newNum: number | null; type: "add" | "remove" | "context"; text: string }[] = [];
for (const change of changes) {
const lines = change.value.replace(/\n$/, "").split("\n");
for (const line of lines) {
if (change.added) {
rows.push({ oldNum: null, newNum: newLine++, type: "add", text: line });
} else if (change.removed) {
rows.push({ oldNum: oldLine++, newNum: null, type: "remove", text: line });
} else {
rows.push({ oldNum: oldLine++, newNum: newLine++, type: "context", text: line });
}
}
}
return (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table className="w-full border-collapse font-mono text-xs leading-5">
<tbody>
{rows.map((row, i) => (
<tr
key={i}
className={cn(
row.type === "add" && "bg-green-50 dark:bg-green-950/30",
row.type === "remove" && "bg-red-50 dark:bg-red-950/30"
)}
>
<td className="w-10 select-none border-r border-zinc-200 px-2 text-right text-zinc-400 dark:border-zinc-700 dark:text-zinc-600">
{row.oldNum ?? ""}
</td>
<td className="w-10 select-none border-r border-zinc-200 px-2 text-right text-zinc-400 dark:border-zinc-700 dark:text-zinc-600">
{row.newNum ?? ""}
</td>
<td className="w-4 select-none px-1 text-center">
{row.type === "add" && <span className="text-green-600 dark:text-green-400">+</span>}
{row.type === "remove" && <span className="text-red-600 dark:text-red-400">-</span>}
</td>
<td className="whitespace-pre px-2">
<span
className={cn(
row.type === "add" && "text-green-800 dark:text-green-300",
row.type === "remove" && "text-red-800 dark:text-red-300",
row.type === "context" && "text-zinc-700 dark:text-zinc-300"
)}
>
{row.text}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function SplitView({ changes }: { changes: Change[] }) {
let oldLine = 1;
let newLine = 1;
type SplitRow = {
left: { num: number | null; text: string; type: "remove" | "context" | "empty" };
right: { num: number | null; text: string; type: "add" | "context" | "empty" };
};
const rows: SplitRow[] = [];
for (const change of changes) {
const lines = change.value.replace(/\n$/, "").split("\n");
if (change.removed) {
for (const line of lines) {
rows.push({
left: { num: oldLine++, text: line, type: "remove" },
right: { num: null, text: "", type: "empty" },
});
}
} else if (change.added) {
let filled = 0;
for (const line of lines) {
// Try to fill in empty right-side slots from preceding removes
const lastUnfilled = rows.length - lines.length + filled;
if (
lastUnfilled >= 0 &&
lastUnfilled < rows.length &&
rows[lastUnfilled].right.type === "empty" &&
rows[lastUnfilled].left.type === "remove"
) {
rows[lastUnfilled].right = { num: newLine++, text: line, type: "add" };
} else {
rows.push({
left: { num: null, text: "", type: "empty" },
right: { num: newLine++, text: line, type: "add" },
});
}
filled++;
}
} else {
for (const line of lines) {
rows.push({
left: { num: oldLine++, text: line, type: "context" },
right: { num: newLine++, text: line, type: "context" },
});
}
}
}
const cellClass = (type: string) =>
cn(
"whitespace-pre px-2",
type === "add" && "bg-green-50 text-green-800 dark:bg-green-950/30 dark:text-green-300",
type === "remove" && "bg-red-50 text-red-800 dark:bg-red-950/30 dark:text-red-300",
type === "context" && "text-zinc-700 dark:text-zinc-300",
type === "empty" && "bg-zinc-50 dark:bg-zinc-900"
);
return (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table className="w-full border-collapse font-mono text-xs leading-5">
<tbody>
{rows.map((row, i) => (
<tr key={i}>
<td className="w-10 select-none border-r border-zinc-200 px-2 text-right text-zinc-400 dark:border-zinc-700 dark:text-zinc-600">
{row.left.num ?? ""}
</td>
<td className={cn("w-1/2 border-r border-zinc-200 dark:border-zinc-700", cellClass(row.left.type))}>
{row.left.text}
</td>
<td className="w-10 select-none border-r border-zinc-200 px-2 text-right text-zinc-400 dark:border-zinc-700 dark:text-zinc-600">
{row.right.num ?? ""}
</td>
<td className={cn("w-1/2", cellClass(row.right.type))}>
{row.right.text}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { motion } from "framer-motion";
import { useTranslations } from "@/lib/i18n";
import { Card } from "@/components/ui/card";
interface WhatsNewProps {
diff: {
from: string;
to: string;
newClasses: string[];
newFunctions: string[];
newTools: string[];
locDelta: number;
} | null;
}
export function WhatsNew({ diff }: WhatsNewProps) {
const t = useTranslations("version");
const td = useTranslations("diff");
if (!diff) {
return null;
}
const hasContent =
diff.newClasses.length > 0 ||
diff.newTools.length > 0 ||
diff.newFunctions.length > 0 ||
diff.locDelta !== 0;
if (!hasContent) {
return null;
}
return (
<div className="space-y-4">
<h2 className="text-xl font-semibold">{t("whats_new")}</h2>
<div className="grid gap-4 sm:grid-cols-2">
{diff.newClasses.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="h-full">
<h3 className="mb-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
{td("new_classes")}
</h3>
<div className="space-y-1.5">
{diff.newClasses.map((cls) => (
<div
key={cls}
className="rounded-md bg-emerald-50 px-3 py-1.5 font-mono text-sm font-medium text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300"
>
{cls}
</div>
))}
</div>
</Card>
</motion.div>
)}
{diff.newTools.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
>
<Card className="h-full">
<h3 className="mb-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
{td("new_tools")}
</h3>
<div className="flex flex-wrap gap-1.5">
{diff.newTools.map((tool) => (
<span
key={tool}
className="rounded-full bg-blue-50 px-3 py-1 font-mono text-xs font-medium text-blue-700 dark:bg-blue-900/20 dark:text-blue-300"
>
{tool}
</span>
))}
</div>
</Card>
</motion.div>
)}
{diff.newFunctions.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card className="h-full">
<h3 className="mb-2 text-sm font-medium text-zinc-500 dark:text-zinc-400">
{td("new_functions")}
</h3>
<ul className="space-y-1 text-sm text-zinc-700 dark:text-zinc-300">
{diff.newFunctions.map((fn) => (
<li key={fn} className="font-mono">
<span className="text-zinc-400 dark:text-zinc-500">
def{" "}
</span>
{fn}()
</li>
))}
</ul>
</Card>
</motion.div>
)}
{diff.locDelta !== 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
>
<Card className="flex h-full items-center">
<div>
<h3 className="mb-1 text-sm font-medium text-zinc-500 dark:text-zinc-400">
{td("loc_delta")}
</h3>
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400">
+{diff.locDelta} lines
</p>
</div>
</Card>
</motion.div>
)}
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useMemo } from "react";
import { useLocale } from "@/lib/i18n";
import docsData from "@/data/generated/docs.json";
import { unified } from "unified";
import remarkParse from "remark-parse";
import remarkGfm from "remark-gfm";
import remarkRehype from "remark-rehype";
import rehypeRaw from "rehype-raw";
import rehypeHighlight from "rehype-highlight";
import rehypeStringify from "rehype-stringify";
interface DocRendererProps {
version: string;
}
function renderMarkdown(md: string): string {
const result = unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeHighlight, { detect: false, ignoreMissing: true })
.use(rehypeStringify)
.processSync(md);
return String(result);
}
function postProcessHtml(html: string): string {
// Add language labels to highlighted code blocks
html = html.replace(
/<pre><code class="hljs language-(\w+)">/g,
'<pre class="code-block" data-language="$1"><code class="hljs language-$1">'
);
// Wrap plain pre>code (ASCII art / diagrams) in diagram container
html = html.replace(
/<pre><code(?! class="hljs)([^>]*)>/g,
'<pre class="ascii-diagram"><code$1>'
);
// Mark the first blockquote as hero callout
html = html.replace(
/<blockquote>/,
'<blockquote class="hero-callout">'
);
// Remove the h1 (it's redundant with the page header)
html = html.replace(/<h1>.*?<\/h1>\n?/, "");
// Fix ordered list counter for interrupted lists (ol start="N")
html = html.replace(
/<ol start="(\d+)">/g,
(_, start) => `<ol style="counter-reset:step-counter ${parseInt(start) - 1}">`
);
return html;
}
export function DocRenderer({ version }: DocRendererProps) {
const locale = useLocale();
const doc = useMemo(() => {
const match = docsData.find(
(d: { version: string; locale: string }) =>
d.version === version && d.locale === locale
);
if (match) return match;
return docsData.find(
(d: { version: string; locale: string }) =>
d.version === version && d.locale === "en"
);
}, [version, locale]);
if (!doc) return null;
const html = useMemo(() => {
const raw = renderMarkdown(doc.content);
return postProcessHtml(raw);
}, [doc.content]);
return (
<div className="py-4">
<div
className="prose-custom"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useTranslations, useLocale } from "@/lib/i18n";
import { Github, Menu, X, Sun, Moon } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ key: "timeline", href: "/timeline" },
{ key: "compare", href: "/compare" },
{ key: "layers", href: "/layers" },
] as const;
const LOCALES = [
{ code: "en", label: "EN" },
{ code: "zh", label: "中文" },
{ code: "ja", label: "日本語" },
];
export function Header() {
const t = useTranslations("nav");
const pathname = usePathname();
const locale = useLocale();
const [mobileOpen, setMobileOpen] = useState(false);
const [dark, setDark] = useState(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem("theme");
if (stored) return stored === "dark";
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
return false;
});
function toggleDark() {
const next = !dark;
setDark(next);
document.documentElement.classList.toggle("dark", next);
localStorage.setItem("theme", next ? "dark" : "light");
}
function switchLocale(newLocale: string) {
const newPath = pathname.replace(`/${locale}`, `/${newLocale}`);
window.location.href = newPath;
}
return (
<header className="sticky top-0 z-50 border-b border-[var(--color-border)] bg-[var(--color-bg)]/80 backdrop-blur-sm">
<div className="mx-auto flex h-14 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
<Link href={`/${locale}`} className="text-lg font-bold">
Learn Claude Code
</Link>
{/* Desktop nav */}
<nav className="hidden items-center gap-6 md:flex">
{NAV_ITEMS.map((item) => (
<Link
key={item.key}
href={`/${locale}${item.href}`}
className={cn(
"text-sm font-medium transition-colors hover:text-zinc-900 dark:hover:text-white",
pathname.includes(item.href)
? "text-zinc-900 dark:text-white"
: "text-zinc-500 dark:text-zinc-400"
)}
>
{t(item.key)}
</Link>
))}
{/* Locale switcher */}
<div className="flex items-center gap-1 rounded-lg border border-[var(--color-border)] p-0.5">
{LOCALES.map((l) => (
<button
key={l.code}
onClick={() => switchLocale(l.code)}
className={cn(
"rounded-md px-2 py-1 text-xs font-medium transition-colors",
locale === l.code
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
: "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400"
)}
>
{l.label}
</button>
))}
</div>
<button
onClick={toggleDark}
className="rounded-md p-1.5 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white"
>
{dark ? <Sun size={16} /> : <Moon size={16} />}
</button>
<a
href="https://github.com/shareAI-lab/learn-claude-code"
target="_blank"
rel="noopener"
className="text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white"
>
<Github size={18} />
</a>
</nav>
{/* Mobile hamburger */}
<button
onClick={() => setMobileOpen(!mobileOpen)}
className="flex min-h-[44px] min-w-[44px] items-center justify-center md:hidden"
>
{mobileOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
{/* Mobile menu */}
{mobileOpen && (
<div className="border-t border-[var(--color-border)] bg-[var(--color-bg)] p-4 md:hidden">
{NAV_ITEMS.map((item) => (
<Link
key={item.key}
href={`/${locale}${item.href}`}
className="flex min-h-[44px] items-center text-sm"
onClick={() => setMobileOpen(false)}
>
{t(item.key)}
</Link>
))}
<div className="mt-3 flex items-center justify-between border-t border-[var(--color-border)] pt-3">
<div className="flex gap-2">
{LOCALES.map((l) => (
<button
key={l.code}
onClick={() => switchLocale(l.code)}
className={cn(
"min-h-[44px] min-w-[44px] rounded-md px-3 text-xs font-medium",
locale === l.code
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
: "border border-[var(--color-border)]"
)}
>
{l.label}
</button>
))}
</div>
<div className="flex items-center gap-2">
<button
onClick={toggleDark}
className="flex min-h-[44px] min-w-[44px] items-center justify-center rounded-md text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white"
>
{dark ? <Sun size={18} /> : <Moon size={18} />}
</button>
<a
href="https://github.com/shareAI-lab/learn-claude-code"
target="_blank"
rel="noopener"
className="flex min-h-[44px] min-w-[44px] items-center justify-center text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-white"
>
<Github size={18} />
</a>
</div>
</div>
</div>
)}
</header>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LAYERS, VERSION_META } from "@/lib/constants";
import { useTranslations } from "@/lib/i18n";
import { cn } from "@/lib/utils";
const LAYER_DOT_BG: Record<string, string> = {
tools: "bg-blue-500",
planning: "bg-emerald-500",
memory: "bg-purple-500",
concurrency: "bg-amber-500",
collaboration: "bg-red-500",
};
export function Sidebar() {
const pathname = usePathname();
const locale = pathname.split("/")[1] || "en";
const t = useTranslations("sessions");
const tLayer = useTranslations("layer_labels");
return (
<nav className="hidden w-56 shrink-0 md:block">
<div className="sticky top-[calc(3.5rem+2rem)] space-y-5">
{LAYERS.map((layer) => (
<div key={layer.id}>
<div className="flex items-center gap-1.5 pb-1.5">
<span className={cn("h-2 w-2 rounded-full", LAYER_DOT_BG[layer.id])} />
<span className="text-[11px] font-semibold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
{tLayer(layer.id)}
</span>
</div>
<ul className="space-y-0.5">
{layer.versions.map((vId) => {
const meta = VERSION_META[vId];
const href = `/${locale}/${vId}`;
const isActive =
pathname === href ||
pathname === `${href}/` ||
pathname.startsWith(`${href}/diff`);
return (
<li key={vId}>
<Link
href={href}
className={cn(
"block rounded-md px-2.5 py-1.5 text-sm transition-colors",
isActive
? "bg-zinc-100 font-medium text-zinc-900 dark:bg-zinc-800 dark:text-white"
: "text-zinc-500 hover:bg-zinc-50 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800/50 dark:hover:text-zinc-300"
)}
>
<span className="font-mono text-xs">{vId}</span>
<span className="ml-1.5">{t(vId) || meta?.title}</span>
</Link>
</li>
);
})}
</ul>
</div>
))}
</div>
</nav>
);
}
@@ -0,0 +1,95 @@
"use client";
import { useRef, useEffect, useState } from "react";
import { AnimatePresence } from "framer-motion";
import { useTranslations } from "@/lib/i18n";
import { useSimulator } from "@/hooks/useSimulator";
import { SimulatorControls } from "./simulator-controls";
import { SimulatorMessage } from "./simulator-message";
import type { Scenario } from "@/types/agent-data";
const scenarioModules: Record<string, () => Promise<{ default: Scenario }>> = {
s01: () => import("@/data/scenarios/s01.json") as Promise<{ default: Scenario }>,
s02: () => import("@/data/scenarios/s02.json") as Promise<{ default: Scenario }>,
s03: () => import("@/data/scenarios/s03.json") as Promise<{ default: Scenario }>,
s04: () => import("@/data/scenarios/s04.json") as Promise<{ default: Scenario }>,
s05: () => import("@/data/scenarios/s05.json") as Promise<{ default: Scenario }>,
s06: () => import("@/data/scenarios/s06.json") as Promise<{ default: Scenario }>,
s07: () => import("@/data/scenarios/s07.json") as Promise<{ default: Scenario }>,
s08: () => import("@/data/scenarios/s08.json") as Promise<{ default: Scenario }>,
s09: () => import("@/data/scenarios/s09.json") as Promise<{ default: Scenario }>,
s10: () => import("@/data/scenarios/s10.json") as Promise<{ default: Scenario }>,
s11: () => import("@/data/scenarios/s11.json") as Promise<{ default: Scenario }>,
};
interface AgentLoopSimulatorProps {
version: string;
}
export function AgentLoopSimulator({ version }: AgentLoopSimulatorProps) {
const t = useTranslations("version");
const [scenario, setScenario] = useState<Scenario | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const loader = scenarioModules[version];
if (loader) {
loader().then((mod) => setScenario(mod.default));
}
}, [version]);
const sim = useSimulator(scenario?.steps ?? []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTo({
top: scrollRef.current.scrollHeight,
behavior: "smooth",
});
}
}, [sim.visibleSteps.length]);
if (!scenario) return null;
return (
<section>
<h2 className="mb-2 text-xl font-semibold">{t("simulator")}</h2>
<p className="mb-4 text-sm text-[var(--color-text-secondary)]">
{scenario.description}
</p>
<div className="overflow-hidden rounded-xl border border-[var(--color-border)]">
<div className="border-b border-[var(--color-border)] bg-zinc-50 px-4 py-3 dark:bg-zinc-900">
<SimulatorControls
isPlaying={sim.isPlaying}
isComplete={sim.isComplete}
currentIndex={sim.currentIndex}
totalSteps={sim.totalSteps}
speed={sim.speed}
onPlay={sim.play}
onPause={sim.pause}
onStep={sim.stepForward}
onReset={sim.reset}
onSpeedChange={sim.setSpeed}
/>
</div>
<div
ref={scrollRef}
className="flex max-h-[500px] min-h-[200px] flex-col gap-3 overflow-y-auto p-4"
>
{sim.visibleSteps.length === 0 && (
<div className="flex flex-1 items-center justify-center text-sm text-[var(--color-text-secondary)]">
Press Play or Step to begin
</div>
)}
<AnimatePresence mode="popLayout">
{sim.visibleSteps.map((step, i) => (
<SimulatorMessage key={i} step={step} index={i} />
))}
</AnimatePresence>
</div>
</div>
</section>
);
}
@@ -0,0 +1,99 @@
"use client";
import { useTranslations } from "@/lib/i18n";
import { Play, Pause, SkipForward, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
interface SimulatorControlsProps {
isPlaying: boolean;
isComplete: boolean;
currentIndex: number;
totalSteps: number;
speed: number;
onPlay: () => void;
onPause: () => void;
onStep: () => void;
onReset: () => void;
onSpeedChange: (speed: number) => void;
}
const SPEEDS = [0.5, 1, 2, 4];
export function SimulatorControls({
isPlaying,
isComplete,
currentIndex,
totalSteps,
speed,
onPlay,
onPause,
onStep,
onReset,
onSpeedChange,
}: SimulatorControlsProps) {
const t = useTranslations("sim");
return (
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5">
{isPlaying ? (
<button
onClick={onPause}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-zinc-900 text-white transition-colors hover:bg-zinc-700 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
title={t("pause")}
>
<Pause size={16} />
</button>
) : (
<button
onClick={onPlay}
disabled={isComplete}
className="flex h-9 w-9 items-center justify-center rounded-lg bg-zinc-900 text-white transition-colors hover:bg-zinc-700 disabled:opacity-40 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200"
title={t("play")}
>
<Play size={16} />
</button>
)}
<button
onClick={onStep}
disabled={isComplete}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--color-border)] transition-colors hover:bg-zinc-100 disabled:opacity-40 dark:hover:bg-zinc-800"
title={t("step")}
>
<SkipForward size={16} />
</button>
<button
onClick={onReset}
className="flex h-9 w-9 items-center justify-center rounded-lg border border-[var(--color-border)] transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800"
title={t("reset")}
>
<RotateCcw size={16} />
</button>
</div>
<div className="flex items-center gap-1.5">
<span className="text-xs text-[var(--color-text-secondary)]">
{t("speed")}:
</span>
{SPEEDS.map((s) => (
<button
key={s}
onClick={() => onSpeedChange(s)}
className={cn(
"rounded px-2 py-1 text-xs font-medium transition-colors",
speed === s
? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900"
: "text-[var(--color-text-secondary)] hover:text-[var(--color-text)]"
)}
>
{s}x
</button>
))}
</div>
<span className="ml-auto text-xs tabular-nums text-[var(--color-text-secondary)]">
{Math.max(0, currentIndex + 1)} {t("step_of")} {totalSteps}
</span>
</div>
);
}
@@ -0,0 +1,93 @@
"use client";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import type { SimStep } from "@/types/agent-data";
import { User, Bot, Terminal, ArrowRight, AlertCircle } from "lucide-react";
interface SimulatorMessageProps {
step: SimStep;
index: number;
}
const TYPE_CONFIG: Record<
string,
{ icon: typeof User; label: string; bgClass: string; borderClass: string }
> = {
user_message: {
icon: User,
label: "User",
bgClass: "bg-blue-50 dark:bg-blue-950/30",
borderClass: "border-blue-200 dark:border-blue-800",
},
assistant_text: {
icon: Bot,
label: "Assistant",
bgClass: "bg-zinc-50 dark:bg-zinc-900",
borderClass: "border-zinc-200 dark:border-zinc-700",
},
tool_call: {
icon: Terminal,
label: "Tool Call",
bgClass: "bg-amber-50 dark:bg-amber-950/30",
borderClass: "border-amber-200 dark:border-amber-800",
},
tool_result: {
icon: ArrowRight,
label: "Tool Result",
bgClass: "bg-emerald-50 dark:bg-emerald-950/30",
borderClass: "border-emerald-200 dark:border-emerald-800",
},
system_event: {
icon: AlertCircle,
label: "System",
bgClass: "bg-purple-50 dark:bg-purple-950/30",
borderClass: "border-purple-200 dark:border-purple-800",
},
};
export function SimulatorMessage({ step, index }: SimulatorMessageProps) {
const config = TYPE_CONFIG[step.type] || TYPE_CONFIG.assistant_text;
const Icon = config.icon;
return (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className={cn(
"rounded-lg border p-3",
config.bgClass,
config.borderClass
)}
>
<div className="mb-1.5 flex items-center gap-2">
<Icon size={14} className="shrink-0 text-[var(--color-text-secondary)]" />
<span className="text-xs font-medium text-[var(--color-text-secondary)]">
{config.label}
{step.toolName && (
<span className="ml-1.5 font-mono text-[var(--color-text)]">
{step.toolName}
</span>
)}
</span>
</div>
{step.type === "tool_call" || step.type === "tool_result" ? (
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-zinc-900 p-2.5 font-mono text-xs leading-relaxed text-zinc-100 dark:bg-zinc-950">
{step.content || "(empty)"}
</pre>
) : step.type === "system_event" ? (
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-purple-900/80 p-2.5 font-mono text-xs leading-relaxed text-purple-100 dark:bg-purple-950">
{step.content}
</pre>
) : (
<p className="text-sm leading-relaxed">{step.content}</p>
)}
<p className="mt-2 text-xs italic text-[var(--color-text-secondary)]">
{step.annotation}
</p>
</motion.div>
);
}
+215
View File
@@ -0,0 +1,215 @@
"use client";
import Link from "next/link";
import { motion } from "framer-motion";
import { useTranslations, useLocale } from "@/lib/i18n";
import { LEARNING_PATH, VERSION_META, LAYERS } from "@/lib/constants";
import { LayerBadge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import versionsData from "@/data/generated/versions.json";
const LAYER_DOT_BG: Record<string, string> = {
tools: "bg-blue-500",
planning: "bg-emerald-500",
memory: "bg-purple-500",
concurrency: "bg-amber-500",
collaboration: "bg-red-500",
};
const LAYER_LINE_BG: Record<string, string> = {
tools: "bg-blue-500/30",
planning: "bg-emerald-500/30",
memory: "bg-purple-500/30",
concurrency: "bg-amber-500/30",
collaboration: "bg-red-500/30",
};
const LAYER_BAR_BG: Record<string, string> = {
tools: "bg-blue-500",
planning: "bg-emerald-500",
memory: "bg-purple-500",
concurrency: "bg-amber-500",
collaboration: "bg-red-500",
};
function getVersionData(id: string) {
return versionsData.versions.find((v) => v.id === id);
}
const MAX_LOC = Math.max(
...versionsData.versions
.filter((v) => LEARNING_PATH.includes(v.id as (typeof LEARNING_PATH)[number]))
.map((v) => v.loc)
);
export function Timeline() {
const t = useTranslations("timeline");
const tv = useTranslations("version");
const locale = useLocale();
return (
<div className="flex flex-col gap-12">
{/* Layer Legend */}
<div>
<h3 className="mb-3 text-sm font-medium text-[var(--color-text-secondary)]">
{t("layer_legend")}
</h3>
<div className="flex flex-wrap gap-2">
{LAYERS.map((layer) => (
<div key={layer.id} className="flex items-center gap-1.5">
<span
className={cn("h-3 w-3 rounded-full", LAYER_DOT_BG[layer.id])}
/>
<span className="text-xs font-medium">{layer.label}</span>
</div>
))}
</div>
</div>
{/* Vertical Timeline */}
<div className="relative">
{LEARNING_PATH.map((versionId, index) => {
const meta = VERSION_META[versionId];
const data = getVersionData(versionId);
if (!meta || !data) return null;
const isLast = index === LEARNING_PATH.length - 1;
const locPercent = Math.round((data.loc / MAX_LOC) * 100);
return (
<div key={versionId} className="relative flex gap-4 pb-8 sm:gap-6">
{/* Timeline line + dot */}
<div className="flex flex-col items-center">
<div
className={cn(
"z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full ring-4 ring-[var(--color-bg)] sm:h-10 sm:w-10",
LAYER_DOT_BG[meta.layer]
)}
>
<span className="text-[10px] font-bold text-white sm:text-xs">
{versionId.replace("s", "").replace("_mini", "m")}
</span>
</div>
{!isLast && (
<div
className={cn(
"w-0.5 flex-1",
LAYER_LINE_BG[
VERSION_META[LEARNING_PATH[index + 1]]?.layer || meta.layer
]
)}
/>
)}
</div>
{/* Content card */}
<div className="flex-1 pb-2">
<motion.div
initial={{ opacity: 0, x: 30 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4, delay: 0.1 }}
className="rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4 transition-colors hover:border-[var(--color-text-secondary)]/30 sm:p-5"
>
<div className="flex flex-wrap items-start gap-2">
<LayerBadge layer={meta.layer}>{versionId}</LayerBadge>
<span className="text-xs text-[var(--color-text-secondary)]">
{meta.coreAddition}
</span>
</div>
<h3 className="mt-2 text-base font-semibold sm:text-lg">
{meta.title}
<span className="ml-2 text-sm font-normal text-[var(--color-text-secondary)]">
{meta.subtitle}
</span>
</h3>
{/* Stats row */}
<div className="mt-3 flex flex-wrap items-center gap-4 text-xs text-[var(--color-text-secondary)]">
<span className="tabular-nums">
{data.loc} {tv("loc")}
</span>
<span className="tabular-nums">
{data.tools.length} {tv("tools")}
</span>
</div>
{/* LOC bar */}
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<div
className={cn(
"h-full rounded-full transition-all",
LAYER_BAR_BG[meta.layer]
)}
style={{ width: `${locPercent}%` }}
/>
</div>
{/* Key insight */}
{meta.keyInsight && (
<p className="mt-3 text-sm italic text-[var(--color-text-secondary)]">
&ldquo;{meta.keyInsight}&rdquo;
</p>
)}
{/* Link */}
<Link
href={`/${locale}/${versionId}`}
className="mt-3 inline-flex items-center gap-1 text-sm font-medium text-zinc-900 hover:underline dark:text-zinc-100"
>
{t("learn_more")}
<span aria-hidden="true">&rarr;</span>
</Link>
</motion.div>
</div>
</div>
);
})}
</div>
{/* LOC Growth Chart */}
<div>
<h3 className="mb-4 text-lg font-semibold">{t("loc_growth")}</h3>
<div className="flex flex-col gap-2">
{LEARNING_PATH.map((versionId) => {
const meta = VERSION_META[versionId];
const data = getVersionData(versionId);
if (!meta || !data) return null;
const widthPercent = Math.max(
2,
Math.round((data.loc / MAX_LOC) * 100)
);
return (
<div key={versionId} className="flex items-center gap-3">
<span className="w-8 shrink-0 text-right text-xs font-medium tabular-nums">
{versionId}
</span>
<div className="flex-1">
<div className="h-5 w-full overflow-hidden rounded bg-zinc-100 dark:bg-zinc-800">
<motion.div
initial={{ width: 0 }}
whileInView={{ width: `${widthPercent}%` }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.05 * LEARNING_PATH.indexOf(versionId) }}
className={cn(
"flex h-full items-center rounded px-2",
LAYER_BAR_BG[meta.layer]
)}
>
<span className="text-[10px] font-medium text-white">
{data.loc}
</span>
</motion.div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { cn } from "@/lib/utils";
const LAYER_COLORS = {
tools:
"bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
planning:
"bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-300",
memory:
"bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300",
concurrency:
"bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
collaboration:
"bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
} as const;
interface BadgeProps {
layer: keyof typeof LAYER_COLORS;
children: React.ReactNode;
className?: string;
}
export function LayerBadge({ layer, children, className }: BadgeProps) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
LAYER_COLORS[layer],
className
)}
>
{children}
</span>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { cn } from "@/lib/utils";
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
export function Card({ className, children, ...props }: CardProps) {
return (
<div
className={cn(
"rounded-xl border border-zinc-200 bg-white p-6 shadow-sm dark:border-zinc-800 dark:bg-zinc-900",
className
)}
{...props}
>
{children}
</div>
);
}
export function CardHeader({ className, children, ...props }: CardProps) {
return (
<div className={cn("mb-4", className)} {...props}>
{children}
</div>
);
}
export function CardTitle({
className,
children,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return (
<h3 className={cn("text-lg font-semibold", className)} {...props}>
{children}
</h3>
);
}
+37
View File
@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
interface TabsProps {
tabs: { id: string; label: string }[];
defaultTab?: string;
children: (activeTab: string) => React.ReactNode;
className?: string;
}
export function Tabs({ tabs, defaultTab, children, className }: TabsProps) {
const [active, setActive] = useState(defaultTab || tabs[0]?.id || "");
return (
<div className={className}>
<div className="flex border-b border-zinc-200 dark:border-zinc-700">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActive(tab.id)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors",
active === tab.id
? "border-b-2 border-zinc-900 text-zinc-900 dark:border-white dark:text-white"
: "text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-200"
)}
>
{tab.label}
</button>
))}
</div>
<div className="mt-4">{children(active)}</div>
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import { lazy, Suspense } from "react";
import { useTranslations } from "@/lib/i18n";
const visualizations: Record<
string,
React.LazyExoticComponent<React.ComponentType<{ title?: string }>>
> = {
s01: lazy(() => import("./s01-agent-loop")),
s02: lazy(() => import("./s02-tool-dispatch")),
s03: lazy(() => import("./s03-todo-write")),
s04: lazy(() => import("./s04-subagent")),
s05: lazy(() => import("./s05-skill-loading")),
s06: lazy(() => import("./s06-context-compact")),
s07: lazy(() => import("./s07-task-system")),
s08: lazy(() => import("./s08-background-tasks")),
s09: lazy(() => import("./s09-agent-teams")),
s10: lazy(() => import("./s10-team-protocols")),
s11: lazy(() => import("./s11-autonomous-agents")),
};
export function SessionVisualization({ version }: { version: string }) {
const t = useTranslations("viz");
const Component = visualizations[version];
if (!Component) return null;
return (
<Suspense
fallback={
<div className="min-h-[500px] animate-pulse rounded-lg bg-zinc-100 dark:bg-zinc-800" />
}
>
<div className="min-h-[500px]">
<Component title={t(version)} />
</div>
</Suspense>
);
}
@@ -0,0 +1,416 @@
"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";
// -- Flowchart node definitions --
interface FlowNode {
id: string;
label: string;
x: number;
y: number;
w: number;
h: number;
type: "rect" | "diamond";
}
const NODES: FlowNode[] = [
{ id: "start", label: "Start", x: 160, y: 30, w: 120, h: 40, type: "rect" },
{ id: "api_call", label: "API Call", x: 160, y: 110, w: 120, h: 40, type: "rect" },
{ id: "check", label: "stop_reason?", x: 160, y: 200, w: 140, h: 50, type: "diamond" },
{ id: "execute", label: "Execute Tool", x: 160, y: 300, w: 120, h: 40, type: "rect" },
{ id: "append", label: "Append Result", x: 160, y: 380, w: 120, h: 40, type: "rect" },
{ id: "end", label: "Break / Done", x: 380, y: 200, w: 120, h: 40, type: "rect" },
];
// Edges between nodes (SVG path data computed inline)
interface FlowEdge {
from: string;
to: string;
label?: string;
}
const EDGES: FlowEdge[] = [
{ from: "start", to: "api_call" },
{ from: "api_call", to: "check" },
{ from: "check", to: "execute", label: "tool_use" },
{ from: "execute", to: "append" },
{ from: "append", to: "api_call" },
{ from: "check", to: "end", label: "end_turn" },
];
// Which nodes light up at each step
const ACTIVE_NODES_PER_STEP: string[][] = [
[],
["start"],
["api_call"],
["check", "execute"],
["execute", "append"],
["api_call", "check", "execute", "append"],
["check", "end"],
];
// Which edges highlight at each step
const ACTIVE_EDGES_PER_STEP: string[][] = [
[],
[],
["start->api_call"],
["api_call->check", "check->execute"],
["execute->append"],
["append->api_call", "api_call->check", "check->execute", "execute->append"],
["api_call->check", "check->end"],
];
// -- Message blocks --
interface MessageBlock {
role: string;
detail: string;
colorClass: string;
}
const MESSAGES_PER_STEP: (MessageBlock | null)[][] = [
[],
[{ role: "user", detail: "Fix the login bug", colorClass: "bg-blue-500 dark:bg-blue-600" }],
[],
[{ role: "assistant", detail: "tool_use: read_file", colorClass: "bg-zinc-600 dark:bg-zinc-500" }],
[{ role: "tool_result", detail: "auth.ts contents...", colorClass: "bg-emerald-500 dark:bg-emerald-600" }],
[
{ role: "assistant", detail: "tool_use: edit_file", colorClass: "bg-zinc-600 dark:bg-zinc-500" },
{ role: "tool_result", detail: "file updated", colorClass: "bg-emerald-500 dark:bg-emerald-600" },
],
[{ role: "assistant", detail: "end_turn: Done!", colorClass: "bg-purple-500 dark:bg-purple-600" }],
];
// -- Step annotations --
const STEP_INFO = [
{ title: "The While Loop", desc: "Every agent is a while loop that keeps calling the model until it says 'stop'." },
{ title: "User Input", desc: "The loop starts when the user sends a message." },
{ title: "Call the Model", desc: "Send all messages to the LLM. It sees everything and decides what to do." },
{ title: "stop_reason: tool_use", desc: "The model wants to use a tool. The loop continues." },
{ title: "Execute & Append", desc: "Run the tool, append the result to messages[]. Feed it back." },
{ title: "Loop Again", desc: "Same code path, second iteration. The model decides to edit a file." },
{ title: "stop_reason: end_turn", desc: "The model is done. Loop exits. That's the entire agent." },
];
// -- Helpers --
function getNode(id: string): FlowNode {
return NODES.find((n) => n.id === id)!;
}
function edgePath(fromId: string, toId: string): string {
const from = getNode(fromId);
const to = getNode(toId);
// Loop-back: append -> api_call (goes to the left side and back up)
if (fromId === "append" && toId === "api_call") {
const startX = from.x - from.w / 2;
const startY = from.y;
const endX = to.x - to.w / 2;
const endY = to.y;
return `M ${startX} ${startY} L ${startX - 50} ${startY} L ${endX - 50} ${endY} L ${endX} ${endY}`;
}
// Horizontal: check -> end
if (fromId === "check" && toId === "end") {
const startX = from.x + from.w / 2;
const startY = from.y;
const endX = to.x - to.w / 2;
const endY = to.y;
return `M ${startX} ${startY} L ${endX} ${endY}`;
}
// Vertical (default)
const startX = from.x;
const startY = from.y + from.h / 2;
const endX = to.x;
const endY = to.y - to.h / 2;
return `M ${startX} ${startY} L ${endX} ${endY}`;
}
// -- Component --
export default function AgentLoop({ title }: { title?: string }) {
const {
currentStep,
totalSteps,
next,
prev,
reset,
isPlaying,
toggleAutoPlay,
} = useSteppedVisualization({ totalSteps: 7, autoPlayInterval: 2500 });
const palette = useSvgPalette();
const activeNodes = ACTIVE_NODES_PER_STEP[currentStep];
const activeEdges = ACTIVE_EDGES_PER_STEP[currentStep];
// Build accumulated messages up to the current step
const visibleMessages: MessageBlock[] = [];
for (let s = 0; s <= currentStep; s++) {
for (const msg of MESSAGES_PER_STEP[s]) {
if (msg) visibleMessages.push(msg);
}
}
const stepInfo = STEP_INFO[currentStep];
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "The Agent While-Loop"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="flex flex-col gap-4 lg:flex-row">
{/* Left panel: SVG Flowchart (60%) */}
<div className="w-full lg:w-[60%]">
<div className="mb-2 font-mono text-xs text-zinc-400 dark:text-zinc-500">
while (stop_reason === "tool_use")
</div>
<svg
viewBox="0 0 500 440"
className="w-full rounded-md border border-zinc-100 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950"
style={{ minHeight: 300 }}
>
<defs>
<filter id="glow-blue">
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#3b82f6" floodOpacity="0.7" />
</filter>
<filter id="glow-purple">
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#a855f7" floodOpacity="0.7" />
</filter>
<marker
id="arrowhead"
markerWidth="8"
markerHeight="6"
refX="8"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill={palette.arrowFill} />
</marker>
<marker
id="arrowhead-active"
markerWidth="8"
markerHeight="6"
refX="8"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill={palette.activeEdgeStroke} />
</marker>
</defs>
{/* Edges */}
{EDGES.map((edge) => {
const key = `${edge.from}->${edge.to}`;
const isActive = activeEdges.includes(key);
const d = edgePath(edge.from, edge.to);
return (
<g key={key}>
<motion.path
d={d}
fill="none"
stroke={isActive ? palette.activeEdgeStroke : palette.edgeStroke}
strokeWidth={isActive ? 2.5 : 1.5}
strokeDasharray={isActive ? "none" : "none"}
markerEnd={isActive ? "url(#arrowhead-active)" : "url(#arrowhead)"}
animate={{
stroke: isActive ? palette.activeEdgeStroke : palette.edgeStroke,
strokeWidth: isActive ? 2.5 : 1.5,
}}
transition={{ duration: 0.4 }}
/>
{edge.label && (
<text
x={
edge.from === "check" && edge.to === "end"
? (getNode("check").x + getNode("end").x) / 2
: getNode(edge.from).x + 75
}
y={
edge.from === "check" && edge.to === "end"
? getNode("check").y - 10
: (getNode(edge.from).y + getNode(edge.to).y) / 2
}
textAnchor="middle"
className="fill-zinc-400 text-[10px] dark:fill-zinc-500"
>
{edge.label}
</text>
)}
</g>
);
})}
{/* Nodes */}
{NODES.map((node) => {
const isActive = activeNodes.includes(node.id);
const isEnd = node.id === "end";
const filterAttr = isActive
? isEnd
? "url(#glow-purple)"
: "url(#glow-blue)"
: "none";
if (node.type === "diamond") {
// Diamond shape for decision node
const cx = node.x;
const cy = node.y;
const hw = node.w / 2;
const hh = node.h / 2;
const points = `${cx},${cy - hh} ${cx + hw},${cy} ${cx},${cy + hh} ${cx - hw},${cy}`;
return (
<g key={node.id}>
<motion.polygon
points={points}
rx={6}
fill={isActive ? palette.activeNodeFill : palette.nodeFill}
stroke={isActive ? palette.activeNodeStroke : palette.nodeStroke}
strokeWidth={1.5}
filter={filterAttr}
animate={{
fill: isActive ? palette.activeNodeFill : palette.nodeFill,
stroke: isActive ? palette.activeNodeStroke : palette.nodeStroke,
}}
transition={{ duration: 0.4 }}
/>
<motion.text
x={cx}
y={cy + 4}
textAnchor="middle"
fontSize={11}
fontWeight={600}
fontFamily="monospace"
animate={{ fill: isActive ? palette.activeNodeText : palette.nodeText }}
transition={{ duration: 0.4 }}
>
{node.label}
</motion.text>
</g>
);
}
return (
<g key={node.id}>
<motion.rect
x={node.x - node.w / 2}
y={node.y - node.h / 2}
width={node.w}
height={node.h}
rx={8}
fill={isActive ? (isEnd ? palette.endNodeFill : palette.activeNodeFill) : palette.nodeFill}
stroke={isActive ? (isEnd ? palette.endNodeStroke : palette.activeNodeStroke) : palette.nodeStroke}
strokeWidth={1.5}
filter={filterAttr}
animate={{
fill: isActive ? (isEnd ? palette.endNodeFill : palette.activeNodeFill) : palette.nodeFill,
stroke: isActive ? (isEnd ? palette.endNodeStroke : palette.activeNodeStroke) : palette.nodeStroke,
}}
transition={{ duration: 0.4 }}
/>
<motion.text
x={node.x}
y={node.y + 4}
textAnchor="middle"
fontSize={12}
fontWeight={600}
fontFamily="monospace"
animate={{ fill: isActive ? palette.activeNodeText : palette.nodeText }}
transition={{ duration: 0.4 }}
>
{node.label}
</motion.text>
</g>
);
})}
{/* Iteration counter */}
{currentStep >= 5 && (
<motion.text
x={60}
y={130}
textAnchor="middle"
fontSize={10}
fontFamily="monospace"
fill="#3b82f6"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
iter #2
</motion.text>
)}
</svg>
</div>
{/* Right panel: messages[] array (40%) */}
<div className="w-full lg:w-[40%]">
<div className="mb-2 font-mono text-xs text-zinc-400 dark:text-zinc-500">
messages[]
</div>
<div className="min-h-[300px] space-y-2 rounded-md border border-zinc-100 bg-zinc-50 p-3 dark:border-zinc-800 dark:bg-zinc-950">
<AnimatePresence mode="popLayout">
{visibleMessages.length === 0 && (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="py-8 text-center text-xs text-zinc-400 dark:text-zinc-600"
>
[ empty ]
</motion.div>
)}
{visibleMessages.map((msg, i) => (
<motion.div
key={`${msg.role}-${msg.detail}-${i}`}
initial={{ opacity: 0, y: 12, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.35, type: "spring", bounce: 0.3 }}
className={`rounded-md px-3 py-2 ${msg.colorClass}`}
>
<div className="font-mono text-[11px] font-semibold text-white">
{msg.role}
</div>
<div className="mt-0.5 text-[10px] text-white/80">
{msg.detail}
</div>
</motion.div>
))}
</AnimatePresence>
{/* Array index markers */}
{visibleMessages.length > 0 && (
<div className="mt-3 border-t border-zinc-200 pt-2 dark:border-zinc-700">
<span className="font-mono text-[10px] text-zinc-400">
length: {visibleMessages.length}
</span>
</div>
)}
</div>
</div>
</div>
</div>
<StepControls
currentStep={currentStep}
totalSteps={totalSteps}
onPrev={prev}
onNext={next}
onReset={reset}
isPlaying={isPlaying}
onToggleAutoPlay={toggleAutoPlay}
stepTitle={stepInfo.title}
stepDescription={stepInfo.desc}
/>
</section>
);
}
@@ -0,0 +1,380 @@
"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";
// -- Tool definitions --
interface ToolDef {
name: string;
desc: string;
color: string;
activeColor: string;
darkColor: string;
darkActiveColor: string;
}
const TOOLS: ToolDef[] = [
{
name: "bash",
desc: "Execute shell commands",
color: "border-orange-300 bg-orange-50",
activeColor: "border-orange-500 bg-orange-100 ring-2 ring-orange-400",
darkColor: "dark:border-zinc-700 dark:bg-zinc-800/50",
darkActiveColor: "dark:border-orange-500 dark:bg-orange-950/40 dark:ring-orange-500",
},
{
name: "read_file",
desc: "Read file contents",
color: "border-sky-300 bg-sky-50",
activeColor: "border-sky-500 bg-sky-100 ring-2 ring-sky-400",
darkColor: "dark:border-zinc-700 dark:bg-zinc-800/50",
darkActiveColor: "dark:border-sky-500 dark:bg-sky-950/40 dark:ring-sky-500",
},
{
name: "write_file",
desc: "Create or overwrite a file",
color: "border-emerald-300 bg-emerald-50",
activeColor: "border-emerald-500 bg-emerald-100 ring-2 ring-emerald-400",
darkColor: "dark:border-zinc-700 dark:bg-zinc-800/50",
darkActiveColor: "dark:border-emerald-500 dark:bg-emerald-950/40 dark:ring-emerald-500",
},
{
name: "edit_file",
desc: "Apply targeted edits",
color: "border-violet-300 bg-violet-50",
activeColor: "border-violet-500 bg-violet-100 ring-2 ring-violet-400",
darkColor: "dark:border-zinc-700 dark:bg-zinc-800/50",
darkActiveColor: "dark:border-violet-500 dark:bg-violet-950/40 dark:ring-violet-500",
},
];
// Per-step: which tool index is active (-1 = none, 4 = all)
const ACTIVE_TOOL_PER_STEP: number[] = [-1, 0, 1, 2, 3, 4];
// Incoming request JSON per step
const REQUEST_PER_STEP: (string | null)[] = [
null,
'{ name: "bash", input: { cmd: "ls -la" } }',
'{ name: "read_file", input: { path: "src/auth.ts" } }',
'{ name: "write_file", input: { path: "config.json" } }',
'{ name: "edit_file", input: { path: "index.ts" } }',
null,
];
// Step annotations
const STEP_INFO = [
{ title: "The Dispatch Map", desc: "A dictionary maps tool names to handler functions. The loop code never changes." },
{ title: "Route: bash", desc: "tool_call.name -> handlers['bash'](input). Name-based routing." },
{ title: "Route: read_file", desc: "Same pattern, different handler. Validate input, execute, return result." },
{ title: "Route: write_file", desc: "Every tool returns a tool_result that goes back into messages[]." },
{ title: "Route: edit_file", desc: "Adding a new tool = adding one entry to the dispatch map." },
{ title: "The Key Insight", desc: "The while loop stays the same. You only grow the dispatch map. That's it." },
];
// SVG layout constants
const SVG_WIDTH = 600;
const SVG_HEIGHT = 320;
const DISPATCHER_X = SVG_WIDTH / 2;
const DISPATCHER_Y = 60;
const DISPATCHER_W = 160;
const DISPATCHER_H = 50;
const CARD_Y = 230;
const CARD_W = 110;
const CARD_H = 65;
const CARD_GAP = 20;
function getCardX(index: number): number {
const totalWidth = TOOLS.length * CARD_W + (TOOLS.length - 1) * CARD_GAP;
const startX = (SVG_WIDTH - totalWidth) / 2;
return startX + index * (CARD_W + CARD_GAP) + CARD_W / 2;
}
export default function ToolDispatch({ title }: { title?: string }) {
const {
currentStep,
totalSteps,
next,
prev,
reset,
isPlaying,
toggleAutoPlay,
} = useSteppedVisualization({ totalSteps: 6, autoPlayInterval: 2500 });
const palette = useSvgPalette();
const activeToolIdx = ACTIVE_TOOL_PER_STEP[currentStep];
const request = REQUEST_PER_STEP[currentStep];
const stepInfo = STEP_INFO[currentStep];
const isAllActive = activeToolIdx === 4;
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Tool Dispatch Map"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
{/* Incoming request display */}
<div className="mb-4 flex min-h-[32px] items-center gap-2">
<span className="shrink-0 text-xs font-medium text-zinc-500 dark:text-zinc-400">
Incoming:
</span>
<AnimatePresence mode="wait">
{request && (
<motion.code
key={request}
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.3 }}
className="rounded bg-blue-100 px-2.5 py-1 font-mono text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-300"
>
{request}
</motion.code>
)}
{!request && currentStep === 0 && (
<motion.span
key="waiting"
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
className="text-xs text-zinc-400 dark:text-zinc-600"
>
waiting for tool_call...
</motion.span>
)}
{isAllActive && (
<motion.span
key="all-routes"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-xs font-medium text-emerald-600 dark:text-emerald-400"
>
All routes active
</motion.span>
)}
</AnimatePresence>
</div>
{/* SVG dispatch diagram */}
<svg
viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
className="w-full rounded-md border border-zinc-100 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-950"
style={{ minHeight: 240 }}
>
<defs>
<filter id="dispatch-glow">
<feDropShadow dx="0" dy="0" stdDeviation="4" floodColor="#3b82f6" floodOpacity="0.6" />
</filter>
<filter id="card-glow-orange">
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor="#f97316" floodOpacity="0.6" />
</filter>
<filter id="card-glow-sky">
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor="#0ea5e9" floodOpacity="0.6" />
</filter>
<filter id="card-glow-emerald">
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor="#10b981" floodOpacity="0.6" />
</filter>
<filter id="card-glow-violet">
<feDropShadow dx="0" dy="0" stdDeviation="3" floodColor="#8b5cf6" floodOpacity="0.6" />
</filter>
<marker
id="dispatch-arrow"
markerWidth="8"
markerHeight="6"
refX="8"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill={palette.activeEdgeStroke} />
</marker>
<marker
id="dispatch-arrow-dim"
markerWidth="8"
markerHeight="6"
refX="8"
refY="3"
orient="auto"
>
<polygon points="0 0, 8 3, 0 6" fill={palette.arrowFill} />
</marker>
</defs>
{/* Dispatcher box */}
<motion.rect
x={DISPATCHER_X - DISPATCHER_W / 2}
y={DISPATCHER_Y - DISPATCHER_H / 2}
width={DISPATCHER_W}
height={DISPATCHER_H}
rx={10}
strokeWidth={2}
animate={{
fill: currentStep > 0 ? palette.activeNodeFill : palette.nodeFill,
stroke: currentStep > 0 ? palette.activeNodeStroke : palette.nodeStroke,
}}
filter={currentStep > 0 ? "url(#dispatch-glow)" : "none"}
transition={{ duration: 0.4 }}
/>
<motion.text
x={DISPATCHER_X}
y={DISPATCHER_Y + 1}
textAnchor="middle"
dominantBaseline="middle"
fontSize={13}
fontWeight={700}
fontFamily="monospace"
animate={{ fill: currentStep > 0 ? palette.activeNodeText : palette.nodeText }}
transition={{ duration: 0.4 }}
>
dispatch(name)
</motion.text>
{/* Connection lines from dispatcher to each tool card */}
{TOOLS.map((tool, i) => {
const cardX = getCardX(i);
const isActive = isAllActive || i === activeToolIdx;
const lineColor = isActive ? palette.activeEdgeStroke : palette.edgeStroke;
return (
<motion.line
key={`line-${tool.name}`}
x1={DISPATCHER_X}
y1={DISPATCHER_Y + DISPATCHER_H / 2}
x2={cardX}
y2={CARD_Y - CARD_H / 2}
strokeWidth={isActive ? 2.5 : 1.5}
markerEnd={isActive ? "url(#dispatch-arrow)" : "url(#dispatch-arrow-dim)"}
animate={{ stroke: lineColor, strokeWidth: isActive ? 2.5 : 1.5 }}
transition={{ duration: 0.4 }}
/>
);
})}
{/* Tool cards */}
{TOOLS.map((tool, i) => {
const cardX = getCardX(i);
const isActive = isAllActive || i === activeToolIdx;
const glowFilters = [
"url(#card-glow-orange)",
"url(#card-glow-sky)",
"url(#card-glow-emerald)",
"url(#card-glow-violet)",
];
const activeColors = ["#f97316", "#0ea5e9", "#10b981", "#8b5cf6"];
const activeBorders = ["#ea580c", "#0284c7", "#059669", "#7c3aed"];
return (
<g key={tool.name}>
<motion.rect
x={cardX - CARD_W / 2}
y={CARD_Y - CARD_H / 2}
width={CARD_W}
height={CARD_H}
rx={8}
strokeWidth={2}
animate={{
fill: isActive ? activeColors[i] : palette.nodeFill,
stroke: isActive ? activeBorders[i] : palette.nodeStroke,
}}
filter={isActive ? glowFilters[i] : "none"}
transition={{ duration: 0.4 }}
/>
<motion.text
x={cardX}
y={CARD_Y - 8}
textAnchor="middle"
dominantBaseline="middle"
fontSize={11}
fontWeight={700}
fontFamily="monospace"
animate={{ fill: isActive ? "#ffffff" : palette.nodeText }}
transition={{ duration: 0.4 }}
>
{tool.name}
</motion.text>
<motion.text
x={cardX}
y={CARD_Y + 12}
textAnchor="middle"
dominantBaseline="middle"
fontSize={8}
fontFamily="sans-serif"
animate={{ fill: isActive ? "rgba(255,255,255,0.8)" : palette.labelFill }}
transition={{ duration: 0.4 }}
>
{tool.desc}
</motion.text>
</g>
);
})}
{/* "+" extensibility indicator on last step */}
{isAllActive && (
<motion.g
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3, duration: 0.4 }}
>
<circle
cx={getCardX(3) + CARD_W / 2 + 30}
cy={CARD_Y}
r={16}
fill="none"
stroke="#3b82f6"
strokeWidth={2}
strokeDasharray="4 3"
/>
<text
x={getCardX(3) + CARD_W / 2 + 30}
y={CARD_Y + 1}
textAnchor="middle"
dominantBaseline="middle"
fontSize={18}
fontWeight={700}
fill="#3b82f6"
>
+
</text>
</motion.g>
)}
</svg>
{/* Code snippet below the diagram */}
<div className="mt-3 rounded-md bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
<code className="block font-mono text-[11px] leading-relaxed text-zinc-600 dark:text-zinc-300">
<span className="text-blue-600 dark:text-blue-400">const</span> handlers = {"{"}
{TOOLS.map((tool, i) => {
const isActive = isAllActive || i === activeToolIdx;
return (
<motion.span
key={tool.name}
animate={{
color: isActive ? "#3b82f6" : undefined,
fontWeight: isActive ? 700 : 400,
}}
className="text-zinc-600 dark:text-zinc-300"
>
{" "}{tool.name},
</motion.span>
);
})}
{" }{"}{"}"};
</code>
</div>
</div>
<StepControls
currentStep={currentStep}
totalSteps={totalSteps}
onPrev={prev}
onNext={next}
onReset={reset}
isPlaying={isPlaying}
onToggleAutoPlay={toggleAutoPlay}
stepTitle={stepInfo.title}
stepDescription={stepInfo.desc}
/>
</section>
);
}
@@ -0,0 +1,323 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { StepControls } from "@/components/visualizations/shared/step-controls";
// -- Task definitions --
type TaskStatus = "pending" | "in_progress" | "done";
interface Task {
id: number;
label: string;
status: TaskStatus;
}
// Snapshot of all 4 tasks at each step
const TASK_STATES: Task[][] = [
// Step 0: all pending
[
{ id: 1, label: "Write auth tests", status: "pending" },
{ id: 2, label: "Fix mobile layout", status: "pending" },
{ id: 3, label: "Add error handling", status: "pending" },
{ id: 4, label: "Update config loader", status: "pending" },
],
// Step 1: still all pending (idle round 1)
[
{ id: 1, label: "Write auth tests", status: "pending" },
{ id: 2, label: "Fix mobile layout", status: "pending" },
{ id: 3, label: "Add error handling", status: "pending" },
{ id: 4, label: "Update config loader", status: "pending" },
],
// Step 2: still all pending (idle round 2)
[
{ id: 1, label: "Write auth tests", status: "pending" },
{ id: 2, label: "Fix mobile layout", status: "pending" },
{ id: 3, label: "Add error handling", status: "pending" },
{ id: 4, label: "Update config loader", status: "pending" },
],
// Step 3: NAG fires, task 1 moves to in_progress
[
{ id: 1, label: "Write auth tests", status: "in_progress" },
{ id: 2, label: "Fix mobile layout", status: "pending" },
{ id: 3, label: "Add error handling", status: "pending" },
{ id: 4, label: "Update config loader", status: "pending" },
],
// Step 4: task 1 done
[
{ id: 1, label: "Write auth tests", status: "done" },
{ id: 2, label: "Fix mobile layout", status: "pending" },
{ id: 3, label: "Add error handling", status: "pending" },
{ id: 4, label: "Update config loader", status: "pending" },
],
// Step 5: task 2 self-directed to in_progress
[
{ id: 1, label: "Write auth tests", status: "done" },
{ id: 2, label: "Fix mobile layout", status: "in_progress" },
{ id: 3, label: "Add error handling", status: "pending" },
{ id: 4, label: "Update config loader", status: "pending" },
],
// Step 6: tasks 2,3 done, task 4 in_progress
[
{ id: 1, label: "Write auth tests", status: "done" },
{ id: 2, label: "Fix mobile layout", status: "done" },
{ id: 3, label: "Add error handling", status: "done" },
{ id: 4, label: "Update config loader", status: "in_progress" },
],
];
// Nag timer value at each step (out of 3)
const NAG_TIMER_PER_STEP = [0, 1, 2, 3, 0, 0, 0];
const NAG_THRESHOLD = 3;
// Whether the nag fires at this step
const NAG_FIRES_PER_STEP = [false, false, false, true, false, false, false];
// Step annotations
const STEP_INFO = [
{ title: "The Plan", desc: "TodoWrite gives the model a visible plan. All tasks start as pending." },
{ title: "Round 1 -- Idle", desc: "The model does work but doesn't touch its todos. The nag counter increments." },
{ title: "Round 2 -- Still Idle", desc: "Two rounds without progress. Pressure builds." },
{ title: "NAG!", desc: "Threshold reached! System message injected: 'You have pending tasks. Pick one up now!'" },
{ title: "Task Complete", desc: "The model completes the task. Timer stays at 0 -- working on todos resets the counter." },
{ title: "Self-Directed", desc: "Once the model learns the pattern, it picks up tasks voluntarily." },
{ title: "Mission Accomplished", desc: "Visible plan + nag pressure = reliable task completion." },
];
// -- Column component --
function KanbanColumn({
title,
tasks,
accentClass,
headerBg,
}: {
title: string;
tasks: Task[];
accentClass: string;
headerBg: string;
}) {
return (
<div className="flex min-h-[280px] flex-1 flex-col rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
<div
className={`rounded-t-lg px-3 py-2 text-center text-xs font-bold uppercase tracking-wider ${headerBg}`}
>
{title}
<span className={`ml-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-bold ${accentClass}`}>
{tasks.length}
</span>
</div>
<div className="flex flex-1 flex-col gap-2 p-2">
<AnimatePresence mode="popLayout">
{tasks.map((task) => (
<TaskCard key={task.id} task={task} />
))}
</AnimatePresence>
{tasks.length === 0 && (
<div className="flex flex-1 items-center justify-center text-xs text-zinc-400 dark:text-zinc-600">
--
</div>
)}
</div>
</div>
);
}
// -- Task card --
function TaskCard({ task }: { task: Task }) {
const statusStyles: Record<TaskStatus, string> = {
pending: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400",
in_progress: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300",
done: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
};
const borderStyles: Record<TaskStatus, string> = {
pending: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-800",
in_progress: "border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30",
done: "border-emerald-300 bg-emerald-50 dark:border-emerald-700 dark:bg-emerald-950/30",
};
return (
<motion.div
layout
layoutId={`task-${task.id}`}
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ type: "spring", stiffness: 400, damping: 30 }}
className={`rounded-md border p-2.5 ${borderStyles[task.status]}`}
>
<div className="mb-1.5 flex items-center justify-between">
<span className="font-mono text-[10px] text-zinc-400 dark:text-zinc-500">
#{task.id}
</span>
<span
className={`rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide ${statusStyles[task.status]}`}
>
{task.status.replace("_", " ")}
</span>
</div>
<div className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
{task.label}
</div>
</motion.div>
);
}
// -- Nag gauge --
function NagGauge({ value, max, firing }: { value: number; max: number; firing: boolean }) {
const pct = Math.min((value / max) * 100, 100);
const barColor =
value === 0
? "bg-zinc-300 dark:bg-zinc-600"
: value === 1
? "bg-green-400 dark:bg-green-500"
: value === 2
? "bg-yellow-400 dark:bg-yellow-500"
: "bg-red-500 dark:bg-red-500";
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-600 dark:text-zinc-300">
Nag Timer
</span>
<span className="font-mono text-xs text-zinc-500 dark:text-zinc-400">
{value}/{max}
</span>
</div>
<div className="relative h-4 w-full overflow-hidden rounded-full bg-zinc-200 dark:bg-zinc-700">
<motion.div
className={`absolute inset-y-0 left-0 rounded-full ${barColor}`}
initial={{ width: "0%" }}
animate={{
width: `${pct}%`,
...(firing ? { scale: [1, 1.05, 1] } : {}),
}}
transition={{
width: { duration: 0.5, ease: "easeOut" },
scale: { duration: 0.3, repeat: 2 },
}}
/>
{firing && (
<motion.div
className="absolute inset-0 rounded-full border-2 border-red-500"
initial={{ opacity: 0 }}
animate={{ opacity: [0, 1, 0, 1, 0] }}
transition={{ duration: 1 }}
/>
)}
</div>
</div>
);
}
// -- Main component --
export default function TodoWrite({ title }: { title?: string }) {
const {
currentStep,
totalSteps,
next,
prev,
reset,
isPlaying,
toggleAutoPlay,
} = useSteppedVisualization({ totalSteps: 7, autoPlayInterval: 2500 });
const tasks = TASK_STATES[currentStep];
const nagValue = NAG_TIMER_PER_STEP[currentStep];
const nagFires = NAG_FIRES_PER_STEP[currentStep];
const stepInfo = STEP_INFO[currentStep];
const pendingTasks = tasks.filter((t) => t.status === "pending");
const inProgressTasks = tasks.filter((t) => t.status === "in_progress");
const doneTasks = tasks.filter((t) => t.status === "done");
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "TodoWrite Nag System"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
{/* Nag gauge + nag message */}
<div className="mb-4 space-y-2">
<NagGauge value={nagValue} max={NAG_THRESHOLD} firing={nagFires} />
<AnimatePresence>
{nagFires && (
<motion.div
initial={{ opacity: 0, y: -8, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -8, height: 0 }}
className="rounded-md border border-red-300 bg-red-50 px-3 py-2 text-center text-xs font-bold text-red-700 dark:border-red-700 dark:bg-red-950/30 dark:text-red-300"
>
SYSTEM: "You have pending tasks. Pick one up now!"
</motion.div>
)}
</AnimatePresence>
</div>
{/* Kanban board */}
<div className="flex gap-3">
<KanbanColumn
title="Pending"
tasks={pendingTasks}
accentClass="bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300"
headerBg="bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
/>
<KanbanColumn
title="In Progress"
tasks={inProgressTasks}
accentClass="bg-amber-200 text-amber-700 dark:bg-amber-800 dark:text-amber-200"
headerBg="bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"
/>
<KanbanColumn
title="Done"
tasks={doneTasks}
accentClass="bg-emerald-200 text-emerald-700 dark:bg-emerald-800 dark:text-emerald-200"
headerBg="bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300"
/>
</div>
{/* Progress summary */}
<div className="mt-3 flex items-center justify-between rounded-md bg-zinc-100 px-3 py-2 dark:bg-zinc-800">
<span className="font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
Progress: {doneTasks.length}/{tasks.length} complete
</span>
<div className="flex gap-0.5">
{tasks.map((t) => (
<div
key={t.id}
className={`h-2 w-6 rounded-sm ${
t.status === "done"
? "bg-emerald-500"
: t.status === "in_progress"
? "bg-amber-400"
: "bg-zinc-300 dark:bg-zinc-600"
}`}
/>
))}
</div>
</div>
</div>
<StepControls
currentStep={currentStep}
totalSteps={totalSteps}
onPrev={prev}
onNext={next}
onReset={reset}
isPlaying={isPlaying}
onToggleAutoPlay={toggleAutoPlay}
stepTitle={stepInfo.title}
stepDescription={stepInfo.desc}
/>
</section>
);
}
@@ -0,0 +1,308 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { StepControls } from "@/components/visualizations/shared/step-controls";
interface MessageBlock {
id: string;
label: string;
color: string;
}
const PARENT_BASE_MESSAGES: MessageBlock[] = [
{ id: "p1", label: "user: Build login + tests", color: "bg-blue-500" },
{ id: "p2", label: "assistant: Planning approach...", color: "bg-zinc-600" },
{ id: "p3", label: "tool_result: project structure", color: "bg-emerald-500" },
];
const TASK_PROMPT: MessageBlock = {
id: "task",
label: "task: Write unit tests for auth",
color: "bg-purple-500",
};
const CHILD_WORK_MESSAGES: MessageBlock[] = [
{ id: "c1", label: "tool_use: read auth.ts", color: "bg-amber-500" },
{ id: "c2", label: "tool_use: write test.ts", color: "bg-amber-500" },
];
const SUMMARY_BLOCK: MessageBlock = {
id: "summary",
label: "summary: 3 tests written, all passing",
color: "bg-teal-500",
};
const STEPS = [
{
title: "Parent Context",
description:
"The parent agent has accumulated messages from the conversation.",
},
{
title: "Spawn Subagent",
description:
"Task tool creates a child with fresh messages[]. Only the task description is passed.",
},
{
title: "Independent Work",
description:
"The child has its own context. It doesn't see the parent's history.",
},
{
title: "Compress Result",
description:
"The child's full conversation compresses into one summary.",
},
{
title: "Return Summary",
description:
"Only the summary returns. The child's full context is discarded.",
},
{
title: "Clean Context",
description:
"The parent gets a clean summary without context bloat. This is process isolation for LLMs.",
},
];
export default function SubagentIsolation({ title }: { title?: string }) {
const {
currentStep,
totalSteps,
next,
prev,
reset,
isPlaying,
toggleAutoPlay,
} = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
// Derive what to show in each container based on step
const parentMessages: MessageBlock[] = (() => {
const base = [...PARENT_BASE_MESSAGES];
if (currentStep >= 5) {
base.push(SUMMARY_BLOCK);
}
return base;
})();
const childMessages: MessageBlock[] = (() => {
if (currentStep < 1) return [];
if (currentStep === 1) return [TASK_PROMPT];
if (currentStep === 2) return [TASK_PROMPT, ...CHILD_WORK_MESSAGES];
if (currentStep === 3) return [SUMMARY_BLOCK];
return currentStep >= 4 ? [TASK_PROMPT, ...CHILD_WORK_MESSAGES] : [];
})();
const showChildEmpty = currentStep === 0;
const showArcToChild = currentStep === 1;
const showCompression = currentStep === 3;
const showArcToParent = currentStep === 4;
const childDiscarded = currentStep >= 4;
const childFaded = currentStep >= 4;
return (
<section className="space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Subagent Context Isolation"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900"
style={{ minHeight: 500 }}
>
{/* Main layout: two containers side by side */}
<div className="relative flex gap-4" style={{ minHeight: 340 }}>
{/* Parent Process Container */}
<div className="flex-1 rounded-xl border-2 border-blue-300 bg-blue-50/50 p-4 dark:border-blue-700 dark:bg-blue-950/20">
<div className="mb-3 flex items-center gap-2">
<div className="h-3 w-3 rounded-full bg-blue-500" />
<span className="text-sm font-bold text-blue-700 dark:text-blue-300">
Parent Process
</span>
</div>
<div className="mb-2 font-mono text-xs text-zinc-400">
messages[]
</div>
<div className="space-y-2">
<AnimatePresence>
{parentMessages.map((msg, i) => (
<motion.div
key={msg.id}
initial={{ opacity: 0, x: -12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -12 }}
transition={{ duration: 0.4, delay: msg.id === "summary" ? 0.3 : 0 }}
className={`rounded-lg px-3 py-2 text-xs font-medium text-white shadow-sm ${msg.color}`}
>
{msg.label}
</motion.div>
))}
</AnimatePresence>
</div>
{currentStep >= 5 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
className="mt-3 rounded border border-blue-200 bg-white/60 px-2 py-1 text-center text-xs text-blue-600 dark:border-blue-700 dark:bg-blue-950/30 dark:text-blue-300"
>
3 original + 1 summary = clean context
</motion.div>
)}
</div>
{/* Isolation Wall */}
<div className="flex flex-col items-center justify-center gap-2">
<div className="h-full w-px border-l-2 border-dashed border-zinc-300 dark:border-zinc-600" />
<motion.div
animate={{
opacity: currentStep >= 1 && currentStep <= 4 ? 1 : 0.4,
}}
className="rounded bg-zinc-200 px-2 py-1 text-center font-mono text-[10px] text-zinc-500 dark:bg-zinc-700 dark:text-zinc-400"
style={{ writingMode: "vertical-rl", textOrientation: "mixed" }}
>
ISOLATION
</motion.div>
<div className="h-full w-px border-l-2 border-dashed border-zinc-300 dark:border-zinc-600" />
</div>
{/* Child Process Container */}
<div
className={`flex-1 rounded-xl border-2 p-4 transition-colors duration-300 ${
showChildEmpty
? "border-dashed border-zinc-300 bg-zinc-50/50 dark:border-zinc-600 dark:bg-zinc-800/30"
: childDiscarded
? "border-zinc-300 bg-zinc-100/50 dark:border-zinc-600 dark:bg-zinc-800/40"
: "border-purple-300 bg-purple-50/50 dark:border-purple-700 dark:bg-purple-950/20"
}`}
>
<div className="mb-3 flex items-center gap-2">
<div
className={`h-3 w-3 rounded-full ${
showChildEmpty
? "bg-zinc-300 dark:bg-zinc-600"
: childDiscarded
? "bg-zinc-400 dark:bg-zinc-500"
: "bg-purple-500"
}`}
/>
<span
className={`text-sm font-bold ${
showChildEmpty
? "text-zinc-400 dark:text-zinc-500"
: childDiscarded
? "text-zinc-400 dark:text-zinc-500"
: "text-purple-700 dark:text-purple-300"
}`}
>
Child Process
</span>
</div>
<div className="mb-2 font-mono text-xs text-zinc-400">
messages[] (fresh)
</div>
{showChildEmpty && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex h-24 items-center justify-center rounded-lg border border-dashed border-zinc-200 dark:border-zinc-700"
>
<span className="text-xs text-zinc-400">
not yet spawned
</span>
</motion.div>
)}
<div className="space-y-2">
<AnimatePresence>
{childMessages.map((msg) => (
<motion.div
key={msg.id + "-child"}
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: childFaded ? 0.3 : 1, x: 0 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.4 }}
className={`rounded-lg px-3 py-2 text-xs font-medium text-white shadow-sm ${msg.color}`}
>
{msg.label}
</motion.div>
))}
</AnimatePresence>
</div>
{showCompression && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="mt-3 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-center text-xs text-amber-700 dark:border-amber-600 dark:bg-amber-900/20 dark:text-amber-300"
>
Compressing full context into summary...
</motion.div>
)}
{childDiscarded && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-3 rounded border border-red-200 bg-red-50 px-2 py-1 text-center text-xs text-red-500 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
context discarded
</motion.div>
)}
</div>
{/* Animated arcs: task prompt going from parent to child */}
<AnimatePresence>
{showArcToChild && (
<motion.div
initial={{ opacity: 0, x: "20%", y: "-10%" }}
animate={{ opacity: 1, x: "55%", y: "-10%" }}
exit={{ opacity: 0 }}
transition={{ duration: 1.0, ease: "easeInOut" }}
className="pointer-events-none absolute left-0 top-0"
style={{ zIndex: 10 }}
>
<div className="rounded-lg bg-purple-500 px-3 py-1.5 text-xs font-medium text-white shadow-lg">
task prompt
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showArcToParent && (
<motion.div
initial={{ opacity: 0, x: "75%", y: "60%" }}
animate={{ opacity: 1, x: "15%", y: "60%" }}
exit={{ opacity: 0 }}
transition={{ duration: 1.0, ease: "easeInOut" }}
className="pointer-events-none absolute left-0 top-0"
style={{ zIndex: 10 }}
>
<div className="rounded-lg bg-teal-500 px-3 py-1.5 text-xs font-medium text-white shadow-lg">
summary
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Step Controls */}
<div className="mt-6">
<StepControls
currentStep={currentStep}
totalSteps={totalSteps}
onPrev={prev}
onNext={next}
onReset={reset}
isPlaying={isPlaying}
onToggleAutoPlay={toggleAutoPlay}
stepTitle={STEPS[currentStep].title}
stepDescription={STEPS[currentStep].description}
/>
</div>
</div>
</section>
);
}
@@ -0,0 +1,407 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { StepControls } from "@/components/visualizations/shared/step-controls";
interface SkillEntry {
name: string;
summary: string;
fullTokens: number;
content: string[];
}
const SKILLS: SkillEntry[] = [
{
name: "/commit",
summary: "Create git commits following repo conventions",
fullTokens: 320,
content: [
"1. Run git status + git diff to see changes",
"2. Analyze all staged changes and draft message",
"3. Create commit with Co-Authored-By trailer",
"4. Run git status after commit to verify",
],
},
{
name: "/review-pr",
summary: "Review pull requests for bugs and style",
fullTokens: 480,
content: [
"1. Fetch PR diff via gh pr view",
"2. Analyze changes file by file for issues",
"3. Check for bugs, security, and style problems",
"4. Post review comments with gh pr review",
],
},
{
name: "/test",
summary: "Run and analyze test suites",
fullTokens: 290,
content: [
"1. Detect test framework from package.json",
"2. Run test suite and capture output",
"3. Analyze failures and suggest fixes",
"4. Re-run after applying fixes",
],
},
{
name: "/deploy",
summary: "Deploy application to target environment",
fullTokens: 350,
content: [
"1. Verify all tests pass before deploy",
"2. Build production bundle",
"3. Push to deployment target via CI",
"4. Verify health check on deployed URL",
],
},
];
const TOKEN_STATES = [120, 120, 440, 440, 780, 780];
const MAX_TOKEN_DISPLAY = 1000;
const STEPS = [
{
title: "Layer 1: Compact Summaries",
description:
"All skills are summarized in the system prompt. Compact, always present.",
},
{
title: "Skill Invocation",
description:
'The model recognizes a skill invocation and triggers the Skill tool.',
},
{
title: "Layer 2: Full Injection",
description:
"The full skill instructions are injected as a tool_result, not into the system prompt.",
},
{
title: "In Context Now",
description:
"The detailed instructions appear as if a tool returned them. The model follows them precisely.",
},
{
title: "Stack Skills",
description:
"Multiple skills can be loaded. Only summaries are permanent; full content comes and goes.",
},
{
title: "Two-Layer Architecture",
description:
"Layer 1: always present, tiny. Layer 2: loaded on demand, detailed. Elegant separation.",
},
];
export default function SkillLoading({ title }: { title?: string }) {
const {
currentStep,
totalSteps,
next,
prev,
reset,
isPlaying,
toggleAutoPlay,
} = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
const tokenCount = TOKEN_STATES[currentStep];
const highlightedSkill = currentStep >= 1 && currentStep <= 3 ? 0 : currentStep >= 4 ? 1 : -1;
const showFirstContent = currentStep >= 2;
const showSecondContent = currentStep >= 4;
const firstContentFaded = currentStep >= 5;
return (
<section className="space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "On-Demand Skill Loading"}
</h2>
<div
className="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900"
style={{ minHeight: 500 }}
>
<div className="flex gap-6">
{/* Main content area */}
<div className="flex-1 space-y-4">
{/* System Prompt Block */}
<div>
<div className="mb-2 flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-zinc-400" />
<span className="text-xs font-semibold text-zinc-600 dark:text-zinc-300">
System Prompt
</span>
<span className="rounded bg-zinc-100 px-1.5 py-0.5 font-mono text-[10px] text-zinc-400 dark:bg-zinc-800">
always present
</span>
</div>
<div className="rounded-lg border border-zinc-300 bg-zinc-900 p-4 dark:border-zinc-600">
<div className="mb-2 font-mono text-[10px] text-zinc-500">
# Available Skills
</div>
<div className="space-y-1.5">
{SKILLS.map((skill, i) => {
const isHighlighted = i === highlightedSkill;
return (
<motion.div
key={skill.name}
animate={{
boxShadow: isHighlighted
? "0 0 12px 2px rgba(59, 130, 246, 0.5)"
: "0 0 0 0px rgba(59, 130, 246, 0)",
}}
transition={{ duration: 0.4 }}
className={`rounded px-3 py-1.5 font-mono text-xs transition-colors ${
isHighlighted
? "bg-blue-900/60 text-blue-300"
: "bg-zinc-800 text-zinc-400"
}`}
>
<span className="font-semibold text-zinc-200">
{skill.name}
</span>
{" - "}
{skill.summary}
</motion.div>
);
})}
</div>
</div>
</div>
{/* User invocation indicator */}
<AnimatePresence>
{currentStep === 1 && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 dark:border-blue-800 dark:bg-blue-950/30"
>
<span className="text-xs text-blue-600 dark:text-blue-400">
User types:
</span>
<code className="rounded bg-blue-100 px-2 py-0.5 text-xs font-bold text-blue-800 dark:bg-blue-900/50 dark:text-blue-200">
/commit
</code>
</motion.div>
)}
{currentStep === 4 && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="flex items-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 dark:border-blue-800 dark:bg-blue-950/30"
>
<span className="text-xs text-blue-600 dark:text-blue-400">
User types:
</span>
<code className="rounded bg-blue-100 px-2 py-0.5 text-xs font-bold text-blue-800 dark:bg-blue-900/50 dark:text-blue-200">
/review-pr
</code>
</motion.div>
)}
</AnimatePresence>
{/* Connecting arrow */}
<AnimatePresence>
{(showFirstContent || showSecondContent) && (
<motion.div
initial={{ opacity: 0, scaleY: 0 }}
animate={{ opacity: 1, scaleY: 1 }}
exit={{ opacity: 0 }}
className="flex justify-center"
>
<div className="flex flex-col items-center">
<div className="h-6 w-px bg-blue-400 dark:bg-blue-500" />
<div className="h-0 w-0 border-l-[5px] border-r-[5px] border-t-[6px] border-l-transparent border-r-transparent border-t-blue-400 dark:border-t-blue-500" />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Expanded Skill Content Blocks */}
<div className="space-y-3">
<AnimatePresence>
{showFirstContent && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{
opacity: firstContentFaded ? 0.4 : 1,
height: "auto",
}}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.4 }}
className="overflow-hidden"
>
<div className="rounded-lg border-2 border-blue-300 bg-white p-4 dark:border-blue-700 dark:bg-zinc-800">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-blue-500" />
<span className="text-xs font-bold text-blue-700 dark:text-blue-300">
SKILL.md: /commit
</span>
</div>
<span className="rounded bg-blue-100 px-1.5 py-0.5 font-mono text-[10px] text-blue-600 dark:bg-blue-900/40 dark:text-blue-300">
tool_result
</span>
</div>
<div className="space-y-1">
{SKILLS[0].content.map((line, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{
opacity: firstContentFaded ? 0.5 : 1,
x: 0,
}}
transition={{ delay: i * 0.08 }}
className="font-mono text-xs text-zinc-600 dark:text-zinc-300"
>
{line}
</motion.div>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showSecondContent && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.4 }}
className="overflow-hidden"
>
<div className="rounded-lg border-2 border-purple-300 bg-white p-4 dark:border-purple-700 dark:bg-zinc-800">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-purple-500" />
<span className="text-xs font-bold text-purple-700 dark:text-purple-300">
SKILL.md: /review-pr
</span>
</div>
<span className="rounded bg-purple-100 px-1.5 py-0.5 font-mono text-[10px] text-purple-600 dark:bg-purple-900/40 dark:text-purple-300">
tool_result
</span>
</div>
<div className="space-y-1">
{SKILLS[1].content.map((line, i) => (
<motion.div
key={i}
initial={{ opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.08 }}
className="font-mono text-xs text-zinc-600 dark:text-zinc-300"
>
{line}
</motion.div>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Mechanism annotation on step 3 */}
<AnimatePresence>
{currentStep === 3 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-300"
>
The Skill tool returns content as a tool_result message.
The model sees it in context and follows the instructions.
No system prompt bloat.
</motion.div>
)}
</AnimatePresence>
{/* Final overview label on step 5 */}
<AnimatePresence>
{currentStep === 5 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex gap-3"
>
<div className="flex-1 rounded border border-zinc-200 bg-zinc-50 p-2 text-center dark:border-zinc-700 dark:bg-zinc-800">
<div className="text-[10px] font-semibold text-zinc-500 dark:text-zinc-400">
LAYER 1
</div>
<div className="text-xs text-zinc-600 dark:text-zinc-300">
Always present, ~120 tokens
</div>
</div>
<div className="flex-1 rounded border border-blue-200 bg-blue-50 p-2 text-center dark:border-blue-700 dark:bg-blue-900/20">
<div className="text-[10px] font-semibold text-blue-500 dark:text-blue-400">
LAYER 2
</div>
<div className="text-xs text-blue-600 dark:text-blue-300">
On demand, ~300-500 tokens each
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Token Gauge (vertical bar on the right) */}
<div className="flex w-16 flex-col items-center">
<div className="mb-1 text-center font-mono text-[10px] text-zinc-400">
Tokens
</div>
<div
className="relative w-8 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800"
style={{ height: 300 }}
>
<motion.div
animate={{
height: `${(tokenCount / MAX_TOKEN_DISPLAY) * 100}%`,
}}
transition={{ duration: 0.5 }}
className={`absolute bottom-0 w-full rounded-full ${
tokenCount > 600
? "bg-amber-500"
: tokenCount > 300
? "bg-blue-500"
: "bg-emerald-500"
}`}
/>
</div>
<motion.div
key={tokenCount}
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
className="mt-2 text-center font-mono text-xs font-semibold text-zinc-600 dark:text-zinc-300"
>
{tokenCount}
</motion.div>
</div>
</div>
{/* Step Controls */}
<div className="mt-6">
<StepControls
currentStep={currentStep}
totalSteps={totalSteps}
onPrev={prev}
onNext={next}
onReset={reset}
isPlaying={isPlaying}
onToggleAutoPlay={toggleAutoPlay}
stepTitle={STEPS[currentStep].title}
stepDescription={STEPS[currentStep].description}
/>
</div>
</div>
</section>
);
}
@@ -0,0 +1,450 @@
"use client";
import { useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { StepControls } from "@/components/visualizations/shared/step-controls";
type BlockType = "user" | "assistant" | "tool_result";
interface ContextBlock {
id: string;
type: BlockType;
label: string;
tokens: number;
}
const BLOCK_COLORS: Record<BlockType, string> = {
user: "bg-blue-500",
assistant: "bg-zinc-500 dark:bg-zinc-600",
tool_result: "bg-emerald-500",
};
const BLOCK_LABELS: Record<BlockType, string> = {
user: "USR",
assistant: "AST",
tool_result: "TRL",
};
function generateBlocks(count: number, seed: number): ContextBlock[] {
const types: BlockType[] = ["user", "assistant", "tool_result"];
const blocks: ContextBlock[] = [];
for (let i = 0; i < count; i++) {
const typeIndex = (i + seed) % 3;
const type = types[typeIndex];
const tokens = type === "tool_result" ? 4000 + (i % 3) * 1000 : 1500 + (i % 4) * 500;
blocks.push({
id: `b-${seed}-${i}`,
type,
label: `${BLOCK_LABELS[type]} ${i + 1}`,
tokens,
});
}
return blocks;
}
const MAX_TOKENS = 100000;
const WINDOW_HEIGHT = 350;
interface StepState {
blocks: { id: string; type: BlockType; label: string; heightPx: number; compressed?: boolean }[];
tokenCount: number;
fillPercent: number;
compressionLabel: string | null;
}
function computeStepState(step: number): StepState {
switch (step) {
case 0: {
const raw = generateBlocks(8, 0);
const tokenCount = 30000;
const totalRawTokens = raw.reduce((a, b) => a + b.tokens, 0);
const blocks = raw.map((b) => ({
...b,
heightPx: Math.max(16, (b.tokens / totalRawTokens) * WINDOW_HEIGHT * 0.3),
}));
return { blocks, tokenCount, fillPercent: 30, compressionLabel: null };
}
case 1: {
const raw = generateBlocks(16, 0);
const tokenCount = 60000;
const totalRawTokens = raw.reduce((a, b) => a + b.tokens, 0);
const blocks = raw.map((b) => ({
...b,
heightPx: Math.max(12, (b.tokens / totalRawTokens) * WINDOW_HEIGHT * 0.6),
}));
return { blocks, tokenCount, fillPercent: 60, compressionLabel: null };
}
case 2: {
const raw = generateBlocks(20, 0);
const tokenCount = 80000;
const totalRawTokens = raw.reduce((a, b) => a + b.tokens, 0);
const blocks = raw.map((b) => ({
...b,
heightPx: Math.max(10, (b.tokens / totalRawTokens) * WINDOW_HEIGHT * 0.8),
}));
return { blocks, tokenCount, fillPercent: 80, compressionLabel: null };
}
case 3: {
const raw = generateBlocks(20, 0);
const tokenCount = 60000;
const totalRawTokens = raw.reduce((a, b) => a + b.tokens, 0);
const blocks = raw.map((b) => ({
...b,
heightPx:
b.type === "tool_result"
? 6
: Math.max(12, (b.tokens / totalRawTokens) * WINDOW_HEIGHT * 0.6),
compressed: b.type === "tool_result",
}));
return {
blocks,
tokenCount,
fillPercent: 60,
compressionLabel: "MICRO-COMPACT",
};
}
case 4: {
const raw = generateBlocks(24, 1);
const tokenCount = 85000;
const totalRawTokens = raw.reduce((a, b) => a + b.tokens, 0);
const blocks = raw.map((b) => ({
...b,
heightPx: Math.max(10, (b.tokens / totalRawTokens) * WINDOW_HEIGHT * 0.85),
}));
return { blocks, tokenCount, fillPercent: 85, compressionLabel: null };
}
case 5: {
const tokenCount = 25000;
const summaryBlock = {
id: "auto-summary",
type: "assistant" as BlockType,
label: "SUMMARY",
heightPx: 40,
compressed: false,
};
const recentBlocks = generateBlocks(4, 2).map((b) => ({
...b,
heightPx: 20,
}));
return {
blocks: [summaryBlock, ...recentBlocks],
tokenCount,
fillPercent: 25,
compressionLabel: "AUTO-COMPACT",
};
}
case 6: {
const tokenCount = 8000;
const compactBlock = {
id: "compact-summary",
type: "assistant" as BlockType,
label: "COMPACT SUMMARY",
heightPx: 24,
compressed: false,
};
return {
blocks: [compactBlock],
tokenCount,
fillPercent: 8,
compressionLabel: "/compact",
};
}
default:
return { blocks: [], tokenCount: 0, fillPercent: 0, compressionLabel: null };
}
}
const STEPS = [
{
title: "Growing Context",
description:
"The context window holds the conversation. Each API call adds more messages.",
},
{
title: "Context Growing",
description:
"As the agent works, messages accumulate. The context window fills up.",
},
{
title: "Approaching Limit",
description:
"Old tool_results are the biggest consumers. Micro-compact targets these first.",
},
{
title: "Stage 1: Micro-Compact",
description:
"Replace old tool_results with short summaries. Automatic, transparent to the model.",
},
{
title: "Still Growing",
description:
"Work continues. Context grows again toward the threshold...",
},
{
title: "Stage 2: Auto-Compact",
description:
"Entire conversation summarized into a compact block. Triggered at token threshold.",
},
{
title: "Stage 3: /compact",
description:
"User-triggered, most aggressive. Three layers of strategic forgetting enable infinite sessions.",
},
];
export default function ContextCompact({ title }: { title?: string }) {
const {
currentStep,
totalSteps,
next,
prev,
reset,
isPlaying,
toggleAutoPlay,
} = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
const state = useMemo(() => computeStepState(currentStep), [currentStep]);
const fillColor =
state.fillPercent > 75
? "bg-red-500"
: state.fillPercent > 45
? "bg-amber-500"
: "bg-emerald-500";
const tokenDisplay = `${(state.tokenCount / 1000).toFixed(0)}K`;
return (
<section className="space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Three-Layer Context Compression"}
</h2>
<div
className="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900"
style={{ minHeight: 500 }}
>
<div className="flex gap-6">
{/* Token Window (tall vertical bar on the left) */}
<div className="flex flex-col items-center">
<div className="mb-2 font-mono text-[10px] font-semibold text-zinc-500 dark:text-zinc-400">
Context Window
</div>
<div
className="relative w-24 overflow-hidden rounded-xl border-2 border-zinc-300 bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800"
style={{ height: WINDOW_HEIGHT }}
>
{/* Blocks stacked from bottom up */}
<div className="absolute bottom-0 left-0 right-0 flex flex-col-reverse gap-px p-1">
<AnimatePresence mode="popLayout">
{state.blocks.map((block) => (
<motion.div
key={block.id}
initial={{ opacity: 0, scaleY: 0 }}
animate={{
opacity: 1,
scaleY: 1,
height: block.heightPx,
}}
exit={{ opacity: 0, scaleY: 0 }}
transition={{ duration: 0.4 }}
className={`flex w-full items-center justify-center rounded-sm ${
block.compressed
? "bg-emerald-300 dark:bg-emerald-700"
: BLOCK_COLORS[block.type]
}`}
style={{ originY: 1 }}
>
{block.heightPx >= 14 && (
<span className="truncate px-1 text-[8px] font-medium text-white">
{block.label}
</span>
)}
</motion.div>
))}
</AnimatePresence>
</div>
{/* Fill level line */}
<motion.div
animate={{ bottom: `${state.fillPercent}%` }}
transition={{ duration: 0.5 }}
className="absolute left-0 right-0 border-t-2 border-dashed border-red-400 dark:border-red-500"
>
<span className="absolute -top-4 right-1 font-mono text-[9px] font-bold text-red-500 dark:text-red-400">
{state.fillPercent}%
</span>
</motion.div>
</div>
{/* Token count */}
<motion.div
key={state.tokenCount}
initial={{ scale: 0.85 }}
animate={{ scale: 1 }}
className="mt-2 font-mono text-sm font-bold text-zinc-700 dark:text-zinc-200"
>
{tokenDisplay}
</motion.div>
<div className="font-mono text-[10px] text-zinc-400">
/ 100K
</div>
</div>
{/* Right side: state display and compression stage */}
<div className="flex flex-1 flex-col justify-between">
{/* Top: horizontal token bar */}
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-zinc-500 dark:text-zinc-400">
Token usage
</span>
<span className="font-mono text-xs text-zinc-500">
{state.tokenCount.toLocaleString()} / {MAX_TOKENS.toLocaleString()}
</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<motion.div
animate={{ width: `${state.fillPercent}%` }}
transition={{ duration: 0.5 }}
className={`h-full rounded-full ${fillColor}`}
/>
</div>
</div>
{/* Message type legend */}
<div className="mt-4 flex items-center gap-4">
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded bg-blue-500" />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">user</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded bg-zinc-500" />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">assistant</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded bg-emerald-500" />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">tool_result</span>
</div>
</div>
{/* Highlight old tool_results at step 2 */}
<AnimatePresence>
{currentStep === 2 && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="mt-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 dark:border-amber-700 dark:bg-amber-900/20"
>
<div className="text-xs font-semibold text-amber-700 dark:text-amber-300">
tool_results are the largest blocks
</div>
<div className="text-[11px] text-amber-600 dark:text-amber-400">
File contents, command outputs, search results -- each one is thousands of tokens.
</div>
</motion.div>
)}
</AnimatePresence>
{/* Compression stage label */}
<AnimatePresence>
{state.compressionLabel && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.4 }}
className="mt-4"
>
<div className={`rounded-lg border-2 p-4 text-center ${
currentStep === 3
? "border-amber-400 bg-amber-50 dark:border-amber-600 dark:bg-amber-900/20"
: currentStep === 5
? "border-blue-400 bg-blue-50 dark:border-blue-600 dark:bg-blue-900/20"
: "border-emerald-400 bg-emerald-50 dark:border-emerald-600 dark:bg-emerald-900/20"
}`}>
<div className={`text-lg font-black ${
currentStep === 3
? "text-amber-600 dark:text-amber-300"
: currentStep === 5
? "text-blue-600 dark:text-blue-300"
: "text-emerald-600 dark:text-emerald-300"
}`}>
{state.compressionLabel}
</div>
<div className={`mt-1 text-xs ${
currentStep === 3
? "text-amber-500 dark:text-amber-400"
: currentStep === 5
? "text-blue-500 dark:text-blue-400"
: "text-emerald-500 dark:text-emerald-400"
}`}>
{currentStep === 3 && "Old tool_results shrunk to tiny summaries"}
{currentStep === 5 && "Full conversation compressed to summary block"}
{currentStep === 6 && "Most aggressive compression -- near-empty context"}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Three stages overview on final step */}
{currentStep === 6 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="mt-4 space-y-2"
>
<div className="flex items-center gap-2 rounded bg-amber-50 px-3 py-1.5 dark:bg-amber-900/10">
<div className="h-2 w-2 rounded-full bg-amber-500" />
<span className="text-xs text-amber-700 dark:text-amber-300">
Stage 1: Micro -- shrink old tool_results
</span>
<span className="ml-auto font-mono text-[10px] text-amber-500">
automatic
</span>
</div>
<div className="flex items-center gap-2 rounded bg-blue-50 px-3 py-1.5 dark:bg-blue-900/10">
<div className="h-2 w-2 rounded-full bg-blue-500" />
<span className="text-xs text-blue-700 dark:text-blue-300">
Stage 2: Auto -- summarize entire conversation
</span>
<span className="ml-auto font-mono text-[10px] text-blue-500">
at threshold
</span>
</div>
<div className="flex items-center gap-2 rounded bg-emerald-50 px-3 py-1.5 dark:bg-emerald-900/10">
<div className="h-2 w-2 rounded-full bg-emerald-500" />
<span className="text-xs text-emerald-700 dark:text-emerald-300">
Stage 3: /compact -- user-triggered, deepest compression
</span>
<span className="ml-auto font-mono text-[10px] text-emerald-500">
manual
</span>
</div>
</motion.div>
)}
</div>
</div>
{/* Step Controls */}
<div className="mt-6">
<StepControls
currentStep={currentStep}
totalSteps={totalSteps}
onPrev={prev}
onNext={next}
onReset={reset}
isPlaying={isPlaying}
onToggleAutoPlay={toggleAutoPlay}
stepTitle={STEPS[currentStep].title}
stepDescription={STEPS[currentStep].description}
/>
</div>
</div>
</section>
);
}
@@ -0,0 +1,494 @@
"use client";
import { useMemo } from "react";
import { motion } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useDarkMode, useSvgPalette } from "@/hooks/useDarkMode";
type TaskStatus = "pending" | "in_progress" | "completed" | "blocked";
interface TaskNode {
id: string;
label: string;
x: number;
y: number;
deps: string[];
}
interface StepInfo {
title: string;
description: string;
}
const TASKS: TaskNode[] = [
{ id: "T1", label: "T1: Setup DB", x: 80, y: 160, deps: [] },
{ id: "T2", label: "T2: API routes", x: 280, y: 80, deps: ["T1"] },
{ id: "T3", label: "T3: Auth module", x: 280, y: 240, deps: ["T1"] },
{ id: "T4", label: "T4: Integration", x: 480, y: 160, deps: ["T2", "T3"] },
{ id: "T5", label: "T5: Deploy", x: 650, y: 160, deps: ["T4"] },
];
const NODE_W = 140;
const NODE_H = 50;
const STEP_INFO: StepInfo[] = [
{
title: "File-Based Tasks",
description:
"Tasks are stored in JSON files on disk. They survive context compaction -- unlike in-memory state.",
},
{
title: "Start T1",
description:
"Tasks without dependencies can start immediately. T1 has no blockers.",
},
{
title: "T1 Complete",
description: "Completing T1 unblocks its dependents: T2 and T3.",
},
{
title: "Parallel Work",
description:
"T2 and T3 have no dependency on each other. Both can run simultaneously.",
},
{
title: "Partial Unblock",
description:
"T4 depends on BOTH T2 and T3. It waits for all blockers to complete.",
},
{
title: "Fully Unblocked",
description: "All blockers resolved. T4 can now proceed.",
},
{
title: "Graph Resolved",
description:
"The entire dependency graph is resolved. File-based persistence means this works across context compressions.",
},
];
function getTaskStatus(taskId: string, step: number): TaskStatus {
const statusMap: Record<string, TaskStatus[]> = {
T1: [
"pending",
"in_progress",
"completed",
"completed",
"completed",
"completed",
"completed",
],
T2: [
"pending",
"pending",
"pending",
"in_progress",
"completed",
"completed",
"completed",
],
T3: [
"pending",
"pending",
"pending",
"in_progress",
"in_progress",
"completed",
"completed",
],
T4: [
"pending",
"pending",
"pending",
"pending",
"blocked",
"in_progress",
"completed",
],
T5: [
"pending",
"pending",
"pending",
"pending",
"pending",
"pending",
"completed",
],
};
return statusMap[taskId]?.[step] ?? "pending";
}
function isEdgeActive(fromId: string, toId: string, step: number): boolean {
const fromStatus = getTaskStatus(fromId, step);
const toStatus = getTaskStatus(toId, step);
return (
fromStatus === "completed" &&
(toStatus === "in_progress" || toStatus === "completed")
);
}
function getStatusColor(status: TaskStatus) {
switch (status) {
case "pending":
return {
fill: "#e2e8f0",
darkFill: "#27272a",
stroke: "#cbd5e1",
darkStroke: "#3f3f46",
text: "#475569",
darkText: "#d4d4d8",
};
case "in_progress":
return {
fill: "#fef3c7",
darkFill: "#451a0340",
stroke: "#f59e0b",
darkStroke: "#d97706",
text: "#b45309",
darkText: "#fbbf24",
};
case "completed":
return {
fill: "#d1fae5",
darkFill: "#06402740",
stroke: "#10b981",
darkStroke: "#059669",
text: "#047857",
darkText: "#34d399",
};
case "blocked":
return {
fill: "#fecaca",
darkFill: "#45050540",
stroke: "#ef4444",
darkStroke: "#dc2626",
text: "#dc2626",
darkText: "#f87171",
};
}
}
function getStatusLabel(status: TaskStatus): string {
switch (status) {
case "pending":
return "pending";
case "in_progress":
return "in_progress";
case "completed":
return "done";
case "blocked":
return "blocked";
}
}
function buildCurvePath(
x1: number,
y1: number,
x2: number,
y2: number
): string {
const midX = (x1 + x2) / 2;
return `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
}
export default function TaskSystem({ title }: { title?: string }) {
const {
currentStep,
totalSteps,
next,
prev,
reset,
isPlaying,
toggleAutoPlay,
} = useSteppedVisualization({ totalSteps: 7, autoPlayInterval: 2500 });
const isDark = useDarkMode();
const palette = useSvgPalette();
const edges = useMemo(() => {
const result: {
fromId: string;
toId: string;
x1: number;
y1: number;
x2: number;
y2: number;
}[] = [];
for (const task of TASKS) {
for (const depId of task.deps) {
const dep = TASKS.find((t) => t.id === depId);
if (!dep) continue;
result.push({
fromId: dep.id,
toId: task.id,
x1: dep.x + NODE_W,
y1: dep.y + NODE_H / 2,
x2: task.x,
y2: task.y + NODE_H / 2,
});
}
}
return result;
}, []);
const stepInfo = STEP_INFO[currentStep];
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Task Dependency Graph"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<svg viewBox="0 0 800 340" className="w-full" aria-label="Task DAG">
<defs>
<marker
id="arrowGray"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
</marker>
<marker
id="arrowGreen"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#10b981" />
</marker>
<marker
id="arrowRed"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#ef4444" />
</marker>
<filter id="glowAmber" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feFlood floodColor="#f59e0b" floodOpacity="0.4" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
<filter
id="glowGreen"
x="-30%"
y="-30%"
width="160%"
height="160%"
>
<feGaussianBlur stdDeviation="3" result="blur" />
<feFlood floodColor="#10b981" floodOpacity="0.3" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Dependency edges */}
{edges.map(({ fromId, toId, x1, y1, x2, y2 }) => {
const active = isEdgeActive(fromId, toId, currentStep);
const toStatus = getTaskStatus(toId, currentStep);
const isBlocked = toStatus === "blocked";
let markerEnd = "url(#arrowGray)";
let strokeColor = palette.arrowFill;
if (active) {
markerEnd = "url(#arrowGreen)";
strokeColor = "#10b981";
} else if (isBlocked) {
markerEnd = "url(#arrowRed)";
strokeColor = "#ef4444";
}
return (
<motion.path
key={`${fromId}-${toId}`}
d={buildCurvePath(x1, y1, x2, y2)}
fill="none"
markerEnd={markerEnd}
animate={{
stroke: strokeColor,
strokeWidth: active ? 2.5 : 1.5,
strokeDasharray: isBlocked ? "6 4" : "none",
}}
transition={{ duration: 0.5 }}
/>
);
})}
{/* Task nodes */}
{TASKS.map((task) => {
const status = getTaskStatus(task.id, currentStep);
const colors = getStatusColor(status);
const statusLabel = getStatusLabel(status);
const isActive = status === "in_progress";
const isComplete = status === "completed";
let filterAttr: string | undefined;
if (isActive) filterAttr = "url(#glowAmber)";
else if (isComplete) filterAttr = "url(#glowGreen)";
return (
<g key={task.id} filter={filterAttr}>
<motion.rect
x={task.x}
y={task.y}
width={NODE_W}
height={NODE_H}
rx={8}
animate={{
fill: isDark ? colors.darkFill : colors.fill,
stroke: isDark ? colors.darkStroke : colors.stroke,
}}
strokeWidth={isActive ? 2 : 1.5}
transition={{ duration: 0.4 }}
/>
<text
x={task.x + NODE_W / 2}
y={task.y + 20}
textAnchor="middle"
dominantBaseline="middle"
fontSize="11"
fontWeight="600"
fill={isDark ? colors.darkText : colors.text}
>
{task.label}
</text>
<text
x={task.x + NODE_W / 2}
y={task.y + 38}
textAnchor="middle"
dominantBaseline="middle"
fontSize="9"
fontFamily="monospace"
fill={isDark ? colors.darkText : colors.text}
opacity={0.8}
>
{statusLabel}
</text>
</g>
);
})}
{/* Blocked annotation for T4 at step 4 */}
{currentStep === 4 && (
<motion.g
initial={{ opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<rect
x={445}
y={118}
width={170}
height={22}
rx={4}
fill={isDark ? "#451a03" : "#fef2f2"}
stroke={isDark ? "#dc2626" : "#fca5a5"}
strokeWidth={1}
/>
<text
x={530}
y={132}
textAnchor="middle"
dominantBaseline="middle"
fontSize="9"
fontFamily="monospace"
fill={isDark ? "#f87171" : "#dc2626"}
>
Blocked: waiting on T3
</text>
</motion.g>
)}
</svg>
{/* File persistence indicator */}
<div className="mt-3 flex items-center gap-2 rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-800/60">
<svg
viewBox="0 0 24 24"
className="h-5 w-5 flex-shrink-0 text-zinc-400 dark:text-zinc-500"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776"
/>
</svg>
<div className="flex flex-col">
<span className="font-mono text-xs font-medium text-zinc-600 dark:text-zinc-300">
.tasks/tasks.json
</span>
<span className="text-[10px] text-zinc-400 dark:text-zinc-500">
Persisted to disk -- survives context compaction
</span>
</div>
<motion.div
className="ml-auto h-2 w-2 rounded-full bg-emerald-500"
animate={{ opacity: [1, 0.3, 1] }}
transition={{ repeat: Infinity, duration: 2 }}
/>
</div>
{/* Legend */}
<div className="mt-3 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-1.5">
<div className="h-3 w-3 rounded bg-zinc-300 dark:bg-zinc-600" />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
pending
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-3 w-3 rounded bg-amber-400 dark:bg-amber-600" />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
in_progress
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-3 w-3 rounded bg-emerald-400 dark:bg-emerald-600" />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
completed
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-3 w-3 rounded bg-red-400 dark:bg-red-600" />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
blocked
</span>
</div>
</div>
</div>
<StepControls
currentStep={currentStep}
totalSteps={totalSteps}
onPrev={prev}
onNext={next}
onReset={reset}
isPlaying={isPlaying}
onToggleAutoPlay={toggleAutoPlay}
stepTitle={stepInfo.title}
stepDescription={stepInfo.description}
/>
</section>
);
}
@@ -0,0 +1,624 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useDarkMode, useSvgPalette } from "@/hooks/useDarkMode";
interface StepInfo {
title: string;
description: string;
}
const STEP_INFO: StepInfo[] = [
{
title: "Three Lanes",
description:
"The agent has a main thread and can spawn daemon background threads for parallel work.",
},
{
title: "Main Thread Working",
description:
"The main agent loop runs as usual, processing user requests.",
},
{
title: "Spawn Background",
description:
"Background tasks run as daemon threads. The main loop doesn't wait for them.",
},
{
title: "Multiple Backgrounds",
description: "Multiple background tasks can run concurrently.",
},
{
title: "Task Completes",
description:
"Background task finishes. Its result goes to the notification queue.",
},
{
title: "Queue Fills",
description:
"Results accumulate in the queue, invisible to the model during this turn.",
},
{
title: "Drain Queue",
description:
"Just before the next LLM call, all queued notifications are injected as tool_results. Non-blocking, async.",
},
];
const LANE_Y = {
main: 60,
bg1: 140,
bg2: 220,
} as const;
const LANE_HEIGHT = 44;
const TIMELINE_LEFT = 160;
const TIMELINE_RIGHT = 720;
const TIMELINE_WIDTH = TIMELINE_RIGHT - TIMELINE_LEFT;
const QUEUE_Y = 300;
interface WorkBlock {
lane: "main" | "bg1" | "bg2";
startFraction: number;
endFraction: number;
color: string;
label?: string;
appearsAtStep: number;
completesAtStep?: number;
}
const WORK_BLOCKS: WorkBlock[] = [
{
lane: "main",
startFraction: 0,
endFraction: 1,
color: "#8b5cf6",
label: "Main agent loop",
appearsAtStep: 1,
},
{
lane: "bg1",
startFraction: 0.18,
endFraction: 0.75,
color: "#10b981",
label: "Run tests",
appearsAtStep: 2,
completesAtStep: 5,
},
{
lane: "bg2",
startFraction: 0.35,
endFraction: 0.58,
color: "#3b82f6",
label: "Lint code",
appearsAtStep: 3,
completesAtStep: 4,
},
];
interface ForkArrow {
fromFraction: number;
toLane: "bg1" | "bg2";
appearsAtStep: number;
}
const FORK_ARROWS: ForkArrow[] = [
{ fromFraction: 0.18, toLane: "bg1", appearsAtStep: 2 },
{ fromFraction: 0.35, toLane: "bg2", appearsAtStep: 3 },
];
interface QueueCard {
id: string;
label: string;
appearsAtStep: number;
drainsAtStep: number;
}
const QUEUE_CARDS: QueueCard[] = [
{
id: "lint-result",
label: "Lint: 0 errors",
appearsAtStep: 4,
drainsAtStep: 6,
},
{
id: "test-result",
label: "Tests: 42 passed",
appearsAtStep: 5,
drainsAtStep: 6,
},
];
function fractionToX(fraction: number): number {
return TIMELINE_LEFT + fraction * TIMELINE_WIDTH;
}
function getBlockEndFraction(block: WorkBlock, step: number): number {
if (step < block.appearsAtStep) return block.startFraction;
if (block.completesAtStep !== undefined && step >= block.completesAtStep) {
return block.endFraction;
}
const growthSteps = (block.completesAtStep ?? 6) - block.appearsAtStep;
const stepsElapsed = step - block.appearsAtStep;
const progress = Math.min(stepsElapsed / growthSteps, 1);
const range = block.endFraction - block.startFraction;
return block.startFraction + range * progress;
}
export default function BackgroundTasks({ title }: { title?: string }) {
const {
currentStep,
totalSteps,
next,
prev,
reset,
isPlaying,
toggleAutoPlay,
} = useSteppedVisualization({ totalSteps: 7, autoPlayInterval: 2500 });
const isDark = useDarkMode();
const palette = useSvgPalette();
const stepInfo = STEP_INFO[currentStep];
const llmCallFraction = 0.82;
const showLlmMarker = currentStep >= 5;
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Background Task Lanes"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<svg viewBox="0 0 780 380" className="w-full" aria-label="Background task lanes">
<defs>
<marker
id="forkArrow"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
</marker>
<marker
id="drainArrow"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#f59e0b" />
</marker>
<filter id="blockGlow" x="-10%" y="-20%" width="120%" height="140%">
<feGaussianBlur stdDeviation="2" result="blur" />
<feFlood floodColor="#8b5cf6" floodOpacity="0.2" result="color" />
<feComposite in="color" in2="blur" operator="in" result="glow" />
<feMerge>
<feMergeNode in="glow" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Timeline axis */}
<line
x1={TIMELINE_LEFT}
y1={30}
x2={TIMELINE_RIGHT}
y2={30}
stroke={palette.labelFill}
strokeWidth={1}
strokeDasharray="4 3"
opacity={0.5}
/>
<text
x={TIMELINE_LEFT}
y={22}
fontSize="9"
fontFamily="monospace"
fill={palette.labelFill}
>
t=0
</text>
<text
x={TIMELINE_RIGHT}
y={22}
fontSize="9"
fontFamily="monospace"
fill={palette.labelFill}
textAnchor="end"
>
time
</text>
{/* Lane backgrounds and labels */}
{(
[
{ key: "main", y: LANE_Y.main, label: "Main Thread" },
{ key: "bg1", y: LANE_Y.bg1, label: "Background 1" },
{ key: "bg2", y: LANE_Y.bg2, label: "Background 2" },
] as const
).map(({ key, y, label }) => (
<g key={key}>
<rect
x={TIMELINE_LEFT}
y={y}
width={TIMELINE_WIDTH}
height={LANE_HEIGHT}
rx={6}
fill="none"
stroke={palette.nodeStroke}
strokeWidth={1}
strokeDasharray="4 2"
opacity={0.6}
/>
<text
x={TIMELINE_LEFT - 10}
y={y + LANE_HEIGHT / 2 + 1}
textAnchor="end"
dominantBaseline="middle"
fontSize="11"
fontWeight="600"
fill={palette.labelFill}
>
{label}
</text>
</g>
))}
{/* Work blocks */}
{WORK_BLOCKS.map((block) => {
if (currentStep < block.appearsAtStep) return null;
const startX = fractionToX(block.startFraction);
const endFraction = getBlockEndFraction(block, currentStep);
const endX = fractionToX(endFraction);
const width = Math.max(endX - startX, 4);
const y = LANE_Y[block.lane];
const isComplete =
block.completesAtStep !== undefined &&
currentStep >= block.completesAtStep;
return (
<motion.g key={`${block.lane}-block`}>
<motion.rect
x={startX}
y={y + 4}
height={LANE_HEIGHT - 8}
rx={5}
initial={{ width: 4 }}
animate={{
width,
opacity: isComplete ? 0.7 : 1,
}}
transition={{ duration: 0.6, ease: "easeOut" }}
fill={block.color}
filter={
!isComplete && block.lane === "main"
? "url(#blockGlow)"
: undefined
}
/>
{width > 60 && block.label && (
<motion.text
x={startX + width / 2}
y={y + LANE_HEIGHT / 2 + 1}
textAnchor="middle"
dominantBaseline="middle"
fontSize="10"
fontWeight="500"
fill="white"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
{block.label}
</motion.text>
)}
{isComplete && (
<motion.text
x={endX + 6}
y={y + LANE_HEIGHT / 2 + 1}
dominantBaseline="middle"
fontSize="9"
fontFamily="monospace"
fill="#10b981"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
>
done
</motion.text>
)}
</motion.g>
);
})}
{/* Fork arrows from main to background lanes */}
{FORK_ARROWS.map((arrow) => {
if (currentStep < arrow.appearsAtStep) return null;
const x = fractionToX(arrow.fromFraction);
const fromY = LANE_Y.main + LANE_HEIGHT;
const toY = LANE_Y[arrow.toLane];
return (
<motion.line
key={`fork-${arrow.toLane}`}
x1={x}
y1={fromY}
x2={x + 20}
y2={toY}
stroke={palette.arrowFill}
strokeWidth={1.5}
markerEnd="url(#forkArrow)"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
/>
);
})}
{/* LLM API call marker */}
{showLlmMarker && (
<motion.g
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.4 }}
>
<line
x1={fractionToX(llmCallFraction)}
y1={LANE_Y.main}
x2={fractionToX(llmCallFraction)}
y2={LANE_Y.main + LANE_HEIGHT}
stroke="#f59e0b"
strokeWidth={2}
strokeDasharray="3 2"
/>
<rect
x={fractionToX(llmCallFraction) - 36}
y={LANE_Y.main - 16}
width={72}
height={16}
rx={3}
fill="#f59e0b"
/>
<text
x={fractionToX(llmCallFraction)}
y={LANE_Y.main - 6}
textAnchor="middle"
dominantBaseline="middle"
fontSize="8"
fontWeight="600"
fill="white"
>
LLM API call
</text>
</motion.g>
)}
{/* Notification queue area */}
<rect
x={TIMELINE_LEFT}
y={QUEUE_Y}
width={TIMELINE_WIDTH}
height={54}
rx={8}
fill="none"
stroke={palette.nodeStroke}
strokeWidth={1}
/>
<text
x={TIMELINE_LEFT - 10}
y={QUEUE_Y + 18}
textAnchor="end"
fontSize="10"
fontWeight="600"
fill={palette.labelFill}
>
Notification
</text>
<text
x={TIMELINE_LEFT - 10}
y={QUEUE_Y + 32}
textAnchor="end"
fontSize="10"
fontWeight="600"
fill={palette.labelFill}
>
Queue
</text>
{/* Queue cards */}
<AnimatePresence>
{QUEUE_CARDS.map((card, idx) => {
if (currentStep < card.appearsAtStep) return null;
const isDraining = currentStep >= card.drainsAtStep;
const cardX = TIMELINE_LEFT + 20 + idx * 150;
const cardY = QUEUE_Y + 10;
const drainTargetY = LANE_Y.main + LANE_HEIGHT / 2 - 12;
const drainTargetX = fractionToX(llmCallFraction) + 10 + idx * 15;
if (isDraining) {
return (
<motion.g
key={`card-${card.id}-drain`}
initial={{ x: cardX, y: cardY, opacity: 1 }}
animate={{
x: drainTargetX,
y: drainTargetY,
opacity: [1, 1, 0],
}}
transition={{ duration: 0.8, ease: "easeInOut" }}
>
<rect
x={0}
y={0}
width={130}
height={34}
rx={5}
fill={isDark ? "#451a0340" : "#fef3c7"}
stroke="#f59e0b"
strokeWidth={1}
/>
<text
x={65}
y={13}
textAnchor="middle"
dominantBaseline="middle"
fontSize="9"
fontWeight="600"
fill={isDark ? "#fbbf24" : "#b45309"}
>
tool_result
</text>
<text
x={65}
y={26}
textAnchor="middle"
dominantBaseline="middle"
fontSize="8"
fontFamily="monospace"
fill={isDark ? "#f59e0b" : "#92400e"}
>
{card.label}
</text>
</motion.g>
);
}
return (
<motion.g
key={`card-${card.id}`}
initial={{ y: cardY - 40, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, ease: "easeOut" }}
>
<rect
x={cardX}
y={cardY}
width={130}
height={34}
rx={5}
fill={isDark ? "#06402740" : "#d1fae5"}
stroke="#10b981"
strokeWidth={1}
/>
<text
x={cardX + 65}
y={cardY + 13}
textAnchor="middle"
dominantBaseline="middle"
fontSize="9"
fontWeight="600"
fill={isDark ? "#34d399" : "#047857"}
>
tool_result
</text>
<text
x={cardX + 65}
y={cardY + 26}
textAnchor="middle"
dominantBaseline="middle"
fontSize="8"
fontFamily="monospace"
fill={isDark ? "#10b981" : "#065f46"}
>
{card.label}
</text>
</motion.g>
);
})}
</AnimatePresence>
{/* Drain arrows from queue to main thread at step 6 */}
{currentStep >= 6 && (
<motion.g
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
<motion.line
x1={fractionToX(llmCallFraction) + 20}
y1={QUEUE_Y}
x2={fractionToX(llmCallFraction) + 20}
y2={LANE_Y.main + LANE_HEIGHT + 4}
stroke="#f59e0b"
strokeWidth={1.5}
markerEnd="url(#drainArrow)"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.5 }}
/>
</motion.g>
)}
{/* Empty queue label when drained */}
{currentStep >= 6 && (
<motion.text
x={TIMELINE_LEFT + TIMELINE_WIDTH / 2}
y={QUEUE_Y + 30}
textAnchor="middle"
dominantBaseline="middle"
fontSize="10"
fontFamily="monospace"
fill={palette.labelFill}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
>
queue drained -- injected into next LLM call
</motion.text>
)}
</svg>
{/* Legend */}
<div className="mt-3 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-1.5">
<div className="h-3 w-3 rounded" style={{ background: "#8b5cf6" }} />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
Main thread
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-3 w-3 rounded" style={{ background: "#10b981" }} />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
Background 1
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-3 w-3 rounded" style={{ background: "#3b82f6" }} />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
Background 2
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="h-3 w-3 rounded" style={{ background: "#f59e0b" }} />
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
LLM boundary
</span>
</div>
</div>
</div>
<StepControls
currentStep={currentStep}
totalSteps={totalSteps}
onPrev={prev}
onNext={next}
onReset={reset}
isPlaying={isPlaying}
onToggleAutoPlay={toggleAutoPlay}
stepTitle={stepInfo.title}
stepDescription={stepInfo.description}
/>
</section>
);
}
@@ -0,0 +1,393 @@
"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>
);
}
@@ -0,0 +1,497 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSvgPalette } from "@/hooks/useDarkMode";
type Protocol = "shutdown" | "plan";
// -- Layout constants for the sequence diagram --
const SVG_W = 560;
const SVG_H = 360;
const LIFELINE_LEFT_X = 140;
const LIFELINE_RIGHT_X = 420;
const LIFELINE_TOP = 60;
const LIFELINE_BOTTOM = 330;
const ACTIVATION_W = 12;
const ARROW_Y_START = 110;
const ARROW_Y_GAP = 70;
// Request ID shown on message tags
const REQUEST_ID = "req_abc";
// -- Shutdown protocol step definitions --
const SHUTDOWN_STEPS = [
{ title: "Structured Protocols", desc: "Protocols define structured message exchanges with correlated request IDs." },
{ title: "Shutdown Request", desc: "The leader initiates shutdown. The request_id links the request to its response." },
{ title: "Teammate Decides", desc: "The teammate can accept or reject. It's not a forced kill -- it's a polite request." },
{ title: "Approved", desc: "Same request_id in the response. Teammate exits cleanly." },
];
// -- Plan approval protocol step definitions --
const PLAN_STEPS = [
{ title: "Plan Approval", desc: "Teammates in plan_mode must get approval before implementing changes." },
{ title: "Submit Plan", desc: "The teammate designs a plan and sends it to the leader for review." },
{ title: "Leader Reviews", desc: "Leader reviews and approves or rejects with feedback. Same request-response pattern." },
];
// Horizontal arrow between lifelines
function SequenceArrow({
y,
direction,
label,
tagLabel,
color,
tagBg,
tagStroke,
tagText,
}: {
y: number;
direction: "right" | "left";
label: string;
tagLabel?: string;
color: string;
tagBg?: string;
tagStroke?: string;
tagText?: string;
}) {
const fromX = direction === "right" ? LIFELINE_LEFT_X + ACTIVATION_W / 2 : LIFELINE_RIGHT_X - ACTIVATION_W / 2;
const toX = direction === "right" ? LIFELINE_RIGHT_X - ACTIVATION_W / 2 : LIFELINE_LEFT_X + ACTIVATION_W / 2;
const arrowTip = direction === "right" ? toX - 6 : toX + 6;
const labelX = (fromX + toX) / 2;
return (
<motion.g
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Arrow line */}
<line
x1={fromX}
y1={y}
x2={toX}
y2={y}
stroke={color}
strokeWidth={2}
/>
{/* Arrow head */}
<polygon
points={
direction === "right"
? `${toX},${y} ${arrowTip},${y - 4} ${arrowTip},${y + 4}`
: `${toX},${y} ${arrowTip},${y - 4} ${arrowTip},${y + 4}`
}
fill={color}
/>
{/* Message label */}
<text
x={labelX}
y={y - 10}
textAnchor="middle"
fontSize={8}
fontFamily="monospace"
fontWeight={600}
fill={color}
>
{label}
</text>
{/* Request ID tag */}
{tagLabel && (
<g>
<rect
x={labelX - 36}
y={y + 4}
width={72}
height={16}
rx={3}
fill={tagBg || "#f5f3ff"}
stroke={tagStroke || "#c4b5fd"}
strokeWidth={0.5}
/>
<text
x={labelX}
y={y + 14}
textAnchor="middle"
fontSize={6}
fontFamily="monospace"
fill={tagText || "#7c3aed"}
>
{tagLabel}
</text>
</g>
)}
</motion.g>
);
}
// Decision diamond on a lifeline
function DecisionBox({ x, y }: { x: number; y: number }) {
const size = 14;
return (
<motion.g
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
>
<polygon
points={`${x},${y - size} ${x + size},${y} ${x},${y + size} ${x - size},${y}`}
fill="#fef3c7"
stroke="#f59e0b"
strokeWidth={1}
/>
<text x={x} y={y + 1} textAnchor="middle" dominantBaseline="middle" fontSize={7} fontWeight={700} fill="#92400e">
?
</text>
<text x={x + size + 6} y={y - 4} fontSize={6} fontFamily="monospace" fill="#10b981">
approve
</text>
<text x={x + size + 6} y={y + 6} fontSize={6} fontFamily="monospace" fill="#ef4444">
reject
</text>
</motion.g>
);
}
// Activation bar on a lifeline
function ActivationBar({
x,
yStart,
yEnd,
color,
}: {
x: number;
yStart: number;
yEnd: number;
color: string;
}) {
return (
<motion.rect
x={x - ACTIVATION_W / 2}
y={yStart}
width={ACTIVATION_W}
height={yEnd - yStart}
rx={2}
fill={color}
initial={{ opacity: 0 }}
animate={{ opacity: 0.6 }}
transition={{ duration: 0.4 }}
/>
);
}
export default function TeamProtocols({ title }: { title?: string }) {
const [protocol, setProtocol] = useState<Protocol>("shutdown");
const totalSteps = protocol === "shutdown" ? SHUTDOWN_STEPS.length : PLAN_STEPS.length;
const steps = protocol === "shutdown" ? SHUTDOWN_STEPS : PLAN_STEPS;
const vis = useSteppedVisualization({ totalSteps, autoPlayInterval: 2500 });
const step = vis.currentStep;
const palette = useSvgPalette();
const switchProtocol = (p: Protocol) => {
setProtocol(p);
vis.reset();
};
const leftLabel = protocol === "shutdown" ? "Leader" : "Leader";
const rightLabel = protocol === "shutdown" ? "Teammate" : "Teammate";
return (
<section className="space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "FSM Team Protocols"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900 min-h-[500px]">
{/* Protocol toggle */}
<div className="flex justify-center gap-2 mb-4">
<button
onClick={() => switchProtocol("shutdown")}
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-colors ${
protocol === "shutdown"
? "bg-blue-500 text-white"
: "bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
}`}
>
Shutdown Protocol
</button>
<button
onClick={() => switchProtocol("plan")}
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-colors ${
protocol === "plan"
? "bg-emerald-500 text-white"
: "bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
}`}
>
Plan Approval Protocol
</button>
</div>
{/* Sequence diagram SVG */}
<svg viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="w-full">
<defs>
<marker
id="seq-arrow"
viewBox="0 0 10 10"
refX="9"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
</marker>
</defs>
{/* Lifeline headers */}
<rect x={LIFELINE_LEFT_X - 40} y={20} width={80} height={28} rx={6} fill="#3b82f6" />
<text x={LIFELINE_LEFT_X} y={37} textAnchor="middle" dominantBaseline="middle" fill="white" fontSize={11} fontWeight={700}>
{leftLabel}
</text>
<rect x={LIFELINE_RIGHT_X - 40} y={20} width={80} height={28} rx={6} fill="#8b5cf6" />
<text x={LIFELINE_RIGHT_X} y={37} textAnchor="middle" dominantBaseline="middle" fill="white" fontSize={11} fontWeight={700}>
{rightLabel}
</text>
{/* Lifeline dashed lines */}
<line
x1={LIFELINE_LEFT_X}
y1={LIFELINE_TOP}
x2={LIFELINE_LEFT_X}
y2={LIFELINE_BOTTOM}
stroke={palette.edgeStroke}
strokeWidth={1}
strokeDasharray="6 4"
/>
<line
x1={LIFELINE_RIGHT_X}
y1={LIFELINE_TOP}
x2={LIFELINE_RIGHT_X}
y2={LIFELINE_BOTTOM}
stroke={palette.edgeStroke}
strokeWidth={1}
strokeDasharray="6 4"
/>
<AnimatePresence mode="wait">
{protocol === "shutdown" && (
<g key="shutdown">
{/* Activation bars appear as needed */}
{step >= 1 && (
<ActivationBar
x={LIFELINE_LEFT_X}
yStart={ARROW_Y_START - 10}
yEnd={step >= 3 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 20 : ARROW_Y_START + 30}
color="#3b82f6"
/>
)}
{step >= 1 && (
<ActivationBar
x={LIFELINE_RIGHT_X}
yStart={ARROW_Y_START - 5}
yEnd={step >= 3 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 15 : ARROW_Y_START + ARROW_Y_GAP + 20}
color="#8b5cf6"
/>
)}
{/* Step 1: shutdown_request arrow (Leader -> Teammate) */}
{step >= 1 && (
<SequenceArrow
y={ARROW_Y_START}
direction="right"
label="shutdown_request"
tagLabel={`request_id: ${REQUEST_ID}`}
color="#3b82f6"
tagBg={palette.bgSubtle}
tagStroke={palette.nodeStroke}
tagText={palette.nodeText}
/>
)}
{/* Step 2: decision box on teammate lifeline */}
{step >= 2 && (
<DecisionBox
x={LIFELINE_RIGHT_X + 50}
y={ARROW_Y_START + ARROW_Y_GAP}
/>
)}
{/* Step 3: shutdown_response arrow (Teammate -> Leader) */}
{step >= 3 && (
<SequenceArrow
y={ARROW_Y_START + ARROW_Y_GAP * 2}
direction="left"
label="shutdown_response { approve: true }"
tagLabel={`request_id: ${REQUEST_ID}`}
color="#10b981"
tagBg={palette.bgSubtle}
tagStroke={palette.nodeStroke}
tagText={palette.nodeText}
/>
)}
{/* Step 3: exit annotation */}
{step >= 3 && (
<motion.g
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
<line
x1={LIFELINE_RIGHT_X - 10}
y1={ARROW_Y_START + ARROW_Y_GAP * 2 + 20}
x2={LIFELINE_RIGHT_X + 10}
y2={ARROW_Y_START + ARROW_Y_GAP * 2 + 36}
stroke="#ef4444"
strokeWidth={2}
/>
<line
x1={LIFELINE_RIGHT_X + 10}
y1={ARROW_Y_START + ARROW_Y_GAP * 2 + 20}
x2={LIFELINE_RIGHT_X - 10}
y2={ARROW_Y_START + ARROW_Y_GAP * 2 + 36}
stroke="#ef4444"
strokeWidth={2}
/>
<text
x={LIFELINE_RIGHT_X + 24}
y={ARROW_Y_START + ARROW_Y_GAP * 2 + 32}
fontSize={8}
fill="#ef4444"
fontWeight={600}
>
exit
</text>
</motion.g>
)}
</g>
)}
{protocol === "plan" && (
<g key="plan">
{/* Activation bars */}
{step >= 1 && (
<ActivationBar
x={LIFELINE_RIGHT_X}
yStart={ARROW_Y_START - 10}
yEnd={step >= 2 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 15 : ARROW_Y_START + 30}
color="#8b5cf6"
/>
)}
{step >= 1 && (
<ActivationBar
x={LIFELINE_LEFT_X}
yStart={ARROW_Y_START - 5}
yEnd={step >= 2 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 15 : ARROW_Y_START + ARROW_Y_GAP + 10}
color="#3b82f6"
/>
)}
{/* Step 1: plan submission arrow (Teammate -> Leader) */}
{step >= 1 && (
<SequenceArrow
y={ARROW_Y_START}
direction="left"
label="exit_plan_mode { plan }"
tagLabel={`request_id: ${REQUEST_ID}`}
color="#8b5cf6"
tagBg={palette.bgSubtle}
tagStroke={palette.nodeStroke}
tagText={palette.nodeText}
/>
)}
{/* Step 1: plan content box */}
{step >= 1 && (
<motion.g
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
>
<rect
x={20}
y={ARROW_Y_START + 20}
width={95}
height={50}
rx={4}
fill={palette.bgSubtle}
stroke={palette.nodeStroke}
strokeWidth={0.5}
/>
<text x={28} y={ARROW_Y_START + 34} fontSize={6} fontFamily="monospace" fill={palette.nodeText} fontWeight={600}>
Plan:
</text>
<text x={28} y={ARROW_Y_START + 44} fontSize={5.5} fontFamily="monospace" fill={palette.labelFill}>
1. Add error handler
</text>
<text x={28} y={ARROW_Y_START + 54} fontSize={5.5} fontFamily="monospace" fill={palette.labelFill}>
2. Update tests
</text>
<text x={28} y={ARROW_Y_START + 64} fontSize={5.5} fontFamily="monospace" fill={palette.labelFill}>
3. Refactor module
</text>
</motion.g>
)}
{/* Step 2: approval response arrow (Leader -> Teammate) */}
{step >= 2 && (
<SequenceArrow
y={ARROW_Y_START + ARROW_Y_GAP * 2}
direction="right"
label="plan_approval_response { approve: true }"
tagLabel={`request_id: ${REQUEST_ID}`}
color="#10b981"
tagBg={palette.bgSubtle}
tagStroke={palette.nodeStroke}
tagText={palette.nodeText}
/>
)}
{/* Step 2: checkmark */}
{step >= 2 && (
<motion.g
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.3 }}
>
<circle cx={LIFELINE_RIGHT_X + 40} cy={ARROW_Y_START + ARROW_Y_GAP * 2} r={10} fill="#10b981" />
<text
x={LIFELINE_RIGHT_X + 40}
y={ARROW_Y_START + ARROW_Y_GAP * 2 + 1}
textAnchor="middle"
dominantBaseline="middle"
fontSize={10}
fill="white"
fontWeight={700}
>
OK
</text>
</motion.g>
)}
</g>
)}
</AnimatePresence>
</svg>
{/* 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>
);
}
@@ -0,0 +1,466 @@
"use client";
import { motion } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSvgPalette } from "@/hooks/useDarkMode";
// -- FSM states and their layout positions (diamond: idle top, poll right, claim bottom, work left) --
type Phase = "idle" | "poll" | "claim" | "work";
const FSM_CX = 110;
const FSM_CY = 110;
const FSM_R = 65;
const FSM_STATE_R = 22;
const FSM_STATES: { id: Phase; label: string; angle: number }[] = [
{ id: "idle", label: "idle", angle: -Math.PI / 2 },
{ id: "poll", label: "poll", angle: 0 },
{ id: "claim", label: "claim", angle: Math.PI / 2 },
{ id: "work", label: "work", angle: Math.PI },
];
const FSM_TRANSITIONS: { from: Phase; to: Phase }[] = [
{ from: "idle", to: "poll" },
{ from: "poll", to: "claim" },
{ from: "claim", to: "work" },
{ from: "work", to: "idle" },
];
function fsmPos(angle: number) {
return { x: FSM_CX + FSM_R * Math.cos(angle), y: FSM_CY + FSM_R * Math.sin(angle) };
}
const PHASE_COLORS: Record<Phase, string> = {
idle: "#a1a1aa",
poll: "#f59e0b",
claim: "#3b82f6",
work: "#10b981",
};
// -- Task board data --
interface TaskRow {
id: string;
name: string;
status: "unclaimed" | "active" | "complete";
owner: string;
}
const INITIAL_TASKS: TaskRow[] = [
{ id: "T1", name: "Fix auth bug", status: "unclaimed", owner: "-" },
{ id: "T2", name: "Add rate limiter", status: "unclaimed", owner: "-" },
{ id: "T3", name: "Write tests", status: "unclaimed", owner: "-" },
{ id: "T4", name: "Update API docs", status: "unclaimed", owner: "-" },
];
// Agent positions around the task board (left panel)
const BOARD_CX = 140;
const BOARD_CY = 90;
const AGENT_ORBIT = 85;
const AGENT_R = 20;
const AGENT_ANGLES = [-Math.PI / 2, Math.PI / 6, (5 * Math.PI) / 6];
function agentPos(index: number) {
const angle = AGENT_ANGLES[index];
return { x: BOARD_CX + AGENT_ORBIT * Math.cos(angle), y: BOARD_CY + AGENT_ORBIT * Math.sin(angle) };
}
// -- Step definitions --
const STEPS = [
{ title: "Self-Governing Agents", desc: "Autonomous agents need no coordinator. They govern themselves with an idle-poll-claim-work cycle." },
{ title: "Idle Timer", desc: "Each idle agent counts rounds. A timeout triggers self-directed task polling." },
{ title: "Poll Task Board", desc: "Timeout! The agent reads the task board looking for unclaimed work." },
{ title: "Claim Task", desc: "The agent writes its name to the task record. Atomic, no conflicts." },
{ title: "Work", desc: "The agent works on the claimed task using its own agent loop." },
{ title: "Independent Polling", desc: "Multiple agents poll and claim independently. No central coordinator needed." },
{ title: "Complete & Reset", desc: "Task done. Agent returns to idle. The cycle repeats." },
{ title: "Self-Organization", desc: "Three agents, zero coordination overhead. Polling + timeout = emergent organization." },
];
// Per-step state for each agent
interface AgentState {
phase: Phase;
timerFill: number;
color: string;
taskClaim: string | null;
}
function getAgentStates(step: number): AgentState[] {
const idle: AgentState = { phase: "idle", timerFill: 0, color: PHASE_COLORS.idle, taskClaim: null };
switch (step) {
case 0:
return [
{ ...idle },
{ ...idle },
{ ...idle },
];
case 1:
return [
{ phase: "idle", timerFill: 0.6, color: PHASE_COLORS.idle, taskClaim: null },
{ ...idle },
{ ...idle },
];
case 2:
return [
{ phase: "poll", timerFill: 1.0, color: PHASE_COLORS.poll, taskClaim: null },
{ ...idle },
{ ...idle },
];
case 3:
return [
{ phase: "claim", timerFill: 0, color: PHASE_COLORS.claim, taskClaim: "T1" },
{ ...idle },
{ ...idle },
];
case 4:
return [
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T1" },
{ ...idle },
{ ...idle },
];
case 5:
return [
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T1" },
{ phase: "claim", timerFill: 0, color: PHASE_COLORS.claim, taskClaim: "T2" },
{ ...idle },
];
case 6:
return [
{ phase: "idle", timerFill: 0, color: PHASE_COLORS.idle, taskClaim: null },
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T2" },
{ ...idle },
];
case 7:
return [
{ phase: "idle", timerFill: 0, color: PHASE_COLORS.idle, taskClaim: null },
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T2" },
{ phase: "claim", timerFill: 0, color: PHASE_COLORS.claim, taskClaim: "T3" },
];
default:
return [{ ...idle }, { ...idle }, { ...idle }];
}
}
function getTaskStates(step: number): TaskRow[] {
const tasks = INITIAL_TASKS.map((t) => ({ ...t }));
if (step >= 3) { tasks[0].status = "active"; tasks[0].owner = "A"; }
if (step >= 5) { tasks[1].status = "active"; tasks[1].owner = "B"; }
if (step >= 6) { tasks[0].status = "complete"; }
if (step >= 7) { tasks[2].status = "active"; tasks[2].owner = "C"; }
return tasks;
}
function getActivePhase(step: number): Phase {
if (step <= 1) return "idle";
if (step === 2) return "poll";
if (step === 3) return "claim";
if (step === 4 || step === 5) return "work";
if (step === 6) return "idle";
return "claim";
}
// Ring timer around an agent
function TimerRing({ cx, cy, r, fill }: { cx: number; cy: number; r: number; fill: number }) {
if (fill <= 0) return null;
const circumference = 2 * Math.PI * (r + 4);
const offset = circumference * (1 - fill);
return (
<motion.circle
cx={cx}
cy={cy}
r={r + 4}
fill="none"
stroke="#f59e0b"
strokeWidth={3}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
initial={{ strokeDashoffset: circumference }}
animate={{ strokeDashoffset: offset }}
transition={{ duration: 0.8, ease: "easeOut" }}
style={{ transform: "rotate(-90deg)", transformOrigin: `${cx}px ${cy}px` }}
/>
);
}
// FSM arrow between two states
function FSMArrow({ from, to, active, inactiveStroke }: { from: Phase; to: Phase; active: boolean; inactiveStroke: string }) {
const fState = FSM_STATES.find((s) => s.id === from)!;
const tState = FSM_STATES.find((s) => s.id === to)!;
const fPos = fsmPos(fState.angle);
const tPos = fsmPos(tState.angle);
const dx = tPos.x - fPos.x;
const dy = tPos.y - fPos.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const ux = dx / dist;
const uy = dy / dist;
const x1 = fPos.x + ux * FSM_STATE_R;
const y1 = fPos.y + uy * FSM_STATE_R;
const x2 = tPos.x - ux * (FSM_STATE_R + 6);
const y2 = tPos.y - uy * (FSM_STATE_R + 6);
const perpX = -uy * 12;
const perpY = ux * 12;
const cx = (x1 + x2) / 2 + perpX;
const cy = (y1 + y2) / 2 + perpY;
return (
<g>
<path
d={`M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`}
fill="none"
stroke={active ? PHASE_COLORS[to] : inactiveStroke}
strokeWidth={active ? 2 : 1}
markerEnd="url(#fsm-arrowhead)"
/>
</g>
);
}
export default function AutonomousAgents({ title }: { title?: string }) {
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
const step = vis.currentStep;
const palette = useSvgPalette();
const agentStates = getAgentStates(step);
const tasks = getTaskStates(step);
const activePhase = getActivePhase(step);
const agentNames = ["A", "B", "C"];
return (
<section className="space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Autonomous Agent Cycle"}
</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">
{/* Left panel: spatial view with agents and task board */}
<div className="flex-1">
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">Spatial View</div>
<svg viewBox="0 0 280 240" className="w-full">
{/* Task board (small table in center) */}
<rect x={BOARD_CX - 35} y={BOARD_CY - 20} width={70} height={40} rx={4}
fill={palette.bgSubtle} stroke={palette.nodeStroke} strokeWidth={1}
/>
<text x={BOARD_CX} y={BOARD_CY - 8} textAnchor="middle" fontSize={7} fontWeight={600}
fill={palette.nodeText}
>
Task Board
</text>
<text x={BOARD_CX} y={BOARD_CY + 4} textAnchor="middle" fontSize={6} fontFamily="monospace"
fill={palette.labelFill}
>
{tasks.filter((t) => t.status === "unclaimed").length} unclaimed
</text>
<text x={BOARD_CX} y={BOARD_CY + 14} textAnchor="middle" fontSize={6} fontFamily="monospace"
fill="#10b981"
>
{tasks.filter((t) => t.status === "complete").length} complete
</text>
{/* Agents */}
{agentStates.map((state, i) => {
const pos = agentPos(i);
const isPulsing = state.phase === "work";
const isPolling = state.phase === "poll";
return (
<g key={i}>
{/* Dashed line from agent to board when polling */}
{isPolling && (
<motion.line
x1={pos.x} y1={pos.y} x2={BOARD_CX} y2={BOARD_CY}
stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="4 3"
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
)}
{/* Solid line from agent to board when claiming */}
{state.phase === "claim" && (
<motion.line
x1={pos.x} y1={pos.y} x2={BOARD_CX} y2={BOARD_CY}
stroke="#3b82f6" strokeWidth={2}
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
/>
)}
{/* Timer ring */}
<TimerRing cx={pos.x} cy={pos.y} r={AGENT_R} fill={state.timerFill} />
{/* Agent circle */}
<motion.circle
cx={pos.x} cy={pos.y} r={AGENT_R}
fill={state.color}
stroke={state.phase === "work" ? "#059669" : palette.nodeStroke}
strokeWidth={1.5}
animate={{
scale: isPulsing ? [1, 1.1, 1] : 1,
fill: state.color,
}}
transition={
isPulsing
? { duration: 0.8, repeat: Infinity, ease: "easeInOut" }
: { duration: 0.4 }
}
/>
<text x={pos.x} y={pos.y + 1} textAnchor="middle" dominantBaseline="middle"
fill="white" fontSize={11} fontWeight={700}
>
{agentNames[i]}
</text>
{/* Task label below agent when claiming or working */}
{state.taskClaim && (
<motion.text
x={pos.x} y={pos.y + AGENT_R + 12}
textAnchor="middle" fontSize={7} fontFamily="monospace"
fill={state.phase === "work" ? "#10b981" : "#3b82f6"}
fontWeight={600}
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{state.taskClaim}
</motion.text>
)}
</g>
);
})}
</svg>
{/* Task table below the spatial view */}
<div className="mt-2 border border-zinc-200 rounded dark:border-zinc-700 overflow-hidden">
<table className="w-full text-[10px]">
<thead>
<tr className="bg-zinc-50 dark:bg-zinc-800">
<th className="px-2 py-1 text-left font-medium text-zinc-500 dark:text-zinc-400">Task</th>
<th className="px-2 py-1 text-left font-medium text-zinc-500 dark:text-zinc-400">Status</th>
<th className="px-2 py-1 text-left font-medium text-zinc-500 dark:text-zinc-400">Owner</th>
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr key={task.id} className="border-t border-zinc-100 dark:border-zinc-800">
<td className="px-2 py-1 font-mono text-zinc-700 dark:text-zinc-300">{task.name}</td>
<td className="px-2 py-1">
<span className={`inline-block rounded px-1.5 py-0.5 text-[9px] font-medium ${
task.status === "complete"
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: task.status === "active"
? "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400"
}`}>
{task.status}
</span>
</td>
<td className="px-2 py-1 font-mono text-zinc-600 dark:text-zinc-400">{task.owner}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Right panel: FSM state machine diagram */}
<div className="flex-1">
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">FSM Cycle</div>
<svg viewBox="0 0 220 220" className="w-full">
<defs>
<marker
id="fsm-arrowhead"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
</marker>
</defs>
{/* Transition arrows */}
{FSM_TRANSITIONS.map((t) => {
const isActive =
(activePhase === t.from) ||
(activePhase === t.to && t.from === FSM_TRANSITIONS.find((tr) => tr.to === activePhase)?.from);
return (
<FSMArrow
key={`${t.from}-${t.to}`}
from={t.from}
to={t.to}
active={isActive}
inactiveStroke={palette.nodeStroke}
/>
);
})}
{/* State circles */}
{FSM_STATES.map((state) => {
const pos = fsmPos(state.angle);
const isActive = state.id === activePhase;
return (
<g key={state.id}>
<motion.circle
cx={pos.x}
cy={pos.y}
r={FSM_STATE_R}
fill={isActive ? PHASE_COLORS[state.id] : palette.nodeFill}
stroke={isActive ? PHASE_COLORS[state.id] : palette.nodeStroke}
strokeWidth={isActive ? 2 : 1}
animate={{
fill: isActive ? PHASE_COLORS[state.id] : palette.nodeFill,
scale: isActive ? 1.1 : 1,
}}
transition={{ duration: 0.4 }}
/>
<text
x={pos.x}
y={pos.y + 1}
textAnchor="middle"
dominantBaseline="middle"
fontSize={9}
fontWeight={600}
fill={isActive ? "white" : palette.nodeText}
>
{state.label}
</text>
</g>
);
})}
</svg>
{/* Legend */}
<div className="mt-2 flex flex-wrap gap-3 justify-center">
{FSM_STATES.map((s) => (
<div key={s.id} className="flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: PHASE_COLORS[s.id] }} />
<span className="text-[10px] font-mono text-zinc-500 dark:text-zinc-400">{s.label}</span>
</div>
))}
</div>
</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>
);
}
@@ -0,0 +1,102 @@
"use client";
import { Play, Pause, SkipBack, SkipForward, RotateCcw } from "lucide-react";
import { cn } from "@/lib/utils";
interface StepControlsProps {
currentStep: number;
totalSteps: number;
onPrev: () => void;
onNext: () => void;
onReset: () => void;
isPlaying: boolean;
onToggleAutoPlay: () => void;
stepTitle: string;
stepDescription: string;
className?: string;
}
export function StepControls({
currentStep,
totalSteps,
onPrev,
onNext,
onReset,
isPlaying,
onToggleAutoPlay,
stepTitle,
stepDescription,
className,
}: StepControlsProps) {
return (
<div className={cn("space-y-3", className)}>
{/* Annotation */}
<div className="rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-800 dark:bg-blue-950/40">
<div className="mb-1 text-sm font-semibold text-blue-900 dark:text-blue-200">
{stepTitle}
</div>
<div className="text-sm text-blue-700 dark:text-blue-300">
{stepDescription}
</div>
</div>
{/* Controls */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<button
onClick={onReset}
className="rounded-md p-1.5 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
title="Reset"
>
<RotateCcw size={16} />
</button>
<button
onClick={onPrev}
disabled={currentStep === 0}
className="rounded-md p-1.5 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 disabled:opacity-30 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
title="Previous step"
>
<SkipBack size={16} />
</button>
<button
onClick={onToggleAutoPlay}
className="rounded-md p-1.5 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
title={isPlaying ? "Pause" : "Auto-play"}
>
{isPlaying ? <Pause size={16} /> : <Play size={16} />}
</button>
<button
onClick={onNext}
disabled={currentStep === totalSteps - 1}
className="rounded-md p-1.5 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 disabled:opacity-30 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-200"
title="Next step"
>
<SkipForward size={16} />
</button>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2">
<div className="flex gap-1">
{Array.from({ length: totalSteps }, (_, i) => (
<div
key={i}
className={cn(
"h-1.5 w-1.5 rounded-full transition-colors",
i === currentStep
? "bg-blue-500"
: i < currentStep
? "bg-blue-300 dark:bg-blue-700"
: "bg-zinc-200 dark:bg-zinc-700"
)}
/>
))}
</div>
<span className="font-mono text-xs text-zinc-400">
{currentStep + 1}/{totalSteps}
</span>
</div>
</div>
</div>
);
}