feat: complete web curriculum visuals

This commit is contained in:
Haoran
2026-05-25 22:37:07 +08:00
parent 956d8272ce
commit fdd7d2a851
155 changed files with 19086 additions and 3584 deletions

View File

@@ -16,6 +16,10 @@ const CLASS_DESCRIPTIONS: Record<string, string> = {
TeammateManager: "Multi-agent team lifecycle and coordination",
Teammate: "Individual agent identity and state tracking",
SharedBoard: "Cross-agent shared state coordination",
CronJob: "Durable recurring job definition",
ProtocolState: "Pending team protocol requests and response matching",
MCPClient: "External tool discovery and invocation client",
RecoveryState: "Retry, fallback, and continuation state",
};
interface ArchDiagramProps {
@@ -68,29 +72,23 @@ function getLayerColorClasses(versionId: string): {
}
}
function collectClassesUpTo(
function collectClassesForVersion(
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 targetIndex = versionsData.versions.findIndex((v) => v.id === targetId);
const version = targetIndex >= 0 ? versionsData.versions[targetIndex] : undefined;
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;
return (
version?.classes?.map((cls) => ({
name: cls.name,
introducedIn:
versionsData.versions
.slice(0, targetIndex + 1)
.find((candidate) =>
candidate.classes?.some((candidateCls) => candidateCls.name === cls.name)
)?.id ?? targetId,
})) ?? []
);
}
function getNewClassNames(version: string): Set<string> {
@@ -103,7 +101,7 @@ function getNewClassNames(version: string): Set<string> {
}
export function ArchDiagram({ version }: ArchDiagramProps) {
const allClasses = collectClassesUpTo(version);
const allClasses = collectClassesForVersion(version);
const newClassNames = getNewClassNames(version);
const versionData = versionsData.versions.find((v) => v.id === version);
const tools = versionData?.tools ?? [];

View File

@@ -18,6 +18,14 @@ import s09Annotations from "@/data/annotations/s09.json";
import s10Annotations from "@/data/annotations/s10.json";
import s11Annotations from "@/data/annotations/s11.json";
import s12Annotations from "@/data/annotations/s12.json";
import s13Annotations from "@/data/annotations/s13.json";
import s14Annotations from "@/data/annotations/s14.json";
import s15Annotations from "@/data/annotations/s15.json";
import s16Annotations from "@/data/annotations/s16.json";
import s17Annotations from "@/data/annotations/s17.json";
import s18Annotations from "@/data/annotations/s18.json";
import s19Annotations from "@/data/annotations/s19.json";
import s20Annotations from "@/data/annotations/s20.json";
interface Decision {
id: string;
@@ -46,6 +54,14 @@ const ANNOTATIONS: Record<string, AnnotationFile> = {
s10: s10Annotations as AnnotationFile,
s11: s11Annotations as AnnotationFile,
s12: s12Annotations as AnnotationFile,
s13: s13Annotations as AnnotationFile,
s14: s14Annotations as AnnotationFile,
s15: s15Annotations as AnnotationFile,
s16: s16Annotations as AnnotationFile,
s17: s17Annotations as AnnotationFile,
s18: s18Annotations as AnnotationFile,
s19: s19Annotations as AnnotationFile,
s20: s20Annotations as AnnotationFile,
};
interface DesignDecisionsProps {
@@ -124,7 +140,14 @@ export function DesignDecisions({ version }: DesignDecisionsProps) {
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="rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-bg)] p-6 text-sm text-[var(--color-text-secondary)]">
Design decisions are not available for this lesson yet.
</div>
</div>
);
}
return (

View File

@@ -6,8 +6,9 @@ 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 NODE_HEIGHT = 44;
const DIAMOND_WIDTH = 92;
const DIAMOND_HEIGHT = 64;
const LAYER_COLORS: Record<string, string> = {
start: "#3B82F6",
@@ -17,39 +18,207 @@ const LAYER_COLORS: Record<string, string> = {
end: "#EF4444",
};
function getNodeCenter(node: FlowNode): { cx: number; cy: number } {
return { cx: node.x, cy: node.y };
function getNodeLines(node: FlowNode): string[] {
const maxChars = node.type === "decision" ? 12 : 18;
return node.label.split("\n").flatMap((line) => {
if (line.length <= maxChars) return [line];
const parts = line.split(/(\s+\/\s+|\s+|_)/).filter(Boolean);
const chunks: string[] = [];
let current = "";
for (const part of parts) {
const next = `${current}${part}`;
if (current && next.trim().length > maxChars) {
chunks.push(current.trim());
current = part.trimStart();
} else {
current = next;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks.length ? chunks : [line];
});
}
function estimateTextWidth(line: string, fontSize: number): number {
return line.length * fontSize * 0.62;
}
function getNodeMetrics(node: FlowNode) {
const lines = getNodeLines(node);
const longest = Math.max(...lines.map((line) => estimateTextWidth(line, 11)), 0);
if (node.type === "decision") {
return {
lines,
width: Math.max(DIAMOND_WIDTH, longest + 54),
height: Math.max(DIAMOND_HEIGHT, lines.length * 15 + 42),
};
}
if (node.type === "start" || node.type === "end") {
return {
lines,
width: Math.max(NODE_WIDTH, longest + 34),
height: Math.max(NODE_HEIGHT, lines.length * 15 + 24),
};
}
return {
lines,
width: Math.max(NODE_WIDTH, longest + 30),
height: Math.max(NODE_HEIGHT, lines.length * 15 + 24),
};
}
function getNodeBounds(node: FlowNode) {
const metrics = getNodeMetrics(node);
const halfW = metrics.width / 2;
const halfH = metrics.height / 2;
return {
cx: node.x,
cy: node.y,
left: node.x - halfW,
right: node.x + halfW,
top: node.y - halfH,
bottom: node.y + halfH,
};
}
const LOOP_RAIL_X = -48;
const RIGHT_LOOP_RAIL_X = 576;
const FLOW_CENTER_X = 300;
const LOOP_PAD = 28;
const LOOP_BACK_DX_LIMIT = 360;
const LOOP_BACK_DY_LIMIT = 70;
type LoopSide = "left" | "right";
function getLoopSide(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>): LoopSide {
return (start.cx + end.cx) / 2 > FLOW_CENTER_X ? "right" : "left";
}
function getLoopRailX(
start: ReturnType<typeof getNodeBounds>,
end: ReturnType<typeof getNodeBounds>,
side = getLoopSide(start, end),
) {
if (side === "right") {
return Math.max(RIGHT_LOOP_RAIL_X, start.right + LOOP_PAD, end.right + LOOP_PAD);
}
return Math.min(LOOP_RAIL_X, start.left - LOOP_PAD, end.left - LOOP_PAD);
}
function isLoopBack(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>) {
const dx = end.cx - start.cx;
const dy = end.cy - start.cy;
return dy < -LOOP_BACK_DY_LIMIT && Math.abs(dx) <= LOOP_BACK_DX_LIMIT;
}
function shouldUseStepRoute(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>) {
const dx = end.cx - start.cx;
const dy = end.cy - start.cy;
return dy > 28 && Math.abs(dx) > 44 && end.top > start.bottom;
}
function getStepBusY(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>) {
const room = end.top - start.bottom;
return Math.min(end.top - 16, start.bottom + Math.max(18, room * 0.35));
}
function getEdgePath(from: FlowNode, to: FlowNode): string {
const { cx: x1, cy: y1 } = getNodeCenter(from);
const { cx: x2, cy: y2 } = getNodeCenter(to);
const start = getNodeBounds(from);
const end = getNodeBounds(to);
const dx = end.cx - start.cx;
const dy = end.cy - start.cy;
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}`;
if (isLoopBack(start, end)) {
const side = getLoopSide(start, end);
const railX = getLoopRailX(start, end, side);
const startX = side === "right" ? start.right : start.left;
const endX = side === "right" ? end.right : end.left;
const midY = (start.cy + end.cy) / 2;
return `M ${startX} ${start.cy} C ${railX} ${start.cy}, ${railX} ${midY}, ${railX} ${midY} C ${railX} ${end.cy}, ${endX} ${end.cy}, ${endX} ${end.cy}`;
}
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}`;
if (Math.abs(dx) < 10) {
if (dy >= 0) {
return `M ${start.cx} ${start.bottom} L ${end.cx} ${end.top}`;
}
return `M ${start.cx} ${start.top} L ${end.cx} ${end.bottom}`;
}
if (Math.abs(dy) < 10) {
const startX = dx > 0 ? start.right : start.left;
const endX = dx > 0 ? end.left : end.right;
const midX = (startX + endX) / 2;
return `M ${startX} ${start.cy} C ${midX} ${start.cy}, ${midX} ${end.cy}, ${endX} ${end.cy}`;
}
if (shouldUseStepRoute(start, end)) {
const busY = getStepBusY(start, end);
return `M ${start.cx} ${start.bottom} L ${start.cx} ${busY} L ${end.cx} ${busY} L ${end.cx} ${end.top}`;
}
if (Math.abs(dx) > 70) {
const startX = dx > 0 ? start.right : start.left;
const endX = dx > 0 ? end.left : end.right;
const control = Math.max(56, Math.abs(dx) * 0.45);
return `M ${startX} ${start.cy} C ${startX + (dx > 0 ? control : -control)} ${start.cy}, ${endX - (dx > 0 ? control : -control)} ${end.cy}, ${endX} ${end.cy}`;
}
const startY = dy > 0 ? start.bottom : start.top;
const endY = dy > 0 ? end.top : end.bottom;
const controlDistance = Math.max(44, Math.abs(endY - startY) * 0.42);
const controlY1 = startY + (endY > startY ? controlDistance : -controlDistance);
const controlY2 = endY - (endY > startY ? controlDistance : -controlDistance);
return `M ${start.cx} ${startY} C ${start.cx} ${controlY1}, ${end.cx} ${controlY2}, ${end.cx} ${endY}`;
}
function getEdgeLabelPosition(from: FlowNode, to: FlowNode): { x: number; y: number } {
const start = getNodeBounds(from);
const end = getNodeBounds(to);
const dx = end.cx - start.cx;
const dy = end.cy - start.cy;
if (isLoopBack(start, end)) {
const side = getLoopSide(start, end);
return {
x: getLoopRailX(start, end, side) + (side === "right" ? -24 : 24),
y: (start.cy + end.cy) / 2 - 6,
};
}
if (Math.abs(dy) < 10) {
return { x: (start.cx + end.cx) / 2, y: start.cy - 12 };
}
if (shouldUseStepRoute(start, end)) {
return { x: (start.cx + end.cx) / 2, y: getStepBusY(start, end) - 8 };
}
return {
x: (start.cx + end.cx) / 2 + (dx > 0 ? 18 : -18),
y: (start.bottom + end.top) / 2 - 8,
};
}
function NodeShape({ node }: { node: FlowNode }) {
const color = LAYER_COLORS[node.type];
const lines = node.label.split("\n");
const { lines, width, height } = getNodeMetrics(node);
if (node.type === "decision") {
const half = DIAMOND_SIZE / 2;
const halfW = width / 2;
const halfH = height / 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}`}
points={`${node.x},${node.y - halfH} ${node.x + halfW},${node.y} ${node.x},${node.y + halfH} ${node.x - halfW},${node.y}`}
fill="none"
stroke={color}
strokeWidth={2}
@@ -58,10 +227,10 @@ function NodeShape({ node }: { node: FlowNode }) {
<text
key={i}
x={node.x}
y={node.y + (i - (lines.length - 1) / 2) * 12}
y={node.y + (i - (lines.length - 1) / 2) * 13}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontSize={lines.length > 2 ? 9 : 10}
fontFamily="monospace"
fill="currentColor"
>
@@ -76,27 +245,30 @@ function NodeShape({ node }: { node: FlowNode }) {
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}
x={node.x - width / 2}
y={node.y - height / 2}
width={width}
height={height}
rx={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>
{lines.map((line, i) => (
<text
key={i}
x={node.x}
y={node.y + (i - (lines.length - 1) / 2) * 14}
textAnchor="middle"
dominantBaseline="central"
fontSize={12}
fontWeight={600}
fontFamily="monospace"
fill="currentColor"
>
{line}
</text>
))}
</g>
);
}
@@ -105,10 +277,10 @@ function NodeShape({ node }: { node: FlowNode }) {
return (
<g>
<rect
x={node.x - NODE_WIDTH / 2}
y={node.y - NODE_HEIGHT / 2}
width={NODE_WIDTH}
height={NODE_HEIGHT}
x={node.x - width / 2}
y={node.y - height / 2}
width={width}
height={height}
rx={4}
fill="none"
stroke={color}
@@ -147,12 +319,14 @@ function EdgePath({
if (!from || !to) return null;
const d = getEdgePath(from, to);
const midX = (from.x + to.x) / 2;
const midY = (from.y + to.y) / 2;
const label = getEdgeLabelPosition(from, to);
return (
<g>
<motion.path
data-edge-from={edge.from}
data-edge-to={edge.to}
data-edge-label={edge.label ?? ""}
d={d}
fill="none"
stroke="var(--color-text-secondary)"
@@ -164,10 +338,15 @@ function EdgePath({
/>
{edge.label && (
<motion.text
x={midX + 8}
y={midY - 4}
x={label.x}
y={label.y}
textAnchor="middle"
fontSize={10}
fill="var(--color-text-secondary)"
stroke="var(--color-bg)"
strokeWidth={5}
strokeLinejoin="round"
paintOrder="stroke"
fontFamily="monospace"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@@ -191,15 +370,24 @@ export function ExecutionFlow({ version }: ExecutionFlowProps) {
setFlow(getFlowForVersion(version));
}, [version]);
if (!flow) return null;
if (!flow) {
return (
<div className="rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-bg)] p-6 text-sm text-[var(--color-text-secondary)]">
Execution flow is not available for this lesson yet.
</div>
);
}
const maxY = Math.max(...flow.nodes.map((n) => n.y)) + 50;
const bounds = flow.nodes.map(getNodeBounds);
const minX = Math.min(-40, ...bounds.map((b) => b.left)) - 24;
const maxX = Math.max(700, ...bounds.map((b) => b.right)) + 24;
const maxY = Math.max(...bounds.map((b) => b.bottom)) + 50;
return (
<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]"
viewBox={`${minX} 0 ${maxX - minX} ${maxY}`}
className="mx-auto w-full max-w-[720px]"
style={{ minHeight: 300 }}
>
<defs>
@@ -219,12 +407,14 @@ export function ExecutionFlow({ version }: ExecutionFlowProps) {
</defs>
{flow.edges.map((edge, i) => (
<EdgePath key={`${edge.from}-${edge.to}`} edge={edge} nodes={flow.nodes} index={i} />
<EdgePath key={`${edge.from}-${edge.to}-${i}`} edge={edge} nodes={flow.nodes} index={i} />
))}
{flow.nodes.map((node, i) => (
<motion.g
key={node.id}
data-node-id={node.id}
data-node-label={node.label}
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.06, duration: 0.3 }}

View File

@@ -40,6 +40,10 @@ function postProcessHtml(html: string): string {
'<pre class="ascii-diagram"><code$1>'
);
// Keep wide Markdown tables inside the prose column on small screens.
html = html.replace(/<table>/g, '<div class="table-scroll"><table>');
html = html.replace(/<\/table>/g, '</table></div>');
// Mark the first blockquote as hero callout
html = html.replace(
/<blockquote>/,

View File

@@ -21,6 +21,14 @@ const scenarioModules: Record<string, () => Promise<{ default: Scenario }>> = {
s10: () => import("@/data/scenarios/s10.json") as Promise<{ default: Scenario }>,
s11: () => import("@/data/scenarios/s11.json") as Promise<{ default: Scenario }>,
s12: () => import("@/data/scenarios/s12.json") as Promise<{ default: Scenario }>,
s13: () => import("@/data/scenarios/s13.json") as Promise<{ default: Scenario }>,
s14: () => import("@/data/scenarios/s14.json") as Promise<{ default: Scenario }>,
s15: () => import("@/data/scenarios/s15.json") as Promise<{ default: Scenario }>,
s16: () => import("@/data/scenarios/s16.json") as Promise<{ default: Scenario }>,
s17: () => import("@/data/scenarios/s17.json") as Promise<{ default: Scenario }>,
s18: () => import("@/data/scenarios/s18.json") as Promise<{ default: Scenario }>,
s19: () => import("@/data/scenarios/s19.json") as Promise<{ default: Scenario }>,
s20: () => import("@/data/scenarios/s20.json") as Promise<{ default: Scenario }>,
};
interface AgentLoopSimulatorProps {
@@ -33,10 +41,27 @@ export function AgentLoopSimulator({ version }: AgentLoopSimulatorProps) {
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
const loader = scenarioModules[version];
if (loader) {
loader().then((mod) => setScenario(mod.default));
setScenario(null);
if (!loader) {
return () => {
cancelled = true;
};
}
loader()
.then((mod) => {
if (!cancelled) setScenario(mod.default);
})
.catch(() => {
if (!cancelled) setScenario(null);
});
return () => {
cancelled = true;
};
}, [version]);
const sim = useSimulator(scenario?.steps ?? []);
@@ -50,7 +75,16 @@ export function AgentLoopSimulator({ version }: AgentLoopSimulatorProps) {
}
}, [sim.visibleSteps.length]);
if (!scenario) return null;
if (!scenario) {
return (
<section>
<h2 className="mb-2 text-xl font-semibold">{t("simulator")}</h2>
<div className="rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-bg)] p-6 text-sm text-[var(--color-text-secondary)]">
Simulator scenario is not available for this lesson yet.
</div>
</section>
);
}
return (
<section>

View File

@@ -9,16 +9,24 @@ const visualizations: Record<
> = {
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")),
s12: lazy(() => import("./s12-worktree-task-isolation")),
s03: lazy(() => import("./s03-permission")),
s04: lazy(() => import("./s04-hooks")),
s05: lazy(() => import("./s03-todo-write")),
s06: lazy(() => import("./s04-subagent")),
s07: lazy(() => import("./s05-skill-loading")),
s08: lazy(() => import("./s06-context-compact")),
s09: lazy(() => import("./s09-memory")),
s10: lazy(() => import("./s10-system-prompt")),
s11: lazy(() => import("./s11-error-recovery")),
s12: lazy(() => import("./s07-task-system")),
s13: lazy(() => import("./s08-background-tasks")),
s14: lazy(() => import("./s14-cron-scheduler")),
s15: lazy(() => import("./s09-agent-teams")),
s16: lazy(() => import("./s10-team-protocols")),
s17: lazy(() => import("./s11-autonomous-agents")),
s18: lazy(() => import("./s12-worktree-task-isolation")),
s19: lazy(() => import("./s19-mcp-tools")),
s20: lazy(() => import("./s20-comprehensive")),
};
export function SessionVisualization({ version }: { version: string }) {

View File

@@ -0,0 +1,386 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { CheckCircle2, ClipboardCheck, OctagonAlert, PlayCircle, ShieldAlert, ShieldCheck, UserCheck } from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
const STEPS = [
{
title: "Three Requests, Three Routes",
desc: "Permission is a router: safe calls run, risky calls ask, forbidden calls stop.",
mode: "overview",
},
{
title: "Allow: Safe Read Runs Immediately",
desc: "A read-only file request passes policy and reaches the handler without a user ticket.",
mode: "allow",
},
{
title: "Ask: Risky Local Delete Becomes a Ticket",
desc: "A local delete command is not forbidden, but it must pause for explicit confirmation.",
mode: "ask",
},
{
title: "Approved Ask: Handler Runs After Yes",
desc: "The same risky request executes only after the user approves this exact action.",
mode: "ask-approved",
},
{
title: "Deny: Forbidden Pattern Stops Early",
desc: "A root-level sudo delete is blocked before any handler can touch the machine.",
mode: "deny",
},
{
title: "One Permission Desk, Three Outcomes",
desc: "The harness keeps allow, ask, and deny decisions outside the model, then returns the decision to the loop.",
mode: "summary",
},
] as const;
const REQUESTS = [
{
id: "allow",
tool: "read_file",
command: "README.md",
result: "allow",
detail: "read-only workspace file",
tone: "emerald",
},
{
id: "ask",
tool: "bash",
command: "rm -rf ./tmp/build-cache",
result: "ask",
detail: "local destructive command",
tone: "amber",
},
{
id: "deny",
tool: "bash",
command: "sudo rm -rf /",
result: "deny",
detail: "forbidden root delete",
tone: "red",
},
] as const;
function toneClass(tone: "emerald" | "amber" | "red" | "blue" | "zinc") {
if (tone === "emerald") return "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200";
if (tone === "amber") return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200";
if (tone === "red") return "border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200";
if (tone === "blue") return "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200";
return "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200";
}
function Surface({
title,
icon,
active,
children,
}: {
title: string;
icon: React.ReactNode;
active: boolean;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"min-w-0 rounded-lg border p-4 transition-colors",
active
? "border-red-300 bg-red-50 dark:border-red-900 dark:bg-red-950/30"
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
)}
>
<div className="mb-4 flex items-center gap-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
<span
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
active
? "bg-red-500 text-white"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
)}
>
{icon}
</span>
{title}
</div>
{children}
</div>
);
}
type StepMode = (typeof STEPS)[number]["mode"];
type RequestId = (typeof REQUESTS)[number]["id"];
function activeRequestId(mode: StepMode): RequestId | null {
if (mode === "allow") return "allow";
if (mode === "ask" || mode === "ask-approved") return "ask";
if (mode === "deny") return "deny";
return null;
}
function RequestCard({
request,
active,
muted,
}: {
request: (typeof REQUESTS)[number];
active: boolean;
muted: boolean;
}) {
return (
<motion.div
layout
animate={active ? { y: -1 } : { y: 0 }}
className={cn(
"min-w-0 rounded-xl border p-4 shadow-sm",
active ? toneClass(request.tone) : "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200",
muted && "opacity-45"
)}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="min-w-0 text-sm font-semibold">tool request</div>
<span className="shrink-0 rounded-full bg-white/70 px-2 py-1 font-mono text-xs font-semibold dark:bg-zinc-950/30">
{request.tool}
</span>
</div>
<code className="block min-w-0 rounded-lg bg-zinc-950 p-3 font-mono text-xs leading-relaxed text-zinc-100 whitespace-pre-wrap break-words">
{request.command}
</code>
<div className="mt-3 flex items-center justify-between gap-3 text-sm">
<span className="min-w-0 text-pretty opacity-80">{request.detail}</span>
<span className="shrink-0 rounded bg-white/75 px-2 py-1 font-semibold dark:bg-zinc-950/30">
{request.result}
</span>
</div>
</motion.div>
);
}
function CheckRow({
label,
detail,
status,
active,
}: {
label: string;
detail: string;
status: "waiting" | "pass" | "allow" | "ask" | "approved" | "deny" | "skip";
active: boolean;
}) {
const icon =
status === "deny" ? <OctagonAlert size={16} /> : status === "pass" || status === "allow" ? <CheckCircle2 size={16} /> : status === "ask" ? <ShieldAlert size={16} /> : status === "approved" ? <UserCheck size={16} /> : <ClipboardCheck size={16} />;
const tone = status === "deny" ? "red" : status === "pass" || status === "allow" || status === "approved" ? "emerald" : status === "ask" ? "amber" : "zinc";
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
"rounded-lg border p-3",
active ? toneClass(tone) : "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
)}
>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm font-semibold">
{icon}
{label}
</div>
<span className="shrink-0 rounded bg-white/70 px-2 py-0.5 text-[11px] font-semibold dark:bg-zinc-950/30">
{status}
</span>
</div>
<div className="text-xs leading-relaxed opacity-80">{detail}</div>
</motion.div>
);
}
function PermissionDesk({ mode }: { mode: StepMode }) {
if (mode === "overview" || mode === "summary") {
return (
<div className="grid gap-2">
<CheckRow label="Safe read" detail="No write, no shell, no approval needed." status="allow" active={mode === "overview"} />
<CheckRow label="Risky local change" detail="May be useful, but requires a human yes." status="ask" active={mode === "overview"} />
<CheckRow label="Forbidden pattern" detail="Root delete and sudo never reach handlers." status="deny" active={mode === "overview"} />
</div>
);
}
if (mode === "allow") {
return (
<div className="space-y-2">
<CheckRow label="Gate 1: hard deny" detail="No sudo, no root path, no forbidden pattern." status="pass" active={false} />
<CheckRow label="Gate 2: allow rule" detail="Read-only workspace file can run immediately." status="allow" active />
<CheckRow label="Gate 3: user approval" detail="Skipped because this call is already safe." status="skip" active={false} />
</div>
);
}
if (mode === "deny") {
return (
<div className="space-y-2">
<CheckRow label="Gate 1: hard deny" detail="sudo + root delete is blocked immediately." status="deny" active />
<CheckRow label="Gate 2: risk rule" detail="Skipped because hard deny already decided." status="skip" active={false} />
<CheckRow label="Gate 3: user approval" detail="Skipped because the user cannot approve forbidden actions." status="skip" active={false} />
</div>
);
}
return (
<div className="space-y-2">
<CheckRow label="Gate 1: hard deny" detail="Local project path is not globally forbidden." status="pass" active={false} />
<CheckRow label="Gate 2: risk rule" detail="Deleting files needs an explicit approval ticket." status="ask" active={mode === "ask"} />
<CheckRow label="Gate 3: user approval" detail="The tool waits until this request is approved." status={mode === "ask-approved" ? "approved" : "waiting"} active={mode === "ask-approved"} />
</div>
);
}
function CodeLine({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-lg bg-white/70 p-2 dark:bg-zinc-950/30">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide opacity-70">{label}</div>
<code className="block min-w-0 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">{value}</code>
</div>
);
}
function Outcome({ mode }: { mode: StepMode }) {
if (mode === "overview") {
return <div className="rounded-lg border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">select a request route</div>;
}
if (mode === "allow") {
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("emerald"))}>
<div className="flex items-center gap-2 text-base font-semibold">
<PlayCircle size={17} />
Handler runs now
</div>
<CodeLine label="handler" value="read_file" />
<CodeLine label="args" value='path: "README.md"' />
</motion.div>
);
}
if (mode === "ask") {
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("rounded-xl border p-4", toneClass("amber"))}>
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
<UserCheck size={17} />
Approval ticket
</div>
<div className="text-sm leading-relaxed">"Allow deleting local build cache?"</div>
</motion.div>
);
}
if (mode === "ask-approved") {
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("blue"))}>
<div className="flex items-center gap-2 text-base font-semibold">
<PlayCircle size={17} />
Handler runs after approval
</div>
<CodeLine label="handler" value="bash" />
<CodeLine label="args" value="rm -rf ./tmp/build-cache" />
</motion.div>
);
}
if (mode === "deny") {
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("rounded-xl border p-4", toneClass("red"))}>
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
<OctagonAlert size={17} />
Blocked before handler
</div>
<div className="text-sm leading-relaxed">No tool execution, no user prompt, no filesystem touch.</div>
</motion.div>
);
}
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
{REQUESTS.map((request) => (
<div key={request.id} className={cn("rounded-lg border p-3", toneClass(request.tone))}>
<div className="mb-1 flex items-center gap-2 text-sm font-semibold">
{request.result === "deny" ? <OctagonAlert size={15} /> : request.result === "ask" ? <ShieldAlert size={15} /> : <ShieldCheck size={15} />}
{request.result}
</div>
<div className="text-xs leading-relaxed opacity-80">{request.detail}</div>
</div>
))}
<div className={cn("rounded-xl border p-4", toneClass("emerald"))}>
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
<ShieldCheck size={17} />
decision returned to loop
</div>
<div className="text-sm leading-relaxed">Permission stays outside the model, but the loop still receives a normal tool_result or blocked result.</div>
</div>
</motion.div>
);
}
export default function PermissionVisualization({ title }: { title?: string }) {
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
const step = vis.currentStep;
const current = STEPS[step];
const mode = current.mode;
const activeId = activeRequestId(mode);
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Permission Desk"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="grid gap-3 lg:grid-cols-[1fr_1.1fr_0.95fr]">
<Surface title="Tool requests" icon={<OctagonAlert size={20} />} active={mode === "overview" || activeId !== null}>
<div className="space-y-2">
{REQUESTS.map((request) => (
<RequestCard
key={request.id}
request={request}
active={activeId === request.id || (mode === "overview" && step === 0)}
muted={activeId !== null && activeId !== request.id}
/>
))}
</div>
</Surface>
<Surface title="Permission desk" icon={<ShieldCheck size={20} />} active={mode !== "overview"}>
<PermissionDesk mode={mode} />
</Surface>
<Surface title="Outcome" icon={<PlayCircle size={20} />} active={mode !== "overview"}>
<AnimatePresence mode="wait">
<Outcome key={mode} mode={mode} />
</AnimatePresence>
</Surface>
</div>
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-relaxed text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800/70 dark:text-zinc-300">
Beginner rule: the model proposes tools; the runtime routes each request to allow, ask, or deny before execution.
</div>
<StepControls
className="mt-4"
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={current.title}
stepDescription={current.desc}
/>
</div>
</section>
);
}

View File

@@ -99,12 +99,12 @@ function KanbanColumn({
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="min-w-0 flex min-h-[220px] flex-col rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 sm:min-h-[280px]">
<div
className={`rounded-t-lg px-3 py-2 text-center text-xs font-bold uppercase tracking-wider ${headerBg}`}
className={`flex items-center justify-center gap-1 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}`}>
<span className="min-w-0 break-words">{title}</span>
<span className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${accentClass}`}>
{tasks.length}
</span>
</div>
@@ -147,19 +147,19 @@ function TaskCard({ task }: { task: Task }) {
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]}`}
className={`min-w-0 rounded-md border p-2.5 ${borderStyles[task.status]}`}
>
<div className="mb-1.5 flex items-center justify-between">
<div className="mb-1.5 flex min-w-0 items-center justify-between gap-2">
<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]}`}
className={`shrink-0 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">
<div className="break-words text-xs font-medium leading-snug text-zinc-700 dark:text-zinc-300">
{task.label}
</div>
</motion.div>
@@ -264,7 +264,7 @@ export default function TodoWrite({ title }: { title?: string }) {
</div>
{/* Kanban board */}
<div className="flex gap-3">
<div className="grid gap-3 sm:grid-cols-3">
<KanbanColumn
title="Pending"
tasks={pendingTasks}

View File

@@ -0,0 +1,269 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { ClipboardList, FileSearch, LogOut, PlugZap, RadioTower, ScrollText, Wrench } from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
type HookId = "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "Stop";
const HOOKS: {
id: HookId;
when: string;
callbacks: string[];
color: "blue" | "amber" | "emerald" | "zinc";
}[] = [
{
id: "UserPromptSubmit",
when: "after input, before LLM",
callbacks: ["context_inject_hook"],
color: "blue",
},
{
id: "PreToolUse",
when: "after tool_use, before handler",
callbacks: ["permission_hook", "log_hook"],
color: "amber",
},
{
id: "PostToolUse",
when: "after handler, before next turn",
callbacks: ["large_output_hook"],
color: "emerald",
},
{
id: "Stop",
when: "before final output",
callbacks: ["summary_hook"],
color: "zinc",
},
];
const STEPS = [
{
title: "Hooks Are Registered Outside the Loop",
desc: "The loop only knows event names; callback behavior lives in the registry.",
active: null,
},
{
title: "UserPromptSubmit",
desc: "Input hooks can log, validate, or inject context before the model sees the prompt.",
active: "UserPromptSubmit" as HookId,
},
{
title: "The Core Loop Still Chooses a Tool",
desc: "Calling the model and receiving tool_use remains the same as before.",
active: null,
},
{
title: "PreToolUse",
desc: "Permission and logging hooks run before the handler touches the workspace.",
active: "PreToolUse" as HookId,
},
{
title: "PostToolUse",
desc: "Result hooks inspect output or trigger side effects after execution.",
active: "PostToolUse" as HookId,
},
{
title: "Stop",
desc: "Cleanup and summary hooks run when the model stops asking for tools.",
active: "Stop" as HookId,
},
] as const;
function toneClass(tone: "blue" | "amber" | "emerald" | "zinc", active = true) {
if (!active) return "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200";
if (tone === "blue") return "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200";
if (tone === "amber") return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200";
if (tone === "emerald") return "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200";
return "border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200";
}
function Surface({
title,
icon,
active,
children,
}: {
title: string;
icon: React.ReactNode;
active: boolean;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"min-w-0 rounded-lg border p-4 transition-colors",
active
? "border-emerald-300 bg-emerald-50 dark:border-emerald-900 dark:bg-emerald-950/30"
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
)}
>
<div className="mb-4 flex items-center gap-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
<span
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
active
? "bg-emerald-500 text-white"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
)}
>
{icon}
</span>
{title}
</div>
{children}
</div>
);
}
function HookCard({
hook,
active,
}: {
hook: (typeof HOOKS)[number];
active: boolean;
}) {
return (
<motion.div
layout
animate={active ? { y: [0, -2, 0] } : { y: 0 }}
transition={{ duration: 0.8, repeat: active ? Infinity : 0 }}
className={cn("rounded-lg border p-3", toneClass(hook.color, active))}
>
<div className="mb-1 flex min-w-0 items-center justify-between gap-2">
<div className="min-w-0 truncate font-mono text-sm font-semibold">{hook.id}</div>
{active && <PlugZap size={16} className="shrink-0" />}
</div>
<div className="mb-3 text-xs leading-relaxed opacity-80">{hook.when}</div>
<div className="flex flex-wrap gap-1.5">
{hook.callbacks.map((callback) => (
<span key={callback} className="rounded bg-white/70 px-2 py-1 font-mono text-[11px] dark:bg-zinc-950/30">
{callback}
</span>
))}
</div>
</motion.div>
);
}
function TurnCard({ step }: { step: number }) {
const state =
step <= 1
? { title: "User input", body: "Read README.md and summarize it.", icon: <ScrollText size={18} /> }
: step === 2
? { title: "LLM chooses tool", body: "tool_use: read_file({ path: 'README.md' })", icon: <Wrench size={18} /> }
: step === 3
? { title: "Tool waits at pre-hook", body: "permission_hook + log_hook inspect the call.", icon: <FileSearch size={18} /> }
: step === 4
? { title: "Handler returned output", body: "large_output_hook checks result size.", icon: <ClipboardList size={18} /> }
: { title: "No more tool_use", body: "summary_hook records final session stats.", icon: <LogOut size={18} /> };
return (
<motion.div
key={state.title}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
>
<div className="mb-2 flex items-center gap-2 text-base font-semibold text-zinc-900 dark:text-zinc-100">
{state.icon}
{state.title}
</div>
<div className="rounded-lg bg-zinc-50 p-3 font-mono text-xs leading-relaxed text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
{state.body}
</div>
</motion.div>
);
}
function AuditLog({ step }: { step: number }) {
const items = [
"[registry] four hook slots registered",
"[UserPromptSubmit] working directory logged",
"[loop] model returned read_file tool_use",
"[PreToolUse] permission allowed; tool call logged",
"[PostToolUse] output size checked",
"[Stop] session used 1 tool call",
].slice(0, step + 1);
return (
<div className="space-y-2">
<AnimatePresence mode="popLayout">
{items.map((item) => (
<motion.div
key={item}
layout
initial={{ opacity: 0, x: 8 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -8 }}
transition={{ duration: 0.2 }}
className="rounded-lg border border-zinc-200 bg-white px-3 py-2 font-mono text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
>
{item}
</motion.div>
))}
</AnimatePresence>
</div>
);
}
export default function HooksVisualization({ title }: { title?: string }) {
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
const step = vis.currentStep;
const current = STEPS[step];
const activeHook = current.active;
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Hook Workbench"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm leading-relaxed text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200">
The loop stays boring on purpose: it calls <span className="font-mono">trigger_hooks(event)</span>, and the registry decides what extra logic runs.
</div>
<div className="grid gap-3 xl:grid-cols-[1.15fr_0.85fr]">
<Surface title="Hook registry" icon={<RadioTower size={20} />} active={step === 0 || activeHook !== null}>
<div className="grid gap-2 sm:grid-cols-2">
{HOOKS.map((hook) => (
<HookCard key={hook.id} hook={hook} active={activeHook === hook.id} />
))}
</div>
</Surface>
<Surface title="This turn" icon={<ScrollText size={20} />} active={step >= 1}>
<div className="space-y-3">
<TurnCard step={step} />
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
<div className="mb-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">Audit log</div>
<AuditLog step={step} />
</div>
</div>
</Surface>
</div>
<div className="mt-4 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-relaxed text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
Beginner rule: adding behavior means registering a callback, not editing the core model-tool-result loop.
</div>
<StepControls
className="mt-4"
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={current.title}
stepDescription={current.desc}
/>
</div>
</section>
);
}

View File

@@ -193,6 +193,36 @@ const STEPS = [
},
];
const COMPRESSION_LAYERS = [
{
label: "Micro",
full: "MICRO-COMPACT",
trigger: "old tool_result",
action: "shrink bulky outputs",
step: 3,
classes:
"border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-200",
},
{
label: "Auto",
full: "AUTO-COMPACT",
trigger: "token threshold",
action: "summarize the conversation",
step: 5,
classes:
"border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-200",
},
{
label: "Manual",
full: "/compact",
trigger: "user command",
action: "keep one compact summary",
step: 6,
classes:
"border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200",
},
];
export default function ContextCompact({ title }: { title?: string }) {
const {
currentStep,
@@ -222,17 +252,17 @@ export default function ContextCompact({ title }: { title?: string }) {
</h2>
<div
className="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900"
className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900 sm:p-6"
style={{ minHeight: 500 }}
>
<div className="flex gap-6">
<div className="grid gap-5 lg:grid-cols-[140px_1fr]">
{/* Token Window (tall vertical bar on the left) */}
<div className="flex flex-col items-center">
<div className="min-w-0 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"
className="relative w-20 max-w-full overflow-hidden rounded-xl border-2 border-zinc-300 bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 sm:w-24"
style={{ height: WINDOW_HEIGHT }}
>
{/* Blocks stacked from bottom up */}
@@ -293,14 +323,14 @@ export default function ContextCompact({ title }: { title?: string }) {
</div>
{/* Right side: state display and compression stage */}
<div className="flex flex-1 flex-col justify-between">
<div className="min-w-0">
{/* Top: horizontal token bar */}
<div>
<div className="mb-1 flex items-center justify-between">
<div className="mb-1 flex flex-col gap-1 sm:flex-row sm:items-center sm: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">
<span className="break-words font-mono text-xs text-zinc-500 dark:text-zinc-400">
{state.tokenCount.toLocaleString()} / {MAX_TOKENS.toLocaleString()}
</span>
</div>
@@ -314,7 +344,7 @@ export default function ContextCompact({ title }: { title?: string }) {
</div>
{/* Message type legend */}
<div className="mt-4 flex items-center gap-4">
<div className="mt-4 flex flex-wrap items-center gap-3">
<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>
@@ -329,6 +359,37 @@ export default function ContextCompact({ title }: { title?: string }) {
</div>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
{COMPRESSION_LAYERS.map((layer) => {
const reached = currentStep >= layer.step;
const active = state.compressionLabel === layer.full;
return (
<motion.div
key={layer.full}
layout
animate={active ? { y: [0, -2, 0] } : { y: 0 }}
transition={{ duration: 0.8, repeat: active ? Infinity : 0 }}
className={`min-w-0 rounded-lg border p-3 transition-colors ${
reached
? layer.classes
: "border-zinc-200 bg-zinc-50 text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800/70 dark:text-zinc-400"
}`}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold">{layer.label}</span>
<span className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-[10px] dark:bg-zinc-900/60">
{reached ? "used" : "waiting"}
</span>
</div>
<div className="mt-2 space-y-1 text-[11px] leading-snug">
<div className="break-words font-mono">{layer.trigger}</div>
<div className="break-words opacity-80">{layer.action}</div>
</div>
</motion.div>
);
})}
</div>
{/* Highlight old tool_results at step 2 */}
<AnimatePresence>
{currentStep === 2 && (
@@ -336,12 +397,12 @@ export default function ContextCompact({ title }: { title?: string }) {
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"
className="mt-3 rounded-lg 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">
<div className="text-[11px] leading-snug text-amber-600 dark:text-amber-400">
File contents, command outputs, search results -- each one is thousands of tokens.
</div>
</motion.div>
@@ -374,7 +435,7 @@ export default function ContextCompact({ title }: { title?: string }) {
}`}>
{state.compressionLabel}
</div>
<div className={`mt-1 text-xs ${
<div className={`mt-1 text-xs leading-snug ${
currentStep === 3
? "text-amber-500 dark:text-amber-400"
: currentStep === 5
@@ -396,35 +457,21 @@ export default function ContextCompact({ title }: { title?: string }) {
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="mt-4 space-y-2"
className="mt-4 grid gap-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>
{COMPRESSION_LAYERS.map((layer, index) => (
<div
key={`summary-${layer.full}`}
className={`flex flex-col gap-1 rounded px-3 py-2 sm:flex-row sm:items-center sm:justify-between ${layer.classes}`}
>
<span className="break-words text-xs">
Stage {index + 1}: {layer.label} -- {layer.action}
</span>
<span className="shrink-0 font-mono text-[10px] opacity-80">
{layer.trigger}
</span>
</div>
))}
</motion.div>
)}
</div>

View File

@@ -1,494 +1,214 @@
"use client";
import { useMemo } from "react";
import { motion } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { AnimatePresence, motion } from "framer-motion";
import { CheckCircle2, ClipboardList, FileJson, LockKeyhole, PlayCircle } from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useDarkMode, useSvgPalette } from "@/hooks/useDarkMode";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
type TaskStatus = "pending" | "in_progress" | "completed" | "blocked";
type Status = "blocked" | "ready" | "active" | "done";
interface TaskNode {
interface TaskCard {
id: string;
label: string;
x: number;
y: number;
deps: string[];
}
interface StepInfo {
title: string;
description: string;
blockers: string[];
status: Status;
}
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 STEPS = [
{
title: "Tasks Become Files",
desc: "The agent writes work as task cards on disk, so the plan survives compaction and restarts.",
},
{
title: "Find the First Ready Card",
desc: "A task with no blockers is ready immediately. Everything else waits visibly.",
},
{
title: "Work One Card",
desc: "The active task is not just text in the model's head; it has a durable status.",
},
{
title: "Completion Unlocks Dependents",
desc: "When T1 is done, the cards that depended on T1 become ready.",
},
{
title: "Parallel Ready Work",
desc: "T2 and T3 can run independently, while T4 still waits for both.",
},
{
title: "All Blockers Cleared",
desc: "Once T2 and T3 are done, T4 moves from waiting to active.",
},
{
title: "Board Resolved",
desc: "Every card reaches done. The dependency idea is visible without drawing a graph.",
},
] as const;
const BASE_TASKS = [
{ id: "T1", title: "Set up database", blockers: [] },
{ id: "T2", title: "Add API routes", blockers: ["T1"] },
{ id: "T3", title: "Build auth module", blockers: ["T1"] },
{ id: "T4", title: "Integration pass", blockers: ["T2", "T3"] },
{ id: "T5", title: "Deploy", blockers: ["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",
],
function taskStatus(id: string, step: number): Status {
const table: Record<string, Status[]> = {
T1: ["ready", "ready", "active", "done", "done", "done", "done"],
T2: ["blocked", "blocked", "blocked", "ready", "active", "done", "done"],
T3: ["blocked", "blocked", "blocked", "ready", "active", "done", "done"],
T4: ["blocked", "blocked", "blocked", "blocked", "blocked", "active", "done"],
T5: ["blocked", "blocked", "blocked", "blocked", "blocked", "blocked", "done"],
};
return statusMap[taskId]?.[step] ?? "pending";
return table[id]?.[step] ?? "blocked";
}
function isEdgeActive(fromId: string, toId: string, step: number): boolean {
const fromStatus = getTaskStatus(fromId, step);
const toStatus = getTaskStatus(toId, step);
function getTasks(step: number): TaskCard[] {
return BASE_TASKS.map((task) => ({ ...task, status: taskStatus(task.id, step) }));
}
function statusClass(status: Status): string {
if (status === "done") return "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200";
if (status === "active") return "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200";
if (status === "ready") return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200";
return "border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
}
function statusIcon(status: Status) {
if (status === "done") return <CheckCircle2 size={15} />;
if (status === "active") return <PlayCircle size={15} />;
if (status === "ready") return <ClipboardList size={15} />;
return <LockKeyhole size={15} />;
}
function TaskCardView({ task }: { task: TaskCard }) {
return (
fromStatus === "completed" &&
(toStatus === "in_progress" || toStatus === "completed")
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97 }}
transition={{ duration: 0.22 }}
className={cn("rounded-md border p-3 shadow-sm", statusClass(task.status))}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="font-mono text-xs font-semibold">{task.id}</div>
<div className="flex items-center gap-1 text-[11px] font-semibold">
{statusIcon(task.status)}
{task.status}
</div>
</div>
<div className="text-sm font-semibold leading-snug">{task.title}</div>
<div className="mt-2 flex flex-wrap gap-1">
{task.blockers.length === 0 ? (
<span className="rounded bg-white/70 px-1.5 py-0.5 text-[10px] dark:bg-zinc-950/30">
no blockers
</span>
) : (
task.blockers.map((blocker) => (
<span key={blocker} className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-[10px] dark:bg-zinc-950/30">
waits for {blocker}
</span>
))
)}
</div>
</motion.div>
);
}
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}`;
function Lane({
title,
subtitle,
tasks,
}: {
title: string;
subtitle: string;
tasks: TaskCard[];
}) {
return (
<div className="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-3">
<div className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">{title}</div>
<div className="text-[11px] text-zinc-500 dark:text-zinc-400">{subtitle}</div>
</div>
<div className="space-y-2">
<AnimatePresence mode="popLayout">
{tasks.length > 0 ? (
tasks.map((task) => <TaskCardView key={`${task.id}-${task.status}`} task={task} />)
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="rounded-md border border-dashed border-zinc-300 px-3 py-6 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"
>
empty
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
export default function TaskSystem({ title }: { title?: string }) {
const {
currentStep,
totalSteps,
next,
prev,
reset,
isPlaying,
toggleAutoPlay,
} = useSteppedVisualization({ totalSteps: 7, autoPlayInterval: 2500 });
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
const step = vis.currentStep;
const tasks = getTasks(step);
const current = STEPS[step];
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];
const blocked = tasks.filter((task) => task.status === "blocked");
const ready = tasks.filter((task) => task.status === "ready");
const active = tasks.filter((task) => task.status === "active");
const done = tasks.filter((task) => task.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 || "Task Dependency Graph"}
{title || "Task Board Dependencies"}
</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 className="mb-4 flex flex-col gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<FileJson size={16} />
.tasks board
</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 className="grid grid-cols-4 gap-2 text-center text-xs">
<div className="rounded bg-zinc-100 px-2 py-1 dark:bg-zinc-900">{blocked.length} blocked</div>
<div className="rounded bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">{ready.length} ready</div>
<div className="rounded bg-blue-100 px-2 py-1 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">{active.length} active</div>
<div className="rounded bg-emerald-100 px-2 py-1 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">{done.length} done</div>
</div>
</div>
<div className="grid gap-3 lg:grid-cols-4">
<Lane title="Waiting" subtitle="blocked by another card" tasks={blocked} />
<Lane title="Ready" subtitle="can be claimed now" tasks={ready} />
<Lane title="Working" subtitle="currently in progress" tasks={active} />
<Lane title="Done" subtitle="unlocks dependents" tasks={done} />
</div>
<div className="mt-4 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs leading-relaxed text-blue-800 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-200">
A dependency is not an arrow students must trace. It is a visible blocker badge on the card.
</div>
<StepControls
className="mt-4"
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={current.title}
stepDescription={current.desc}
/>
</div>
<StepControls
currentStep={currentStep}
totalSteps={totalSteps}
onPrev={prev}
onNext={next}
onReset={reset}
isPlaying={isPlaying}
onToggleAutoPlay={toggleAutoPlay}
stepTitle={stepInfo.title}
stepDescription={stepInfo.description}
/>
</section>
);
}

View File

@@ -1,392 +1,237 @@
"use client";
import { motion, AnimatePresence } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { AnimatePresence, motion } from "framer-motion";
import { Inbox, MessageSquareText, UsersRound } from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSvgPalette } from "@/hooks/useDarkMode";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
// -- Layout constants --
const SVG_W = 560;
const SVG_H = 340;
const AGENT_R = 40;
type AgentId = "lead" | "coder" | "reviewer";
// 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)!;
interface Mail {
id: string;
from: AgentId;
to: AgentId;
subject: string;
body: string;
appearsAt: number;
consumedAt?: number;
}
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." },
const AGENTS: { id: AgentId; label: string; role: string }[] = [
{ id: "lead", label: "Lead", role: "splits work and reads results" },
{ id: "coder", label: "Coder", role: "implements one slice" },
{ id: "reviewer", label: "Reviewer", role: "checks the result" },
];
// 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;
const MAIL: Mail[] = [
{
id: "assign",
from: "lead",
to: "coder",
subject: "Build login UI",
body: "Please implement the login form and report back.",
appearsAt: 1,
consumedAt: 2,
},
{
id: "result",
from: "coder",
to: "reviewer",
subject: "Login UI done",
body: "Files changed, ready for review.",
appearsAt: 4,
consumedAt: 5,
},
{
id: "feedback",
from: "reviewer",
to: "lead",
subject: "Review passed",
body: "No blockers. One small polish note.",
appearsAt: 5,
},
];
const STEPS = [
{
title: "A Team Is Mailboxes",
desc: "Each agent has its own inbox file. The team does not need shared memory to coordinate.",
},
{
title: "Lead Drops a Card",
desc: "Assigning work means appending a message to the coder's inbox.",
},
{
title: "Coder Reads Before Thinking",
desc: "Before its next model call, the coder drains its inbox and turns messages into context.",
},
{
title: "Coder Works Alone",
desc: "The coder now runs its own loop. The lead does not have to hold the full context.",
},
{
title: "Result Becomes Mail",
desc: "The coder sends a result card to the reviewer through the same mailbox mechanism.",
},
{
title: "Reviewer Sends Feedback",
desc: "Review feedback is just another card. The lead reads it from its inbox.",
},
{
title: "Files Are the Coordination Layer",
desc: "The whole team is inspectable as append-only inbox files: lead.jsonl, coder.jsonl, reviewer.jsonl.",
},
] as const;
function visibleMail(agent: AgentId, step: number) {
return MAIL.filter((mail) => mail.to === agent && mail.appearsAt <= step && (mail.consumedAt === undefined || step < mail.consumedAt));
}
// 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;
function agentState(agent: AgentId, step: number): "waiting" | "reading" | "working" | "reviewing" | "done" {
if (agent === "lead" && step === 1) return "working";
if (agent === "coder" && step === 2) return "reading";
if (agent === "coder" && (step === 3 || step === 4)) return "working";
if (agent === "reviewer" && step === 5) return "reviewing";
if (agent === "lead" && step >= 5) return "reading";
if (step === 6) return "done";
return "waiting";
}
// 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;
}) {
function stateClass(state: ReturnType<typeof agentState>) {
if (state === "working") return "border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30";
if (state === "reading" || state === "reviewing") return "border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30";
if (state === "done") return "border-emerald-300 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30";
return "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900";
}
function MailCard({ mail }: { mail: Mail }) {
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",
}}
<motion.div
layout
initial={{ opacity: 0, y: 8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.98 }}
transition={{ duration: 0.22 }}
className="rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-900 shadow-sm dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
>
<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>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="font-mono text-[11px] font-semibold">{mail.from} -&gt; {mail.to}</span>
<MessageSquareText size={14} />
</div>
<div className="text-sm font-semibold leading-snug">{mail.subject}</div>
<div className="mt-1 text-xs leading-relaxed opacity-85">{mail.body}</div>
</motion.div>
);
}
// 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);
function AgentPanel({ agent, step }: { agent: (typeof AGENTS)[number]; step: number }) {
const state = agentState(agent.id, step);
const inbox = visibleMail(agent.id, step);
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 }}
/>
<div className={cn("rounded-lg border p-3 transition-colors", stateClass(state))}>
<div className="mb-3 flex items-start justify-between gap-2">
<div>
<div className="text-base font-bold text-zinc-900 dark:text-zinc-100">{agent.label}</div>
<div className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">{agent.role}</div>
</div>
<span className="rounded-md bg-white px-2 py-1 text-[11px] font-semibold capitalize text-zinc-600 shadow-sm dark:bg-zinc-900 dark:text-zinc-300">
{state}
</span>
</div>
<div className="rounded-md border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<Inbox size={15} />
{agent.id}.jsonl
</div>
<div className="min-h-[118px] space-y-2">
<AnimatePresence mode="popLayout">
{inbox.length > 0 ? (
inbox.map((mail) => <MailCard key={mail.id} mail={mail} />)
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="rounded-md border border-dashed border-zinc-300 px-3 py-8 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"
>
inbox empty
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}
function ActivityLog({ step }: { step: number }) {
const items = [
"team config creates lead, coder, reviewer",
"lead appends task card to coder.jsonl",
"coder drains inbox before model call",
"coder works in its own loop",
"coder appends result to reviewer.jsonl",
"reviewer appends feedback to lead.jsonl",
"all coordination remains visible on disk",
].slice(0, step + 1);
return (
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<UsersRound size={16} />
What changed
</div>
<div className="space-y-2">
{items.map((item) => (
<motion.div
key={item}
initial={{ opacity: 0, x: 8 }}
animate={{ opacity: 1, x: 0 }}
className="rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
>
{item}
</motion.div>
))}
</div>
</div>
);
}
export default function AgentTeams({ title }: { title?: string }) {
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
const step = vis.currentStep;
const palette = useSvgPalette();
const current = STEPS[step];
return (
<section className="space-y-4">
<section className="min-h-[500px] 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 className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="grid gap-3 xl:grid-cols-[1fr_1fr_1fr_0.9fr]">
{AGENTS.map((agent) => (
<AgentPanel key={agent.id} agent={agent} step={step} />
))}
<ActivityLog step={step} />
</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>
<StepControls
className="mt-4"
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={current.title}
stepDescription={current.desc}
/>
</div>
</section>
);

View File

@@ -0,0 +1,392 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { BookOpen, CheckCircle2, FileText, Inbox, Search, Sparkles } from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
type MemoryType = "feedback" | "project" | "reference";
interface MemoryFile {
id: string;
type: MemoryType;
title: string;
filename: string;
description: string;
body: string;
relevant?: boolean;
}
const MEMORY_FILES: MemoryFile[] = [
{
id: "visual-preference",
type: "feedback",
title: "Beginner visual preference",
filename: "lcc_visual_preference.md",
description: "Use concrete mental models for LCC web pages.",
body: "Prefer cards, boards, shelves, and workbenches over abstract flowcharts.",
relevant: true,
},
{
id: "project-path",
type: "project",
title: "LCC web paths",
filename: "lcc_web_paths.md",
description: "Web app reads root lesson folders and generated JSON.",
body: "Build from web/, extract content from s01-s20 lesson directories.",
},
{
id: "test-command",
type: "reference",
title: "Verification commands",
filename: "lcc_test_commands.md",
description: "Useful smoke checks for the course website.",
body: "Run npm run build, then browser-check /zh/s09 and /zh/s20.",
},
];
const STEPS = [
{
title: "A Fact Worth Keeping",
desc: "The user says something that should survive future sessions.",
},
{
title: "Stamp It After the Turn",
desc: "Memory extraction happens after useful work, so the main loop stays focused.",
},
{
title: "Write One Memory File",
desc: "The durable detail goes into a Markdown file with a readable title and metadata.",
},
{
title: "Update the Catalog",
desc: "MEMORY.md is the cheap catalog: short enough to keep nearby.",
},
{
title: "A Future Request Arrives",
desc: "Later, the agent sees a new request and the catalog, not the whole library.",
},
{
title: "Catalog Picks One",
desc: "Selection chooses the one memory file that is relevant now.",
},
{
title: "Build the Reading Stack",
desc: "Only the selected memory joins the current request before the model call.",
},
{
title: "Continuity Without Clutter",
desc: "The answer reflects old context while unrelated memories stay on the shelf.",
},
] as const;
function typeClass(type: MemoryType): string {
if (type === "feedback") return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200";
if (type === "project") return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200";
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200";
}
function Surface({
title,
icon,
active,
children,
className,
}: {
title: string;
icon: React.ReactNode;
active: boolean;
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"min-w-0 rounded-lg border p-4 transition-colors",
active
? "border-violet-300 bg-violet-50 dark:border-violet-800 dark:bg-violet-950/30"
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900",
className
)}
>
<div className="mb-4 flex min-w-0 items-center gap-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
<span
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
active
? "bg-violet-500 text-white"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
)}
>
{icon}
</span>
<span className="min-w-0 break-words">{title}</span>
</div>
{children}
</div>
);
}
function QuoteCard({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-lg border border-zinc-200 bg-white p-4 text-lg leading-relaxed text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200">
{children}
</div>
);
}
function CatalogRow({ file, visible, selected }: { file: MemoryFile; visible: boolean; selected: boolean }) {
if (!visible) return null;
return (
<motion.div
layout
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
"min-w-0 rounded-lg border p-3",
selected
? "border-violet-300 bg-violet-50 dark:border-violet-800 dark:bg-violet-950/40"
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
)}
>
<div className="mb-1 flex min-w-0 items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{file.title}
</div>
<span className={cn("shrink-0 rounded px-2 py-0.5 text-[11px] font-semibold", typeClass(file.type))}>
{file.type}
</span>
</div>
<div className="line-clamp-2 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
{file.description}
</div>
<div className="mt-2 truncate font-mono text-[11px] text-zinc-400">
{file.filename}
</div>
</motion.div>
);
}
function MemoryDetail({ file, selected }: { file: MemoryFile; selected: boolean }) {
return (
<motion.div
key={`${file.id}-${selected}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
"rounded-xl border p-4 shadow-sm",
selected
? "border-violet-300 bg-violet-50 dark:border-violet-800 dark:bg-violet-950/40"
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
)}
>
<div className="mb-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
<div className="min-w-0">
<div className="break-words text-base font-bold text-zinc-900 dark:text-zinc-100">
{file.title}
</div>
<div className="mt-1 truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
{file.filename}
</div>
</div>
{selected && (
<span className="flex shrink-0 items-center gap-1 rounded-full bg-violet-500 px-2.5 py-1 text-xs font-semibold text-white">
<CheckCircle2 size={13} />
selected
</span>
)}
</div>
<div className="rounded-lg bg-white p-3 text-sm leading-relaxed text-zinc-700 dark:bg-zinc-900 dark:text-zinc-200">
{file.body}
</div>
</motion.div>
);
}
function EmptyState({ label }: { label: string }) {
return (
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
{label}
</div>
);
}
export default function MemoryVisualization({ title }: { title?: string }) {
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
const step = vis.currentStep;
const current = STEPS[step];
const selectedFile = MEMORY_FILES[0];
const catalogVisible = step >= 3;
const futureVisible = step >= 4;
const selected = step >= 5;
const injected = step >= 6;
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Memory Library"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-4 grid gap-2 text-sm sm:grid-cols-3">
{["learn", "catalog", "recall"].map((label, index) => {
const active =
(index === 0 && step <= 2) ||
(index === 1 && (step === 3 || selected)) ||
(index === 2 && futureVisible);
return (
<div
key={label}
className={cn(
"rounded-lg px-3 py-2 font-medium capitalize",
active
? "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-200"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
)}
>
{index + 1}. {label}
</div>
);
})}
</div>
<div className="grid gap-3 xl:grid-cols-2">
<Surface title="Session A: learn" icon={<Inbox size={20} />} active={step <= 2}>
<div className="space-y-3">
<QuoteCard>"Please keep LCC pages concrete for beginners."</QuoteCard>
<AnimatePresence>
{step >= 1 && (
<motion.div
key="stamp"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm leading-relaxed text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200"
>
<div className="mb-1 text-base font-semibold">Memory extractor stamp</div>
Save a durable preference after the useful work is done.
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{step >= 2 && <MemoryDetail file={selectedFile} selected={false} />}
</AnimatePresence>
</div>
</Surface>
<Surface title="Session B: recall" icon={selected ? <Search size={20} /> : <Sparkles size={20} />} active={futureVisible}>
<div className="space-y-3">
{!futureVisible && <EmptyState label="future request has not arrived" />}
{futureVisible && <QuoteCard>"Continue improving the web lesson visuals."</QuoteCard>}
{selected && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-lg border border-violet-200 bg-violet-50 p-4 text-sm leading-relaxed text-violet-900 dark:border-violet-900 dark:bg-violet-950/40 dark:text-violet-200"
>
Catalog search selects <span className="font-mono">lcc_visual_preference.md</span>
</motion.div>
)}
{injected && (
<div className="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-3 text-base font-semibold text-zinc-900 dark:text-zinc-100">
Reading stack before LLM
</div>
<div className="grid gap-2">
<div className="rounded-lg bg-zinc-100 px-3 py-2 text-sm dark:bg-zinc-800">current request</div>
<div className="rounded-lg bg-violet-100 px-3 py-2 text-sm text-violet-800 dark:bg-violet-900/30 dark:text-violet-200">
selected memory detail
</div>
{step >= 7 && (
<div className="rounded-lg bg-emerald-100 px-3 py-2 text-sm text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200">
answer keeps the user's preference
</div>
)}
</div>
</div>
)}
</div>
</Surface>
</div>
<Surface
title=".memory library"
icon={<BookOpen size={20} />}
active={catalogVisible || selected}
className="mt-3"
>
<div className="grid min-w-0 gap-3 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="min-w-0 rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<FileText size={16} />
MEMORY.md catalog
</div>
<div className="space-y-2">
{MEMORY_FILES.map((file, index) => (
<CatalogRow
key={file.id}
file={file}
visible={catalogVisible && (index === 0 || step >= 4)}
selected={selected && file.relevant === true}
/>
))}
{!catalogVisible && <EmptyState label="catalog has not been rebuilt yet" />}
</div>
</div>
<div className="min-w-0">
<div className="mb-3 text-sm font-semibold text-zinc-500 dark:text-zinc-400">
Memory file preview
</div>
{step >= 2 ? (
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.15fr)_minmax(220px,0.85fr)]">
<MemoryDetail file={selectedFile} selected={selected} />
<div className="space-y-2">
{MEMORY_FILES.slice(1).map((file) => (
<div
key={file.id}
className="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900"
>
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="min-w-0 truncate text-sm font-semibold text-zinc-800 dark:text-zinc-100">
{file.title}
</div>
<span className={cn("shrink-0 rounded px-2 py-0.5 text-[11px] font-semibold", typeClass(file.type))}>
not loaded
</span>
</div>
<div className="mt-1 line-clamp-2 text-xs text-zinc-500 dark:text-zinc-400">
{file.description}
</div>
</div>
))}
</div>
</div>
) : (
<EmptyState label="no files on the shelf yet" />
)}
</div>
</div>
</Surface>
<div className="mt-4 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-relaxed text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
Beginner rule: the catalog stays cheap and readable; full memory files are borrowed only when the current request needs them.
</div>
<StepControls
className="mt-4"
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={current.title}
stepDescription={current.desc}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,260 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Boxes, Brain, CheckCircle2, FileText, KeyRound, Library, Rocket, Wrench } from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
const STEPS = [
{
title: "Runtime State Arrives",
desc: "The prompt is not a fixed paragraph; it starts from workspace, tools, memory, and skills.",
mode: "state",
},
{
title: "Section Shelf Selects Owners",
desc: "Each subsystem owns one prompt section, so a bad rule has a place to debug.",
mode: "sections",
},
{
title: "Context Key Checks the Cache",
desc: "The same runtime state produces the same deterministic cache key.",
mode: "cache-miss",
},
{
title: "Prompt Is Assembled",
desc: "Selected sections are joined into one system prompt that the LLM can read.",
mode: "assemble",
},
{
title: "Same Key Reuses the Prompt",
desc: "If nothing changed, the runtime skips assembly and reuses the cached prompt.",
mode: "cache-hit",
},
{
title: "LLM Sees the Built Prompt",
desc: "The model receives a traceable product of runtime state, not a stale hardcoded string.",
mode: "llm",
},
] as const;
const SOURCES = [
{ id: "workspace", label: "workspace", value: "/repo", icon: <Boxes size={16} />, tone: "blue" },
{ id: "tools", label: "tools", value: "bash, read_file", icon: <Wrench size={16} />, tone: "emerald" },
{ id: "memory", label: "memory", value: "enabled", icon: <Brain size={16} />, tone: "amber" },
{ id: "skills", label: "skills", value: "code-review", icon: <Library size={16} />, tone: "violet" },
] as const;
const SECTIONS = [
{ id: "identity", title: "identity", body: "You are a helpful coding agent.", owner: "core" },
{ id: "tools", title: "tools", body: "Available tools: bash, read_file.", owner: "tool registry" },
{ id: "workspace", title: "workspace", body: "Current workspace: /repo.", owner: "runtime" },
{ id: "memory", title: "memory + skills", body: "Load memory index and code-review skill.", owner: "context loader" },
] as const;
type StepMode = (typeof STEPS)[number]["mode"];
type Tone = "blue" | "emerald" | "amber" | "violet" | "zinc";
function toneClass(tone: Tone, active = true) {
if (!active) return "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200";
if (tone === "blue") return "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200";
if (tone === "emerald") return "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200";
if (tone === "amber") return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200";
if (tone === "violet") return "border-violet-200 bg-violet-50 text-violet-800 dark:border-violet-900 dark:bg-violet-950/40 dark:text-violet-200";
return "border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200";
}
function Surface({
title,
icon,
active,
children,
}: {
title: string;
icon: React.ReactNode;
active: boolean;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"min-w-0 rounded-lg border p-4 transition-colors",
active
? "border-blue-300 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30"
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
)}
>
<div className="mb-4 flex min-w-0 items-center gap-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
<span
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
active ? "bg-blue-500 text-white" : "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
)}
>
{icon}
</span>
<span className="min-w-0 text-wrap">{title}</span>
</div>
{children}
</div>
);
}
function SourceCard({
source,
active,
}: {
source: (typeof SOURCES)[number];
active: boolean;
}) {
return (
<motion.div layout animate={active ? { y: -1 } : { y: 0 }} className={cn("rounded-lg border p-3", toneClass(source.tone as Tone, active))}>
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
{source.icon}
{source.label}
</div>
<code className="block min-w-0 whitespace-pre-wrap break-words rounded bg-white/70 px-2 py-1 font-mono text-xs dark:bg-zinc-950/30">
{source.value}
</code>
</motion.div>
);
}
function SectionCard({
section,
active,
assembled,
}: {
section: (typeof SECTIONS)[number];
active: boolean;
assembled: boolean;
}) {
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={cn(
"min-w-0 rounded-lg border p-3",
active || assembled ? toneClass("emerald") : "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
)}
>
<div className="mb-1 flex min-w-0 items-center justify-between gap-2">
<div className="min-w-0 break-words font-mono text-sm font-semibold leading-snug">{section.title}</div>
{(active || assembled) && <CheckCircle2 size={15} className="shrink-0" />}
</div>
<div className="mb-2 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">owner: {section.owner}</div>
<div className="text-sm leading-relaxed text-zinc-700 dark:text-zinc-200">{section.body}</div>
</motion.div>
);
}
function CachePanel({ mode }: { mode: StepMode }) {
const isHit = mode === "cache-hit";
const isActive = mode === "cache-miss" || mode === "cache-hit";
return (
<div className={cn("rounded-lg border p-3", toneClass(isHit ? "emerald" : "amber", isActive))}>
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
<KeyRound size={16} />
context key
</div>
<code className="block min-w-0 whitespace-pre-wrap break-words rounded bg-white/70 p-2 font-mono text-xs dark:bg-zinc-950/30">
json.dumps(context, sort_keys=True)
</code>
<div className="mt-2 text-sm font-semibold">{isHit ? "cache hit: reuse prompt" : isActive ? "cache miss: assemble sections" : "waiting for state"}</div>
</div>
);
}
function PromptPreview({ mode }: { mode: StepMode }) {
const assembled = mode === "assemble" || mode === "cache-hit" || mode === "llm";
if (!assembled) {
return (
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
prompt not built yet
</div>
);
}
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
{SECTIONS.map((section) => (
<div key={section.id} className="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-1 font-mono text-xs font-semibold text-blue-700 dark:text-blue-300">[{section.title}]</div>
<div className="text-sm leading-relaxed text-zinc-700 dark:text-zinc-200">{section.body}</div>
</div>
))}
<div className={cn("rounded-xl border p-4", toneClass(mode === "llm" ? "blue" : "zinc"))}>
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
<Rocket size={17} />
{mode === "llm" ? "sent to LLM" : "system prompt ready"}
</div>
<div className="text-sm leading-relaxed">Traceable prompt text, assembled from named runtime owners.</div>
</div>
</motion.div>
);
}
export default function SystemPromptVisualization({ title }: { title?: string }) {
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2600 });
const current = STEPS[vis.currentStep];
const mode = current.mode;
const sourceActive = mode === "state" || mode === "sections" || mode === "cache-miss";
const sectionsActive = mode === "sections" || mode === "assemble";
const promptActive = mode === "assemble" || mode === "cache-hit" || mode === "llm";
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{title || "Runtime Prompt Assembly"}</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="grid gap-3 xl:grid-cols-[0.95fr_1.1fr_0.95fr]">
<Surface title="Runtime context" icon={<Boxes size={20} />} active={sourceActive}>
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-1">
{SOURCES.map((source) => (
<SourceCard key={source.id} source={source} active={sourceActive} />
))}
</div>
</Surface>
<Surface title="Section shelf + cache" icon={<FileText size={20} />} active={sectionsActive || mode === "cache-miss" || mode === "cache-hit"}>
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-2">
{SECTIONS.map((section) => (
<SectionCard key={section.id} section={section} active={sectionsActive} assembled={promptActive} />
))}
</div>
<CachePanel mode={mode} />
</div>
</Surface>
<Surface title="System prompt" icon={<Rocket size={20} />} active={promptActive}>
<AnimatePresence mode="wait">
<PromptPreview key={mode} mode={mode} />
</AnimatePresence>
</Surface>
</div>
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-relaxed text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800/70 dark:text-zinc-300">
Beginner rule: system prompts should be assembled from named runtime facts, then cached only when those facts are unchanged.
</div>
<StepControls
className="mt-4"
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={current.title}
stepDescription={current.desc}
/>
</div>
</section>
);
}

View File

@@ -1,496 +1,362 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { useState, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowRight, CheckCircle2, ClipboardCheck, FileText, LockKeyhole, UserCheck } from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSvgPalette } from "@/hooks/useDarkMode";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
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." },
{
title: "Agree on a Small Form",
desc: "A protocol is just a shared card shape: request type, request_id, and the expected answer.",
},
{
title: "Leader Files a Request",
desc: "The leader writes a shutdown request card instead of force-stopping the teammate.",
},
{
title: "Teammate Chooses",
desc: "The teammate can approve or reject, and the request_id keeps the answer attached to the right request.",
},
{
title: "Clean Exit",
desc: "The approved response returns to the leader, and the 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." },
{
title: "Work Is Locked",
desc: "In plan mode, implementation stays locked until a plan card is approved.",
},
{
title: "Submit the Plan Card",
desc: "The teammate sends a concrete plan with the same request-response shape.",
},
{
title: "Approval Unlocks Action",
desc: "The leader approves the card, then implementation can begin.",
},
];
// 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;
const PROTOCOL_STATES: Record<Protocol, { label: string; detail: string }[]> = {
shutdown: [
{ label: "drafted", detail: "Lead creates request_id" },
{ label: "pending", detail: "card waits in inbox" },
{ label: "deciding", detail: "teammate replies" },
{ label: "closed", detail: "Lead matches response" },
],
plan: [
{ label: "locked", detail: "work cannot start" },
{ label: "submitted", detail: "plan card is sent" },
{ label: "approved", detail: "implementation unlocks" },
],
};
function ToggleButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
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>
<button
onClick={onClick}
className={cn(
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors active:scale-95",
active
? "bg-blue-500 text-white"
: "bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
)}
</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>
{children}
</button>
);
}
// Activation bar on a lifeline
function ActivationBar({
x,
yStart,
yEnd,
color,
function StateRail({
states,
currentStep,
}: {
x: number;
yStart: number;
yEnd: number;
color: string;
states: { label: string; detail: string }[];
currentStep: number;
}) {
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 }}
/>
<div className="mb-4 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
<div className="mb-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
Protocol state
</div>
<div className="break-words font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
request_id: {REQUEST_ID}
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
{states.map((state, index) => {
const active = index === currentStep;
const done = index < currentStep;
return (
<div key={state.label} className="flex min-w-0 flex-1 items-stretch gap-2">
<motion.div
layout
animate={active ? { y: [0, -2, 0] } : { y: 0 }}
transition={{ duration: 0.8, repeat: active ? Infinity : 0 }}
className={cn(
"min-w-0 flex-1 rounded-md border px-3 py-2 transition-colors",
active
? "border-blue-300 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950/35 dark:text-blue-200"
: done
? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200"
: "border-zinc-200 bg-white text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-400"
)}
>
<div className="break-words text-sm font-semibold">{state.label}</div>
<div className="mt-1 break-words text-[11px] leading-snug opacity-80">
{state.detail}
</div>
</motion.div>
{index < states.length - 1 && (
<div className="hidden items-center text-zinc-300 dark:text-zinc-600 sm:flex">
<ArrowRight size={15} />
</div>
)}
</div>
);
})}
</div>
</div>
);
}
function Desk({
title,
icon,
active,
children,
}: {
title: string;
icon: ReactNode;
active: boolean;
children: ReactNode;
}) {
return (
<div
className={cn(
"min-h-[260px] rounded-lg border p-3 transition-colors",
active
? "border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30"
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
)}
>
<div className="mb-3 flex min-w-0 items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<span
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md",
active
? "bg-blue-500 text-white"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
)}
>
{icon}
</span>
<span className="min-w-0 break-words">{title}</span>
</div>
{children}
</div>
);
}
function ProtocolCard({
title,
rows,
tone = "blue",
}: {
title: string;
rows: string[];
tone?: "blue" | "amber" | "emerald" | "zinc";
}) {
const toneClass = {
blue: "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200",
amber: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200",
emerald:
"border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200",
zinc: "border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200",
}[tone];
return (
<motion.div
layout
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: 0.25 }}
className={cn("rounded-md border p-3 shadow-sm", toneClass)}
>
<div className="break-words font-mono text-xs font-semibold">{title}</div>
<div className="mt-2 space-y-1 font-mono text-[11px] opacity-85">
{rows.map((row) => (
<div key={row} className="break-words">
{row}
</div>
))}
</div>
</motion.div>
);
}
function EmptyTray({ label }: { label: string }) {
return (
<div className="rounded-md border border-dashed border-zinc-300 px-3 py-5 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
{label}
</div>
);
}
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 vis = useSteppedVisualization({ totalSteps: steps.length, autoPlayInterval: 2500 });
const step = vis.currentStep;
const palette = useSvgPalette();
const switchProtocol = (p: Protocol) => {
setProtocol(p);
const switchProtocol = (value: Protocol) => {
setProtocol(value);
vis.reset();
};
const leftLabel = protocol === "shutdown" ? "Leader" : "Leader";
const rightLabel = protocol === "shutdown" ? "Teammate" : "Teammate";
const isPlan = protocol === "plan";
return (
<section className="space-y-4">
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "FSM Team Protocols"}
{title || "Team Protocol Cards"}
</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 className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-4 flex justify-center gap-2">
<ToggleButton active={!isPlan} onClick={() => switchProtocol("shutdown")}>
Shutdown
</ToggleButton>
<ToggleButton active={isPlan} onClick={() => switchProtocol("plan")}>
Plan Approval
</ToggleButton>
</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>
<StateRail states={PROTOCOL_STATES[protocol]} currentStep={step} />
{/* 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"
<div className="grid gap-3 lg:grid-cols-3">
<Desk
title="Leader desk"
icon={<UserCheck size={15} />}
active={(!isPlan && (step === 1 || step === 3)) || (isPlan && step === 2)}
>
<div className="space-y-3">
<AnimatePresence mode="popLayout">
{!isPlan && step >= 1 && (
<ProtocolCard
key="shutdown-request"
title="shutdown_request"
rows={[`request_id: ${REQUEST_ID}`, "target: teammate", "mode: polite"]}
tone={step >= 3 ? "zinc" : "blue"}
/>
)}
{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"
{!isPlan && step >= 3 && (
<ProtocolCard
key="shutdown-response"
title="shutdown_response"
rows={[`request_id: ${REQUEST_ID}`, "approve: true", "status: closed"]}
tone="emerald"
/>
)}
{/* 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}
{isPlan && step >= 2 && (
<ProtocolCard
key="plan-approved"
title="plan_approval_response"
rows={[`request_id: ${REQUEST_ID}`, "approve: true", "unlock: implementation"]}
tone="emerald"
/>
)}
</AnimatePresence>
{((!isPlan && step === 0) || (isPlan && step < 2)) && (
<EmptyTray label="waiting for a protocol card" />
)}
</div>
</Desk>
{/* Step 2: decision box on teammate lifeline */}
{step >= 2 && (
<DecisionBox
x={LIFELINE_RIGHT_X + 50}
y={ARROW_Y_START + ARROW_Y_GAP}
<Desk
title="Shared card shape"
icon={<ClipboardCheck size={15} />}
active={(!isPlan && step === 0) || (isPlan && step === 0)}
>
<div className="space-y-3">
<ProtocolCard
title="protocol fields"
rows={["type", "request_id", "payload", "response"]}
tone="amber"
/>
<div className="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
The key idea is correlation, not ceremony.
</div>
{isPlan && (
<div className="flex items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
<LockKeyhole size={14} />
implementation locked until approval
</div>
)}
</div>
</Desk>
<Desk
title="Teammate desk"
icon={isPlan ? <FileText size={15} /> : <CheckCircle2 size={15} />}
active={(!isPlan && step === 2) || (isPlan && step === 1)}
>
<div className="space-y-3">
<AnimatePresence mode="popLayout">
{!isPlan && step >= 2 && (
<ProtocolCard
key="teammate-decision"
title="decision card"
rows={[`request_id: ${REQUEST_ID}`, "choice: approve", step >= 3 ? "state: exited" : "state: deciding"]}
tone={step >= 3 ? "emerald" : "amber"}
/>
)}
{/* 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}
{isPlan && step >= 1 && (
<ProtocolCard
key="plan-card"
title="exit_plan_mode"
rows={[`request_id: ${REQUEST_ID}`, "1. edit module", "2. run tests", "3. report diff"]}
tone={step >= 2 ? "emerald" : "blue"}
/>
)}
{/* 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}
/>
</AnimatePresence>
{((!isPlan && step < 2) || (isPlan && step === 0)) && (
<EmptyTray label={isPlan ? "draft plan not submitted" : "no request received"} />
)}
</div>
</Desk>
</div>
<StepControls
className="mt-4"
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>
</section>
);

View File

@@ -1,465 +1,276 @@
"use client";
import { motion } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { AnimatePresence, motion } from "framer-motion";
import { CheckCircle2, ClipboardList, Hourglass, UserRoundCog } from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSvgPalette } from "@/hooks/useDarkMode";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
// -- FSM states and their layout positions (diamond: idle top, poll right, claim bottom, work left) --
type Phase = "idle" | "poll" | "claim" | "work";
type AgentPhase = "idle" | "polling" | "claiming" | "working" | "done";
type TaskStatus = "open" | "claimed" | "complete";
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;
id: string;
phase: AgentPhase;
timer: number;
task?: string;
}
function getAgentStates(step: number): AgentState[] {
const idle: AgentState = { phase: "idle", timerFill: 0, color: PHASE_COLORS.idle, taskClaim: null };
interface TaskState {
id: string;
title: string;
status: TaskStatus;
owner?: string;
}
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 }];
const STEPS = [
{
title: "Quiet Agents",
desc: "Autonomous agents start by waiting. The important mental model is a work board, not a central dispatcher.",
},
{
title: "Idle Timer Fills",
desc: "An agent watches its own idle timer. When it waits long enough, it decides to look for work.",
},
{
title: "Read the Board",
desc: "The agent polls the shared task board and looks for an open card.",
},
{
title: "Claim One Card",
desc: "Claiming writes the agent name onto one task, making ownership visible.",
},
{
title: "Work Independently",
desc: "The claimed task moves into the agent workspace. No coordinator has to babysit it.",
},
{
title: "Others Join In",
desc: "A second agent can claim a different card through the same simple habit.",
},
{
title: "Finish and Free Up",
desc: "Completed work goes back to the board as done, and the agent returns to waiting.",
},
{
title: "Self Organization",
desc: "Timers plus visible ownership let a small group organize itself without a manager loop.",
},
] as const;
const TASKS = [
{ id: "T1", title: "Fix auth bug" },
{ id: "T2", title: "Add rate limiter" },
{ id: "T3", title: "Write docs" },
{ id: "T4", title: "Clean tests" },
];
function getAgents(step: number): AgentState[] {
if (step === 0) {
return [
{ id: "A", phase: "idle", timer: 0.1 },
{ id: "B", phase: "idle", timer: 0 },
{ id: "C", phase: "idle", timer: 0 },
];
}
if (step === 1) {
return [
{ id: "A", phase: "idle", timer: 0.85 },
{ id: "B", phase: "idle", timer: 0.25 },
{ id: "C", phase: "idle", timer: 0 },
];
}
if (step === 2) {
return [
{ id: "A", phase: "polling", timer: 1 },
{ id: "B", phase: "idle", timer: 0.25 },
{ id: "C", phase: "idle", timer: 0 },
];
}
if (step === 3) {
return [
{ id: "A", phase: "claiming", timer: 0, task: "T1" },
{ id: "B", phase: "idle", timer: 0.45 },
{ id: "C", phase: "idle", timer: 0.1 },
];
}
if (step === 4) {
return [
{ id: "A", phase: "working", timer: 0, task: "T1" },
{ id: "B", phase: "idle", timer: 0.65 },
{ id: "C", phase: "idle", timer: 0.2 },
];
}
if (step === 5) {
return [
{ id: "A", phase: "working", timer: 0, task: "T1" },
{ id: "B", phase: "claiming", timer: 0, task: "T2" },
{ id: "C", phase: "idle", timer: 0.35 },
];
}
if (step === 6) {
return [
{ id: "A", phase: "done", timer: 0, task: "T1" },
{ id: "B", phase: "working", timer: 0, task: "T2" },
{ id: "C", phase: "idle", timer: 0.6 },
];
}
return [
{ id: "A", phase: "idle", timer: 0.15 },
{ id: "B", phase: "working", timer: 0, task: "T2" },
{ id: "C", phase: "claiming", timer: 0, task: "T3" },
];
}
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 getTasks(step: number): TaskState[] {
return TASKS.map((task) => {
if (task.id === "T1" && step >= 6) {
return { ...task, status: "complete", owner: "A" };
}
if (task.id === "T1" && step >= 3) {
return { ...task, status: "claimed", owner: "A" };
}
if (task.id === "T2" && step >= 5) {
return { ...task, status: "claimed", owner: "B" };
}
if (task.id === "T3" && step >= 7) {
return { ...task, status: "claimed", owner: "C" };
}
return { ...task, status: "open" };
});
}
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";
function phaseClass(phase: AgentPhase): string {
if (phase === "working") return "border-emerald-300 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30";
if (phase === "claiming" || phase === "polling") return "border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30";
if (phase === "done") return "border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30";
return "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900";
}
// 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` }}
/>
);
function statusClass(status: TaskStatus): string {
if (status === "complete") return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
if (status === "claimed") return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
return "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300";
}
// 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;
function AgentCard({ agent }: { agent: AgentState }) {
const timerPercent = Math.round(agent.timer * 100);
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>
<motion.div
layout
animate={agent.phase !== "idle" ? { y: [0, -2, 0] } : { y: 0 }}
transition={{ duration: 0.9, repeat: agent.phase !== "idle" ? Infinity : 0 }}
className={cn("rounded-lg border p-3 transition-colors", phaseClass(agent.phase))}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 text-sm font-bold text-white dark:bg-zinc-100 dark:text-zinc-900">
{agent.id}
</span>
<div>
<div className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">Agent {agent.id}</div>
<div className="text-[11px] capitalize text-zinc-500 dark:text-zinc-400">{agent.phase}</div>
</div>
</div>
{agent.phase === "done" ? (
<CheckCircle2 size={18} className="text-emerald-500" />
) : (
<UserRoundCog size={18} className="text-zinc-400" />
)}
</div>
<div className="mb-2 h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
<motion.div
className="h-full rounded-full bg-amber-400"
initial={{ width: 0 }}
animate={{ width: `${timerPercent}%` }}
transition={{ duration: 0.35 }}
/>
</div>
<div className="font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
{agent.task ? `task: ${agent.task}` : `idle timer: ${timerPercent}%`}
</div>
</motion.div>
);
}
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"];
const agents = getAgents(step);
const tasks = getTasks(step);
const current = STEPS[step];
return (
<section className="space-y-4">
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Autonomous Agent Cycle"}
{title || "Autonomous Work Board"}
</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 className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="grid gap-3 lg:grid-cols-[1fr_1.2fr]">
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<Hourglass size={16} />
Agents watch their own idle timer
</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 className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1">
{agents.map((agent) => (
<AgentCard key={`${agent.id}-${agent.phase}-${agent.task ?? "none"}-${step}`} agent={agent} />
))}
</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<ClipboardList size={16} />
Shared task board
</div>
<div className="grid gap-2 sm:grid-cols-2">
<AnimatePresence mode="popLayout">
{tasks.map((task) => (
<motion.div
layout
key={`${task.id}-${task.status}-${task.owner ?? "open"}`}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.2 }}
className="rounded-md border border-zinc-200 bg-white p-3 text-xs shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
>
<div className="mb-2 flex items-center justify-between gap-2">
<span className="font-mono font-semibold text-zinc-500 dark:text-zinc-400">{task.id}</span>
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-semibold", statusClass(task.status))}>
{task.status}
</span>
</div>
<div className="font-medium text-zinc-800 dark:text-zinc-100">{task.title}</div>
<div className="mt-2 font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
owner: {task.owner ?? "-"}
</div>
</motion.div>
))}
</AnimatePresence>
</div>
<div className="mt-3 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
Nobody assigns tasks directly; agents claim visible open cards when their timers wake them.
</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>
<StepControls
className="mt-4"
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={current.title}
stepDescription={current.desc}
/>
</div>
</section>
);

View File

@@ -0,0 +1,347 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Activity, AlertTriangle, Gauge, History, Repeat2, RotateCcw, ShieldCheck, TimerReset, Workflow } from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
const STEPS = [
{
title: "Normal Call Still Comes First",
desc: "The runtime starts with a regular LLM call and only enters recovery when a specific failure appears.",
mode: "normal",
},
{
title: "max_tokens Means Output Was Cut Off",
desc: "First recovery is to retry with a larger budget before adding any synthetic continuation message.",
mode: "max-tokens",
},
{
title: "prompt_too_long Means Context Must Shrink",
desc: "The runtime performs reactive compact once, then retries the same task with a smaller message list.",
mode: "prompt-too-long",
},
{
title: "429 Means Wait, Then Retry",
desc: "Rate limits use exponential backoff with jitter so retries do not stampede the provider.",
mode: "rate-limit",
},
{
title: "Repeated 529 Can Switch Models",
desc: "Provider overload increments RecoveryState and can move to a fallback model after repeated failures.",
mode: "overloaded",
},
{
title: "Recovered Calls Return to the Loop",
desc: "Each recovery path is bounded, inspectable, and eventually returns to the normal tool loop or exits cleanly.",
mode: "summary",
},
] as const;
const CASES = [
{
id: "max-tokens",
label: "max_tokens",
symptom: "model stopped mid-answer",
action: "8K -> 64K, retry same request",
state: "token escalated once",
tone: "amber",
},
{
id: "prompt-too-long",
label: "prompt_too_long",
symptom: "context too large",
action: "reactive_compact(messages), retry once",
state: "compact retry used",
tone: "orange",
},
{
id: "rate-limit",
label: "429",
symptom: "rate limited",
action: "backoff + jitter, max 10 retries",
state: "retry attempt counted",
tone: "blue",
},
{
id: "overloaded",
label: "529",
symptom: "provider overloaded",
action: "backoff; 3 consecutive -> fallback model",
state: "consecutive_529 tracked",
tone: "red",
},
] as const;
type StepMode = (typeof STEPS)[number]["mode"];
type CaseId = (typeof CASES)[number]["id"];
type Tone = "amber" | "orange" | "blue" | "red" | "emerald" | "zinc";
function toneClass(tone: Tone, active = true) {
if (!active) return "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200";
if (tone === "amber") return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200";
if (tone === "orange") return "border-orange-200 bg-orange-50 text-orange-800 dark:border-orange-900 dark:bg-orange-950/40 dark:text-orange-200";
if (tone === "blue") return "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200";
if (tone === "red") return "border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200";
if (tone === "emerald") return "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200";
return "border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200";
}
function activeCase(mode: StepMode): CaseId | null {
if (mode === "max-tokens") return "max-tokens";
if (mode === "prompt-too-long") return "prompt-too-long";
if (mode === "rate-limit") return "rate-limit";
if (mode === "overloaded") return "overloaded";
return null;
}
function Surface({
title,
icon,
active,
children,
}: {
title: string;
icon: React.ReactNode;
active: boolean;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"min-w-0 rounded-lg border p-4 transition-colors",
active
? "border-red-300 bg-red-50 dark:border-red-900 dark:bg-red-950/30"
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
)}
>
<div className="mb-4 flex min-w-0 items-center gap-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
<span
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
active ? "bg-red-500 text-white" : "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
)}
>
{icon}
</span>
<span className="min-w-0 text-wrap">{title}</span>
</div>
{children}
</div>
);
}
function CaseCard({
item,
active,
muted,
}: {
item: (typeof CASES)[number];
active: boolean;
muted: boolean;
}) {
return (
<motion.div
layout
animate={active ? { y: -1 } : { y: 0 }}
className={cn(
"min-w-0 rounded-lg border p-3",
active ? toneClass(item.tone as Tone) : "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900",
muted && "opacity-45"
)}
>
<div className="mb-2 flex min-w-0 items-center justify-between gap-2">
<div className="min-w-0 font-mono text-sm font-semibold">{item.label}</div>
{active && <AlertTriangle size={15} className="shrink-0" />}
</div>
<div className="text-sm leading-relaxed text-zinc-700 dark:text-zinc-200">{item.symptom}</div>
<div className="mt-2 rounded bg-white/70 px-2 py-1 text-xs leading-relaxed dark:bg-zinc-950/30">{item.action}</div>
</motion.div>
);
}
function RecoveryStatePanel({ mode }: { mode: StepMode }) {
const values = {
token: mode === "max-tokens" || mode === "summary" ? "64K used" : "8K",
compact: mode === "prompt-too-long" || mode === "summary" ? "used once" : "unused",
retry: mode === "rate-limit" || mode === "overloaded" || mode === "summary" ? "counting" : "0",
model: mode === "overloaded" ? "fallback ready" : "primary",
};
return (
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-1">
{[
["max_tokens", values.token],
["reactive_compact", values.compact],
["retry_attempt", values.retry],
["current_model", values.model],
].map(([label, value]) => (
<div key={label} className="min-w-0 rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-1 font-mono text-xs text-zinc-500 dark:text-zinc-400">{label}</div>
<div className="break-words text-sm font-semibold text-zinc-900 dark:text-zinc-100">{value}</div>
</div>
))}
</div>
);
}
function ActionPanel({ mode }: { mode: StepMode }) {
if (mode === "normal") {
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("rounded-xl border p-4", toneClass("emerald"))}>
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
<ShieldCheck size={17} />
normal tool loop
</div>
<div className="text-sm leading-relaxed">LLM succeeds, tool_use continues as usual.</div>
</motion.div>
);
}
if (mode === "max-tokens") {
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("amber"))}>
<div className="flex items-center gap-2 text-base font-semibold">
<Gauge size={17} />
escalate output budget
</div>
<div className="grid gap-2 sm:grid-cols-2">
<CodePill label="before" value="max_tokens=8000" />
<CodePill label="retry" value="max_tokens=64000" />
</div>
<div className="text-sm leading-relaxed">No fake "continue" user message on the first escalation.</div>
</motion.div>
);
}
if (mode === "prompt-too-long") {
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("orange"))}>
<div className="flex items-center gap-2 text-base font-semibold">
<History size={17} />
shrink context, retry once
</div>
<CodePill label="recovery" value="messages = reactive_compact(messages)" />
<div className="text-sm leading-relaxed">If it is still too long after compact, exit cleanly instead of looping forever.</div>
</motion.div>
);
}
if (mode === "rate-limit") {
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("blue"))}>
<div className="flex items-center gap-2 text-base font-semibold">
<TimerReset size={17} />
exponential backoff
</div>
<div className="grid grid-cols-3 gap-2 text-center text-xs font-semibold">
{["0.5s", "1s", "2s"].map((delay) => (
<div key={delay} className="rounded bg-white/70 px-2 py-2 dark:bg-zinc-950/30">{delay} + jitter</div>
))}
</div>
<div className="text-sm leading-relaxed">Wait before retrying so the provider has time to recover.</div>
</motion.div>
);
}
if (mode === "overloaded") {
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("red"))}>
<div className="flex items-center gap-2 text-base font-semibold">
<RotateCcw size={17} />
fallback model path
</div>
<CodePill label="state" value="consecutive_529 >= 3" />
<CodePill label="action" value="current_model = FALLBACK_MODEL_ID" />
</motion.div>
);
}
return (
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
{CASES.map((item) => (
<div key={item.id} className={cn("rounded-lg border p-3", toneClass(item.tone as Tone))}>
<div className="mb-1 text-sm font-semibold">{item.label}</div>
<div className="text-xs leading-relaxed opacity-80">{item.state}</div>
</div>
))}
<div className={cn("rounded-xl border p-4", toneClass("emerald"))}>
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
<Repeat2 size={17} />
continue or exit cleanly
</div>
<div className="text-sm leading-relaxed">Every path has a limit, then returns to the normal loop or stops with an explicit error.</div>
</div>
</motion.div>
);
}
function CodePill({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-lg bg-white/70 p-2 dark:bg-zinc-950/30">
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide opacity-70">{label}</div>
<code className="block min-w-0 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">{value}</code>
</div>
);
}
export default function ErrorRecoveryVisualization({ title }: { title?: string }) {
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2600 });
const current = STEPS[vis.currentStep];
const mode = current.mode;
const active = activeCase(mode);
const isSummary = mode === "summary";
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{title || "Error Recovery Paths"}</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="grid gap-3 xl:grid-cols-[1fr_0.9fr_1fr]">
<Surface title="Failure inbox" icon={<Activity size={20} />} active={mode !== "normal"}>
<div className="space-y-2">
<div className={cn("rounded-lg border p-3", toneClass("emerald", mode === "normal"))}>
<div className="mb-1 flex items-center gap-2 text-sm font-semibold">
<ShieldCheck size={15} />
success
</div>
<div className="text-sm leading-relaxed">No recovery needed; continue to tool loop.</div>
</div>
{CASES.map((item) => (
<CaseCard key={item.id} item={item} active={active === item.id || isSummary} muted={active !== null && active !== item.id && !isSummary} />
))}
</div>
</Surface>
<Surface title="RecoveryState" icon={<Workflow size={20} />} active={mode !== "normal"}>
<RecoveryStatePanel mode={mode} />
</Surface>
<Surface title="Recovery action" icon={<Repeat2 size={20} />} active>
<AnimatePresence mode="wait">
<ActionPanel key={mode} mode={mode} />
</AnimatePresence>
</Surface>
</div>
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-relaxed text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800/70 dark:text-zinc-300">
Beginner rule: do not blindly retry; classify the failure, run the smallest recovery, and track whether that recovery was already used.
</div>
<StepControls
className="mt-4"
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={current.title}
stepDescription={current.desc}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,248 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Bot, CalendarDays, CheckCircle2, Clock3, Database, Inbox } from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
const STEPS = [
{
title: "Make It Repeatable",
desc: "The user turns one normal prompt into a reusable schedule card.",
active: "composer",
},
{
title: "Store the Card",
desc: "The schedule lives in durable data, so it is not tied to the current chat turn.",
active: "ledger",
},
{
title: "Time Keeps Moving",
desc: "A tiny scheduler watches the clock while the agent can do other work.",
active: "clock",
},
{
title: "Copy Goes to the Queue",
desc: "When the cron expression matches, the scheduler puts a due copy in the queue.",
active: "queue",
},
{
title: "Run as a Normal Turn",
desc: "The queue processor hands the due prompt to the same agent loop beginners already know.",
active: "inbox",
},
{
title: "Keep the Original",
desc: "The result is recorded, and the schedule card remains ready for the next matching time.",
active: "done",
},
] as const;
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"];
function Panel({
title,
icon,
active,
children,
}: {
title: string;
icon: React.ReactNode;
active: boolean;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"min-h-[230px] rounded-lg border p-3 transition-colors",
active
? "border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30"
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
)}
>
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<span
className={cn(
"flex h-7 w-7 items-center justify-center rounded-md",
active
? "bg-blue-500 text-white"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
)}
>
{icon}
</span>
{title}
</div>
{children}
</div>
);
}
function ScheduleCard({
title,
subtitle,
tone = "blue",
}: {
title: string;
subtitle: string;
tone?: "blue" | "amber" | "emerald";
}) {
const toneClass = {
blue: "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200",
amber: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200",
emerald:
"border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200",
}[tone];
return (
<motion.div
layout
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: 0.25 }}
className={cn("rounded-md border p-3 shadow-sm", toneClass)}
>
<div className="font-mono text-xs font-semibold">{title}</div>
<div className="mt-1 text-xs opacity-80">{subtitle}</div>
</motion.div>
);
}
export default function CronSchedulerVisualization({ title }: { title?: string }) {
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
const step = vis.currentStep;
const current = STEPS[step];
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Cron Scheduler"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-4 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<CalendarDays size={16} />
Weekly clock
</div>
<motion.div
animate={step >= 2 ? { scale: [1, 1.08, 1] } : { scale: 1 }}
transition={{ duration: 1.1, repeat: step >= 2 && step <= 4 ? Infinity : 0 }}
className="rounded-md bg-white px-2 py-1 font-mono text-xs text-zinc-600 shadow-sm dark:bg-zinc-900 dark:text-zinc-300"
>
{step < 2 ? "08:59" : "09:00"}
</motion.div>
</div>
<div className="grid grid-cols-5 gap-2">
{DAYS.map((day, index) => (
<div
key={day}
className={cn(
"rounded-md border px-2 py-2 text-center text-xs font-medium",
step >= 2 && index === 2
? "border-amber-300 bg-amber-100 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-200"
: "border-zinc-200 bg-white text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-400"
)}
>
{day}
</div>
))}
</div>
</div>
<div className="grid gap-3 lg:grid-cols-3">
<Panel
title="Schedule book"
icon={<Database size={15} />}
active={current.active === "ledger" || current.active === "done"}
>
<div className="space-y-3">
{step === 0 && (
<ScheduleCard title="Draft prompt" subtitle="review open PR every weekday" />
)}
<AnimatePresence>
{step >= 1 && (
<ScheduleCard
title="0 9 * * 1-5"
subtitle="review open PR every weekday"
tone={step === 5 ? "emerald" : "blue"}
/>
)}
</AnimatePresence>
<div className="rounded-md border border-dashed border-zinc-300 px-3 py-4 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
{step >= 1 ? "stored schedules stay here" : "no saved schedule yet"}
</div>
</div>
</Panel>
<Panel
title="Due queue"
icon={<Clock3 size={15} />}
active={current.active === "clock" || current.active === "queue"}
>
<div className="space-y-3">
<div className="rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
watcher: {step >= 2 ? "running" : "waiting"}
</div>
<AnimatePresence>
{step >= 3 && step <= 4 && (
<ScheduleCard
title="due copy"
subtitle="same prompt, current timestamp"
tone="amber"
/>
)}
</AnimatePresence>
{step < 3 && (
<div className="rounded-md border border-dashed border-zinc-300 px-3 py-8 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
queue is empty
</div>
)}
{step === 5 && (
<ScheduleCard title="queue drained" subtitle="ready for next tick" tone="emerald" />
)}
</div>
</Panel>
<Panel
title="Agent inbox"
icon={<Inbox size={15} />}
active={current.active === "inbox" || current.active === "done"}
>
<div className="space-y-3">
<AnimatePresence>
{step >= 4 && (
<ScheduleCard
title="agent turn"
subtitle={step >= 5 ? "result appended" : "runs like a normal prompt"}
tone={step >= 5 ? "emerald" : "blue"}
/>
)}
</AnimatePresence>
<div className="flex items-center gap-2 rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
{step >= 5 ? <CheckCircle2 size={14} /> : <Bot size={14} />}
{step >= 5 ? "review summary saved" : "agent loop available"}
</div>
</div>
</Panel>
</div>
<StepControls
className="mt-4"
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={current.title}
stepDescription={current.desc}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,268 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { Cable, CheckCircle2, PlugZap, Search, Server, Wrench } from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
const STEPS = [
{
title: "Need a New Tool",
desc: "The agent starts with built-in tools, then notices this task needs an outside capability.",
active: "need",
},
{
title: "Plug In a Server",
desc: "MCP is easiest to picture as plugging a named toolbox into the agent workbench.",
active: "server",
},
{
title: "Read the Tool Labels",
desc: "The server advertises schemas, so the agent can see what each tool expects.",
active: "discover",
},
{
title: "Name the Tools Clearly",
desc: "Each external tool gets a namespaced label, which avoids collisions with built-ins.",
active: "belt",
},
{
title: "Use It Like Any Tool",
desc: "Once on the tool belt, the MCP tool follows the same call-and-result rhythm.",
active: "call",
},
{
title: "Result Comes Back",
desc: "The returned data is just another tool result for the next model turn.",
active: "result",
},
] as const;
const BUILT_INS = ["read_file", "edit_file", "bash"];
const SERVER_TOOLS = [
{ raw: "search", namespaced: "mcp__docs__search" },
{ raw: "fetch", namespaced: "mcp__docs__fetch" },
{ raw: "list_sections", namespaced: "mcp__docs__list_sections" },
];
function ToolChip({
label,
active,
external,
}: {
label: string;
active?: boolean;
external?: boolean;
}) {
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96 }}
transition={{ duration: 0.2 }}
className={cn(
"min-w-0 max-w-full break-all rounded-md border px-2 py-1.5 font-mono text-[11px] leading-snug",
active
? "border-blue-300 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-200"
: external
? "border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-200"
: "border-zinc-200 bg-white text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
)}
>
{label}
</motion.div>
);
}
function Shelf({
title,
icon,
active,
children,
}: {
title: string;
icon: React.ReactNode;
active: boolean;
children: React.ReactNode;
}) {
return (
<div
className={cn(
"rounded-lg border p-3 transition-colors",
active
? "border-emerald-300 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30"
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
)}
>
<div className="mb-3 flex min-w-0 items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<span
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
active
? "bg-emerald-500 text-white"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
)}
>
{icon}
</span>
<span className="min-w-0 break-words">{title}</span>
</div>
{children}
</div>
);
}
export default function McpToolsVisualization({ title }: { title?: string }) {
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
const step = vis.currentStep;
const current = STEPS[step];
const connected = step >= 1;
const discovered = step >= 2;
const namespaced = step >= 3;
const called = step >= 4;
const returned = step >= 5;
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "MCP Tool Bridge"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="grid gap-3 lg:grid-cols-[1fr_1.2fr_1fr]">
<Shelf
title="Built-in belt"
icon={<Wrench size={15} />}
active={current.active === "need"}
>
<div className="space-y-2">
{BUILT_INS.map((tool) => (
<ToolChip key={tool} label={tool} />
))}
<div className="rounded-md border border-dashed border-zinc-300 px-3 py-5 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
limited to local skills
</div>
</div>
</Shelf>
<div className="space-y-3">
<Shelf
title="External toolbox"
icon={<Server size={15} />}
active={current.active === "server" || current.active === "discover"}
>
<div className="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs dark:border-zinc-700 dark:bg-zinc-800">
<div className="flex items-center gap-2 font-mono text-zinc-700 dark:text-zinc-200">
<Cable size={14} />
docs-server
</div>
<span
className={cn(
"rounded px-2 py-0.5 text-[10px] font-semibold",
connected
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-700 dark:text-zinc-300"
)}
>
{connected ? "connected" : "offline"}
</span>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-3">
<AnimatePresence>
{discovered ? (
SERVER_TOOLS.map((tool) => (
<ToolChip key={tool.raw} label={tool.raw} external />
))
) : (
<motion.div
key="no-schema"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="col-span-full rounded-md border border-dashed border-zinc-300 px-3 py-5 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"
>
schemas hidden until connected
</motion.div>
)}
</AnimatePresence>
</div>
</Shelf>
<Shelf
title="Agent workbench"
icon={<PlugZap size={15} />}
active={current.active === "belt" || current.active === "call"}
>
<div className="grid gap-2 sm:grid-cols-2">
<AnimatePresence>
{namespaced ? (
SERVER_TOOLS.slice(0, 2).map((tool, index) => (
<ToolChip
key={tool.namespaced}
label={tool.namespaced}
active={called && index === 0}
/>
))
) : (
<motion.div
key="empty-belt"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="col-span-full rounded-md border border-dashed border-zinc-300 px-3 py-4 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"
>
no MCP tools on the belt
</motion.div>
)}
</AnimatePresence>
</div>
</Shelf>
</div>
<Shelf
title="Call notebook"
icon={called ? <Search size={15} /> : <CheckCircle2 size={15} />}
active={current.active === "call" || current.active === "result"}
>
<div className="space-y-2">
<motion.div
animate={called && !returned ? { y: [0, -2, 0] } : { y: 0 }}
transition={{ duration: 1, repeat: called && !returned ? Infinity : 0 }}
className="break-all rounded-md border border-zinc-200 bg-zinc-50 p-3 font-mono text-[11px] leading-snug text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
>
{called ? "mcp__docs__search({ query })" : "waiting for a tool call"}
</motion.div>
<AnimatePresence>
{returned && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="break-words rounded-md border border-emerald-200 bg-emerald-50 p-3 text-xs leading-snug text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200"
>
tool_result: 3 relevant docs found
</motion.div>
)}
</AnimatePresence>
</div>
</Shelf>
</div>
<StepControls
className="mt-4"
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={current.title}
stepDescription={current.desc}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,413 @@
"use client";
import { type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
Archive,
Blocks,
Bot,
CheckCircle2,
Clock3,
FileText,
GitBranch,
Inbox,
Network,
ShieldCheck,
Sparkles,
Wrench,
} from "lucide-react";
import { StepControls } from "@/components/visualizations/shared/step-controls";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { cn } from "@/lib/utils";
type StageId =
| "intake"
| "guardrails"
| "route"
| "execute"
| "external"
| "recover"
| "append";
const STAGES: {
id: StageId;
label: string;
detail: string;
icon: ReactNode;
}[] = [
{
id: "intake",
label: "Intake",
detail: "request, memory, background notes",
icon: <Inbox size={15} />,
},
{
id: "guardrails",
label: "Guardrails",
detail: "permissions, hooks, policy",
icon: <ShieldCheck size={15} />,
},
{
id: "route",
label: "Route",
detail: "choose the right work surface",
icon: <GitBranch size={15} />,
},
{
id: "execute",
label: "Execute",
detail: "local tools, teams, worktrees",
icon: <Wrench size={15} />,
},
{
id: "external",
label: "External",
detail: "MCP toolboxes return results",
icon: <Blocks size={15} />,
},
{
id: "recover",
label: "Recover",
detail: "retry, compact, repair state",
icon: <Sparkles size={15} />,
},
{
id: "append",
label: "Append",
detail: "one transcript stays authoritative",
icon: <FileText size={15} />,
},
];
const SURFACES = [
{ label: "background", icon: <Clock3 size={14} />, text: "slow commands can finish later" },
{ label: "team", icon: <Network size={14} />, text: "teammates work through mailboxes" },
{ label: "worktree", icon: <GitBranch size={14} />, text: "risky edits stay isolated" },
{ label: "MCP", icon: <Blocks size={14} />, text: "external tools are normalized" },
];
const STEPS: {
title: string;
desc: string;
stage: StageId;
used: StageId[];
packet: {
request: string;
carried: string[];
decision: string;
result: string;
};
transcript: string[];
}[] = [
{
title: "A Turn Starts as a Packet",
desc: "The comprehensive agent first gathers everything the model should see, instead of scattering context across hidden places.",
stage: "intake",
used: ["intake"],
packet: {
request: "Fix the web lesson visuals and verify the pages.",
carried: ["recent messages", "relevant memory", "background notes"],
decision: "build one model-visible input packet",
result: "ready for a model call",
},
transcript: ["user request enters", "memory and notes are attached"],
},
{
title: "Guardrails Check the Packet",
desc: "Permissions and hooks are not separate side quests; they are the inspection gate before work happens.",
stage: "guardrails",
used: ["intake", "guardrails"],
packet: {
request: "Edit files, run build, open browser.",
carried: ["permission mode", "hook output", "workspace rules"],
decision: "allowed work continues; risky work asks first",
result: "safe action envelope",
},
transcript: ["policy checked", "allowed actions are visible"],
},
{
title: "The Agent Picks Work Surfaces",
desc: "The model does not need every mechanism at once. It chooses the smallest surface that matches the job.",
stage: "route",
used: ["route", "execute", "external"],
packet: {
request: "Search code, patch UI, verify rendered pages.",
carried: ["available tools", "team status", "MCP registry"],
decision: "local edit first, external tools only when needed",
result: "work split into clear lanes",
},
transcript: ["route: code search", "route: browser check", "route: no teammate needed"],
},
{
title: "Work Runs in Bounded Places",
desc: "Tools, teammates, and worktrees all produce small result cards, so parallel work does not become one unreadable chat log.",
stage: "execute",
used: ["execute", "route"],
packet: {
request: "Apply the patch and run the build.",
carried: ["tool call", "worktree lane", "expected output"],
decision: "execute, then return summarized results",
result: "local evidence collected",
},
transcript: ["patch applied", "build output summarized"],
},
{
title: "External Results Re-enter the Same Lane",
desc: "MCP tools expand capability, but they still come back as ordinary tool results the agent can reason over.",
stage: "external",
used: ["external", "execute"],
packet: {
request: "Use an external source or tool if local context is missing.",
carried: ["MCP tool name", "structured arguments", "returned artifact"],
decision: "normalize external output before the next model step",
result: "outside work is no longer special",
},
transcript: ["MCP result received", "result card appended"],
},
{
title: "Recovery Keeps the Turn Understandable",
desc: "Long context, command errors, and retries are handled as named recovery moves, not as mysterious branches.",
stage: "recover",
used: ["recover", "intake"],
packet: {
request: "If context or execution gets messy, repair before continuing.",
carried: ["error text", "retry count", "compact summary"],
decision: "retry once, compact old detail, keep the reason visible",
result: "the turn remains legible",
},
transcript: ["error classified", "recovery note added", "work resumes"],
},
{
title: "Everything Writes Back to One Transcript",
desc: "The big lesson is boring in the best way: all mechanisms eventually append evidence to the same source of truth.",
stage: "append",
used: ["append", "intake"],
packet: {
request: "Report what changed and what was verified.",
carried: ["tool evidence", "browser checks", "remaining risks"],
decision: "answer from the transcript, not from memory alone",
result: "next turn has a clean starting point",
},
transcript: ["tests pass", "visual checks recorded", "final answer drafted"],
},
];
function StageNode({
stage,
index,
currentIndex,
}: {
stage: (typeof STAGES)[number];
index: number;
currentIndex: number;
}) {
const active = index === currentIndex;
const done = index < currentIndex;
return (
<motion.div
layout
animate={active ? { y: [0, -2, 0] } : { y: 0 }}
transition={{ duration: 0.8, repeat: active ? Infinity : 0 }}
className={cn(
"min-w-0 rounded-lg border p-3 transition-colors",
active
? "border-blue-300 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950/35 dark:text-blue-200"
: done
? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200"
: "border-zinc-200 bg-white text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
)}
>
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
active
? "bg-blue-500 text-white"
: done
? "bg-emerald-500 text-white"
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
)}
>
{done ? <CheckCircle2 size={15} /> : stage.icon}
</span>
<div className="min-w-0">
<div className="break-words text-sm font-semibold">
{index + 1}. {stage.label}
</div>
<div className="break-words text-[11px] leading-snug opacity-80">{stage.detail}</div>
</div>
</div>
</motion.div>
);
}
function PacketLine({
label,
value,
tone = "zinc",
}: {
label: string;
value: string;
tone?: "zinc" | "blue" | "emerald";
}) {
const toneClass = {
zinc: "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200",
blue: "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/35 dark:text-blue-200",
emerald:
"border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/35 dark:text-emerald-200",
}[tone];
return (
<motion.div
layout
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.22 }}
className={cn("min-w-0 rounded-md border px-3 py-2 shadow-sm", toneClass)}
>
<div className="font-mono text-[10px] uppercase tracking-normal opacity-70">{label}</div>
<div className="mt-1 break-words text-sm font-medium leading-snug">{value}</div>
</motion.div>
);
}
export default function ComprehensiveVisualization({ title }: { title?: string }) {
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2800 });
const step = STEPS[vis.currentStep];
const currentStageIndex = STAGES.findIndex((stage) => stage.id === step.stage);
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || "Comprehensive Agent Turn"}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<div className="grid gap-3 lg:grid-cols-[0.9fr_1.2fr]">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<Bot size={16} />
One-turn journey
</div>
<div className="space-y-2">
{STAGES.map((stage, index) => (
<StageNode
key={stage.id}
stage={stage}
index={index}
currentIndex={currentStageIndex}
/>
))}
</div>
</div>
<div className="space-y-3">
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<Archive size={16} />
Turn packet
</div>
<span className="w-fit rounded-md bg-white px-2 py-1 font-mono text-[11px] text-zinc-500 dark:bg-zinc-900 dark:text-zinc-300">
step {vis.currentStep + 1}/{STEPS.length}
</span>
</div>
<AnimatePresence mode="wait">
<motion.div
key={step.title}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.25 }}
className="space-y-3"
>
<PacketLine label="request" value={step.packet.request} tone="blue" />
<div className="grid gap-2 sm:grid-cols-2">
<div className="rounded-md border border-zinc-200 bg-white px-3 py-2 dark:border-zinc-700 dark:bg-zinc-900">
<div className="font-mono text-[10px] uppercase tracking-normal text-zinc-500 dark:text-zinc-400">
carried context
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{step.packet.carried.map((item) => (
<span
key={item}
className="max-w-full break-words rounded bg-zinc-100 px-2 py-1 text-[11px] text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
>
{item}
</span>
))}
</div>
</div>
<PacketLine label="decision" value={step.packet.decision} />
</div>
<PacketLine label="result" value={step.packet.result} tone="emerald" />
</motion.div>
</AnimatePresence>
</div>
<div className="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<FileText size={15} />
Source-of-truth transcript
</div>
<div className="space-y-2">
<AnimatePresence mode="popLayout">
{step.transcript.map((item) => (
<motion.div
key={item}
layout
initial={{ opacity: 0, x: 12 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -8 }}
transition={{ duration: 0.22 }}
className="break-words rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
>
{item}
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</div>
</div>
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
{SURFACES.map((surface) => (
<div
key={surface.label}
className="min-w-0 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70"
>
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-zinc-100 text-zinc-500 dark:bg-zinc-900 dark:text-zinc-300">
{surface.icon}
</span>
<span className="break-words">{surface.label}</span>
</div>
<div className="mt-2 break-words text-[11px] leading-snug text-zinc-500 dark:text-zinc-400">
{surface.text}
</div>
</div>
))}
</div>
<StepControls
className="mt-4"
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={step.title}
stepDescription={step.desc}
/>
</div>
</section>
);
}

View File

@@ -0,0 +1,301 @@
"use client";
import { motion } from "framer-motion";
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
import { useSvgPalette } from "@/hooks/useDarkMode";
import { StepControls } from "./step-controls";
import { cn } from "@/lib/utils";
type NodeKind = "start" | "process" | "decision" | "store" | "external" | "end";
export interface MechanismNode {
id: string;
label: string;
x: number;
y: number;
kind?: NodeKind;
appearsAt?: number;
}
export interface MechanismEdge {
id: string;
from: string;
to: string;
label?: string;
appearsAt?: number;
}
export interface MechanismStep {
title: string;
description: string;
focus?: string[];
}
interface MechanismFlowProps {
title?: string;
fallbackTitle: string;
nodes: MechanismNode[];
edges: MechanismEdge[];
steps: MechanismStep[];
viewBox?: string;
footer?: string[];
}
const NODE_WIDTH = 118;
const NODE_HEIGHT = 42;
const DIAMOND_SIZE = 54;
const KIND_COLORS: Record<NodeKind, string> = {
start: "#3b82f6",
process: "#10b981",
decision: "#f59e0b",
store: "#8b5cf6",
external: "#ef4444",
end: "#64748b",
};
function bounds(node: MechanismNode) {
const halfW = node.kind === "decision" ? DIAMOND_SIZE / 2 : NODE_WIDTH / 2;
const halfH = node.kind === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
return {
left: node.x - halfW,
right: node.x + halfW,
top: node.y - halfH,
bottom: node.y + halfH,
};
}
function edgePath(from: MechanismNode, to: MechanismNode) {
const a = bounds(from);
const b = bounds(to);
if (Math.abs(from.x - to.x) < 12) {
return `M ${from.x} ${a.bottom} L ${to.x} ${b.top}`;
}
if (Math.abs(from.y - to.y) < 12) {
const startX = to.x > from.x ? a.right : a.left;
const endX = to.x > from.x ? b.left : b.right;
const midX = (startX + endX) / 2;
return `M ${startX} ${from.y} C ${midX} ${from.y}, ${midX} ${to.y}, ${endX} ${to.y}`;
}
const startY = to.y > from.y ? a.bottom : a.top;
const endY = to.y > from.y ? b.top : b.bottom;
const control = Math.max(36, Math.abs(endY - startY) * 0.45);
const c1 = startY + (endY > startY ? control : -control);
const c2 = endY - (endY > startY ? control : -control);
return `M ${from.x} ${startY} C ${from.x} ${c1}, ${to.x} ${c2}, ${to.x} ${endY}`;
}
function labelPosition(from: MechanismNode, to: MechanismNode) {
return {
x: (from.x + to.x) / 2,
y: (from.y + to.y) / 2 - 10,
};
}
function FlowNode({
node,
active,
visible,
}: {
node: MechanismNode;
active: boolean;
visible: boolean;
}) {
const kind = node.kind ?? "process";
const color = KIND_COLORS[kind];
const lines = node.label.split("\n");
if (kind === "decision") {
const half = DIAMOND_SIZE / 2;
return (
<motion.g
initial={{ opacity: 0, scale: 0.94 }}
animate={{ opacity: visible ? 1 : 0.16, scale: active ? 1.04 : 1 }}
transition={{ duration: 0.25 }}
>
<polygon
points={`${node.x},${node.y - half} ${node.x + half},${node.y} ${node.x},${node.y + half} ${node.x - half},${node.y}`}
fill={active ? `${color}20` : "transparent"}
stroke={color}
strokeWidth={active ? 2.4 : 1.5}
/>
{lines.map((line, i) => (
<text
key={line}
x={node.x}
y={node.y + (i - (lines.length - 1) / 2) * 12}
textAnchor="middle"
dominantBaseline="central"
fontSize={9}
fontFamily="monospace"
fontWeight={active ? 700 : 500}
fill="currentColor"
>
{line}
</text>
))}
</motion.g>
);
}
return (
<motion.g
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: visible ? 1 : 0.16, y: 0, scale: active ? 1.03 : 1 }}
transition={{ duration: 0.25 }}
>
<rect
x={node.x - NODE_WIDTH / 2}
y={node.y - NODE_HEIGHT / 2}
width={NODE_WIDTH}
height={NODE_HEIGHT}
rx={kind === "start" || kind === "end" ? NODE_HEIGHT / 2 : 6}
fill={active ? `${color}1f` : "transparent"}
stroke={color}
strokeWidth={active ? 2.4 : 1.5}
strokeDasharray={kind === "external" ? "6 3" : undefined}
/>
{lines.map((line, i) => (
<text
key={line}
x={node.x}
y={node.y + (i - (lines.length - 1) / 2) * 12}
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontFamily="monospace"
fontWeight={active ? 700 : 500}
fill="currentColor"
>
{line}
</text>
))}
</motion.g>
);
}
export function MechanismFlow({
title,
fallbackTitle,
nodes,
edges,
steps,
viewBox = "0 0 720 360",
footer,
}: MechanismFlowProps) {
const vis = useSteppedVisualization({ totalSteps: steps.length, autoPlayInterval: 2300 });
const step = steps[vis.currentStep];
const palette = useSvgPalette();
const focused = new Set(step.focus ?? []);
return (
<section className="min-h-[500px] space-y-4">
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
{title || fallbackTitle}
</h2>
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
<svg viewBox={viewBox} className="w-full" aria-label={title || fallbackTitle}>
<defs>
<marker
id={`mechanism-arrow-${fallbackTitle.replace(/\W/g, "-")}`}
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>
{edges.map((edge) => {
const from = nodes.find((node) => node.id === edge.from);
const to = nodes.find((node) => node.id === edge.to);
if (!from || !to) return null;
const active = focused.has(edge.id) || focused.has(edge.from) || focused.has(edge.to);
const visible = active || vis.currentStep >= (edge.appearsAt ?? 0);
const label = labelPosition(from, to);
return (
<motion.g
key={edge.id}
initial={{ opacity: 0 }}
animate={{ opacity: visible ? 1 : 0.12 }}
transition={{ duration: 0.25 }}
>
<path
d={edgePath(from, to)}
fill="none"
stroke={active ? "#3b82f6" : palette.arrowFill}
strokeWidth={active ? 2.4 : 1.3}
markerEnd={`url(#mechanism-arrow-${fallbackTitle.replace(/\W/g, "-")})`}
/>
{edge.label && visible && (
<text
x={label.x}
y={label.y}
textAnchor="middle"
fontSize={9}
fontFamily="monospace"
fill={active ? "#2563eb" : palette.labelFill}
stroke={palette.bgSubtle}
strokeWidth={4}
paintOrder="stroke"
>
{edge.label}
</text>
)}
</motion.g>
);
})}
{nodes.map((node) => {
const active = focused.has(node.id);
const visible = active || vis.currentStep >= (node.appearsAt ?? 0);
return (
<FlowNode
key={node.id}
node={node}
visible={visible}
active={active}
/>
);
})}
</svg>
{footer && (
<div className="mt-3 flex flex-wrap gap-1.5">
{footer.map((item) => (
<span
key={item}
className={cn(
"rounded-md px-2 py-1 font-mono text-xs",
"bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
)}
>
{item}
</span>
))}
</div>
)}
</div>
<StepControls
currentStep={vis.currentStep}
totalSteps={vis.totalSteps}
onPrev={vis.prev}
onNext={vis.next}
onReset={vis.reset}
isPlaying={vis.isPlaying}
onToggleAutoPlay={vis.toggleAutoPlay}
stepTitle={step.title}
stepDescription={step.description}
/>
</section>
);
}