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