mirror of
https://github.com/shareAI-lab/analysis_claude_code.git
synced 2026-08-12 17:13:38 +08:00
feat: complete web curriculum visuals
This commit is contained in:
@@ -16,6 +16,10 @@ const CLASS_DESCRIPTIONS: Record<string, string> = {
|
||||
TeammateManager: "Multi-agent team lifecycle and coordination",
|
||||
Teammate: "Individual agent identity and state tracking",
|
||||
SharedBoard: "Cross-agent shared state coordination",
|
||||
CronJob: "Durable recurring job definition",
|
||||
ProtocolState: "Pending team protocol requests and response matching",
|
||||
MCPClient: "External tool discovery and invocation client",
|
||||
RecoveryState: "Retry, fallback, and continuation state",
|
||||
};
|
||||
|
||||
interface ArchDiagramProps {
|
||||
@@ -68,29 +72,23 @@ function getLayerColorClasses(versionId: string): {
|
||||
}
|
||||
}
|
||||
|
||||
function collectClassesUpTo(
|
||||
function collectClassesForVersion(
|
||||
targetId: string
|
||||
): { name: string; introducedIn: string }[] {
|
||||
const { versions, diffs } = versionsData;
|
||||
const order = versions.map((v) => v.id);
|
||||
const targetIdx = order.indexOf(targetId);
|
||||
if (targetIdx < 0) return [];
|
||||
const targetIndex = versionsData.versions.findIndex((v) => v.id === targetId);
|
||||
const version = targetIndex >= 0 ? versionsData.versions[targetIndex] : undefined;
|
||||
|
||||
const result: { name: string; introducedIn: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i <= targetIdx; i++) {
|
||||
const v = versions[i];
|
||||
if (!v.classes) continue;
|
||||
for (const cls of v.classes) {
|
||||
if (!seen.has(cls.name)) {
|
||||
seen.add(cls.name);
|
||||
result.push({ name: cls.name, introducedIn: v.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return (
|
||||
version?.classes?.map((cls) => ({
|
||||
name: cls.name,
|
||||
introducedIn:
|
||||
versionsData.versions
|
||||
.slice(0, targetIndex + 1)
|
||||
.find((candidate) =>
|
||||
candidate.classes?.some((candidateCls) => candidateCls.name === cls.name)
|
||||
)?.id ?? targetId,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function getNewClassNames(version: string): Set<string> {
|
||||
@@ -103,7 +101,7 @@ function getNewClassNames(version: string): Set<string> {
|
||||
}
|
||||
|
||||
export function ArchDiagram({ version }: ArchDiagramProps) {
|
||||
const allClasses = collectClassesUpTo(version);
|
||||
const allClasses = collectClassesForVersion(version);
|
||||
const newClassNames = getNewClassNames(version);
|
||||
const versionData = versionsData.versions.find((v) => v.id === version);
|
||||
const tools = versionData?.tools ?? [];
|
||||
|
||||
@@ -18,6 +18,14 @@ import s09Annotations from "@/data/annotations/s09.json";
|
||||
import s10Annotations from "@/data/annotations/s10.json";
|
||||
import s11Annotations from "@/data/annotations/s11.json";
|
||||
import s12Annotations from "@/data/annotations/s12.json";
|
||||
import s13Annotations from "@/data/annotations/s13.json";
|
||||
import s14Annotations from "@/data/annotations/s14.json";
|
||||
import s15Annotations from "@/data/annotations/s15.json";
|
||||
import s16Annotations from "@/data/annotations/s16.json";
|
||||
import s17Annotations from "@/data/annotations/s17.json";
|
||||
import s18Annotations from "@/data/annotations/s18.json";
|
||||
import s19Annotations from "@/data/annotations/s19.json";
|
||||
import s20Annotations from "@/data/annotations/s20.json";
|
||||
|
||||
interface Decision {
|
||||
id: string;
|
||||
@@ -46,6 +54,14 @@ const ANNOTATIONS: Record<string, AnnotationFile> = {
|
||||
s10: s10Annotations as AnnotationFile,
|
||||
s11: s11Annotations as AnnotationFile,
|
||||
s12: s12Annotations as AnnotationFile,
|
||||
s13: s13Annotations as AnnotationFile,
|
||||
s14: s14Annotations as AnnotationFile,
|
||||
s15: s15Annotations as AnnotationFile,
|
||||
s16: s16Annotations as AnnotationFile,
|
||||
s17: s17Annotations as AnnotationFile,
|
||||
s18: s18Annotations as AnnotationFile,
|
||||
s19: s19Annotations as AnnotationFile,
|
||||
s20: s20Annotations as AnnotationFile,
|
||||
};
|
||||
|
||||
interface DesignDecisionsProps {
|
||||
@@ -124,7 +140,14 @@ export function DesignDecisions({ version }: DesignDecisionsProps) {
|
||||
|
||||
const annotations = ANNOTATIONS[version];
|
||||
if (!annotations || annotations.decisions.length === 0) {
|
||||
return null;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">{t("design_decisions")}</h2>
|
||||
<div className="rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-bg)] p-6 text-sm text-[var(--color-text-secondary)]">
|
||||
Design decisions are not available for this lesson yet.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -6,8 +6,9 @@ import { getFlowForVersion } from "@/data/execution-flows";
|
||||
import type { FlowNode, FlowEdge } from "@/types/agent-data";
|
||||
|
||||
const NODE_WIDTH = 140;
|
||||
const NODE_HEIGHT = 40;
|
||||
const DIAMOND_SIZE = 50;
|
||||
const NODE_HEIGHT = 44;
|
||||
const DIAMOND_WIDTH = 92;
|
||||
const DIAMOND_HEIGHT = 64;
|
||||
|
||||
const LAYER_COLORS: Record<string, string> = {
|
||||
start: "#3B82F6",
|
||||
@@ -17,39 +18,207 @@ const LAYER_COLORS: Record<string, string> = {
|
||||
end: "#EF4444",
|
||||
};
|
||||
|
||||
function getNodeCenter(node: FlowNode): { cx: number; cy: number } {
|
||||
return { cx: node.x, cy: node.y };
|
||||
function getNodeLines(node: FlowNode): string[] {
|
||||
const maxChars = node.type === "decision" ? 12 : 18;
|
||||
return node.label.split("\n").flatMap((line) => {
|
||||
if (line.length <= maxChars) return [line];
|
||||
|
||||
const parts = line.split(/(\s+\/\s+|\s+|_)/).filter(Boolean);
|
||||
const chunks: string[] = [];
|
||||
let current = "";
|
||||
|
||||
for (const part of parts) {
|
||||
const next = `${current}${part}`;
|
||||
if (current && next.trim().length > maxChars) {
|
||||
chunks.push(current.trim());
|
||||
current = part.trimStart();
|
||||
} else {
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.trim()) chunks.push(current.trim());
|
||||
return chunks.length ? chunks : [line];
|
||||
});
|
||||
}
|
||||
|
||||
function estimateTextWidth(line: string, fontSize: number): number {
|
||||
return line.length * fontSize * 0.62;
|
||||
}
|
||||
|
||||
function getNodeMetrics(node: FlowNode) {
|
||||
const lines = getNodeLines(node);
|
||||
const longest = Math.max(...lines.map((line) => estimateTextWidth(line, 11)), 0);
|
||||
|
||||
if (node.type === "decision") {
|
||||
return {
|
||||
lines,
|
||||
width: Math.max(DIAMOND_WIDTH, longest + 54),
|
||||
height: Math.max(DIAMOND_HEIGHT, lines.length * 15 + 42),
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === "start" || node.type === "end") {
|
||||
return {
|
||||
lines,
|
||||
width: Math.max(NODE_WIDTH, longest + 34),
|
||||
height: Math.max(NODE_HEIGHT, lines.length * 15 + 24),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
lines,
|
||||
width: Math.max(NODE_WIDTH, longest + 30),
|
||||
height: Math.max(NODE_HEIGHT, lines.length * 15 + 24),
|
||||
};
|
||||
}
|
||||
|
||||
function getNodeBounds(node: FlowNode) {
|
||||
const metrics = getNodeMetrics(node);
|
||||
const halfW = metrics.width / 2;
|
||||
const halfH = metrics.height / 2;
|
||||
|
||||
return {
|
||||
cx: node.x,
|
||||
cy: node.y,
|
||||
left: node.x - halfW,
|
||||
right: node.x + halfW,
|
||||
top: node.y - halfH,
|
||||
bottom: node.y + halfH,
|
||||
};
|
||||
}
|
||||
|
||||
const LOOP_RAIL_X = -48;
|
||||
const RIGHT_LOOP_RAIL_X = 576;
|
||||
const FLOW_CENTER_X = 300;
|
||||
const LOOP_PAD = 28;
|
||||
const LOOP_BACK_DX_LIMIT = 360;
|
||||
const LOOP_BACK_DY_LIMIT = 70;
|
||||
|
||||
type LoopSide = "left" | "right";
|
||||
|
||||
function getLoopSide(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>): LoopSide {
|
||||
return (start.cx + end.cx) / 2 > FLOW_CENTER_X ? "right" : "left";
|
||||
}
|
||||
|
||||
function getLoopRailX(
|
||||
start: ReturnType<typeof getNodeBounds>,
|
||||
end: ReturnType<typeof getNodeBounds>,
|
||||
side = getLoopSide(start, end),
|
||||
) {
|
||||
if (side === "right") {
|
||||
return Math.max(RIGHT_LOOP_RAIL_X, start.right + LOOP_PAD, end.right + LOOP_PAD);
|
||||
}
|
||||
|
||||
return Math.min(LOOP_RAIL_X, start.left - LOOP_PAD, end.left - LOOP_PAD);
|
||||
}
|
||||
|
||||
function isLoopBack(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>) {
|
||||
const dx = end.cx - start.cx;
|
||||
const dy = end.cy - start.cy;
|
||||
return dy < -LOOP_BACK_DY_LIMIT && Math.abs(dx) <= LOOP_BACK_DX_LIMIT;
|
||||
}
|
||||
|
||||
function shouldUseStepRoute(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>) {
|
||||
const dx = end.cx - start.cx;
|
||||
const dy = end.cy - start.cy;
|
||||
return dy > 28 && Math.abs(dx) > 44 && end.top > start.bottom;
|
||||
}
|
||||
|
||||
function getStepBusY(start: ReturnType<typeof getNodeBounds>, end: ReturnType<typeof getNodeBounds>) {
|
||||
const room = end.top - start.bottom;
|
||||
return Math.min(end.top - 16, start.bottom + Math.max(18, room * 0.35));
|
||||
}
|
||||
|
||||
function getEdgePath(from: FlowNode, to: FlowNode): string {
|
||||
const { cx: x1, cy: y1 } = getNodeCenter(from);
|
||||
const { cx: x2, cy: y2 } = getNodeCenter(to);
|
||||
const start = getNodeBounds(from);
|
||||
const end = getNodeBounds(to);
|
||||
const dx = end.cx - start.cx;
|
||||
const dy = end.cy - start.cy;
|
||||
|
||||
const halfH = from.type === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
|
||||
const halfHTo = to.type === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
|
||||
|
||||
if (Math.abs(x1 - x2) < 10) {
|
||||
const startY = y1 + halfH;
|
||||
const endY = y2 - halfHTo;
|
||||
return `M ${x1} ${startY} L ${x2} ${endY}`;
|
||||
if (isLoopBack(start, end)) {
|
||||
const side = getLoopSide(start, end);
|
||||
const railX = getLoopRailX(start, end, side);
|
||||
const startX = side === "right" ? start.right : start.left;
|
||||
const endX = side === "right" ? end.right : end.left;
|
||||
const midY = (start.cy + end.cy) / 2;
|
||||
return `M ${startX} ${start.cy} C ${railX} ${start.cy}, ${railX} ${midY}, ${railX} ${midY} C ${railX} ${end.cy}, ${endX} ${end.cy}, ${endX} ${end.cy}`;
|
||||
}
|
||||
|
||||
const startY = y1 + halfH;
|
||||
const endY = y2 - halfHTo;
|
||||
const midY = (startY + endY) / 2;
|
||||
return `M ${x1} ${startY} L ${x1} ${midY} L ${x2} ${midY} L ${x2} ${endY}`;
|
||||
if (Math.abs(dx) < 10) {
|
||||
if (dy >= 0) {
|
||||
return `M ${start.cx} ${start.bottom} L ${end.cx} ${end.top}`;
|
||||
}
|
||||
return `M ${start.cx} ${start.top} L ${end.cx} ${end.bottom}`;
|
||||
}
|
||||
|
||||
if (Math.abs(dy) < 10) {
|
||||
const startX = dx > 0 ? start.right : start.left;
|
||||
const endX = dx > 0 ? end.left : end.right;
|
||||
const midX = (startX + endX) / 2;
|
||||
return `M ${startX} ${start.cy} C ${midX} ${start.cy}, ${midX} ${end.cy}, ${endX} ${end.cy}`;
|
||||
}
|
||||
|
||||
if (shouldUseStepRoute(start, end)) {
|
||||
const busY = getStepBusY(start, end);
|
||||
return `M ${start.cx} ${start.bottom} L ${start.cx} ${busY} L ${end.cx} ${busY} L ${end.cx} ${end.top}`;
|
||||
}
|
||||
|
||||
if (Math.abs(dx) > 70) {
|
||||
const startX = dx > 0 ? start.right : start.left;
|
||||
const endX = dx > 0 ? end.left : end.right;
|
||||
const control = Math.max(56, Math.abs(dx) * 0.45);
|
||||
return `M ${startX} ${start.cy} C ${startX + (dx > 0 ? control : -control)} ${start.cy}, ${endX - (dx > 0 ? control : -control)} ${end.cy}, ${endX} ${end.cy}`;
|
||||
}
|
||||
|
||||
const startY = dy > 0 ? start.bottom : start.top;
|
||||
const endY = dy > 0 ? end.top : end.bottom;
|
||||
const controlDistance = Math.max(44, Math.abs(endY - startY) * 0.42);
|
||||
const controlY1 = startY + (endY > startY ? controlDistance : -controlDistance);
|
||||
const controlY2 = endY - (endY > startY ? controlDistance : -controlDistance);
|
||||
|
||||
return `M ${start.cx} ${startY} C ${start.cx} ${controlY1}, ${end.cx} ${controlY2}, ${end.cx} ${endY}`;
|
||||
}
|
||||
|
||||
function getEdgeLabelPosition(from: FlowNode, to: FlowNode): { x: number; y: number } {
|
||||
const start = getNodeBounds(from);
|
||||
const end = getNodeBounds(to);
|
||||
const dx = end.cx - start.cx;
|
||||
const dy = end.cy - start.cy;
|
||||
|
||||
if (isLoopBack(start, end)) {
|
||||
const side = getLoopSide(start, end);
|
||||
return {
|
||||
x: getLoopRailX(start, end, side) + (side === "right" ? -24 : 24),
|
||||
y: (start.cy + end.cy) / 2 - 6,
|
||||
};
|
||||
}
|
||||
|
||||
if (Math.abs(dy) < 10) {
|
||||
return { x: (start.cx + end.cx) / 2, y: start.cy - 12 };
|
||||
}
|
||||
|
||||
if (shouldUseStepRoute(start, end)) {
|
||||
return { x: (start.cx + end.cx) / 2, y: getStepBusY(start, end) - 8 };
|
||||
}
|
||||
|
||||
return {
|
||||
x: (start.cx + end.cx) / 2 + (dx > 0 ? 18 : -18),
|
||||
y: (start.bottom + end.top) / 2 - 8,
|
||||
};
|
||||
}
|
||||
|
||||
function NodeShape({ node }: { node: FlowNode }) {
|
||||
const color = LAYER_COLORS[node.type];
|
||||
const lines = node.label.split("\n");
|
||||
const { lines, width, height } = getNodeMetrics(node);
|
||||
|
||||
if (node.type === "decision") {
|
||||
const half = DIAMOND_SIZE / 2;
|
||||
const halfW = width / 2;
|
||||
const halfH = height / 2;
|
||||
return (
|
||||
<g>
|
||||
<polygon
|
||||
points={`${node.x},${node.y - half} ${node.x + half},${node.y} ${node.x},${node.y + half} ${node.x - half},${node.y}`}
|
||||
points={`${node.x},${node.y - halfH} ${node.x + halfW},${node.y} ${node.x},${node.y + halfH} ${node.x - halfW},${node.y}`}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
@@ -58,10 +227,10 @@ function NodeShape({ node }: { node: FlowNode }) {
|
||||
<text
|
||||
key={i}
|
||||
x={node.x}
|
||||
y={node.y + (i - (lines.length - 1) / 2) * 12}
|
||||
y={node.y + (i - (lines.length - 1) / 2) * 13}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={10}
|
||||
fontSize={lines.length > 2 ? 9 : 10}
|
||||
fontFamily="monospace"
|
||||
fill="currentColor"
|
||||
>
|
||||
@@ -76,27 +245,30 @@ function NodeShape({ node }: { node: FlowNode }) {
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={node.x - NODE_WIDTH / 2}
|
||||
y={node.y - NODE_HEIGHT / 2}
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
rx={NODE_HEIGHT / 2}
|
||||
x={node.x - width / 2}
|
||||
y={node.y - height / 2}
|
||||
width={width}
|
||||
height={height}
|
||||
rx={height / 2}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight={600}
|
||||
fontFamily="monospace"
|
||||
fill="currentColor"
|
||||
>
|
||||
{node.label}
|
||||
</text>
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={node.x}
|
||||
y={node.y + (i - (lines.length - 1) / 2) * 14}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={12}
|
||||
fontWeight={600}
|
||||
fontFamily="monospace"
|
||||
fill="currentColor"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -105,10 +277,10 @@ function NodeShape({ node }: { node: FlowNode }) {
|
||||
return (
|
||||
<g>
|
||||
<rect
|
||||
x={node.x - NODE_WIDTH / 2}
|
||||
y={node.y - NODE_HEIGHT / 2}
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
x={node.x - width / 2}
|
||||
y={node.y - height / 2}
|
||||
width={width}
|
||||
height={height}
|
||||
rx={4}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
@@ -147,12 +319,14 @@ function EdgePath({
|
||||
if (!from || !to) return null;
|
||||
|
||||
const d = getEdgePath(from, to);
|
||||
const midX = (from.x + to.x) / 2;
|
||||
const midY = (from.y + to.y) / 2;
|
||||
const label = getEdgeLabelPosition(from, to);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<motion.path
|
||||
data-edge-from={edge.from}
|
||||
data-edge-to={edge.to}
|
||||
data-edge-label={edge.label ?? ""}
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="var(--color-text-secondary)"
|
||||
@@ -164,10 +338,15 @@ function EdgePath({
|
||||
/>
|
||||
{edge.label && (
|
||||
<motion.text
|
||||
x={midX + 8}
|
||||
y={midY - 4}
|
||||
x={label.x}
|
||||
y={label.y}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fill="var(--color-text-secondary)"
|
||||
stroke="var(--color-bg)"
|
||||
strokeWidth={5}
|
||||
strokeLinejoin="round"
|
||||
paintOrder="stroke"
|
||||
fontFamily="monospace"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -191,15 +370,24 @@ export function ExecutionFlow({ version }: ExecutionFlowProps) {
|
||||
setFlow(getFlowForVersion(version));
|
||||
}, [version]);
|
||||
|
||||
if (!flow) return null;
|
||||
if (!flow) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-bg)] p-6 text-sm text-[var(--color-text-secondary)]">
|
||||
Execution flow is not available for this lesson yet.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const maxY = Math.max(...flow.nodes.map((n) => n.y)) + 50;
|
||||
const bounds = flow.nodes.map(getNodeBounds);
|
||||
const minX = Math.min(-40, ...bounds.map((b) => b.left)) - 24;
|
||||
const maxX = Math.max(700, ...bounds.map((b) => b.right)) + 24;
|
||||
const maxY = Math.max(...bounds.map((b) => b.bottom)) + 50;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] p-4">
|
||||
<svg
|
||||
viewBox={`0 0 600 ${maxY}`}
|
||||
className="mx-auto w-full max-w-[600px]"
|
||||
viewBox={`${minX} 0 ${maxX - minX} ${maxY}`}
|
||||
className="mx-auto w-full max-w-[720px]"
|
||||
style={{ minHeight: 300 }}
|
||||
>
|
||||
<defs>
|
||||
@@ -219,12 +407,14 @@ export function ExecutionFlow({ version }: ExecutionFlowProps) {
|
||||
</defs>
|
||||
|
||||
{flow.edges.map((edge, i) => (
|
||||
<EdgePath key={`${edge.from}-${edge.to}`} edge={edge} nodes={flow.nodes} index={i} />
|
||||
<EdgePath key={`${edge.from}-${edge.to}-${i}`} edge={edge} nodes={flow.nodes} index={i} />
|
||||
))}
|
||||
|
||||
{flow.nodes.map((node, i) => (
|
||||
<motion.g
|
||||
key={node.id}
|
||||
data-node-id={node.id}
|
||||
data-node-label={node.label}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.06, duration: 0.3 }}
|
||||
|
||||
@@ -40,6 +40,10 @@ function postProcessHtml(html: string): string {
|
||||
'<pre class="ascii-diagram"><code$1>'
|
||||
);
|
||||
|
||||
// Keep wide Markdown tables inside the prose column on small screens.
|
||||
html = html.replace(/<table>/g, '<div class="table-scroll"><table>');
|
||||
html = html.replace(/<\/table>/g, '</table></div>');
|
||||
|
||||
// Mark the first blockquote as hero callout
|
||||
html = html.replace(
|
||||
/<blockquote>/,
|
||||
|
||||
@@ -21,6 +21,14 @@ const scenarioModules: Record<string, () => Promise<{ default: Scenario }>> = {
|
||||
s10: () => import("@/data/scenarios/s10.json") as Promise<{ default: Scenario }>,
|
||||
s11: () => import("@/data/scenarios/s11.json") as Promise<{ default: Scenario }>,
|
||||
s12: () => import("@/data/scenarios/s12.json") as Promise<{ default: Scenario }>,
|
||||
s13: () => import("@/data/scenarios/s13.json") as Promise<{ default: Scenario }>,
|
||||
s14: () => import("@/data/scenarios/s14.json") as Promise<{ default: Scenario }>,
|
||||
s15: () => import("@/data/scenarios/s15.json") as Promise<{ default: Scenario }>,
|
||||
s16: () => import("@/data/scenarios/s16.json") as Promise<{ default: Scenario }>,
|
||||
s17: () => import("@/data/scenarios/s17.json") as Promise<{ default: Scenario }>,
|
||||
s18: () => import("@/data/scenarios/s18.json") as Promise<{ default: Scenario }>,
|
||||
s19: () => import("@/data/scenarios/s19.json") as Promise<{ default: Scenario }>,
|
||||
s20: () => import("@/data/scenarios/s20.json") as Promise<{ default: Scenario }>,
|
||||
};
|
||||
|
||||
interface AgentLoopSimulatorProps {
|
||||
@@ -33,10 +41,27 @@ export function AgentLoopSimulator({ version }: AgentLoopSimulatorProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const loader = scenarioModules[version];
|
||||
if (loader) {
|
||||
loader().then((mod) => setScenario(mod.default));
|
||||
setScenario(null);
|
||||
|
||||
if (!loader) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
loader()
|
||||
.then((mod) => {
|
||||
if (!cancelled) setScenario(mod.default);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setScenario(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [version]);
|
||||
|
||||
const sim = useSimulator(scenario?.steps ?? []);
|
||||
@@ -50,7 +75,16 @@ export function AgentLoopSimulator({ version }: AgentLoopSimulatorProps) {
|
||||
}
|
||||
}, [sim.visibleSteps.length]);
|
||||
|
||||
if (!scenario) return null;
|
||||
if (!scenario) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-2 text-xl font-semibold">{t("simulator")}</h2>
|
||||
<div className="rounded-xl border border-dashed border-[var(--color-border)] bg-[var(--color-bg)] p-6 text-sm text-[var(--color-text-secondary)]">
|
||||
Simulator scenario is not available for this lesson yet.
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
|
||||
@@ -9,16 +9,24 @@ const visualizations: Record<
|
||||
> = {
|
||||
s01: lazy(() => import("./s01-agent-loop")),
|
||||
s02: lazy(() => import("./s02-tool-dispatch")),
|
||||
s03: lazy(() => import("./s03-todo-write")),
|
||||
s04: lazy(() => import("./s04-subagent")),
|
||||
s05: lazy(() => import("./s05-skill-loading")),
|
||||
s06: lazy(() => import("./s06-context-compact")),
|
||||
s07: lazy(() => import("./s07-task-system")),
|
||||
s08: lazy(() => import("./s08-background-tasks")),
|
||||
s09: lazy(() => import("./s09-agent-teams")),
|
||||
s10: lazy(() => import("./s10-team-protocols")),
|
||||
s11: lazy(() => import("./s11-autonomous-agents")),
|
||||
s12: lazy(() => import("./s12-worktree-task-isolation")),
|
||||
s03: lazy(() => import("./s03-permission")),
|
||||
s04: lazy(() => import("./s04-hooks")),
|
||||
s05: lazy(() => import("./s03-todo-write")),
|
||||
s06: lazy(() => import("./s04-subagent")),
|
||||
s07: lazy(() => import("./s05-skill-loading")),
|
||||
s08: lazy(() => import("./s06-context-compact")),
|
||||
s09: lazy(() => import("./s09-memory")),
|
||||
s10: lazy(() => import("./s10-system-prompt")),
|
||||
s11: lazy(() => import("./s11-error-recovery")),
|
||||
s12: lazy(() => import("./s07-task-system")),
|
||||
s13: lazy(() => import("./s08-background-tasks")),
|
||||
s14: lazy(() => import("./s14-cron-scheduler")),
|
||||
s15: lazy(() => import("./s09-agent-teams")),
|
||||
s16: lazy(() => import("./s10-team-protocols")),
|
||||
s17: lazy(() => import("./s11-autonomous-agents")),
|
||||
s18: lazy(() => import("./s12-worktree-task-isolation")),
|
||||
s19: lazy(() => import("./s19-mcp-tools")),
|
||||
s20: lazy(() => import("./s20-comprehensive")),
|
||||
};
|
||||
|
||||
export function SessionVisualization({ version }: { version: string }) {
|
||||
|
||||
386
web/src/components/visualizations/s03-permission.tsx
Normal file
386
web/src/components/visualizations/s03-permission.tsx
Normal file
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CheckCircle2, ClipboardCheck, OctagonAlert, PlayCircle, ShieldAlert, ShieldCheck, UserCheck } from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Three Requests, Three Routes",
|
||||
desc: "Permission is a router: safe calls run, risky calls ask, forbidden calls stop.",
|
||||
mode: "overview",
|
||||
},
|
||||
{
|
||||
title: "Allow: Safe Read Runs Immediately",
|
||||
desc: "A read-only file request passes policy and reaches the handler without a user ticket.",
|
||||
mode: "allow",
|
||||
},
|
||||
{
|
||||
title: "Ask: Risky Local Delete Becomes a Ticket",
|
||||
desc: "A local delete command is not forbidden, but it must pause for explicit confirmation.",
|
||||
mode: "ask",
|
||||
},
|
||||
{
|
||||
title: "Approved Ask: Handler Runs After Yes",
|
||||
desc: "The same risky request executes only after the user approves this exact action.",
|
||||
mode: "ask-approved",
|
||||
},
|
||||
{
|
||||
title: "Deny: Forbidden Pattern Stops Early",
|
||||
desc: "A root-level sudo delete is blocked before any handler can touch the machine.",
|
||||
mode: "deny",
|
||||
},
|
||||
{
|
||||
title: "One Permission Desk, Three Outcomes",
|
||||
desc: "The harness keeps allow, ask, and deny decisions outside the model, then returns the decision to the loop.",
|
||||
mode: "summary",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const REQUESTS = [
|
||||
{
|
||||
id: "allow",
|
||||
tool: "read_file",
|
||||
command: "README.md",
|
||||
result: "allow",
|
||||
detail: "read-only workspace file",
|
||||
tone: "emerald",
|
||||
},
|
||||
{
|
||||
id: "ask",
|
||||
tool: "bash",
|
||||
command: "rm -rf ./tmp/build-cache",
|
||||
result: "ask",
|
||||
detail: "local destructive command",
|
||||
tone: "amber",
|
||||
},
|
||||
{
|
||||
id: "deny",
|
||||
tool: "bash",
|
||||
command: "sudo rm -rf /",
|
||||
result: "deny",
|
||||
detail: "forbidden root delete",
|
||||
tone: "red",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function toneClass(tone: "emerald" | "amber" | "red" | "blue" | "zinc") {
|
||||
if (tone === "emerald") return "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200";
|
||||
if (tone === "amber") return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200";
|
||||
if (tone === "red") return "border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200";
|
||||
if (tone === "blue") return "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200";
|
||||
return "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200";
|
||||
}
|
||||
|
||||
function Surface({
|
||||
title,
|
||||
icon,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg border p-4 transition-colors",
|
||||
active
|
||||
? "border-red-300 bg-red-50 dark:border-red-900 dark:bg-red-950/30"
|
||||
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
|
||||
active
|
||||
? "bg-red-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StepMode = (typeof STEPS)[number]["mode"];
|
||||
type RequestId = (typeof REQUESTS)[number]["id"];
|
||||
|
||||
function activeRequestId(mode: StepMode): RequestId | null {
|
||||
if (mode === "allow") return "allow";
|
||||
if (mode === "ask" || mode === "ask-approved") return "ask";
|
||||
if (mode === "deny") return "deny";
|
||||
return null;
|
||||
}
|
||||
|
||||
function RequestCard({
|
||||
request,
|
||||
active,
|
||||
muted,
|
||||
}: {
|
||||
request: (typeof REQUESTS)[number];
|
||||
active: boolean;
|
||||
muted: boolean;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
animate={active ? { y: -1 } : { y: 0 }}
|
||||
className={cn(
|
||||
"min-w-0 rounded-xl border p-4 shadow-sm",
|
||||
active ? toneClass(request.tone) : "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200",
|
||||
muted && "opacity-45"
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 text-sm font-semibold">tool request</div>
|
||||
<span className="shrink-0 rounded-full bg-white/70 px-2 py-1 font-mono text-xs font-semibold dark:bg-zinc-950/30">
|
||||
{request.tool}
|
||||
</span>
|
||||
</div>
|
||||
<code className="block min-w-0 rounded-lg bg-zinc-950 p-3 font-mono text-xs leading-relaxed text-zinc-100 whitespace-pre-wrap break-words">
|
||||
{request.command}
|
||||
</code>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 text-pretty opacity-80">{request.detail}</span>
|
||||
<span className="shrink-0 rounded bg-white/75 px-2 py-1 font-semibold dark:bg-zinc-950/30">
|
||||
{request.result}
|
||||
</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckRow({
|
||||
label,
|
||||
detail,
|
||||
status,
|
||||
active,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
status: "waiting" | "pass" | "allow" | "ask" | "approved" | "deny" | "skip";
|
||||
active: boolean;
|
||||
}) {
|
||||
const icon =
|
||||
status === "deny" ? <OctagonAlert size={16} /> : status === "pass" || status === "allow" ? <CheckCircle2 size={16} /> : status === "ask" ? <ShieldAlert size={16} /> : status === "approved" ? <UserCheck size={16} /> : <ClipboardCheck size={16} />;
|
||||
const tone = status === "deny" ? "red" : status === "pass" || status === "allow" || status === "approved" ? "emerald" : status === "ask" ? "amber" : "zinc";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"rounded-lg border p-3",
|
||||
active ? toneClass(tone) : "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<span className="shrink-0 rounded bg-white/70 px-2 py-0.5 text-[11px] font-semibold dark:bg-zinc-950/30">
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs leading-relaxed opacity-80">{detail}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionDesk({ mode }: { mode: StepMode }) {
|
||||
if (mode === "overview" || mode === "summary") {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<CheckRow label="Safe read" detail="No write, no shell, no approval needed." status="allow" active={mode === "overview"} />
|
||||
<CheckRow label="Risky local change" detail="May be useful, but requires a human yes." status="ask" active={mode === "overview"} />
|
||||
<CheckRow label="Forbidden pattern" detail="Root delete and sudo never reach handlers." status="deny" active={mode === "overview"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "allow") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<CheckRow label="Gate 1: hard deny" detail="No sudo, no root path, no forbidden pattern." status="pass" active={false} />
|
||||
<CheckRow label="Gate 2: allow rule" detail="Read-only workspace file can run immediately." status="allow" active />
|
||||
<CheckRow label="Gate 3: user approval" detail="Skipped because this call is already safe." status="skip" active={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "deny") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<CheckRow label="Gate 1: hard deny" detail="sudo + root delete is blocked immediately." status="deny" active />
|
||||
<CheckRow label="Gate 2: risk rule" detail="Skipped because hard deny already decided." status="skip" active={false} />
|
||||
<CheckRow label="Gate 3: user approval" detail="Skipped because the user cannot approve forbidden actions." status="skip" active={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<CheckRow label="Gate 1: hard deny" detail="Local project path is not globally forbidden." status="pass" active={false} />
|
||||
<CheckRow label="Gate 2: risk rule" detail="Deleting files needs an explicit approval ticket." status="ask" active={mode === "ask"} />
|
||||
<CheckRow label="Gate 3: user approval" detail="The tool waits until this request is approved." status={mode === "ask-approved" ? "approved" : "waiting"} active={mode === "ask-approved"} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeLine({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-lg bg-white/70 p-2 dark:bg-zinc-950/30">
|
||||
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide opacity-70">{label}</div>
|
||||
<code className="block min-w-0 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">{value}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Outcome({ mode }: { mode: StepMode }) {
|
||||
if (mode === "overview") {
|
||||
return <div className="rounded-lg border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">select a request route</div>;
|
||||
}
|
||||
|
||||
if (mode === "allow") {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("emerald"))}>
|
||||
<div className="flex items-center gap-2 text-base font-semibold">
|
||||
<PlayCircle size={17} />
|
||||
Handler runs now
|
||||
</div>
|
||||
<CodeLine label="handler" value="read_file" />
|
||||
<CodeLine label="args" value='path: "README.md"' />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "ask") {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("rounded-xl border p-4", toneClass("amber"))}>
|
||||
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
|
||||
<UserCheck size={17} />
|
||||
Approval ticket
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">"Allow deleting local build cache?"</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "ask-approved") {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("blue"))}>
|
||||
<div className="flex items-center gap-2 text-base font-semibold">
|
||||
<PlayCircle size={17} />
|
||||
Handler runs after approval
|
||||
</div>
|
||||
<CodeLine label="handler" value="bash" />
|
||||
<CodeLine label="args" value="rm -rf ./tmp/build-cache" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "deny") {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("rounded-xl border p-4", toneClass("red"))}>
|
||||
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
|
||||
<OctagonAlert size={17} />
|
||||
Blocked before handler
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">No tool execution, no user prompt, no filesystem touch.</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
|
||||
{REQUESTS.map((request) => (
|
||||
<div key={request.id} className={cn("rounded-lg border p-3", toneClass(request.tone))}>
|
||||
<div className="mb-1 flex items-center gap-2 text-sm font-semibold">
|
||||
{request.result === "deny" ? <OctagonAlert size={15} /> : request.result === "ask" ? <ShieldAlert size={15} /> : <ShieldCheck size={15} />}
|
||||
{request.result}
|
||||
</div>
|
||||
<div className="text-xs leading-relaxed opacity-80">{request.detail}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className={cn("rounded-xl border p-4", toneClass("emerald"))}>
|
||||
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
|
||||
<ShieldCheck size={17} />
|
||||
decision returned to loop
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">Permission stays outside the model, but the loop still receives a normal tool_result or blocked result.</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PermissionVisualization({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const current = STEPS[step];
|
||||
const mode = current.mode;
|
||||
const activeId = activeRequestId(mode);
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Permission Desk"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_1.1fr_0.95fr]">
|
||||
<Surface title="Tool requests" icon={<OctagonAlert size={20} />} active={mode === "overview" || activeId !== null}>
|
||||
<div className="space-y-2">
|
||||
{REQUESTS.map((request) => (
|
||||
<RequestCard
|
||||
key={request.id}
|
||||
request={request}
|
||||
active={activeId === request.id || (mode === "overview" && step === 0)}
|
||||
muted={activeId !== null && activeId !== request.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface title="Permission desk" icon={<ShieldCheck size={20} />} active={mode !== "overview"}>
|
||||
<PermissionDesk mode={mode} />
|
||||
</Surface>
|
||||
|
||||
<Surface title="Outcome" icon={<PlayCircle size={20} />} active={mode !== "overview"}>
|
||||
<AnimatePresence mode="wait">
|
||||
<Outcome key={mode} mode={mode} />
|
||||
</AnimatePresence>
|
||||
</Surface>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-relaxed text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800/70 dark:text-zinc-300">
|
||||
Beginner rule: the model proposes tools; the runtime routes each request to allow, ask, or deny before execution.
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={current.title}
|
||||
stepDescription={current.desc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -99,12 +99,12 @@ function KanbanColumn({
|
||||
headerBg: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-[280px] flex-1 flex-col rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="min-w-0 flex min-h-[220px] flex-col rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900 sm:min-h-[280px]">
|
||||
<div
|
||||
className={`rounded-t-lg px-3 py-2 text-center text-xs font-bold uppercase tracking-wider ${headerBg}`}
|
||||
className={`flex items-center justify-center gap-1 rounded-t-lg px-3 py-2 text-center text-xs font-bold uppercase tracking-wider ${headerBg}`}
|
||||
>
|
||||
{title}
|
||||
<span className={`ml-1.5 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-bold ${accentClass}`}>
|
||||
<span className="min-w-0 break-words">{title}</span>
|
||||
<span className={`inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold ${accentClass}`}>
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -147,19 +147,19 @@ function TaskCard({ task }: { task: Task }) {
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 30 }}
|
||||
className={`rounded-md border p-2.5 ${borderStyles[task.status]}`}
|
||||
className={`min-w-0 rounded-md border p-2.5 ${borderStyles[task.status]}`}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<div className="mb-1.5 flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="font-mono text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||
#{task.id}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide ${statusStyles[task.status]}`}
|
||||
className={`shrink-0 rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide ${statusStyles[task.status]}`}
|
||||
>
|
||||
{task.status.replace("_", " ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-zinc-700 dark:text-zinc-300">
|
||||
<div className="break-words text-xs font-medium leading-snug text-zinc-700 dark:text-zinc-300">
|
||||
{task.label}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -264,7 +264,7 @@ export default function TodoWrite({ title }: { title?: string }) {
|
||||
</div>
|
||||
|
||||
{/* Kanban board */}
|
||||
<div className="flex gap-3">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<KanbanColumn
|
||||
title="Pending"
|
||||
tasks={pendingTasks}
|
||||
|
||||
269
web/src/components/visualizations/s04-hooks.tsx
Normal file
269
web/src/components/visualizations/s04-hooks.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ClipboardList, FileSearch, LogOut, PlugZap, RadioTower, ScrollText, Wrench } from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type HookId = "UserPromptSubmit" | "PreToolUse" | "PostToolUse" | "Stop";
|
||||
|
||||
const HOOKS: {
|
||||
id: HookId;
|
||||
when: string;
|
||||
callbacks: string[];
|
||||
color: "blue" | "amber" | "emerald" | "zinc";
|
||||
}[] = [
|
||||
{
|
||||
id: "UserPromptSubmit",
|
||||
when: "after input, before LLM",
|
||||
callbacks: ["context_inject_hook"],
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
id: "PreToolUse",
|
||||
when: "after tool_use, before handler",
|
||||
callbacks: ["permission_hook", "log_hook"],
|
||||
color: "amber",
|
||||
},
|
||||
{
|
||||
id: "PostToolUse",
|
||||
when: "after handler, before next turn",
|
||||
callbacks: ["large_output_hook"],
|
||||
color: "emerald",
|
||||
},
|
||||
{
|
||||
id: "Stop",
|
||||
when: "before final output",
|
||||
callbacks: ["summary_hook"],
|
||||
color: "zinc",
|
||||
},
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Hooks Are Registered Outside the Loop",
|
||||
desc: "The loop only knows event names; callback behavior lives in the registry.",
|
||||
active: null,
|
||||
},
|
||||
{
|
||||
title: "UserPromptSubmit",
|
||||
desc: "Input hooks can log, validate, or inject context before the model sees the prompt.",
|
||||
active: "UserPromptSubmit" as HookId,
|
||||
},
|
||||
{
|
||||
title: "The Core Loop Still Chooses a Tool",
|
||||
desc: "Calling the model and receiving tool_use remains the same as before.",
|
||||
active: null,
|
||||
},
|
||||
{
|
||||
title: "PreToolUse",
|
||||
desc: "Permission and logging hooks run before the handler touches the workspace.",
|
||||
active: "PreToolUse" as HookId,
|
||||
},
|
||||
{
|
||||
title: "PostToolUse",
|
||||
desc: "Result hooks inspect output or trigger side effects after execution.",
|
||||
active: "PostToolUse" as HookId,
|
||||
},
|
||||
{
|
||||
title: "Stop",
|
||||
desc: "Cleanup and summary hooks run when the model stops asking for tools.",
|
||||
active: "Stop" as HookId,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function toneClass(tone: "blue" | "amber" | "emerald" | "zinc", active = true) {
|
||||
if (!active) return "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200";
|
||||
if (tone === "blue") return "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200";
|
||||
if (tone === "amber") return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200";
|
||||
if (tone === "emerald") return "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200";
|
||||
return "border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200";
|
||||
}
|
||||
|
||||
function Surface({
|
||||
title,
|
||||
icon,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg border p-4 transition-colors",
|
||||
active
|
||||
? "border-emerald-300 bg-emerald-50 dark:border-emerald-900 dark:bg-emerald-950/30"
|
||||
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center gap-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
|
||||
active
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HookCard({
|
||||
hook,
|
||||
active,
|
||||
}: {
|
||||
hook: (typeof HOOKS)[number];
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
animate={active ? { y: [0, -2, 0] } : { y: 0 }}
|
||||
transition={{ duration: 0.8, repeat: active ? Infinity : 0 }}
|
||||
className={cn("rounded-lg border p-3", toneClass(hook.color, active))}
|
||||
>
|
||||
<div className="mb-1 flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate font-mono text-sm font-semibold">{hook.id}</div>
|
||||
{active && <PlugZap size={16} className="shrink-0" />}
|
||||
</div>
|
||||
<div className="mb-3 text-xs leading-relaxed opacity-80">{hook.when}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{hook.callbacks.map((callback) => (
|
||||
<span key={callback} className="rounded bg-white/70 px-2 py-1 font-mono text-[11px] dark:bg-zinc-950/30">
|
||||
{callback}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function TurnCard({ step }: { step: number }) {
|
||||
const state =
|
||||
step <= 1
|
||||
? { title: "User input", body: "Read README.md and summarize it.", icon: <ScrollText size={18} /> }
|
||||
: step === 2
|
||||
? { title: "LLM chooses tool", body: "tool_use: read_file({ path: 'README.md' })", icon: <Wrench size={18} /> }
|
||||
: step === 3
|
||||
? { title: "Tool waits at pre-hook", body: "permission_hook + log_hook inspect the call.", icon: <FileSearch size={18} /> }
|
||||
: step === 4
|
||||
? { title: "Handler returned output", body: "large_output_hook checks result size.", icon: <ClipboardList size={18} /> }
|
||||
: { title: "No more tool_use", body: "summary_hook records final session stats.", icon: <LogOut size={18} /> };
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={state.title}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{state.icon}
|
||||
{state.title}
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 p-3 font-mono text-xs leading-relaxed text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
{state.body}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuditLog({ step }: { step: number }) {
|
||||
const items = [
|
||||
"[registry] four hook slots registered",
|
||||
"[UserPromptSubmit] working directory logged",
|
||||
"[loop] model returned read_file tool_use",
|
||||
"[PreToolUse] permission allowed; tool call logged",
|
||||
"[PostToolUse] output size checked",
|
||||
"[Stop] session used 1 tool call",
|
||||
].slice(0, step + 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{items.map((item) => (
|
||||
<motion.div
|
||||
key={item}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="rounded-lg border border-zinc-200 bg-white px-3 py-2 font-mono text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
|
||||
>
|
||||
{item}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HooksVisualization({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const current = STEPS[step];
|
||||
const activeHook = current.active;
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Hook Workbench"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm leading-relaxed text-emerald-900 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200">
|
||||
The loop stays boring on purpose: it calls <span className="font-mono">trigger_hooks(event)</span>, and the registry decides what extra logic runs.
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<Surface title="Hook registry" icon={<RadioTower size={20} />} active={step === 0 || activeHook !== null}>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{HOOKS.map((hook) => (
|
||||
<HookCard key={hook.id} hook={hook} active={activeHook === hook.id} />
|
||||
))}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface title="This turn" icon={<ScrollText size={20} />} active={step >= 1}>
|
||||
<div className="space-y-3">
|
||||
<TurnCard step={step} />
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
|
||||
<div className="mb-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">Audit log</div>
|
||||
<AuditLog step={step} />
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-relaxed text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
Beginner rule: adding behavior means registering a callback, not editing the core model-tool-result loop.
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={current.title}
|
||||
stepDescription={current.desc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -193,6 +193,36 @@ const STEPS = [
|
||||
},
|
||||
];
|
||||
|
||||
const COMPRESSION_LAYERS = [
|
||||
{
|
||||
label: "Micro",
|
||||
full: "MICRO-COMPACT",
|
||||
trigger: "old tool_result",
|
||||
action: "shrink bulky outputs",
|
||||
step: 3,
|
||||
classes:
|
||||
"border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/30 dark:text-amber-200",
|
||||
},
|
||||
{
|
||||
label: "Auto",
|
||||
full: "AUTO-COMPACT",
|
||||
trigger: "token threshold",
|
||||
action: "summarize the conversation",
|
||||
step: 5,
|
||||
classes:
|
||||
"border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-200",
|
||||
},
|
||||
{
|
||||
label: "Manual",
|
||||
full: "/compact",
|
||||
trigger: "user command",
|
||||
action: "keep one compact summary",
|
||||
step: 6,
|
||||
classes:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200",
|
||||
},
|
||||
];
|
||||
|
||||
export default function ContextCompact({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
@@ -222,17 +252,17 @@ export default function ContextCompact({ title }: { title?: string }) {
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className="rounded-lg border border-zinc-200 bg-white p-6 dark:border-zinc-700 dark:bg-zinc-900"
|
||||
className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900 sm:p-6"
|
||||
style={{ minHeight: 500 }}
|
||||
>
|
||||
<div className="flex gap-6">
|
||||
<div className="grid gap-5 lg:grid-cols-[140px_1fr]">
|
||||
{/* Token Window (tall vertical bar on the left) */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="min-w-0 flex flex-col items-center">
|
||||
<div className="mb-2 font-mono text-[10px] font-semibold text-zinc-500 dark:text-zinc-400">
|
||||
Context Window
|
||||
</div>
|
||||
<div
|
||||
className="relative w-24 overflow-hidden rounded-xl border-2 border-zinc-300 bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800"
|
||||
className="relative w-20 max-w-full overflow-hidden rounded-xl border-2 border-zinc-300 bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-800 sm:w-24"
|
||||
style={{ height: WINDOW_HEIGHT }}
|
||||
>
|
||||
{/* Blocks stacked from bottom up */}
|
||||
@@ -293,14 +323,14 @@ export default function ContextCompact({ title }: { title?: string }) {
|
||||
</div>
|
||||
|
||||
{/* Right side: state display and compression stage */}
|
||||
<div className="flex flex-1 flex-col justify-between">
|
||||
<div className="min-w-0">
|
||||
{/* Top: horizontal token bar */}
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="mb-1 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">
|
||||
Token usage
|
||||
</span>
|
||||
<span className="font-mono text-xs text-zinc-500">
|
||||
<span className="break-words font-mono text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{state.tokenCount.toLocaleString()} / {MAX_TOKENS.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -314,7 +344,7 @@ export default function ContextCompact({ title }: { title?: string }) {
|
||||
</div>
|
||||
|
||||
{/* Message type legend */}
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="h-3 w-3 rounded bg-blue-500" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">user</span>
|
||||
@@ -329,6 +359,37 @@ export default function ContextCompact({ title }: { title?: string }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||
{COMPRESSION_LAYERS.map((layer) => {
|
||||
const reached = currentStep >= layer.step;
|
||||
const active = state.compressionLabel === layer.full;
|
||||
return (
|
||||
<motion.div
|
||||
key={layer.full}
|
||||
layout
|
||||
animate={active ? { y: [0, -2, 0] } : { y: 0 }}
|
||||
transition={{ duration: 0.8, repeat: active ? Infinity : 0 }}
|
||||
className={`min-w-0 rounded-lg border p-3 transition-colors ${
|
||||
reached
|
||||
? layer.classes
|
||||
: "border-zinc-200 bg-zinc-50 text-zinc-500 dark:border-zinc-700 dark:bg-zinc-800/70 dark:text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold">{layer.label}</span>
|
||||
<span className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-[10px] dark:bg-zinc-900/60">
|
||||
{reached ? "used" : "waiting"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1 text-[11px] leading-snug">
|
||||
<div className="break-words font-mono">{layer.trigger}</div>
|
||||
<div className="break-words opacity-80">{layer.action}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Highlight old tool_results at step 2 */}
|
||||
<AnimatePresence>
|
||||
{currentStep === 2 && (
|
||||
@@ -336,12 +397,12 @@ export default function ContextCompact({ title }: { title?: string }) {
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="mt-3 rounded border border-amber-300 bg-amber-50 px-3 py-2 dark:border-amber-700 dark:bg-amber-900/20"
|
||||
className="mt-3 rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 dark:border-amber-700 dark:bg-amber-900/20"
|
||||
>
|
||||
<div className="text-xs font-semibold text-amber-700 dark:text-amber-300">
|
||||
tool_results are the largest blocks
|
||||
</div>
|
||||
<div className="text-[11px] text-amber-600 dark:text-amber-400">
|
||||
<div className="text-[11px] leading-snug text-amber-600 dark:text-amber-400">
|
||||
File contents, command outputs, search results -- each one is thousands of tokens.
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -374,7 +435,7 @@ export default function ContextCompact({ title }: { title?: string }) {
|
||||
}`}>
|
||||
{state.compressionLabel}
|
||||
</div>
|
||||
<div className={`mt-1 text-xs ${
|
||||
<div className={`mt-1 text-xs leading-snug ${
|
||||
currentStep === 3
|
||||
? "text-amber-500 dark:text-amber-400"
|
||||
: currentStep === 5
|
||||
@@ -396,35 +457,21 @@ export default function ContextCompact({ title }: { title?: string }) {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="mt-4 space-y-2"
|
||||
className="mt-4 grid gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 rounded bg-amber-50 px-3 py-1.5 dark:bg-amber-900/10">
|
||||
<div className="h-2 w-2 rounded-full bg-amber-500" />
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Stage 1: Micro -- shrink old tool_results
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] text-amber-500">
|
||||
automatic
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded bg-blue-50 px-3 py-1.5 dark:bg-blue-900/10">
|
||||
<div className="h-2 w-2 rounded-full bg-blue-500" />
|
||||
<span className="text-xs text-blue-700 dark:text-blue-300">
|
||||
Stage 2: Auto -- summarize entire conversation
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] text-blue-500">
|
||||
at threshold
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded bg-emerald-50 px-3 py-1.5 dark:bg-emerald-900/10">
|
||||
<div className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
<span className="text-xs text-emerald-700 dark:text-emerald-300">
|
||||
Stage 3: /compact -- user-triggered, deepest compression
|
||||
</span>
|
||||
<span className="ml-auto font-mono text-[10px] text-emerald-500">
|
||||
manual
|
||||
</span>
|
||||
</div>
|
||||
{COMPRESSION_LAYERS.map((layer, index) => (
|
||||
<div
|
||||
key={`summary-${layer.full}`}
|
||||
className={`flex flex-col gap-1 rounded px-3 py-2 sm:flex-row sm:items-center sm:justify-between ${layer.classes}`}
|
||||
>
|
||||
<span className="break-words text-xs">
|
||||
Stage {index + 1}: {layer.label} -- {layer.action}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-[10px] opacity-80">
|
||||
{layer.trigger}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,494 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CheckCircle2, ClipboardList, FileJson, LockKeyhole, PlayCircle } from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useDarkMode, useSvgPalette } from "@/hooks/useDarkMode";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type TaskStatus = "pending" | "in_progress" | "completed" | "blocked";
|
||||
type Status = "blocked" | "ready" | "active" | "done";
|
||||
|
||||
interface TaskNode {
|
||||
interface TaskCard {
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
deps: string[];
|
||||
}
|
||||
|
||||
interface StepInfo {
|
||||
title: string;
|
||||
description: string;
|
||||
blockers: string[];
|
||||
status: Status;
|
||||
}
|
||||
|
||||
const TASKS: TaskNode[] = [
|
||||
{ id: "T1", label: "T1: Setup DB", x: 80, y: 160, deps: [] },
|
||||
{ id: "T2", label: "T2: API routes", x: 280, y: 80, deps: ["T1"] },
|
||||
{ id: "T3", label: "T3: Auth module", x: 280, y: 240, deps: ["T1"] },
|
||||
{ id: "T4", label: "T4: Integration", x: 480, y: 160, deps: ["T2", "T3"] },
|
||||
{ id: "T5", label: "T5: Deploy", x: 650, y: 160, deps: ["T4"] },
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Tasks Become Files",
|
||||
desc: "The agent writes work as task cards on disk, so the plan survives compaction and restarts.",
|
||||
},
|
||||
{
|
||||
title: "Find the First Ready Card",
|
||||
desc: "A task with no blockers is ready immediately. Everything else waits visibly.",
|
||||
},
|
||||
{
|
||||
title: "Work One Card",
|
||||
desc: "The active task is not just text in the model's head; it has a durable status.",
|
||||
},
|
||||
{
|
||||
title: "Completion Unlocks Dependents",
|
||||
desc: "When T1 is done, the cards that depended on T1 become ready.",
|
||||
},
|
||||
{
|
||||
title: "Parallel Ready Work",
|
||||
desc: "T2 and T3 can run independently, while T4 still waits for both.",
|
||||
},
|
||||
{
|
||||
title: "All Blockers Cleared",
|
||||
desc: "Once T2 and T3 are done, T4 moves from waiting to active.",
|
||||
},
|
||||
{
|
||||
title: "Board Resolved",
|
||||
desc: "Every card reaches done. The dependency idea is visible without drawing a graph.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const BASE_TASKS = [
|
||||
{ id: "T1", title: "Set up database", blockers: [] },
|
||||
{ id: "T2", title: "Add API routes", blockers: ["T1"] },
|
||||
{ id: "T3", title: "Build auth module", blockers: ["T1"] },
|
||||
{ id: "T4", title: "Integration pass", blockers: ["T2", "T3"] },
|
||||
{ id: "T5", title: "Deploy", blockers: ["T4"] },
|
||||
];
|
||||
|
||||
const NODE_W = 140;
|
||||
const NODE_H = 50;
|
||||
|
||||
const STEP_INFO: StepInfo[] = [
|
||||
{
|
||||
title: "File-Based Tasks",
|
||||
description:
|
||||
"Tasks are stored in JSON files on disk. They survive context compaction -- unlike in-memory state.",
|
||||
},
|
||||
{
|
||||
title: "Start T1",
|
||||
description:
|
||||
"Tasks without dependencies can start immediately. T1 has no blockers.",
|
||||
},
|
||||
{
|
||||
title: "T1 Complete",
|
||||
description: "Completing T1 unblocks its dependents: T2 and T3.",
|
||||
},
|
||||
{
|
||||
title: "Parallel Work",
|
||||
description:
|
||||
"T2 and T3 have no dependency on each other. Both can run simultaneously.",
|
||||
},
|
||||
{
|
||||
title: "Partial Unblock",
|
||||
description:
|
||||
"T4 depends on BOTH T2 and T3. It waits for all blockers to complete.",
|
||||
},
|
||||
{
|
||||
title: "Fully Unblocked",
|
||||
description: "All blockers resolved. T4 can now proceed.",
|
||||
},
|
||||
{
|
||||
title: "Graph Resolved",
|
||||
description:
|
||||
"The entire dependency graph is resolved. File-based persistence means this works across context compressions.",
|
||||
},
|
||||
];
|
||||
|
||||
function getTaskStatus(taskId: string, step: number): TaskStatus {
|
||||
const statusMap: Record<string, TaskStatus[]> = {
|
||||
T1: [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"completed",
|
||||
"completed",
|
||||
"completed",
|
||||
"completed",
|
||||
],
|
||||
T2: [
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"completed",
|
||||
"completed",
|
||||
],
|
||||
T3: [
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"in_progress",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"completed",
|
||||
],
|
||||
T4: [
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"blocked",
|
||||
"in_progress",
|
||||
"completed",
|
||||
],
|
||||
T5: [
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
"completed",
|
||||
],
|
||||
function taskStatus(id: string, step: number): Status {
|
||||
const table: Record<string, Status[]> = {
|
||||
T1: ["ready", "ready", "active", "done", "done", "done", "done"],
|
||||
T2: ["blocked", "blocked", "blocked", "ready", "active", "done", "done"],
|
||||
T3: ["blocked", "blocked", "blocked", "ready", "active", "done", "done"],
|
||||
T4: ["blocked", "blocked", "blocked", "blocked", "blocked", "active", "done"],
|
||||
T5: ["blocked", "blocked", "blocked", "blocked", "blocked", "blocked", "done"],
|
||||
};
|
||||
return statusMap[taskId]?.[step] ?? "pending";
|
||||
return table[id]?.[step] ?? "blocked";
|
||||
}
|
||||
|
||||
function isEdgeActive(fromId: string, toId: string, step: number): boolean {
|
||||
const fromStatus = getTaskStatus(fromId, step);
|
||||
const toStatus = getTaskStatus(toId, step);
|
||||
function getTasks(step: number): TaskCard[] {
|
||||
return BASE_TASKS.map((task) => ({ ...task, status: taskStatus(task.id, step) }));
|
||||
}
|
||||
|
||||
function statusClass(status: Status): string {
|
||||
if (status === "done") return "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200";
|
||||
if (status === "active") return "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200";
|
||||
if (status === "ready") return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200";
|
||||
return "border-zinc-200 bg-zinc-50 text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
|
||||
}
|
||||
|
||||
function statusIcon(status: Status) {
|
||||
if (status === "done") return <CheckCircle2 size={15} />;
|
||||
if (status === "active") return <PlayCircle size={15} />;
|
||||
if (status === "ready") return <ClipboardList size={15} />;
|
||||
return <LockKeyhole size={15} />;
|
||||
}
|
||||
|
||||
function TaskCardView({ task }: { task: TaskCard }) {
|
||||
return (
|
||||
fromStatus === "completed" &&
|
||||
(toStatus === "in_progress" || toStatus === "completed")
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.97 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
className={cn("rounded-md border p-3 shadow-sm", statusClass(task.status))}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="font-mono text-xs font-semibold">{task.id}</div>
|
||||
<div className="flex items-center gap-1 text-[11px] font-semibold">
|
||||
{statusIcon(task.status)}
|
||||
{task.status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold leading-snug">{task.title}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{task.blockers.length === 0 ? (
|
||||
<span className="rounded bg-white/70 px-1.5 py-0.5 text-[10px] dark:bg-zinc-950/30">
|
||||
no blockers
|
||||
</span>
|
||||
) : (
|
||||
task.blockers.map((blocker) => (
|
||||
<span key={blocker} className="rounded bg-white/70 px-1.5 py-0.5 font-mono text-[10px] dark:bg-zinc-950/30">
|
||||
waits for {blocker}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStatusColor(status: TaskStatus) {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return {
|
||||
fill: "#e2e8f0",
|
||||
darkFill: "#27272a",
|
||||
stroke: "#cbd5e1",
|
||||
darkStroke: "#3f3f46",
|
||||
text: "#475569",
|
||||
darkText: "#d4d4d8",
|
||||
};
|
||||
case "in_progress":
|
||||
return {
|
||||
fill: "#fef3c7",
|
||||
darkFill: "#451a0340",
|
||||
stroke: "#f59e0b",
|
||||
darkStroke: "#d97706",
|
||||
text: "#b45309",
|
||||
darkText: "#fbbf24",
|
||||
};
|
||||
case "completed":
|
||||
return {
|
||||
fill: "#d1fae5",
|
||||
darkFill: "#06402740",
|
||||
stroke: "#10b981",
|
||||
darkStroke: "#059669",
|
||||
text: "#047857",
|
||||
darkText: "#34d399",
|
||||
};
|
||||
case "blocked":
|
||||
return {
|
||||
fill: "#fecaca",
|
||||
darkFill: "#45050540",
|
||||
stroke: "#ef4444",
|
||||
darkStroke: "#dc2626",
|
||||
text: "#dc2626",
|
||||
darkText: "#f87171",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: TaskStatus): string {
|
||||
switch (status) {
|
||||
case "pending":
|
||||
return "pending";
|
||||
case "in_progress":
|
||||
return "in_progress";
|
||||
case "completed":
|
||||
return "done";
|
||||
case "blocked":
|
||||
return "blocked";
|
||||
}
|
||||
}
|
||||
|
||||
function buildCurvePath(
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number
|
||||
): string {
|
||||
const midX = (x1 + x2) / 2;
|
||||
return `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
|
||||
function Lane({
|
||||
title,
|
||||
subtitle,
|
||||
tasks,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
tasks: TaskCard[];
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">{title}</div>
|
||||
<div className="text-[11px] text-zinc-500 dark:text-zinc-400">{subtitle}</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{tasks.length > 0 ? (
|
||||
tasks.map((task) => <TaskCardView key={`${task.id}-${task.status}`} task={task} />)
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="rounded-md border border-dashed border-zinc-300 px-3 py-6 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"
|
||||
>
|
||||
empty
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskSystem({ title }: { title?: string }) {
|
||||
const {
|
||||
currentStep,
|
||||
totalSteps,
|
||||
next,
|
||||
prev,
|
||||
reset,
|
||||
isPlaying,
|
||||
toggleAutoPlay,
|
||||
} = useSteppedVisualization({ totalSteps: 7, autoPlayInterval: 2500 });
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const tasks = getTasks(step);
|
||||
const current = STEPS[step];
|
||||
|
||||
const isDark = useDarkMode();
|
||||
const palette = useSvgPalette();
|
||||
|
||||
const edges = useMemo(() => {
|
||||
const result: {
|
||||
fromId: string;
|
||||
toId: string;
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}[] = [];
|
||||
for (const task of TASKS) {
|
||||
for (const depId of task.deps) {
|
||||
const dep = TASKS.find((t) => t.id === depId);
|
||||
if (!dep) continue;
|
||||
result.push({
|
||||
fromId: dep.id,
|
||||
toId: task.id,
|
||||
x1: dep.x + NODE_W,
|
||||
y1: dep.y + NODE_H / 2,
|
||||
x2: task.x,
|
||||
y2: task.y + NODE_H / 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const stepInfo = STEP_INFO[currentStep];
|
||||
const blocked = tasks.filter((task) => task.status === "blocked");
|
||||
const ready = tasks.filter((task) => task.status === "ready");
|
||||
const active = tasks.filter((task) => task.status === "active");
|
||||
const done = tasks.filter((task) => task.status === "done");
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Task Dependency Graph"}
|
||||
{title || "Task Board Dependencies"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<svg viewBox="0 0 800 340" className="w-full" aria-label="Task DAG">
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowGray"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrowGreen"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#10b981" />
|
||||
</marker>
|
||||
<marker
|
||||
id="arrowRed"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="6"
|
||||
markerHeight="6"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#ef4444" />
|
||||
</marker>
|
||||
<filter id="glowAmber" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feFlood floodColor="#f59e0b" floodOpacity="0.4" result="color" />
|
||||
<feComposite in="color" in2="blur" operator="in" result="glow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="glow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter
|
||||
id="glowGreen"
|
||||
x="-30%"
|
||||
y="-30%"
|
||||
width="160%"
|
||||
height="160%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="3" result="blur" />
|
||||
<feFlood floodColor="#10b981" floodOpacity="0.3" result="color" />
|
||||
<feComposite in="color" in2="blur" operator="in" result="glow" />
|
||||
<feMerge>
|
||||
<feMergeNode in="glow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Dependency edges */}
|
||||
{edges.map(({ fromId, toId, x1, y1, x2, y2 }) => {
|
||||
const active = isEdgeActive(fromId, toId, currentStep);
|
||||
const toStatus = getTaskStatus(toId, currentStep);
|
||||
const isBlocked = toStatus === "blocked";
|
||||
let markerEnd = "url(#arrowGray)";
|
||||
let strokeColor = palette.arrowFill;
|
||||
if (active) {
|
||||
markerEnd = "url(#arrowGreen)";
|
||||
strokeColor = "#10b981";
|
||||
} else if (isBlocked) {
|
||||
markerEnd = "url(#arrowRed)";
|
||||
strokeColor = "#ef4444";
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.path
|
||||
key={`${fromId}-${toId}`}
|
||||
d={buildCurvePath(x1, y1, x2, y2)}
|
||||
fill="none"
|
||||
markerEnd={markerEnd}
|
||||
animate={{
|
||||
stroke: strokeColor,
|
||||
strokeWidth: active ? 2.5 : 1.5,
|
||||
strokeDasharray: isBlocked ? "6 4" : "none",
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Task nodes */}
|
||||
{TASKS.map((task) => {
|
||||
const status = getTaskStatus(task.id, currentStep);
|
||||
const colors = getStatusColor(status);
|
||||
const statusLabel = getStatusLabel(status);
|
||||
const isActive = status === "in_progress";
|
||||
const isComplete = status === "completed";
|
||||
|
||||
let filterAttr: string | undefined;
|
||||
if (isActive) filterAttr = "url(#glowAmber)";
|
||||
else if (isComplete) filterAttr = "url(#glowGreen)";
|
||||
|
||||
return (
|
||||
<g key={task.id} filter={filterAttr}>
|
||||
<motion.rect
|
||||
x={task.x}
|
||||
y={task.y}
|
||||
width={NODE_W}
|
||||
height={NODE_H}
|
||||
rx={8}
|
||||
animate={{
|
||||
fill: isDark ? colors.darkFill : colors.fill,
|
||||
stroke: isDark ? colors.darkStroke : colors.stroke,
|
||||
}}
|
||||
strokeWidth={isActive ? 2 : 1.5}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<text
|
||||
x={task.x + NODE_W / 2}
|
||||
y={task.y + 20}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="11"
|
||||
fontWeight="600"
|
||||
fill={isDark ? colors.darkText : colors.text}
|
||||
>
|
||||
{task.label}
|
||||
</text>
|
||||
<text
|
||||
x={task.x + NODE_W / 2}
|
||||
y={task.y + 38}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="9"
|
||||
fontFamily="monospace"
|
||||
fill={isDark ? colors.darkText : colors.text}
|
||||
opacity={0.8}
|
||||
>
|
||||
{statusLabel}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Blocked annotation for T4 at step 4 */}
|
||||
{currentStep === 4 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, y: 5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<rect
|
||||
x={445}
|
||||
y={118}
|
||||
width={170}
|
||||
height={22}
|
||||
rx={4}
|
||||
fill={isDark ? "#451a03" : "#fef2f2"}
|
||||
stroke={isDark ? "#dc2626" : "#fca5a5"}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={530}
|
||||
y={132}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize="9"
|
||||
fontFamily="monospace"
|
||||
fill={isDark ? "#f87171" : "#dc2626"}
|
||||
>
|
||||
Blocked: waiting on T3
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* File persistence indicator */}
|
||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-800/60">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
className="h-5 w-5 flex-shrink-0 text-zinc-400 dark:text-zinc-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776"
|
||||
/>
|
||||
</svg>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs font-medium text-zinc-600 dark:text-zinc-300">
|
||||
.tasks/tasks.json
|
||||
</span>
|
||||
<span className="text-[10px] text-zinc-400 dark:text-zinc-500">
|
||||
Persisted to disk -- survives context compaction
|
||||
</span>
|
||||
<div className="mb-4 flex flex-col gap-3 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<FileJson size={16} />
|
||||
.tasks board
|
||||
</div>
|
||||
<motion.div
|
||||
className="ml-auto h-2 w-2 rounded-full bg-emerald-500"
|
||||
animate={{ opacity: [1, 0.3, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 2 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded bg-zinc-300 dark:bg-zinc-600" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
pending
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded bg-amber-400 dark:bg-amber-600" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
in_progress
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded bg-emerald-400 dark:bg-emerald-600" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
completed
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-3 w-3 rounded bg-red-400 dark:bg-red-600" />
|
||||
<span className="text-[10px] text-zinc-500 dark:text-zinc-400">
|
||||
blocked
|
||||
</span>
|
||||
<div className="grid grid-cols-4 gap-2 text-center text-xs">
|
||||
<div className="rounded bg-zinc-100 px-2 py-1 dark:bg-zinc-900">{blocked.length} blocked</div>
|
||||
<div className="rounded bg-amber-100 px-2 py-1 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">{ready.length} ready</div>
|
||||
<div className="rounded bg-blue-100 px-2 py-1 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">{active.length} active</div>
|
||||
<div className="rounded bg-emerald-100 px-2 py-1 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">{done.length} done</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-4">
|
||||
<Lane title="Waiting" subtitle="blocked by another card" tasks={blocked} />
|
||||
<Lane title="Ready" subtitle="can be claimed now" tasks={ready} />
|
||||
<Lane title="Working" subtitle="currently in progress" tasks={active} />
|
||||
<Lane title="Done" subtitle="unlocks dependents" tasks={done} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs leading-relaxed text-blue-800 dark:border-blue-900 dark:bg-blue-950/30 dark:text-blue-200">
|
||||
A dependency is not an arrow students must trace. It is a visible blocker badge on the card.
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={current.title}
|
||||
stepDescription={current.desc}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
onPrev={prev}
|
||||
onNext={next}
|
||||
onReset={reset}
|
||||
isPlaying={isPlaying}
|
||||
onToggleAutoPlay={toggleAutoPlay}
|
||||
stepTitle={stepInfo.title}
|
||||
stepDescription={stepInfo.description}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,392 +1,237 @@
|
||||
"use client";
|
||||
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Inbox, MessageSquareText, UsersRound } from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// -- Layout constants --
|
||||
const SVG_W = 560;
|
||||
const SVG_H = 340;
|
||||
const AGENT_R = 40;
|
||||
type AgentId = "lead" | "coder" | "reviewer";
|
||||
|
||||
// Agent positions: inverted triangle (Lead top-center, Coder bottom-left, Reviewer bottom-right)
|
||||
const AGENTS = [
|
||||
{ id: "lead", label: "Lead", cx: SVG_W / 2, cy: 70, inbox: "lead.jsonl" },
|
||||
{ id: "coder", label: "Coder", cx: 140, cy: 230, inbox: "coder.jsonl" },
|
||||
{ id: "reviewer", label: "Reviewer", cx: SVG_W - 140, cy: 230, inbox: "reviewer.jsonl" },
|
||||
] as const;
|
||||
|
||||
// Inbox tray dimensions, positioned below each agent circle
|
||||
const TRAY_W = 72;
|
||||
const TRAY_H = 22;
|
||||
const TRAY_OFFSET_Y = AGENT_R + 14;
|
||||
|
||||
// Message block dimensions
|
||||
const MSG_W = 60;
|
||||
const MSG_H = 20;
|
||||
|
||||
function agentById(id: string) {
|
||||
return AGENTS.find((a) => a.id === id)!;
|
||||
interface Mail {
|
||||
id: string;
|
||||
from: AgentId;
|
||||
to: AgentId;
|
||||
subject: string;
|
||||
body: string;
|
||||
appearsAt: number;
|
||||
consumedAt?: number;
|
||||
}
|
||||
|
||||
function trayCenter(id: string) {
|
||||
const a = agentById(id);
|
||||
return { x: a.cx, y: a.cy + TRAY_OFFSET_Y + TRAY_H / 2 };
|
||||
}
|
||||
|
||||
// Step configuration
|
||||
const STEPS = [
|
||||
{ title: "The Team", desc: "Teams use a leader-worker pattern. Each teammate has a file-based mailbox inbox." },
|
||||
{ title: "Lead Assigns Work", desc: "Communication is async: write a message to the recipient's .jsonl inbox file." },
|
||||
{ title: "Read Inbox", desc: "Teammates poll their inbox before each LLM call. New messages become context." },
|
||||
{ title: "Independent Work", desc: "Each teammate runs its own agent loop independently." },
|
||||
{ title: "Pass Result", desc: "Results flow through the same mailbox mechanism. All communication is via files." },
|
||||
{ title: "Feedback Loop", desc: "The mailbox pattern supports any communication topology: linear, broadcast, round-robin." },
|
||||
{ title: "File-Based Coordination", desc: "No shared memory, no locks. All coordination through append-only files. Simple, robust, debuggable." },
|
||||
const AGENTS: { id: AgentId; label: string; role: string }[] = [
|
||||
{ id: "lead", label: "Lead", role: "splits work and reads results" },
|
||||
{ id: "coder", label: "Coder", role: "implements one slice" },
|
||||
{ id: "reviewer", label: "Reviewer", role: "checks the result" },
|
||||
];
|
||||
|
||||
// Helper: determine which agent glows at each step
|
||||
function agentGlows(agentId: string, step: number): boolean {
|
||||
if (step === 1 && agentId === "lead") return true;
|
||||
if (step === 2 && agentId === "coder") return true;
|
||||
if (step === 3 && agentId === "coder") return true;
|
||||
if (step === 4 && agentId === "coder") return true;
|
||||
if (step === 5 && agentId === "reviewer") return true;
|
||||
return false;
|
||||
const MAIL: Mail[] = [
|
||||
{
|
||||
id: "assign",
|
||||
from: "lead",
|
||||
to: "coder",
|
||||
subject: "Build login UI",
|
||||
body: "Please implement the login form and report back.",
|
||||
appearsAt: 1,
|
||||
consumedAt: 2,
|
||||
},
|
||||
{
|
||||
id: "result",
|
||||
from: "coder",
|
||||
to: "reviewer",
|
||||
subject: "Login UI done",
|
||||
body: "Files changed, ready for review.",
|
||||
appearsAt: 4,
|
||||
consumedAt: 5,
|
||||
},
|
||||
{
|
||||
id: "feedback",
|
||||
from: "reviewer",
|
||||
to: "lead",
|
||||
subject: "Review passed",
|
||||
body: "No blockers. One small polish note.",
|
||||
appearsAt: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "A Team Is Mailboxes",
|
||||
desc: "Each agent has its own inbox file. The team does not need shared memory to coordinate.",
|
||||
},
|
||||
{
|
||||
title: "Lead Drops a Card",
|
||||
desc: "Assigning work means appending a message to the coder's inbox.",
|
||||
},
|
||||
{
|
||||
title: "Coder Reads Before Thinking",
|
||||
desc: "Before its next model call, the coder drains its inbox and turns messages into context.",
|
||||
},
|
||||
{
|
||||
title: "Coder Works Alone",
|
||||
desc: "The coder now runs its own loop. The lead does not have to hold the full context.",
|
||||
},
|
||||
{
|
||||
title: "Result Becomes Mail",
|
||||
desc: "The coder sends a result card to the reviewer through the same mailbox mechanism.",
|
||||
},
|
||||
{
|
||||
title: "Reviewer Sends Feedback",
|
||||
desc: "Review feedback is just another card. The lead reads it from its inbox.",
|
||||
},
|
||||
{
|
||||
title: "Files Are the Coordination Layer",
|
||||
desc: "The whole team is inspectable as append-only inbox files: lead.jsonl, coder.jsonl, reviewer.jsonl.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function visibleMail(agent: AgentId, step: number) {
|
||||
return MAIL.filter((mail) => mail.to === agent && mail.appearsAt <= step && (mail.consumedAt === undefined || step < mail.consumedAt));
|
||||
}
|
||||
|
||||
// Helper: determine which inbox tray has a message sitting in it
|
||||
function trayHasMessage(agentId: string, step: number): boolean {
|
||||
if (step === 2 && agentId === "coder") return true;
|
||||
if (step === 4 && agentId === "reviewer") return false;
|
||||
if (step === 5 && agentId === "reviewer") return true;
|
||||
return false;
|
||||
function agentState(agent: AgentId, step: number): "waiting" | "reading" | "working" | "reviewing" | "done" {
|
||||
if (agent === "lead" && step === 1) return "working";
|
||||
if (agent === "coder" && step === 2) return "reading";
|
||||
if (agent === "coder" && (step === 3 || step === 4)) return "working";
|
||||
if (agent === "reviewer" && step === 5) return "reviewing";
|
||||
if (agent === "lead" && step >= 5) return "reading";
|
||||
if (step === 6) return "done";
|
||||
return "waiting";
|
||||
}
|
||||
|
||||
// Animated message that travels from one point to another
|
||||
function TravelingMessage({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
label,
|
||||
delay = 0,
|
||||
}: {
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
label: string;
|
||||
delay?: number;
|
||||
}) {
|
||||
function stateClass(state: ReturnType<typeof agentState>) {
|
||||
if (state === "working") return "border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30";
|
||||
if (state === "reading" || state === "reviewing") return "border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30";
|
||||
if (state === "done") return "border-emerald-300 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30";
|
||||
return "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900";
|
||||
}
|
||||
|
||||
function MailCard({ mail }: { mail: Mail }) {
|
||||
return (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: [0, 1, 1, 0.8],
|
||||
x: [fromX - MSG_W / 2, fromX - MSG_W / 2, toX - MSG_W / 2, toX - MSG_W / 2],
|
||||
y: [fromY - MSG_H / 2, fromY - MSG_H / 2, toY - MSG_H / 2, toY - MSG_H / 2],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.4,
|
||||
delay,
|
||||
times: [0, 0.1, 0.7, 1],
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
className="rounded-md border border-amber-200 bg-amber-50 p-3 text-amber-900 shadow-sm dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
|
||||
>
|
||||
<rect width={MSG_W} height={MSG_H} rx={4} fill="#f59e0b" />
|
||||
<text
|
||||
x={MSG_W / 2}
|
||||
y={MSG_H / 2 + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize={8}
|
||||
fontWeight={600}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
</motion.g>
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="font-mono text-[11px] font-semibold">{mail.from} -> {mail.to}</span>
|
||||
<MessageSquareText size={14} />
|
||||
</div>
|
||||
<div className="text-sm font-semibold leading-snug">{mail.subject}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed opacity-85">{mail.body}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Faded trace line between two agents
|
||||
function TraceLine({ from, to, strokeColor }: { from: string; to: string; strokeColor: string }) {
|
||||
const f = trayCenter(from);
|
||||
const t = trayCenter(to);
|
||||
function AgentPanel({ agent, step }: { agent: (typeof AGENTS)[number]; step: number }) {
|
||||
const state = agentState(agent.id, step);
|
||||
const inbox = visibleMail(agent.id, step);
|
||||
|
||||
return (
|
||||
<motion.line
|
||||
x1={f.x}
|
||||
y1={f.y}
|
||||
x2={t.x}
|
||||
y2={t.y}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={1.5}
|
||||
strokeDasharray="6 4"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.4 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
/>
|
||||
<div className={cn("rounded-lg border p-3 transition-colors", stateClass(state))}>
|
||||
<div className="mb-3 flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-base font-bold text-zinc-900 dark:text-zinc-100">{agent.label}</div>
|
||||
<div className="text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">{agent.role}</div>
|
||||
</div>
|
||||
<span className="rounded-md bg-white px-2 py-1 text-[11px] font-semibold capitalize text-zinc-600 shadow-sm dark:bg-zinc-900 dark:text-zinc-300">
|
||||
{state}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<Inbox size={15} />
|
||||
{agent.id}.jsonl
|
||||
</div>
|
||||
<div className="min-h-[118px] space-y-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{inbox.length > 0 ? (
|
||||
inbox.map((mail) => <MailCard key={mail.id} mail={mail} />)
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="rounded-md border border-dashed border-zinc-300 px-3 py-8 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"
|
||||
>
|
||||
inbox empty
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityLog({ step }: { step: number }) {
|
||||
const items = [
|
||||
"team config creates lead, coder, reviewer",
|
||||
"lead appends task card to coder.jsonl",
|
||||
"coder drains inbox before model call",
|
||||
"coder works in its own loop",
|
||||
"coder appends result to reviewer.jsonl",
|
||||
"reviewer appends feedback to lead.jsonl",
|
||||
"all coordination remains visible on disk",
|
||||
].slice(0, step + 1);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<UsersRound size={16} />
|
||||
What changed
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<motion.div
|
||||
key={item}
|
||||
initial={{ opacity: 0, x: 8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
className="rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200"
|
||||
>
|
||||
{item}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentTeams({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const palette = useSvgPalette();
|
||||
const current = STEPS[step];
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Agent Team Mailboxes"}
|
||||
</h2>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900 min-h-[500px]">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* SVG visualization */}
|
||||
<div className="flex-1">
|
||||
<svg viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="w-full">
|
||||
<defs>
|
||||
<filter id="agent-glow">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Step 6: trace lines */}
|
||||
{step === 6 && (
|
||||
<>
|
||||
<TraceLine from="lead" to="coder" strokeColor={palette.edgeStroke} />
|
||||
<TraceLine from="coder" to="reviewer" strokeColor={palette.edgeStroke} />
|
||||
<TraceLine from="reviewer" to="lead" strokeColor={palette.edgeStroke} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Agent nodes */}
|
||||
{AGENTS.map((agent) => {
|
||||
const glowing = agentGlows(agent.id, step);
|
||||
const pulsing = step === 3 && agent.id === "coder";
|
||||
|
||||
return (
|
||||
<g key={agent.id}>
|
||||
{/* Agent circle */}
|
||||
<motion.circle
|
||||
cx={agent.cx}
|
||||
cy={agent.cy}
|
||||
r={AGENT_R}
|
||||
fill={glowing ? "#3b82f6" : palette.edgeStroke}
|
||||
stroke={glowing ? "#60a5fa" : palette.labelFill}
|
||||
strokeWidth={2}
|
||||
animate={{
|
||||
scale: pulsing ? [1, 1.08, 1] : 1,
|
||||
fill: glowing ? "#3b82f6" : palette.edgeStroke,
|
||||
}}
|
||||
transition={
|
||||
pulsing
|
||||
? { duration: 0.8, repeat: Infinity, ease: "easeInOut" }
|
||||
: { duration: 0.4 }
|
||||
}
|
||||
filter={glowing ? "url(#agent-glow)" : undefined}
|
||||
/>
|
||||
{/* Agent label */}
|
||||
<text
|
||||
x={agent.cx}
|
||||
y={agent.cy + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize={12}
|
||||
fontWeight={700}
|
||||
>
|
||||
{agent.label}
|
||||
</text>
|
||||
|
||||
{/* Inbox tray (file icon style) */}
|
||||
<rect
|
||||
x={agent.cx - TRAY_W / 2}
|
||||
y={agent.cy + TRAY_OFFSET_Y}
|
||||
width={TRAY_W}
|
||||
height={TRAY_H}
|
||||
rx={3}
|
||||
fill={trayHasMessage(agent.id, step) ? "#fef3c7" : palette.nodeFill}
|
||||
stroke={trayHasMessage(agent.id, step) ? "#f59e0b" : palette.nodeStroke}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
x={agent.cx}
|
||||
y={agent.cy + TRAY_OFFSET_Y + TRAY_H / 2 + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={8}
|
||||
fontFamily="monospace"
|
||||
fill={palette.labelFill}
|
||||
>
|
||||
{agent.inbox}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Step 0: team config card */}
|
||||
{step === 0 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<rect x={12} y={12} width={100} height={44} rx={4} fill="#f0f9ff" stroke="#bae6fd" strokeWidth={1} />
|
||||
<text x={20} y={28} fontSize={7} fontFamily="monospace" fill="#0284c7" fontWeight={600}>
|
||||
team.config
|
||||
</text>
|
||||
<text x={20} y={40} fontSize={6} fontFamily="monospace" fill="#0369a1">
|
||||
workers: [coder, reviewer]
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
|
||||
{/* Step 1: message from Lead to Coder inbox */}
|
||||
<AnimatePresence>
|
||||
{step === 1 && (
|
||||
<TravelingMessage
|
||||
key="msg-lead-coder"
|
||||
fromX={agentById("lead").cx}
|
||||
fromY={agentById("lead").cy + AGENT_R}
|
||||
toX={agentById("coder").cx}
|
||||
toY={agentById("coder").cy + TRAY_OFFSET_Y + TRAY_H / 2}
|
||||
label="task:login"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Step 2: message from Coder inbox to Coder circle */}
|
||||
<AnimatePresence>
|
||||
{step === 2 && (
|
||||
<TravelingMessage
|
||||
key="msg-inbox-coder"
|
||||
fromX={agentById("coder").cx}
|
||||
fromY={agentById("coder").cy + TRAY_OFFSET_Y + TRAY_H / 2}
|
||||
toX={agentById("coder").cx}
|
||||
toY={agentById("coder").cy}
|
||||
label="task:login"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Step 3: Coder working, result appears */}
|
||||
<AnimatePresence>
|
||||
{step === 3 && (
|
||||
<motion.g
|
||||
key="result-msg"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.8, duration: 0.4 }}
|
||||
>
|
||||
<rect
|
||||
x={agentById("coder").cx + AGENT_R + 8}
|
||||
y={agentById("coder").cy - MSG_H / 2}
|
||||
width={MSG_W + 10}
|
||||
height={MSG_H}
|
||||
rx={4}
|
||||
fill="#10b981"
|
||||
/>
|
||||
<text
|
||||
x={agentById("coder").cx + AGENT_R + 8 + (MSG_W + 10) / 2}
|
||||
y={agentById("coder").cy + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fill="white"
|
||||
fontSize={8}
|
||||
fontWeight={600}
|
||||
>
|
||||
result:done
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Step 4: Coder result message travels to Reviewer inbox */}
|
||||
<AnimatePresence>
|
||||
{step === 4 && (
|
||||
<TravelingMessage
|
||||
key="msg-coder-reviewer"
|
||||
fromX={agentById("coder").cx + AGENT_R + 8 + (MSG_W + 10) / 2}
|
||||
fromY={agentById("coder").cy}
|
||||
toX={agentById("reviewer").cx}
|
||||
toY={agentById("reviewer").cy + TRAY_OFFSET_Y + TRAY_H / 2}
|
||||
label="result:done"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Step 5: Reviewer reads inbox, sends feedback to Lead */}
|
||||
<AnimatePresence>
|
||||
{step === 5 && (
|
||||
<>
|
||||
<TravelingMessage
|
||||
key="msg-reviewer-read"
|
||||
fromX={agentById("reviewer").cx}
|
||||
fromY={agentById("reviewer").cy + TRAY_OFFSET_Y + TRAY_H / 2}
|
||||
toX={agentById("reviewer").cx}
|
||||
toY={agentById("reviewer").cy}
|
||||
label="result:done"
|
||||
delay={0}
|
||||
/>
|
||||
<TravelingMessage
|
||||
key="msg-reviewer-lead"
|
||||
fromX={agentById("reviewer").cx}
|
||||
fromY={agentById("reviewer").cy}
|
||||
toX={agentById("lead").cx}
|
||||
toY={agentById("lead").cy + TRAY_OFFSET_Y + TRAY_H / 2}
|
||||
label="feedback"
|
||||
delay={1.0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Step 6: filesystem tree */}
|
||||
{step === 6 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<rect x={SVG_W / 2 - 110} y={SVG_H - 80} width={220} height={68} rx={6} fill={palette.bgSubtle} stroke={palette.nodeStroke} strokeWidth={1} />
|
||||
<text x={SVG_W / 2 - 96} y={SVG_H - 60} fontSize={8} fontFamily="monospace" fill={palette.labelFill}>
|
||||
.claude/teams/project/
|
||||
</text>
|
||||
<text x={SVG_W / 2 - 82} y={SVG_H - 48} fontSize={8} fontFamily="monospace" fill="#60a5fa">
|
||||
lead.jsonl
|
||||
</text>
|
||||
<text x={SVG_W / 2 - 82} y={SVG_H - 36} fontSize={8} fontFamily="monospace" fill="#60a5fa">
|
||||
coder.jsonl
|
||||
</text>
|
||||
<text x={SVG_W / 2 - 82} y={SVG_H - 24} fontSize={8} fontFamily="monospace" fill="#60a5fa">
|
||||
reviewer.jsonl
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="grid gap-3 xl:grid-cols-[1fr_1fr_1fr_0.9fr]">
|
||||
{AGENTS.map((agent) => (
|
||||
<AgentPanel key={agent.id} agent={agent} step={step} />
|
||||
))}
|
||||
<ActivityLog step={step} />
|
||||
</div>
|
||||
|
||||
{/* Step controls */}
|
||||
<div className="mt-4">
|
||||
<StepControls
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={STEPS[step].title}
|
||||
stepDescription={STEPS[step].desc}
|
||||
/>
|
||||
</div>
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={current.title}
|
||||
stepDescription={current.desc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
392
web/src/components/visualizations/s09-memory.tsx
Normal file
392
web/src/components/visualizations/s09-memory.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { BookOpen, CheckCircle2, FileText, Inbox, Search, Sparkles } from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MemoryType = "feedback" | "project" | "reference";
|
||||
|
||||
interface MemoryFile {
|
||||
id: string;
|
||||
type: MemoryType;
|
||||
title: string;
|
||||
filename: string;
|
||||
description: string;
|
||||
body: string;
|
||||
relevant?: boolean;
|
||||
}
|
||||
|
||||
const MEMORY_FILES: MemoryFile[] = [
|
||||
{
|
||||
id: "visual-preference",
|
||||
type: "feedback",
|
||||
title: "Beginner visual preference",
|
||||
filename: "lcc_visual_preference.md",
|
||||
description: "Use concrete mental models for LCC web pages.",
|
||||
body: "Prefer cards, boards, shelves, and workbenches over abstract flowcharts.",
|
||||
relevant: true,
|
||||
},
|
||||
{
|
||||
id: "project-path",
|
||||
type: "project",
|
||||
title: "LCC web paths",
|
||||
filename: "lcc_web_paths.md",
|
||||
description: "Web app reads root lesson folders and generated JSON.",
|
||||
body: "Build from web/, extract content from s01-s20 lesson directories.",
|
||||
},
|
||||
{
|
||||
id: "test-command",
|
||||
type: "reference",
|
||||
title: "Verification commands",
|
||||
filename: "lcc_test_commands.md",
|
||||
description: "Useful smoke checks for the course website.",
|
||||
body: "Run npm run build, then browser-check /zh/s09 and /zh/s20.",
|
||||
},
|
||||
];
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "A Fact Worth Keeping",
|
||||
desc: "The user says something that should survive future sessions.",
|
||||
},
|
||||
{
|
||||
title: "Stamp It After the Turn",
|
||||
desc: "Memory extraction happens after useful work, so the main loop stays focused.",
|
||||
},
|
||||
{
|
||||
title: "Write One Memory File",
|
||||
desc: "The durable detail goes into a Markdown file with a readable title and metadata.",
|
||||
},
|
||||
{
|
||||
title: "Update the Catalog",
|
||||
desc: "MEMORY.md is the cheap catalog: short enough to keep nearby.",
|
||||
},
|
||||
{
|
||||
title: "A Future Request Arrives",
|
||||
desc: "Later, the agent sees a new request and the catalog, not the whole library.",
|
||||
},
|
||||
{
|
||||
title: "Catalog Picks One",
|
||||
desc: "Selection chooses the one memory file that is relevant now.",
|
||||
},
|
||||
{
|
||||
title: "Build the Reading Stack",
|
||||
desc: "Only the selected memory joins the current request before the model call.",
|
||||
},
|
||||
{
|
||||
title: "Continuity Without Clutter",
|
||||
desc: "The answer reflects old context while unrelated memories stay on the shelf.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function typeClass(type: MemoryType): string {
|
||||
if (type === "feedback") return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-200";
|
||||
if (type === "project") return "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-200";
|
||||
return "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200";
|
||||
}
|
||||
|
||||
function Surface({
|
||||
title,
|
||||
icon,
|
||||
active,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg border p-4 transition-colors",
|
||||
active
|
||||
? "border-violet-300 bg-violet-50 dark:border-violet-800 dark:bg-violet-950/30"
|
||||
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex min-w-0 items-center gap-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
|
||||
active
|
||||
? "bg-violet-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 break-words">{title}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QuoteCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 text-lg leading-relaxed text-zinc-700 shadow-sm dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogRow({ file, visible, selected }: { file: MemoryFile; visible: boolean; selected: boolean }) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg border p-3",
|
||||
selected
|
||||
? "border-violet-300 bg-violet-50 dark:border-violet-800 dark:bg-violet-950/40"
|
||||
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{file.title}
|
||||
</div>
|
||||
<span className={cn("shrink-0 rounded px-2 py-0.5 text-[11px] font-semibold", typeClass(file.type))}>
|
||||
{file.type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="line-clamp-2 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">
|
||||
{file.description}
|
||||
</div>
|
||||
<div className="mt-2 truncate font-mono text-[11px] text-zinc-400">
|
||||
{file.filename}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoryDetail({ file, selected }: { file: MemoryFile; selected: boolean }) {
|
||||
return (
|
||||
<motion.div
|
||||
key={`${file.id}-${selected}`}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"rounded-xl border p-4 shadow-sm",
|
||||
selected
|
||||
? "border-violet-300 bg-violet-50 dark:border-violet-800 dark:bg-violet-950/40"
|
||||
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="break-words text-base font-bold text-zinc-900 dark:text-zinc-100">
|
||||
{file.title}
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{file.filename}
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<span className="flex shrink-0 items-center gap-1 rounded-full bg-violet-500 px-2.5 py-1 text-xs font-semibold text-white">
|
||||
<CheckCircle2 size={13} />
|
||||
selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-white p-3 text-sm leading-relaxed text-zinc-700 dark:bg-zinc-900 dark:text-zinc-200">
|
||||
{file.body}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MemoryVisualization({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const current = STEPS[step];
|
||||
const selectedFile = MEMORY_FILES[0];
|
||||
const catalogVisible = step >= 3;
|
||||
const futureVisible = step >= 4;
|
||||
const selected = step >= 5;
|
||||
const injected = step >= 6;
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Memory Library"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-4 grid gap-2 text-sm sm:grid-cols-3">
|
||||
{["learn", "catalog", "recall"].map((label, index) => {
|
||||
const active =
|
||||
(index === 0 && step <= 2) ||
|
||||
(index === 1 && (step === 3 || selected)) ||
|
||||
(index === 2 && futureVisible);
|
||||
return (
|
||||
<div
|
||||
key={label}
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 font-medium capitalize",
|
||||
active
|
||||
? "bg-violet-100 text-violet-800 dark:bg-violet-900/30 dark:text-violet-200"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{index + 1}. {label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<Surface title="Session A: learn" icon={<Inbox size={20} />} active={step <= 2}>
|
||||
<div className="space-y-3">
|
||||
<QuoteCard>"Please keep LCC pages concrete for beginners."</QuoteCard>
|
||||
<AnimatePresence>
|
||||
{step >= 1 && (
|
||||
<motion.div
|
||||
key="stamp"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm leading-relaxed text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200"
|
||||
>
|
||||
<div className="mb-1 text-base font-semibold">Memory extractor stamp</div>
|
||||
Save a durable preference after the useful work is done.
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{step >= 2 && <MemoryDetail file={selectedFile} selected={false} />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface title="Session B: recall" icon={selected ? <Search size={20} /> : <Sparkles size={20} />} active={futureVisible}>
|
||||
<div className="space-y-3">
|
||||
{!futureVisible && <EmptyState label="future request has not arrived" />}
|
||||
{futureVisible && <QuoteCard>"Continue improving the web lesson visuals."</QuoteCard>}
|
||||
{selected && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="rounded-lg border border-violet-200 bg-violet-50 p-4 text-sm leading-relaxed text-violet-900 dark:border-violet-900 dark:bg-violet-950/40 dark:text-violet-200"
|
||||
>
|
||||
Catalog search selects <span className="font-mono">lcc_visual_preference.md</span>
|
||||
</motion.div>
|
||||
)}
|
||||
{injected && (
|
||||
<div className="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-3 text-base font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
Reading stack before LLM
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="rounded-lg bg-zinc-100 px-3 py-2 text-sm dark:bg-zinc-800">current request</div>
|
||||
<div className="rounded-lg bg-violet-100 px-3 py-2 text-sm text-violet-800 dark:bg-violet-900/30 dark:text-violet-200">
|
||||
selected memory detail
|
||||
</div>
|
||||
{step >= 7 && (
|
||||
<div className="rounded-lg bg-emerald-100 px-3 py-2 text-sm text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200">
|
||||
answer keeps the user's preference
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
|
||||
<Surface
|
||||
title=".memory library"
|
||||
icon={<BookOpen size={20} />}
|
||||
active={catalogVisible || selected}
|
||||
className="mt-3"
|
||||
>
|
||||
<div className="grid min-w-0 gap-3 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<div className="min-w-0 rounded-xl border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<FileText size={16} />
|
||||
MEMORY.md catalog
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{MEMORY_FILES.map((file, index) => (
|
||||
<CatalogRow
|
||||
key={file.id}
|
||||
file={file}
|
||||
visible={catalogVisible && (index === 0 || step >= 4)}
|
||||
selected={selected && file.relevant === true}
|
||||
/>
|
||||
))}
|
||||
{!catalogVisible && <EmptyState label="catalog has not been rebuilt yet" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="mb-3 text-sm font-semibold text-zinc-500 dark:text-zinc-400">
|
||||
Memory file preview
|
||||
</div>
|
||||
{step >= 2 ? (
|
||||
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.15fr)_minmax(220px,0.85fr)]">
|
||||
<MemoryDetail file={selectedFile} selected={selected} />
|
||||
<div className="space-y-2">
|
||||
{MEMORY_FILES.slice(1).map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900"
|
||||
>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="min-w-0 truncate text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
{file.title}
|
||||
</div>
|
||||
<span className={cn("shrink-0 rounded px-2 py-0.5 text-[11px] font-semibold", typeClass(file.type))}>
|
||||
not loaded
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{file.description}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState label="no files on the shelf yet" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<div className="mt-4 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-relaxed text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
Beginner rule: the catalog stays cheap and readable; full memory files are borrowed only when the current request needs them.
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={current.title}
|
||||
stepDescription={current.desc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
260
web/src/components/visualizations/s10-system-prompt.tsx
Normal file
260
web/src/components/visualizations/s10-system-prompt.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Boxes, Brain, CheckCircle2, FileText, KeyRound, Library, Rocket, Wrench } from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Runtime State Arrives",
|
||||
desc: "The prompt is not a fixed paragraph; it starts from workspace, tools, memory, and skills.",
|
||||
mode: "state",
|
||||
},
|
||||
{
|
||||
title: "Section Shelf Selects Owners",
|
||||
desc: "Each subsystem owns one prompt section, so a bad rule has a place to debug.",
|
||||
mode: "sections",
|
||||
},
|
||||
{
|
||||
title: "Context Key Checks the Cache",
|
||||
desc: "The same runtime state produces the same deterministic cache key.",
|
||||
mode: "cache-miss",
|
||||
},
|
||||
{
|
||||
title: "Prompt Is Assembled",
|
||||
desc: "Selected sections are joined into one system prompt that the LLM can read.",
|
||||
mode: "assemble",
|
||||
},
|
||||
{
|
||||
title: "Same Key Reuses the Prompt",
|
||||
desc: "If nothing changed, the runtime skips assembly and reuses the cached prompt.",
|
||||
mode: "cache-hit",
|
||||
},
|
||||
{
|
||||
title: "LLM Sees the Built Prompt",
|
||||
desc: "The model receives a traceable product of runtime state, not a stale hardcoded string.",
|
||||
mode: "llm",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const SOURCES = [
|
||||
{ id: "workspace", label: "workspace", value: "/repo", icon: <Boxes size={16} />, tone: "blue" },
|
||||
{ id: "tools", label: "tools", value: "bash, read_file", icon: <Wrench size={16} />, tone: "emerald" },
|
||||
{ id: "memory", label: "memory", value: "enabled", icon: <Brain size={16} />, tone: "amber" },
|
||||
{ id: "skills", label: "skills", value: "code-review", icon: <Library size={16} />, tone: "violet" },
|
||||
] as const;
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: "identity", title: "identity", body: "You are a helpful coding agent.", owner: "core" },
|
||||
{ id: "tools", title: "tools", body: "Available tools: bash, read_file.", owner: "tool registry" },
|
||||
{ id: "workspace", title: "workspace", body: "Current workspace: /repo.", owner: "runtime" },
|
||||
{ id: "memory", title: "memory + skills", body: "Load memory index and code-review skill.", owner: "context loader" },
|
||||
] as const;
|
||||
|
||||
type StepMode = (typeof STEPS)[number]["mode"];
|
||||
type Tone = "blue" | "emerald" | "amber" | "violet" | "zinc";
|
||||
|
||||
function toneClass(tone: Tone, active = true) {
|
||||
if (!active) return "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200";
|
||||
if (tone === "blue") return "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200";
|
||||
if (tone === "emerald") return "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200";
|
||||
if (tone === "amber") return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200";
|
||||
if (tone === "violet") return "border-violet-200 bg-violet-50 text-violet-800 dark:border-violet-900 dark:bg-violet-950/40 dark:text-violet-200";
|
||||
return "border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200";
|
||||
}
|
||||
|
||||
function Surface({
|
||||
title,
|
||||
icon,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg border p-4 transition-colors",
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 dark:border-blue-900 dark:bg-blue-950/30"
|
||||
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex min-w-0 items-center gap-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
|
||||
active ? "bg-blue-500 text-white" : "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 text-wrap">{title}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SourceCard({
|
||||
source,
|
||||
active,
|
||||
}: {
|
||||
source: (typeof SOURCES)[number];
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<motion.div layout animate={active ? { y: -1 } : { y: 0 }} className={cn("rounded-lg border p-3", toneClass(source.tone as Tone, active))}>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
|
||||
{source.icon}
|
||||
{source.label}
|
||||
</div>
|
||||
<code className="block min-w-0 whitespace-pre-wrap break-words rounded bg-white/70 px-2 py-1 font-mono text-xs dark:bg-zinc-950/30">
|
||||
{source.value}
|
||||
</code>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
section,
|
||||
active,
|
||||
assembled,
|
||||
}: {
|
||||
section: (typeof SECTIONS)[number];
|
||||
active: boolean;
|
||||
assembled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg border p-3",
|
||||
active || assembled ? toneClass("emerald") : "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="mb-1 flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="min-w-0 break-words font-mono text-sm font-semibold leading-snug">{section.title}</div>
|
||||
{(active || assembled) && <CheckCircle2 size={15} className="shrink-0" />}
|
||||
</div>
|
||||
<div className="mb-2 text-xs leading-relaxed text-zinc-500 dark:text-zinc-400">owner: {section.owner}</div>
|
||||
<div className="text-sm leading-relaxed text-zinc-700 dark:text-zinc-200">{section.body}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function CachePanel({ mode }: { mode: StepMode }) {
|
||||
const isHit = mode === "cache-hit";
|
||||
const isActive = mode === "cache-miss" || mode === "cache-hit";
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border p-3", toneClass(isHit ? "emerald" : "amber", isActive))}>
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
|
||||
<KeyRound size={16} />
|
||||
context key
|
||||
</div>
|
||||
<code className="block min-w-0 whitespace-pre-wrap break-words rounded bg-white/70 p-2 font-mono text-xs dark:bg-zinc-950/30">
|
||||
json.dumps(context, sort_keys=True)
|
||||
</code>
|
||||
<div className="mt-2 text-sm font-semibold">{isHit ? "cache hit: reuse prompt" : isActive ? "cache miss: assemble sections" : "waiting for state"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptPreview({ mode }: { mode: StepMode }) {
|
||||
const assembled = mode === "assemble" || mode === "cache-hit" || mode === "llm";
|
||||
|
||||
if (!assembled) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||
prompt not built yet
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
|
||||
{SECTIONS.map((section) => (
|
||||
<div key={section.id} className="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-1 font-mono text-xs font-semibold text-blue-700 dark:text-blue-300">[{section.title}]</div>
|
||||
<div className="text-sm leading-relaxed text-zinc-700 dark:text-zinc-200">{section.body}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className={cn("rounded-xl border p-4", toneClass(mode === "llm" ? "blue" : "zinc"))}>
|
||||
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
|
||||
<Rocket size={17} />
|
||||
{mode === "llm" ? "sent to LLM" : "system prompt ready"}
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">Traceable prompt text, assembled from named runtime owners.</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SystemPromptVisualization({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2600 });
|
||||
const current = STEPS[vis.currentStep];
|
||||
const mode = current.mode;
|
||||
const sourceActive = mode === "state" || mode === "sections" || mode === "cache-miss";
|
||||
const sectionsActive = mode === "sections" || mode === "assemble";
|
||||
const promptActive = mode === "assemble" || mode === "cache-hit" || mode === "llm";
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{title || "Runtime Prompt Assembly"}</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="grid gap-3 xl:grid-cols-[0.95fr_1.1fr_0.95fr]">
|
||||
<Surface title="Runtime context" icon={<Boxes size={20} />} active={sourceActive}>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{SOURCES.map((source) => (
|
||||
<SourceCard key={source.id} source={source} active={sourceActive} />
|
||||
))}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface title="Section shelf + cache" icon={<FileText size={20} />} active={sectionsActive || mode === "cache-miss" || mode === "cache-hit"}>
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{SECTIONS.map((section) => (
|
||||
<SectionCard key={section.id} section={section} active={sectionsActive} assembled={promptActive} />
|
||||
))}
|
||||
</div>
|
||||
<CachePanel mode={mode} />
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface title="System prompt" icon={<Rocket size={20} />} active={promptActive}>
|
||||
<AnimatePresence mode="wait">
|
||||
<PromptPreview key={mode} mode={mode} />
|
||||
</AnimatePresence>
|
||||
</Surface>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-relaxed text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800/70 dark:text-zinc-300">
|
||||
Beginner rule: system prompts should be assembled from named runtime facts, then cached only when those facts are unchanged.
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={current.title}
|
||||
stepDescription={current.desc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,496 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ArrowRight, CheckCircle2, ClipboardCheck, FileText, LockKeyhole, UserCheck } from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Protocol = "shutdown" | "plan";
|
||||
|
||||
// -- Layout constants for the sequence diagram --
|
||||
const SVG_W = 560;
|
||||
const SVG_H = 360;
|
||||
const LIFELINE_LEFT_X = 140;
|
||||
const LIFELINE_RIGHT_X = 420;
|
||||
const LIFELINE_TOP = 60;
|
||||
const LIFELINE_BOTTOM = 330;
|
||||
const ACTIVATION_W = 12;
|
||||
const ARROW_Y_START = 110;
|
||||
const ARROW_Y_GAP = 70;
|
||||
|
||||
// Request ID shown on message tags
|
||||
const REQUEST_ID = "req_abc";
|
||||
|
||||
// -- Shutdown protocol step definitions --
|
||||
const SHUTDOWN_STEPS = [
|
||||
{ title: "Structured Protocols", desc: "Protocols define structured message exchanges with correlated request IDs." },
|
||||
{ title: "Shutdown Request", desc: "The leader initiates shutdown. The request_id links the request to its response." },
|
||||
{ title: "Teammate Decides", desc: "The teammate can accept or reject. It's not a forced kill -- it's a polite request." },
|
||||
{ title: "Approved", desc: "Same request_id in the response. Teammate exits cleanly." },
|
||||
{
|
||||
title: "Agree on a Small Form",
|
||||
desc: "A protocol is just a shared card shape: request type, request_id, and the expected answer.",
|
||||
},
|
||||
{
|
||||
title: "Leader Files a Request",
|
||||
desc: "The leader writes a shutdown request card instead of force-stopping the teammate.",
|
||||
},
|
||||
{
|
||||
title: "Teammate Chooses",
|
||||
desc: "The teammate can approve or reject, and the request_id keeps the answer attached to the right request.",
|
||||
},
|
||||
{
|
||||
title: "Clean Exit",
|
||||
desc: "The approved response returns to the leader, and the teammate exits cleanly.",
|
||||
},
|
||||
];
|
||||
|
||||
// -- Plan approval protocol step definitions --
|
||||
const PLAN_STEPS = [
|
||||
{ title: "Plan Approval", desc: "Teammates in plan_mode must get approval before implementing changes." },
|
||||
{ title: "Submit Plan", desc: "The teammate designs a plan and sends it to the leader for review." },
|
||||
{ title: "Leader Reviews", desc: "Leader reviews and approves or rejects with feedback. Same request-response pattern." },
|
||||
{
|
||||
title: "Work Is Locked",
|
||||
desc: "In plan mode, implementation stays locked until a plan card is approved.",
|
||||
},
|
||||
{
|
||||
title: "Submit the Plan Card",
|
||||
desc: "The teammate sends a concrete plan with the same request-response shape.",
|
||||
},
|
||||
{
|
||||
title: "Approval Unlocks Action",
|
||||
desc: "The leader approves the card, then implementation can begin.",
|
||||
},
|
||||
];
|
||||
|
||||
// Horizontal arrow between lifelines
|
||||
function SequenceArrow({
|
||||
y,
|
||||
direction,
|
||||
label,
|
||||
tagLabel,
|
||||
color,
|
||||
tagBg,
|
||||
tagStroke,
|
||||
tagText,
|
||||
}: {
|
||||
y: number;
|
||||
direction: "right" | "left";
|
||||
label: string;
|
||||
tagLabel?: string;
|
||||
color: string;
|
||||
tagBg?: string;
|
||||
tagStroke?: string;
|
||||
tagText?: string;
|
||||
}) {
|
||||
const fromX = direction === "right" ? LIFELINE_LEFT_X + ACTIVATION_W / 2 : LIFELINE_RIGHT_X - ACTIVATION_W / 2;
|
||||
const toX = direction === "right" ? LIFELINE_RIGHT_X - ACTIVATION_W / 2 : LIFELINE_LEFT_X + ACTIVATION_W / 2;
|
||||
const arrowTip = direction === "right" ? toX - 6 : toX + 6;
|
||||
const labelX = (fromX + toX) / 2;
|
||||
const PROTOCOL_STATES: Record<Protocol, { label: string; detail: string }[]> = {
|
||||
shutdown: [
|
||||
{ label: "drafted", detail: "Lead creates request_id" },
|
||||
{ label: "pending", detail: "card waits in inbox" },
|
||||
{ label: "deciding", detail: "teammate replies" },
|
||||
{ label: "closed", detail: "Lead matches response" },
|
||||
],
|
||||
plan: [
|
||||
{ label: "locked", detail: "work cannot start" },
|
||||
{ label: "submitted", detail: "plan card is sent" },
|
||||
{ label: "approved", detail: "implementation unlocks" },
|
||||
],
|
||||
};
|
||||
|
||||
function ToggleButton({
|
||||
active,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Arrow line */}
|
||||
<line
|
||||
x1={fromX}
|
||||
y1={y}
|
||||
x2={toX}
|
||||
y2={y}
|
||||
stroke={color}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/* Arrow head */}
|
||||
<polygon
|
||||
points={
|
||||
direction === "right"
|
||||
? `${toX},${y} ${arrowTip},${y - 4} ${arrowTip},${y + 4}`
|
||||
: `${toX},${y} ${arrowTip},${y - 4} ${arrowTip},${y + 4}`
|
||||
}
|
||||
fill={color}
|
||||
/>
|
||||
{/* Message label */}
|
||||
<text
|
||||
x={labelX}
|
||||
y={y - 10}
|
||||
textAnchor="middle"
|
||||
fontSize={8}
|
||||
fontFamily="monospace"
|
||||
fontWeight={600}
|
||||
fill={color}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
{/* Request ID tag */}
|
||||
{tagLabel && (
|
||||
<g>
|
||||
<rect
|
||||
x={labelX - 36}
|
||||
y={y + 4}
|
||||
width={72}
|
||||
height={16}
|
||||
rx={3}
|
||||
fill={tagBg || "#f5f3ff"}
|
||||
stroke={tagStroke || "#c4b5fd"}
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<text
|
||||
x={labelX}
|
||||
y={y + 14}
|
||||
textAnchor="middle"
|
||||
fontSize={6}
|
||||
fontFamily="monospace"
|
||||
fill={tagText || "#7c3aed"}
|
||||
>
|
||||
{tagLabel}
|
||||
</text>
|
||||
</g>
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors active:scale-95",
|
||||
active
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
|
||||
)}
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
|
||||
// Decision diamond on a lifeline
|
||||
function DecisionBox({ x, y }: { x: number; y: number }) {
|
||||
const size = 14;
|
||||
return (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<polygon
|
||||
points={`${x},${y - size} ${x + size},${y} ${x},${y + size} ${x - size},${y}`}
|
||||
fill="#fef3c7"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text x={x} y={y + 1} textAnchor="middle" dominantBaseline="middle" fontSize={7} fontWeight={700} fill="#92400e">
|
||||
?
|
||||
</text>
|
||||
<text x={x + size + 6} y={y - 4} fontSize={6} fontFamily="monospace" fill="#10b981">
|
||||
approve
|
||||
</text>
|
||||
<text x={x + size + 6} y={y + 6} fontSize={6} fontFamily="monospace" fill="#ef4444">
|
||||
reject
|
||||
</text>
|
||||
</motion.g>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Activation bar on a lifeline
|
||||
function ActivationBar({
|
||||
x,
|
||||
yStart,
|
||||
yEnd,
|
||||
color,
|
||||
function StateRail({
|
||||
states,
|
||||
currentStep,
|
||||
}: {
|
||||
x: number;
|
||||
yStart: number;
|
||||
yEnd: number;
|
||||
color: string;
|
||||
states: { label: string; detail: string }[];
|
||||
currentStep: number;
|
||||
}) {
|
||||
return (
|
||||
<motion.rect
|
||||
x={x - ACTIVATION_W / 2}
|
||||
y={yStart}
|
||||
width={ACTIVATION_W}
|
||||
height={yEnd - yStart}
|
||||
rx={2}
|
||||
fill={color}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.6 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<div className="mb-4 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
|
||||
<div className="mb-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
Protocol state
|
||||
</div>
|
||||
<div className="break-words font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||
request_id: {REQUEST_ID}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
{states.map((state, index) => {
|
||||
const active = index === currentStep;
|
||||
const done = index < currentStep;
|
||||
return (
|
||||
<div key={state.label} className="flex min-w-0 flex-1 items-stretch gap-2">
|
||||
<motion.div
|
||||
layout
|
||||
animate={active ? { y: [0, -2, 0] } : { y: 0 }}
|
||||
transition={{ duration: 0.8, repeat: active ? Infinity : 0 }}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 rounded-md border px-3 py-2 transition-colors",
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950/35 dark:text-blue-200"
|
||||
: done
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200"
|
||||
: "border-zinc-200 bg-white text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-400"
|
||||
)}
|
||||
>
|
||||
<div className="break-words text-sm font-semibold">{state.label}</div>
|
||||
<div className="mt-1 break-words text-[11px] leading-snug opacity-80">
|
||||
{state.detail}
|
||||
</div>
|
||||
</motion.div>
|
||||
{index < states.length - 1 && (
|
||||
<div className="hidden items-center text-zinc-300 dark:text-zinc-600 sm:flex">
|
||||
<ArrowRight size={15} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Desk({
|
||||
title,
|
||||
icon,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-[260px] rounded-lg border p-3 transition-colors",
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30"
|
||||
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex min-w-0 items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md",
|
||||
active
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 break-words">{title}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtocolCard({
|
||||
title,
|
||||
rows,
|
||||
tone = "blue",
|
||||
}: {
|
||||
title: string;
|
||||
rows: string[];
|
||||
tone?: "blue" | "amber" | "emerald" | "zinc";
|
||||
}) {
|
||||
const toneClass = {
|
||||
blue: "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200",
|
||||
amber: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200",
|
||||
emerald:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200",
|
||||
zinc: "border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className={cn("rounded-md border p-3 shadow-sm", toneClass)}
|
||||
>
|
||||
<div className="break-words font-mono text-xs font-semibold">{title}</div>
|
||||
<div className="mt-2 space-y-1 font-mono text-[11px] opacity-85">
|
||||
{rows.map((row) => (
|
||||
<div key={row} className="break-words">
|
||||
{row}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTray({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-zinc-300 px-3 py-5 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamProtocols({ title }: { title?: string }) {
|
||||
const [protocol, setProtocol] = useState<Protocol>("shutdown");
|
||||
|
||||
const totalSteps = protocol === "shutdown" ? SHUTDOWN_STEPS.length : PLAN_STEPS.length;
|
||||
const steps = protocol === "shutdown" ? SHUTDOWN_STEPS : PLAN_STEPS;
|
||||
|
||||
const vis = useSteppedVisualization({ totalSteps, autoPlayInterval: 2500 });
|
||||
const vis = useSteppedVisualization({ totalSteps: steps.length, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const palette = useSvgPalette();
|
||||
|
||||
const switchProtocol = (p: Protocol) => {
|
||||
setProtocol(p);
|
||||
const switchProtocol = (value: Protocol) => {
|
||||
setProtocol(value);
|
||||
vis.reset();
|
||||
};
|
||||
|
||||
const leftLabel = protocol === "shutdown" ? "Leader" : "Leader";
|
||||
const rightLabel = protocol === "shutdown" ? "Teammate" : "Teammate";
|
||||
const isPlan = protocol === "plan";
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "FSM Team Protocols"}
|
||||
{title || "Team Protocol Cards"}
|
||||
</h2>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900 min-h-[500px]">
|
||||
{/* Protocol toggle */}
|
||||
<div className="flex justify-center gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => switchProtocol("shutdown")}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-colors ${
|
||||
protocol === "shutdown"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
Shutdown Protocol
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchProtocol("plan")}
|
||||
className={`rounded-md px-4 py-1.5 text-xs font-medium transition-colors ${
|
||||
protocol === "plan"
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
Plan Approval Protocol
|
||||
</button>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-4 flex justify-center gap-2">
|
||||
<ToggleButton active={!isPlan} onClick={() => switchProtocol("shutdown")}>
|
||||
Shutdown
|
||||
</ToggleButton>
|
||||
<ToggleButton active={isPlan} onClick={() => switchProtocol("plan")}>
|
||||
Plan Approval
|
||||
</ToggleButton>
|
||||
</div>
|
||||
|
||||
{/* Sequence diagram SVG */}
|
||||
<svg viewBox={`0 0 ${SVG_W} ${SVG_H}`} className="w-full">
|
||||
<defs>
|
||||
<marker
|
||||
id="seq-arrow"
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="5"
|
||||
markerHeight="5"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
</defs>
|
||||
<StateRail states={PROTOCOL_STATES[protocol]} currentStep={step} />
|
||||
|
||||
{/* Lifeline headers */}
|
||||
<rect x={LIFELINE_LEFT_X - 40} y={20} width={80} height={28} rx={6} fill="#3b82f6" />
|
||||
<text x={LIFELINE_LEFT_X} y={37} textAnchor="middle" dominantBaseline="middle" fill="white" fontSize={11} fontWeight={700}>
|
||||
{leftLabel}
|
||||
</text>
|
||||
|
||||
<rect x={LIFELINE_RIGHT_X - 40} y={20} width={80} height={28} rx={6} fill="#8b5cf6" />
|
||||
<text x={LIFELINE_RIGHT_X} y={37} textAnchor="middle" dominantBaseline="middle" fill="white" fontSize={11} fontWeight={700}>
|
||||
{rightLabel}
|
||||
</text>
|
||||
|
||||
{/* Lifeline dashed lines */}
|
||||
<line
|
||||
x1={LIFELINE_LEFT_X}
|
||||
y1={LIFELINE_TOP}
|
||||
x2={LIFELINE_LEFT_X}
|
||||
y2={LIFELINE_BOTTOM}
|
||||
stroke={palette.edgeStroke}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="6 4"
|
||||
/>
|
||||
<line
|
||||
x1={LIFELINE_RIGHT_X}
|
||||
y1={LIFELINE_TOP}
|
||||
x2={LIFELINE_RIGHT_X}
|
||||
y2={LIFELINE_BOTTOM}
|
||||
stroke={palette.edgeStroke}
|
||||
strokeWidth={1}
|
||||
strokeDasharray="6 4"
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{protocol === "shutdown" && (
|
||||
<g key="shutdown">
|
||||
{/* Activation bars appear as needed */}
|
||||
{step >= 1 && (
|
||||
<ActivationBar
|
||||
x={LIFELINE_LEFT_X}
|
||||
yStart={ARROW_Y_START - 10}
|
||||
yEnd={step >= 3 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 20 : ARROW_Y_START + 30}
|
||||
color="#3b82f6"
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<Desk
|
||||
title="Leader desk"
|
||||
icon={<UserCheck size={15} />}
|
||||
active={(!isPlan && (step === 1 || step === 3)) || (isPlan && step === 2)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{!isPlan && step >= 1 && (
|
||||
<ProtocolCard
|
||||
key="shutdown-request"
|
||||
title="shutdown_request"
|
||||
rows={[`request_id: ${REQUEST_ID}`, "target: teammate", "mode: polite"]}
|
||||
tone={step >= 3 ? "zinc" : "blue"}
|
||||
/>
|
||||
)}
|
||||
{step >= 1 && (
|
||||
<ActivationBar
|
||||
x={LIFELINE_RIGHT_X}
|
||||
yStart={ARROW_Y_START - 5}
|
||||
yEnd={step >= 3 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 15 : ARROW_Y_START + ARROW_Y_GAP + 20}
|
||||
color="#8b5cf6"
|
||||
{!isPlan && step >= 3 && (
|
||||
<ProtocolCard
|
||||
key="shutdown-response"
|
||||
title="shutdown_response"
|
||||
rows={[`request_id: ${REQUEST_ID}`, "approve: true", "status: closed"]}
|
||||
tone="emerald"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 1: shutdown_request arrow (Leader -> Teammate) */}
|
||||
{step >= 1 && (
|
||||
<SequenceArrow
|
||||
y={ARROW_Y_START}
|
||||
direction="right"
|
||||
label="shutdown_request"
|
||||
tagLabel={`request_id: ${REQUEST_ID}`}
|
||||
color="#3b82f6"
|
||||
tagBg={palette.bgSubtle}
|
||||
tagStroke={palette.nodeStroke}
|
||||
tagText={palette.nodeText}
|
||||
{isPlan && step >= 2 && (
|
||||
<ProtocolCard
|
||||
key="plan-approved"
|
||||
title="plan_approval_response"
|
||||
rows={[`request_id: ${REQUEST_ID}`, "approve: true", "unlock: implementation"]}
|
||||
tone="emerald"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{((!isPlan && step === 0) || (isPlan && step < 2)) && (
|
||||
<EmptyTray label="waiting for a protocol card" />
|
||||
)}
|
||||
</div>
|
||||
</Desk>
|
||||
|
||||
{/* Step 2: decision box on teammate lifeline */}
|
||||
{step >= 2 && (
|
||||
<DecisionBox
|
||||
x={LIFELINE_RIGHT_X + 50}
|
||||
y={ARROW_Y_START + ARROW_Y_GAP}
|
||||
<Desk
|
||||
title="Shared card shape"
|
||||
icon={<ClipboardCheck size={15} />}
|
||||
active={(!isPlan && step === 0) || (isPlan && step === 0)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<ProtocolCard
|
||||
title="protocol fields"
|
||||
rows={["type", "request_id", "payload", "response"]}
|
||||
tone="amber"
|
||||
/>
|
||||
<div className="rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
The key idea is correlation, not ceremony.
|
||||
</div>
|
||||
{isPlan && (
|
||||
<div className="flex items-center gap-2 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
|
||||
<LockKeyhole size={14} />
|
||||
implementation locked until approval
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Desk>
|
||||
|
||||
<Desk
|
||||
title="Teammate desk"
|
||||
icon={isPlan ? <FileText size={15} /> : <CheckCircle2 size={15} />}
|
||||
active={(!isPlan && step === 2) || (isPlan && step === 1)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{!isPlan && step >= 2 && (
|
||||
<ProtocolCard
|
||||
key="teammate-decision"
|
||||
title="decision card"
|
||||
rows={[`request_id: ${REQUEST_ID}`, "choice: approve", step >= 3 ? "state: exited" : "state: deciding"]}
|
||||
tone={step >= 3 ? "emerald" : "amber"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: shutdown_response arrow (Teammate -> Leader) */}
|
||||
{step >= 3 && (
|
||||
<SequenceArrow
|
||||
y={ARROW_Y_START + ARROW_Y_GAP * 2}
|
||||
direction="left"
|
||||
label="shutdown_response { approve: true }"
|
||||
tagLabel={`request_id: ${REQUEST_ID}`}
|
||||
color="#10b981"
|
||||
tagBg={palette.bgSubtle}
|
||||
tagStroke={palette.nodeStroke}
|
||||
tagText={palette.nodeText}
|
||||
{isPlan && step >= 1 && (
|
||||
<ProtocolCard
|
||||
key="plan-card"
|
||||
title="exit_plan_mode"
|
||||
rows={[`request_id: ${REQUEST_ID}`, "1. edit module", "2. run tests", "3. report diff"]}
|
||||
tone={step >= 2 ? "emerald" : "blue"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: exit annotation */}
|
||||
{step >= 3 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<line
|
||||
x1={LIFELINE_RIGHT_X - 10}
|
||||
y1={ARROW_Y_START + ARROW_Y_GAP * 2 + 20}
|
||||
x2={LIFELINE_RIGHT_X + 10}
|
||||
y2={ARROW_Y_START + ARROW_Y_GAP * 2 + 36}
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<line
|
||||
x1={LIFELINE_RIGHT_X + 10}
|
||||
y1={ARROW_Y_START + ARROW_Y_GAP * 2 + 20}
|
||||
x2={LIFELINE_RIGHT_X - 10}
|
||||
y2={ARROW_Y_START + ARROW_Y_GAP * 2 + 36}
|
||||
stroke="#ef4444"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<text
|
||||
x={LIFELINE_RIGHT_X + 24}
|
||||
y={ARROW_Y_START + ARROW_Y_GAP * 2 + 32}
|
||||
fontSize={8}
|
||||
fill="#ef4444"
|
||||
fontWeight={600}
|
||||
>
|
||||
exit
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{protocol === "plan" && (
|
||||
<g key="plan">
|
||||
{/* Activation bars */}
|
||||
{step >= 1 && (
|
||||
<ActivationBar
|
||||
x={LIFELINE_RIGHT_X}
|
||||
yStart={ARROW_Y_START - 10}
|
||||
yEnd={step >= 2 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 15 : ARROW_Y_START + 30}
|
||||
color="#8b5cf6"
|
||||
/>
|
||||
)}
|
||||
{step >= 1 && (
|
||||
<ActivationBar
|
||||
x={LIFELINE_LEFT_X}
|
||||
yStart={ARROW_Y_START - 5}
|
||||
yEnd={step >= 2 ? ARROW_Y_START + ARROW_Y_GAP * 2 + 15 : ARROW_Y_START + ARROW_Y_GAP + 10}
|
||||
color="#3b82f6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 1: plan submission arrow (Teammate -> Leader) */}
|
||||
{step >= 1 && (
|
||||
<SequenceArrow
|
||||
y={ARROW_Y_START}
|
||||
direction="left"
|
||||
label="exit_plan_mode { plan }"
|
||||
tagLabel={`request_id: ${REQUEST_ID}`}
|
||||
color="#8b5cf6"
|
||||
tagBg={palette.bgSubtle}
|
||||
tagStroke={palette.nodeStroke}
|
||||
tagText={palette.nodeText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 1: plan content box */}
|
||||
{step >= 1 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<rect
|
||||
x={20}
|
||||
y={ARROW_Y_START + 20}
|
||||
width={95}
|
||||
height={50}
|
||||
rx={4}
|
||||
fill={palette.bgSubtle}
|
||||
stroke={palette.nodeStroke}
|
||||
strokeWidth={0.5}
|
||||
/>
|
||||
<text x={28} y={ARROW_Y_START + 34} fontSize={6} fontFamily="monospace" fill={palette.nodeText} fontWeight={600}>
|
||||
Plan:
|
||||
</text>
|
||||
<text x={28} y={ARROW_Y_START + 44} fontSize={5.5} fontFamily="monospace" fill={palette.labelFill}>
|
||||
1. Add error handler
|
||||
</text>
|
||||
<text x={28} y={ARROW_Y_START + 54} fontSize={5.5} fontFamily="monospace" fill={palette.labelFill}>
|
||||
2. Update tests
|
||||
</text>
|
||||
<text x={28} y={ARROW_Y_START + 64} fontSize={5.5} fontFamily="monospace" fill={palette.labelFill}>
|
||||
3. Refactor module
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
|
||||
{/* Step 2: approval response arrow (Leader -> Teammate) */}
|
||||
{step >= 2 && (
|
||||
<SequenceArrow
|
||||
y={ARROW_Y_START + ARROW_Y_GAP * 2}
|
||||
direction="right"
|
||||
label="plan_approval_response { approve: true }"
|
||||
tagLabel={`request_id: ${REQUEST_ID}`}
|
||||
color="#10b981"
|
||||
tagBg={palette.bgSubtle}
|
||||
tagStroke={palette.nodeStroke}
|
||||
tagText={palette.nodeText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 2: checkmark */}
|
||||
{step >= 2 && (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<circle cx={LIFELINE_RIGHT_X + 40} cy={ARROW_Y_START + ARROW_Y_GAP * 2} r={10} fill="#10b981" />
|
||||
<text
|
||||
x={LIFELINE_RIGHT_X + 40}
|
||||
y={ARROW_Y_START + ARROW_Y_GAP * 2 + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={10}
|
||||
fill="white"
|
||||
fontWeight={700}
|
||||
>
|
||||
OK
|
||||
</text>
|
||||
</motion.g>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</svg>
|
||||
|
||||
{/* Step controls */}
|
||||
<div className="mt-4">
|
||||
<StepControls
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={steps[step].title}
|
||||
stepDescription={steps[step].desc}
|
||||
/>
|
||||
</AnimatePresence>
|
||||
{((!isPlan && step < 2) || (isPlan && step === 0)) && (
|
||||
<EmptyTray label={isPlan ? "draft plan not submitted" : "no request received"} />
|
||||
)}
|
||||
</div>
|
||||
</Desk>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={steps[step].title}
|
||||
stepDescription={steps[step].desc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,465 +1,276 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CheckCircle2, ClipboardList, Hourglass, UserRoundCog } from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// -- FSM states and their layout positions (diamond: idle top, poll right, claim bottom, work left) --
|
||||
type Phase = "idle" | "poll" | "claim" | "work";
|
||||
type AgentPhase = "idle" | "polling" | "claiming" | "working" | "done";
|
||||
type TaskStatus = "open" | "claimed" | "complete";
|
||||
|
||||
const FSM_CX = 110;
|
||||
const FSM_CY = 110;
|
||||
const FSM_R = 65;
|
||||
const FSM_STATE_R = 22;
|
||||
|
||||
const FSM_STATES: { id: Phase; label: string; angle: number }[] = [
|
||||
{ id: "idle", label: "idle", angle: -Math.PI / 2 },
|
||||
{ id: "poll", label: "poll", angle: 0 },
|
||||
{ id: "claim", label: "claim", angle: Math.PI / 2 },
|
||||
{ id: "work", label: "work", angle: Math.PI },
|
||||
];
|
||||
|
||||
const FSM_TRANSITIONS: { from: Phase; to: Phase }[] = [
|
||||
{ from: "idle", to: "poll" },
|
||||
{ from: "poll", to: "claim" },
|
||||
{ from: "claim", to: "work" },
|
||||
{ from: "work", to: "idle" },
|
||||
];
|
||||
|
||||
function fsmPos(angle: number) {
|
||||
return { x: FSM_CX + FSM_R * Math.cos(angle), y: FSM_CY + FSM_R * Math.sin(angle) };
|
||||
}
|
||||
|
||||
const PHASE_COLORS: Record<Phase, string> = {
|
||||
idle: "#a1a1aa",
|
||||
poll: "#f59e0b",
|
||||
claim: "#3b82f6",
|
||||
work: "#10b981",
|
||||
};
|
||||
|
||||
// -- Task board data --
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "unclaimed" | "active" | "complete";
|
||||
owner: string;
|
||||
}
|
||||
|
||||
const INITIAL_TASKS: TaskRow[] = [
|
||||
{ id: "T1", name: "Fix auth bug", status: "unclaimed", owner: "-" },
|
||||
{ id: "T2", name: "Add rate limiter", status: "unclaimed", owner: "-" },
|
||||
{ id: "T3", name: "Write tests", status: "unclaimed", owner: "-" },
|
||||
{ id: "T4", name: "Update API docs", status: "unclaimed", owner: "-" },
|
||||
];
|
||||
|
||||
// Agent positions around the task board (left panel)
|
||||
const BOARD_CX = 140;
|
||||
const BOARD_CY = 90;
|
||||
const AGENT_ORBIT = 85;
|
||||
const AGENT_R = 20;
|
||||
|
||||
const AGENT_ANGLES = [-Math.PI / 2, Math.PI / 6, (5 * Math.PI) / 6];
|
||||
|
||||
function agentPos(index: number) {
|
||||
const angle = AGENT_ANGLES[index];
|
||||
return { x: BOARD_CX + AGENT_ORBIT * Math.cos(angle), y: BOARD_CY + AGENT_ORBIT * Math.sin(angle) };
|
||||
}
|
||||
|
||||
// -- Step definitions --
|
||||
const STEPS = [
|
||||
{ title: "Self-Governing Agents", desc: "Autonomous agents need no coordinator. They govern themselves with an idle-poll-claim-work cycle." },
|
||||
{ title: "Idle Timer", desc: "Each idle agent counts rounds. A timeout triggers self-directed task polling." },
|
||||
{ title: "Poll Task Board", desc: "Timeout! The agent reads the task board looking for unclaimed work." },
|
||||
{ title: "Claim Task", desc: "The agent writes its name to the task record. Atomic, no conflicts." },
|
||||
{ title: "Work", desc: "The agent works on the claimed task using its own agent loop." },
|
||||
{ title: "Independent Polling", desc: "Multiple agents poll and claim independently. No central coordinator needed." },
|
||||
{ title: "Complete & Reset", desc: "Task done. Agent returns to idle. The cycle repeats." },
|
||||
{ title: "Self-Organization", desc: "Three agents, zero coordination overhead. Polling + timeout = emergent organization." },
|
||||
];
|
||||
|
||||
// Per-step state for each agent
|
||||
interface AgentState {
|
||||
phase: Phase;
|
||||
timerFill: number;
|
||||
color: string;
|
||||
taskClaim: string | null;
|
||||
id: string;
|
||||
phase: AgentPhase;
|
||||
timer: number;
|
||||
task?: string;
|
||||
}
|
||||
|
||||
function getAgentStates(step: number): AgentState[] {
|
||||
const idle: AgentState = { phase: "idle", timerFill: 0, color: PHASE_COLORS.idle, taskClaim: null };
|
||||
interface TaskState {
|
||||
id: string;
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
switch (step) {
|
||||
case 0:
|
||||
return [
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
];
|
||||
case 1:
|
||||
return [
|
||||
{ phase: "idle", timerFill: 0.6, color: PHASE_COLORS.idle, taskClaim: null },
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
];
|
||||
case 2:
|
||||
return [
|
||||
{ phase: "poll", timerFill: 1.0, color: PHASE_COLORS.poll, taskClaim: null },
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
];
|
||||
case 3:
|
||||
return [
|
||||
{ phase: "claim", timerFill: 0, color: PHASE_COLORS.claim, taskClaim: "T1" },
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
];
|
||||
case 4:
|
||||
return [
|
||||
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T1" },
|
||||
{ ...idle },
|
||||
{ ...idle },
|
||||
];
|
||||
case 5:
|
||||
return [
|
||||
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T1" },
|
||||
{ phase: "claim", timerFill: 0, color: PHASE_COLORS.claim, taskClaim: "T2" },
|
||||
{ ...idle },
|
||||
];
|
||||
case 6:
|
||||
return [
|
||||
{ phase: "idle", timerFill: 0, color: PHASE_COLORS.idle, taskClaim: null },
|
||||
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T2" },
|
||||
{ ...idle },
|
||||
];
|
||||
case 7:
|
||||
return [
|
||||
{ phase: "idle", timerFill: 0, color: PHASE_COLORS.idle, taskClaim: null },
|
||||
{ phase: "work", timerFill: 0, color: PHASE_COLORS.work, taskClaim: "T2" },
|
||||
{ phase: "claim", timerFill: 0, color: PHASE_COLORS.claim, taskClaim: "T3" },
|
||||
];
|
||||
default:
|
||||
return [{ ...idle }, { ...idle }, { ...idle }];
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Quiet Agents",
|
||||
desc: "Autonomous agents start by waiting. The important mental model is a work board, not a central dispatcher.",
|
||||
},
|
||||
{
|
||||
title: "Idle Timer Fills",
|
||||
desc: "An agent watches its own idle timer. When it waits long enough, it decides to look for work.",
|
||||
},
|
||||
{
|
||||
title: "Read the Board",
|
||||
desc: "The agent polls the shared task board and looks for an open card.",
|
||||
},
|
||||
{
|
||||
title: "Claim One Card",
|
||||
desc: "Claiming writes the agent name onto one task, making ownership visible.",
|
||||
},
|
||||
{
|
||||
title: "Work Independently",
|
||||
desc: "The claimed task moves into the agent workspace. No coordinator has to babysit it.",
|
||||
},
|
||||
{
|
||||
title: "Others Join In",
|
||||
desc: "A second agent can claim a different card through the same simple habit.",
|
||||
},
|
||||
{
|
||||
title: "Finish and Free Up",
|
||||
desc: "Completed work goes back to the board as done, and the agent returns to waiting.",
|
||||
},
|
||||
{
|
||||
title: "Self Organization",
|
||||
desc: "Timers plus visible ownership let a small group organize itself without a manager loop.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const TASKS = [
|
||||
{ id: "T1", title: "Fix auth bug" },
|
||||
{ id: "T2", title: "Add rate limiter" },
|
||||
{ id: "T3", title: "Write docs" },
|
||||
{ id: "T4", title: "Clean tests" },
|
||||
];
|
||||
|
||||
function getAgents(step: number): AgentState[] {
|
||||
if (step === 0) {
|
||||
return [
|
||||
{ id: "A", phase: "idle", timer: 0.1 },
|
||||
{ id: "B", phase: "idle", timer: 0 },
|
||||
{ id: "C", phase: "idle", timer: 0 },
|
||||
];
|
||||
}
|
||||
if (step === 1) {
|
||||
return [
|
||||
{ id: "A", phase: "idle", timer: 0.85 },
|
||||
{ id: "B", phase: "idle", timer: 0.25 },
|
||||
{ id: "C", phase: "idle", timer: 0 },
|
||||
];
|
||||
}
|
||||
if (step === 2) {
|
||||
return [
|
||||
{ id: "A", phase: "polling", timer: 1 },
|
||||
{ id: "B", phase: "idle", timer: 0.25 },
|
||||
{ id: "C", phase: "idle", timer: 0 },
|
||||
];
|
||||
}
|
||||
if (step === 3) {
|
||||
return [
|
||||
{ id: "A", phase: "claiming", timer: 0, task: "T1" },
|
||||
{ id: "B", phase: "idle", timer: 0.45 },
|
||||
{ id: "C", phase: "idle", timer: 0.1 },
|
||||
];
|
||||
}
|
||||
if (step === 4) {
|
||||
return [
|
||||
{ id: "A", phase: "working", timer: 0, task: "T1" },
|
||||
{ id: "B", phase: "idle", timer: 0.65 },
|
||||
{ id: "C", phase: "idle", timer: 0.2 },
|
||||
];
|
||||
}
|
||||
if (step === 5) {
|
||||
return [
|
||||
{ id: "A", phase: "working", timer: 0, task: "T1" },
|
||||
{ id: "B", phase: "claiming", timer: 0, task: "T2" },
|
||||
{ id: "C", phase: "idle", timer: 0.35 },
|
||||
];
|
||||
}
|
||||
if (step === 6) {
|
||||
return [
|
||||
{ id: "A", phase: "done", timer: 0, task: "T1" },
|
||||
{ id: "B", phase: "working", timer: 0, task: "T2" },
|
||||
{ id: "C", phase: "idle", timer: 0.6 },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ id: "A", phase: "idle", timer: 0.15 },
|
||||
{ id: "B", phase: "working", timer: 0, task: "T2" },
|
||||
{ id: "C", phase: "claiming", timer: 0, task: "T3" },
|
||||
];
|
||||
}
|
||||
|
||||
function getTaskStates(step: number): TaskRow[] {
|
||||
const tasks = INITIAL_TASKS.map((t) => ({ ...t }));
|
||||
if (step >= 3) { tasks[0].status = "active"; tasks[0].owner = "A"; }
|
||||
if (step >= 5) { tasks[1].status = "active"; tasks[1].owner = "B"; }
|
||||
if (step >= 6) { tasks[0].status = "complete"; }
|
||||
if (step >= 7) { tasks[2].status = "active"; tasks[2].owner = "C"; }
|
||||
return tasks;
|
||||
function getTasks(step: number): TaskState[] {
|
||||
return TASKS.map((task) => {
|
||||
if (task.id === "T1" && step >= 6) {
|
||||
return { ...task, status: "complete", owner: "A" };
|
||||
}
|
||||
if (task.id === "T1" && step >= 3) {
|
||||
return { ...task, status: "claimed", owner: "A" };
|
||||
}
|
||||
if (task.id === "T2" && step >= 5) {
|
||||
return { ...task, status: "claimed", owner: "B" };
|
||||
}
|
||||
if (task.id === "T3" && step >= 7) {
|
||||
return { ...task, status: "claimed", owner: "C" };
|
||||
}
|
||||
return { ...task, status: "open" };
|
||||
});
|
||||
}
|
||||
|
||||
function getActivePhase(step: number): Phase {
|
||||
if (step <= 1) return "idle";
|
||||
if (step === 2) return "poll";
|
||||
if (step === 3) return "claim";
|
||||
if (step === 4 || step === 5) return "work";
|
||||
if (step === 6) return "idle";
|
||||
return "claim";
|
||||
function phaseClass(phase: AgentPhase): string {
|
||||
if (phase === "working") return "border-emerald-300 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30";
|
||||
if (phase === "claiming" || phase === "polling") return "border-amber-300 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30";
|
||||
if (phase === "done") return "border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30";
|
||||
return "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900";
|
||||
}
|
||||
|
||||
// Ring timer around an agent
|
||||
function TimerRing({ cx, cy, r, fill }: { cx: number; cy: number; r: number; fill: number }) {
|
||||
if (fill <= 0) return null;
|
||||
const circumference = 2 * Math.PI * (r + 4);
|
||||
const offset = circumference * (1 - fill);
|
||||
return (
|
||||
<motion.circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r + 4}
|
||||
fill="none"
|
||||
stroke="#f59e0b"
|
||||
strokeWidth={3}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
initial={{ strokeDashoffset: circumference }}
|
||||
animate={{ strokeDashoffset: offset }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
style={{ transform: "rotate(-90deg)", transformOrigin: `${cx}px ${cy}px` }}
|
||||
/>
|
||||
);
|
||||
function statusClass(status: TaskStatus): string {
|
||||
if (status === "complete") return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
|
||||
if (status === "claimed") return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
|
||||
return "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300";
|
||||
}
|
||||
|
||||
// FSM arrow between two states
|
||||
function FSMArrow({ from, to, active, inactiveStroke }: { from: Phase; to: Phase; active: boolean; inactiveStroke: string }) {
|
||||
const fState = FSM_STATES.find((s) => s.id === from)!;
|
||||
const tState = FSM_STATES.find((s) => s.id === to)!;
|
||||
const fPos = fsmPos(fState.angle);
|
||||
const tPos = fsmPos(tState.angle);
|
||||
|
||||
const dx = tPos.x - fPos.x;
|
||||
const dy = tPos.y - fPos.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const ux = dx / dist;
|
||||
const uy = dy / dist;
|
||||
|
||||
const x1 = fPos.x + ux * FSM_STATE_R;
|
||||
const y1 = fPos.y + uy * FSM_STATE_R;
|
||||
const x2 = tPos.x - ux * (FSM_STATE_R + 6);
|
||||
const y2 = tPos.y - uy * (FSM_STATE_R + 6);
|
||||
|
||||
const perpX = -uy * 12;
|
||||
const perpY = ux * 12;
|
||||
const cx = (x1 + x2) / 2 + perpX;
|
||||
const cy = (y1 + y2) / 2 + perpY;
|
||||
function AgentCard({ agent }: { agent: AgentState }) {
|
||||
const timerPercent = Math.round(agent.timer * 100);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
d={`M ${x1} ${y1} Q ${cx} ${cy} ${x2} ${y2}`}
|
||||
fill="none"
|
||||
stroke={active ? PHASE_COLORS[to] : inactiveStroke}
|
||||
strokeWidth={active ? 2 : 1}
|
||||
markerEnd="url(#fsm-arrowhead)"
|
||||
/>
|
||||
</g>
|
||||
<motion.div
|
||||
layout
|
||||
animate={agent.phase !== "idle" ? { y: [0, -2, 0] } : { y: 0 }}
|
||||
transition={{ duration: 0.9, repeat: agent.phase !== "idle" ? Infinity : 0 }}
|
||||
className={cn("rounded-lg border p-3 transition-colors", phaseClass(agent.phase))}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-zinc-900 text-sm font-bold text-white dark:bg-zinc-100 dark:text-zinc-900">
|
||||
{agent.id}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-zinc-800 dark:text-zinc-100">Agent {agent.id}</div>
|
||||
<div className="text-[11px] capitalize text-zinc-500 dark:text-zinc-400">{agent.phase}</div>
|
||||
</div>
|
||||
</div>
|
||||
{agent.phase === "done" ? (
|
||||
<CheckCircle2 size={18} className="text-emerald-500" />
|
||||
) : (
|
||||
<UserRoundCog size={18} className="text-zinc-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-2 h-2 overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<motion.div
|
||||
className="h-full rounded-full bg-amber-400"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${timerPercent}%` }}
|
||||
transition={{ duration: 0.35 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||
{agent.task ? `task: ${agent.task}` : `idle timer: ${timerPercent}%`}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AutonomousAgents({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const palette = useSvgPalette();
|
||||
|
||||
const agentStates = getAgentStates(step);
|
||||
const tasks = getTaskStates(step);
|
||||
const activePhase = getActivePhase(step);
|
||||
const agentNames = ["A", "B", "C"];
|
||||
const agents = getAgents(step);
|
||||
const tasks = getTasks(step);
|
||||
const current = STEPS[step];
|
||||
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Autonomous Agent Cycle"}
|
||||
{title || "Autonomous Work Board"}
|
||||
</h2>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900 min-h-[500px]">
|
||||
<div className="flex flex-col lg:flex-row gap-4">
|
||||
{/* Left panel: spatial view with agents and task board */}
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">Spatial View</div>
|
||||
<svg viewBox="0 0 280 240" className="w-full">
|
||||
{/* Task board (small table in center) */}
|
||||
<rect x={BOARD_CX - 35} y={BOARD_CY - 20} width={70} height={40} rx={4}
|
||||
fill={palette.bgSubtle} stroke={palette.nodeStroke} strokeWidth={1}
|
||||
/>
|
||||
<text x={BOARD_CX} y={BOARD_CY - 8} textAnchor="middle" fontSize={7} fontWeight={600}
|
||||
fill={palette.nodeText}
|
||||
>
|
||||
Task Board
|
||||
</text>
|
||||
<text x={BOARD_CX} y={BOARD_CY + 4} textAnchor="middle" fontSize={6} fontFamily="monospace"
|
||||
fill={palette.labelFill}
|
||||
>
|
||||
{tasks.filter((t) => t.status === "unclaimed").length} unclaimed
|
||||
</text>
|
||||
<text x={BOARD_CX} y={BOARD_CY + 14} textAnchor="middle" fontSize={6} fontFamily="monospace"
|
||||
fill="#10b981"
|
||||
>
|
||||
{tasks.filter((t) => t.status === "complete").length} complete
|
||||
</text>
|
||||
|
||||
{/* Agents */}
|
||||
{agentStates.map((state, i) => {
|
||||
const pos = agentPos(i);
|
||||
const isPulsing = state.phase === "work";
|
||||
const isPolling = state.phase === "poll";
|
||||
|
||||
return (
|
||||
<g key={i}>
|
||||
{/* Dashed line from agent to board when polling */}
|
||||
{isPolling && (
|
||||
<motion.line
|
||||
x1={pos.x} y1={pos.y} x2={BOARD_CX} y2={BOARD_CY}
|
||||
stroke="#f59e0b" strokeWidth={1.5} strokeDasharray="4 3"
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
)}
|
||||
{/* Solid line from agent to board when claiming */}
|
||||
{state.phase === "claim" && (
|
||||
<motion.line
|
||||
x1={pos.x} y1={pos.y} x2={BOARD_CX} y2={BOARD_CY}
|
||||
stroke="#3b82f6" strokeWidth={2}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Timer ring */}
|
||||
<TimerRing cx={pos.x} cy={pos.y} r={AGENT_R} fill={state.timerFill} />
|
||||
|
||||
{/* Agent circle */}
|
||||
<motion.circle
|
||||
cx={pos.x} cy={pos.y} r={AGENT_R}
|
||||
fill={state.color}
|
||||
stroke={state.phase === "work" ? "#059669" : palette.nodeStroke}
|
||||
strokeWidth={1.5}
|
||||
animate={{
|
||||
scale: isPulsing ? [1, 1.1, 1] : 1,
|
||||
fill: state.color,
|
||||
}}
|
||||
transition={
|
||||
isPulsing
|
||||
? { duration: 0.8, repeat: Infinity, ease: "easeInOut" }
|
||||
: { duration: 0.4 }
|
||||
}
|
||||
/>
|
||||
<text x={pos.x} y={pos.y + 1} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="white" fontSize={11} fontWeight={700}
|
||||
>
|
||||
{agentNames[i]}
|
||||
</text>
|
||||
|
||||
{/* Task label below agent when claiming or working */}
|
||||
{state.taskClaim && (
|
||||
<motion.text
|
||||
x={pos.x} y={pos.y + AGENT_R + 12}
|
||||
textAnchor="middle" fontSize={7} fontFamily="monospace"
|
||||
fill={state.phase === "work" ? "#10b981" : "#3b82f6"}
|
||||
fontWeight={600}
|
||||
initial={{ opacity: 0 }} animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{state.taskClaim}
|
||||
</motion.text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Task table below the spatial view */}
|
||||
<div className="mt-2 border border-zinc-200 rounded dark:border-zinc-700 overflow-hidden">
|
||||
<table className="w-full text-[10px]">
|
||||
<thead>
|
||||
<tr className="bg-zinc-50 dark:bg-zinc-800">
|
||||
<th className="px-2 py-1 text-left font-medium text-zinc-500 dark:text-zinc-400">Task</th>
|
||||
<th className="px-2 py-1 text-left font-medium text-zinc-500 dark:text-zinc-400">Status</th>
|
||||
<th className="px-2 py-1 text-left font-medium text-zinc-500 dark:text-zinc-400">Owner</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tasks.map((task) => (
|
||||
<tr key={task.id} className="border-t border-zinc-100 dark:border-zinc-800">
|
||||
<td className="px-2 py-1 font-mono text-zinc-700 dark:text-zinc-300">{task.name}</td>
|
||||
<td className="px-2 py-1">
|
||||
<span className={`inline-block rounded px-1.5 py-0.5 text-[9px] font-medium ${
|
||||
task.status === "complete"
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
: task.status === "active"
|
||||
? "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400"
|
||||
}`}>
|
||||
{task.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1 font-mono text-zinc-600 dark:text-zinc-400">{task.owner}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_1.2fr]">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<Hourglass size={16} />
|
||||
Agents watch their own idle timer
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel: FSM state machine diagram */}
|
||||
<div className="flex-1">
|
||||
<div className="text-xs font-medium text-zinc-500 dark:text-zinc-400 mb-2">FSM Cycle</div>
|
||||
<svg viewBox="0 0 220 220" className="w-full">
|
||||
<defs>
|
||||
<marker
|
||||
id="fsm-arrowhead"
|
||||
viewBox="0 0 10 10"
|
||||
refX="8"
|
||||
refY="5"
|
||||
markerWidth="5"
|
||||
markerHeight="5"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Transition arrows */}
|
||||
{FSM_TRANSITIONS.map((t) => {
|
||||
const isActive =
|
||||
(activePhase === t.from) ||
|
||||
(activePhase === t.to && t.from === FSM_TRANSITIONS.find((tr) => tr.to === activePhase)?.from);
|
||||
return (
|
||||
<FSMArrow
|
||||
key={`${t.from}-${t.to}`}
|
||||
from={t.from}
|
||||
to={t.to}
|
||||
active={isActive}
|
||||
inactiveStroke={palette.nodeStroke}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* State circles */}
|
||||
{FSM_STATES.map((state) => {
|
||||
const pos = fsmPos(state.angle);
|
||||
const isActive = state.id === activePhase;
|
||||
return (
|
||||
<g key={state.id}>
|
||||
<motion.circle
|
||||
cx={pos.x}
|
||||
cy={pos.y}
|
||||
r={FSM_STATE_R}
|
||||
fill={isActive ? PHASE_COLORS[state.id] : palette.nodeFill}
|
||||
stroke={isActive ? PHASE_COLORS[state.id] : palette.nodeStroke}
|
||||
strokeWidth={isActive ? 2 : 1}
|
||||
animate={{
|
||||
fill: isActive ? PHASE_COLORS[state.id] : palette.nodeFill,
|
||||
scale: isActive ? 1.1 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.4 }}
|
||||
/>
|
||||
<text
|
||||
x={pos.x}
|
||||
y={pos.y + 1}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={9}
|
||||
fontWeight={600}
|
||||
fill={isActive ? "white" : palette.nodeText}
|
||||
>
|
||||
{state.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-2 flex flex-wrap gap-3 justify-center">
|
||||
{FSM_STATES.map((s) => (
|
||||
<div key={s.id} className="flex items-center gap-1">
|
||||
<span className="inline-block h-2.5 w-2.5 rounded-full" style={{ backgroundColor: PHASE_COLORS[s.id] }} />
|
||||
<span className="text-[10px] font-mono text-zinc-500 dark:text-zinc-400">{s.label}</span>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard key={`${agent.id}-${agent.phase}-${agent.task ?? "none"}-${step}`} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<ClipboardList size={16} />
|
||||
Shared task board
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{tasks.map((task) => (
|
||||
<motion.div
|
||||
layout
|
||||
key={`${task.id}-${task.status}-${task.owner ?? "open"}`}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="rounded-md border border-zinc-200 bg-white p-3 text-xs shadow-sm dark:border-zinc-700 dark:bg-zinc-900"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="font-mono font-semibold text-zinc-500 dark:text-zinc-400">{task.id}</span>
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-[10px] font-semibold", statusClass(task.status))}>
|
||||
{task.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-100">{task.title}</div>
|
||||
<div className="mt-2 font-mono text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||
owner: {task.owner ?? "-"}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="mt-3 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
|
||||
Nobody assigns tasks directly; agents claim visible open cards when their timers wake them.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step controls */}
|
||||
<div className="mt-4">
|
||||
<StepControls
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={STEPS[step].title}
|
||||
stepDescription={STEPS[step].desc}
|
||||
/>
|
||||
</div>
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={current.title}
|
||||
stepDescription={current.desc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
347
web/src/components/visualizations/s11-error-recovery.tsx
Normal file
347
web/src/components/visualizations/s11-error-recovery.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Activity, AlertTriangle, Gauge, History, Repeat2, RotateCcw, ShieldCheck, TimerReset, Workflow } from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Normal Call Still Comes First",
|
||||
desc: "The runtime starts with a regular LLM call and only enters recovery when a specific failure appears.",
|
||||
mode: "normal",
|
||||
},
|
||||
{
|
||||
title: "max_tokens Means Output Was Cut Off",
|
||||
desc: "First recovery is to retry with a larger budget before adding any synthetic continuation message.",
|
||||
mode: "max-tokens",
|
||||
},
|
||||
{
|
||||
title: "prompt_too_long Means Context Must Shrink",
|
||||
desc: "The runtime performs reactive compact once, then retries the same task with a smaller message list.",
|
||||
mode: "prompt-too-long",
|
||||
},
|
||||
{
|
||||
title: "429 Means Wait, Then Retry",
|
||||
desc: "Rate limits use exponential backoff with jitter so retries do not stampede the provider.",
|
||||
mode: "rate-limit",
|
||||
},
|
||||
{
|
||||
title: "Repeated 529 Can Switch Models",
|
||||
desc: "Provider overload increments RecoveryState and can move to a fallback model after repeated failures.",
|
||||
mode: "overloaded",
|
||||
},
|
||||
{
|
||||
title: "Recovered Calls Return to the Loop",
|
||||
desc: "Each recovery path is bounded, inspectable, and eventually returns to the normal tool loop or exits cleanly.",
|
||||
mode: "summary",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const CASES = [
|
||||
{
|
||||
id: "max-tokens",
|
||||
label: "max_tokens",
|
||||
symptom: "model stopped mid-answer",
|
||||
action: "8K -> 64K, retry same request",
|
||||
state: "token escalated once",
|
||||
tone: "amber",
|
||||
},
|
||||
{
|
||||
id: "prompt-too-long",
|
||||
label: "prompt_too_long",
|
||||
symptom: "context too large",
|
||||
action: "reactive_compact(messages), retry once",
|
||||
state: "compact retry used",
|
||||
tone: "orange",
|
||||
},
|
||||
{
|
||||
id: "rate-limit",
|
||||
label: "429",
|
||||
symptom: "rate limited",
|
||||
action: "backoff + jitter, max 10 retries",
|
||||
state: "retry attempt counted",
|
||||
tone: "blue",
|
||||
},
|
||||
{
|
||||
id: "overloaded",
|
||||
label: "529",
|
||||
symptom: "provider overloaded",
|
||||
action: "backoff; 3 consecutive -> fallback model",
|
||||
state: "consecutive_529 tracked",
|
||||
tone: "red",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type StepMode = (typeof STEPS)[number]["mode"];
|
||||
type CaseId = (typeof CASES)[number]["id"];
|
||||
type Tone = "amber" | "orange" | "blue" | "red" | "emerald" | "zinc";
|
||||
|
||||
function toneClass(tone: Tone, active = true) {
|
||||
if (!active) return "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200";
|
||||
if (tone === "amber") return "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200";
|
||||
if (tone === "orange") return "border-orange-200 bg-orange-50 text-orange-800 dark:border-orange-900 dark:bg-orange-950/40 dark:text-orange-200";
|
||||
if (tone === "blue") return "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200";
|
||||
if (tone === "red") return "border-red-200 bg-red-50 text-red-800 dark:border-red-900 dark:bg-red-950/40 dark:text-red-200";
|
||||
if (tone === "emerald") return "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200";
|
||||
return "border-zinc-200 bg-zinc-50 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200";
|
||||
}
|
||||
|
||||
function activeCase(mode: StepMode): CaseId | null {
|
||||
if (mode === "max-tokens") return "max-tokens";
|
||||
if (mode === "prompt-too-long") return "prompt-too-long";
|
||||
if (mode === "rate-limit") return "rate-limit";
|
||||
if (mode === "overloaded") return "overloaded";
|
||||
return null;
|
||||
}
|
||||
|
||||
function Surface({
|
||||
title,
|
||||
icon,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg border p-4 transition-colors",
|
||||
active
|
||||
? "border-red-300 bg-red-50 dark:border-red-900 dark:bg-red-950/30"
|
||||
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex min-w-0 items-center gap-3 text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
|
||||
active ? "bg-red-500 text-white" : "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 text-wrap">{title}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CaseCard({
|
||||
item,
|
||||
active,
|
||||
muted,
|
||||
}: {
|
||||
item: (typeof CASES)[number];
|
||||
active: boolean;
|
||||
muted: boolean;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
animate={active ? { y: -1 } : { y: 0 }}
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg border p-3",
|
||||
active ? toneClass(item.tone as Tone) : "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900",
|
||||
muted && "opacity-45"
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="min-w-0 font-mono text-sm font-semibold">{item.label}</div>
|
||||
{active && <AlertTriangle size={15} className="shrink-0" />}
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed text-zinc-700 dark:text-zinc-200">{item.symptom}</div>
|
||||
<div className="mt-2 rounded bg-white/70 px-2 py-1 text-xs leading-relaxed dark:bg-zinc-950/30">{item.action}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecoveryStatePanel({ mode }: { mode: StepMode }) {
|
||||
const values = {
|
||||
token: mode === "max-tokens" || mode === "summary" ? "64K used" : "8K",
|
||||
compact: mode === "prompt-too-long" || mode === "summary" ? "used once" : "unused",
|
||||
retry: mode === "rate-limit" || mode === "overloaded" || mode === "summary" ? "counting" : "0",
|
||||
model: mode === "overloaded" ? "fallback ready" : "primary",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-1">
|
||||
{[
|
||||
["max_tokens", values.token],
|
||||
["reactive_compact", values.compact],
|
||||
["retry_attempt", values.retry],
|
||||
["current_model", values.model],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="min-w-0 rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-1 font-mono text-xs text-zinc-500 dark:text-zinc-400">{label}</div>
|
||||
<div className="break-words text-sm font-semibold text-zinc-900 dark:text-zinc-100">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionPanel({ mode }: { mode: StepMode }) {
|
||||
if (mode === "normal") {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("rounded-xl border p-4", toneClass("emerald"))}>
|
||||
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
|
||||
<ShieldCheck size={17} />
|
||||
normal tool loop
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">LLM succeeds, tool_use continues as usual.</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "max-tokens") {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("amber"))}>
|
||||
<div className="flex items-center gap-2 text-base font-semibold">
|
||||
<Gauge size={17} />
|
||||
escalate output budget
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<CodePill label="before" value="max_tokens=8000" />
|
||||
<CodePill label="retry" value="max_tokens=64000" />
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">No fake "continue" user message on the first escalation.</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "prompt-too-long") {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("orange"))}>
|
||||
<div className="flex items-center gap-2 text-base font-semibold">
|
||||
<History size={17} />
|
||||
shrink context, retry once
|
||||
</div>
|
||||
<CodePill label="recovery" value="messages = reactive_compact(messages)" />
|
||||
<div className="text-sm leading-relaxed">If it is still too long after compact, exit cleanly instead of looping forever.</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "rate-limit") {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("blue"))}>
|
||||
<div className="flex items-center gap-2 text-base font-semibold">
|
||||
<TimerReset size={17} />
|
||||
exponential backoff
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-center text-xs font-semibold">
|
||||
{["0.5s", "1s", "2s"].map((delay) => (
|
||||
<div key={delay} className="rounded bg-white/70 px-2 py-2 dark:bg-zinc-950/30">{delay} + jitter</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">Wait before retrying so the provider has time to recover.</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "overloaded") {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className={cn("space-y-3 rounded-xl border p-4", toneClass("red"))}>
|
||||
<div className="flex items-center gap-2 text-base font-semibold">
|
||||
<RotateCcw size={17} />
|
||||
fallback model path
|
||||
</div>
|
||||
<CodePill label="state" value="consecutive_529 >= 3" />
|
||||
<CodePill label="action" value="current_model = FALLBACK_MODEL_ID" />
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="space-y-2">
|
||||
{CASES.map((item) => (
|
||||
<div key={item.id} className={cn("rounded-lg border p-3", toneClass(item.tone as Tone))}>
|
||||
<div className="mb-1 text-sm font-semibold">{item.label}</div>
|
||||
<div className="text-xs leading-relaxed opacity-80">{item.state}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className={cn("rounded-xl border p-4", toneClass("emerald"))}>
|
||||
<div className="mb-2 flex items-center gap-2 text-base font-semibold">
|
||||
<Repeat2 size={17} />
|
||||
continue or exit cleanly
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">Every path has a limit, then returns to the normal loop or stops with an explicit error.</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodePill({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-lg bg-white/70 p-2 dark:bg-zinc-950/30">
|
||||
<div className="mb-1 text-[11px] font-semibold uppercase tracking-wide opacity-70">{label}</div>
|
||||
<code className="block min-w-0 whitespace-pre-wrap break-words font-mono text-xs leading-relaxed">{value}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ErrorRecoveryVisualization({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2600 });
|
||||
const current = STEPS[vis.currentStep];
|
||||
const mode = current.mode;
|
||||
const active = activeCase(mode);
|
||||
const isSummary = mode === "summary";
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">{title || "Error Recovery Paths"}</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="grid gap-3 xl:grid-cols-[1fr_0.9fr_1fr]">
|
||||
<Surface title="Failure inbox" icon={<Activity size={20} />} active={mode !== "normal"}>
|
||||
<div className="space-y-2">
|
||||
<div className={cn("rounded-lg border p-3", toneClass("emerald", mode === "normal"))}>
|
||||
<div className="mb-1 flex items-center gap-2 text-sm font-semibold">
|
||||
<ShieldCheck size={15} />
|
||||
success
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed">No recovery needed; continue to tool loop.</div>
|
||||
</div>
|
||||
{CASES.map((item) => (
|
||||
<CaseCard key={item.id} item={item} active={active === item.id || isSummary} muted={active !== null && active !== item.id && !isSummary} />
|
||||
))}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface title="RecoveryState" icon={<Workflow size={20} />} active={mode !== "normal"}>
|
||||
<RecoveryStatePanel mode={mode} />
|
||||
</Surface>
|
||||
|
||||
<Surface title="Recovery action" icon={<Repeat2 size={20} />} active>
|
||||
<AnimatePresence mode="wait">
|
||||
<ActionPanel key={mode} mode={mode} />
|
||||
</AnimatePresence>
|
||||
</Surface>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 text-sm leading-relaxed text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800/70 dark:text-zinc-300">
|
||||
Beginner rule: do not blindly retry; classify the failure, run the smallest recovery, and track whether that recovery was already used.
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={current.title}
|
||||
stepDescription={current.desc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
248
web/src/components/visualizations/s14-cron-scheduler.tsx
Normal file
248
web/src/components/visualizations/s14-cron-scheduler.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Bot, CalendarDays, CheckCircle2, Clock3, Database, Inbox } from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Make It Repeatable",
|
||||
desc: "The user turns one normal prompt into a reusable schedule card.",
|
||||
active: "composer",
|
||||
},
|
||||
{
|
||||
title: "Store the Card",
|
||||
desc: "The schedule lives in durable data, so it is not tied to the current chat turn.",
|
||||
active: "ledger",
|
||||
},
|
||||
{
|
||||
title: "Time Keeps Moving",
|
||||
desc: "A tiny scheduler watches the clock while the agent can do other work.",
|
||||
active: "clock",
|
||||
},
|
||||
{
|
||||
title: "Copy Goes to the Queue",
|
||||
desc: "When the cron expression matches, the scheduler puts a due copy in the queue.",
|
||||
active: "queue",
|
||||
},
|
||||
{
|
||||
title: "Run as a Normal Turn",
|
||||
desc: "The queue processor hands the due prompt to the same agent loop beginners already know.",
|
||||
active: "inbox",
|
||||
},
|
||||
{
|
||||
title: "Keep the Original",
|
||||
desc: "The result is recorded, and the schedule card remains ready for the next matching time.",
|
||||
active: "done",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri"];
|
||||
|
||||
function Panel({
|
||||
title,
|
||||
icon,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-[230px] rounded-lg border p-3 transition-colors",
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30"
|
||||
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-7 w-7 items-center justify-center rounded-md",
|
||||
active
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
{title}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleCard({
|
||||
title,
|
||||
subtitle,
|
||||
tone = "blue",
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
tone?: "blue" | "amber" | "emerald";
|
||||
}) {
|
||||
const toneClass = {
|
||||
blue: "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200",
|
||||
amber: "border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200",
|
||||
emerald:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -8, scale: 0.98 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className={cn("rounded-md border p-3 shadow-sm", toneClass)}
|
||||
>
|
||||
<div className="font-mono text-xs font-semibold">{title}</div>
|
||||
<div className="mt-1 text-xs opacity-80">{subtitle}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CronSchedulerVisualization({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const current = STEPS[step];
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Cron Scheduler"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-4 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<CalendarDays size={16} />
|
||||
Weekly clock
|
||||
</div>
|
||||
<motion.div
|
||||
animate={step >= 2 ? { scale: [1, 1.08, 1] } : { scale: 1 }}
|
||||
transition={{ duration: 1.1, repeat: step >= 2 && step <= 4 ? Infinity : 0 }}
|
||||
className="rounded-md bg-white px-2 py-1 font-mono text-xs text-zinc-600 shadow-sm dark:bg-zinc-900 dark:text-zinc-300"
|
||||
>
|
||||
{step < 2 ? "08:59" : "09:00"}
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{DAYS.map((day, index) => (
|
||||
<div
|
||||
key={day}
|
||||
className={cn(
|
||||
"rounded-md border px-2 py-2 text-center text-xs font-medium",
|
||||
step >= 2 && index === 2
|
||||
? "border-amber-300 bg-amber-100 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-200"
|
||||
: "border-zinc-200 bg-white text-zinc-500 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-400"
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<Panel
|
||||
title="Schedule book"
|
||||
icon={<Database size={15} />}
|
||||
active={current.active === "ledger" || current.active === "done"}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{step === 0 && (
|
||||
<ScheduleCard title="Draft prompt" subtitle="review open PR every weekday" />
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{step >= 1 && (
|
||||
<ScheduleCard
|
||||
title="0 9 * * 1-5"
|
||||
subtitle="review open PR every weekday"
|
||||
tone={step === 5 ? "emerald" : "blue"}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="rounded-md border border-dashed border-zinc-300 px-3 py-4 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||
{step >= 1 ? "stored schedules stay here" : "no saved schedule yet"}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Due queue"
|
||||
icon={<Clock3 size={15} />}
|
||||
active={current.active === "clock" || current.active === "queue"}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300">
|
||||
watcher: {step >= 2 ? "running" : "waiting"}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{step >= 3 && step <= 4 && (
|
||||
<ScheduleCard
|
||||
title="due copy"
|
||||
subtitle="same prompt, current timestamp"
|
||||
tone="amber"
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{step < 3 && (
|
||||
<div className="rounded-md border border-dashed border-zinc-300 px-3 py-8 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||
queue is empty
|
||||
</div>
|
||||
)}
|
||||
{step === 5 && (
|
||||
<ScheduleCard title="queue drained" subtitle="ready for next tick" tone="emerald" />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Agent inbox"
|
||||
icon={<Inbox size={15} />}
|
||||
active={current.active === "inbox" || current.active === "done"}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence>
|
||||
{step >= 4 && (
|
||||
<ScheduleCard
|
||||
title="agent turn"
|
||||
subtitle={step >= 5 ? "result appended" : "runs like a normal prompt"}
|
||||
tone={step >= 5 ? "emerald" : "blue"}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<div className="flex items-center gap-2 rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
{step >= 5 ? <CheckCircle2 size={14} /> : <Bot size={14} />}
|
||||
{step >= 5 ? "review summary saved" : "agent loop available"}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={current.title}
|
||||
stepDescription={current.desc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
268
web/src/components/visualizations/s19-mcp-tools.tsx
Normal file
268
web/src/components/visualizations/s19-mcp-tools.tsx
Normal file
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Cable, CheckCircle2, PlugZap, Search, Server, Wrench } from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
title: "Need a New Tool",
|
||||
desc: "The agent starts with built-in tools, then notices this task needs an outside capability.",
|
||||
active: "need",
|
||||
},
|
||||
{
|
||||
title: "Plug In a Server",
|
||||
desc: "MCP is easiest to picture as plugging a named toolbox into the agent workbench.",
|
||||
active: "server",
|
||||
},
|
||||
{
|
||||
title: "Read the Tool Labels",
|
||||
desc: "The server advertises schemas, so the agent can see what each tool expects.",
|
||||
active: "discover",
|
||||
},
|
||||
{
|
||||
title: "Name the Tools Clearly",
|
||||
desc: "Each external tool gets a namespaced label, which avoids collisions with built-ins.",
|
||||
active: "belt",
|
||||
},
|
||||
{
|
||||
title: "Use It Like Any Tool",
|
||||
desc: "Once on the tool belt, the MCP tool follows the same call-and-result rhythm.",
|
||||
active: "call",
|
||||
},
|
||||
{
|
||||
title: "Result Comes Back",
|
||||
desc: "The returned data is just another tool result for the next model turn.",
|
||||
active: "result",
|
||||
},
|
||||
] as const;
|
||||
|
||||
const BUILT_INS = ["read_file", "edit_file", "bash"];
|
||||
const SERVER_TOOLS = [
|
||||
{ raw: "search", namespaced: "mcp__docs__search" },
|
||||
{ raw: "fetch", namespaced: "mcp__docs__fetch" },
|
||||
{ raw: "list_sections", namespaced: "mcp__docs__list_sections" },
|
||||
];
|
||||
|
||||
function ToolChip({
|
||||
label,
|
||||
active,
|
||||
external,
|
||||
}: {
|
||||
label: string;
|
||||
active?: boolean;
|
||||
external?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
"min-w-0 max-w-full break-all rounded-md border px-2 py-1.5 font-mono text-[11px] leading-snug",
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950/40 dark:text-blue-200"
|
||||
: external
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-800 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-200"
|
||||
: "border-zinc-200 bg-white text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function Shelf({
|
||||
title,
|
||||
icon,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
active: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border p-3 transition-colors",
|
||||
active
|
||||
? "border-emerald-300 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/30"
|
||||
: "border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900"
|
||||
)}
|
||||
>
|
||||
<div className="mb-3 flex min-w-0 items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
|
||||
active
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 break-words">{title}</span>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function McpToolsVisualization({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2500 });
|
||||
const step = vis.currentStep;
|
||||
const current = STEPS[step];
|
||||
const connected = step >= 1;
|
||||
const discovered = step >= 2;
|
||||
const namespaced = step >= 3;
|
||||
const called = step >= 4;
|
||||
const returned = step >= 5;
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "MCP Tool Bridge"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_1.2fr_1fr]">
|
||||
<Shelf
|
||||
title="Built-in belt"
|
||||
icon={<Wrench size={15} />}
|
||||
active={current.active === "need"}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{BUILT_INS.map((tool) => (
|
||||
<ToolChip key={tool} label={tool} />
|
||||
))}
|
||||
<div className="rounded-md border border-dashed border-zinc-300 px-3 py-5 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400">
|
||||
limited to local skills
|
||||
</div>
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Shelf
|
||||
title="External toolbox"
|
||||
icon={<Server size={15} />}
|
||||
active={current.active === "server" || current.active === "discover"}
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs dark:border-zinc-700 dark:bg-zinc-800">
|
||||
<div className="flex items-center gap-2 font-mono text-zinc-700 dark:text-zinc-200">
|
||||
<Cable size={14} />
|
||||
docs-server
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded px-2 py-0.5 text-[10px] font-semibold",
|
||||
connected
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-700 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{connected ? "connected" : "offline"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
<AnimatePresence>
|
||||
{discovered ? (
|
||||
SERVER_TOOLS.map((tool) => (
|
||||
<ToolChip key={tool.raw} label={tool.raw} external />
|
||||
))
|
||||
) : (
|
||||
<motion.div
|
||||
key="no-schema"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="col-span-full rounded-md border border-dashed border-zinc-300 px-3 py-5 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"
|
||||
>
|
||||
schemas hidden until connected
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Shelf>
|
||||
|
||||
<Shelf
|
||||
title="Agent workbench"
|
||||
icon={<PlugZap size={15} />}
|
||||
active={current.active === "belt" || current.active === "call"}
|
||||
>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<AnimatePresence>
|
||||
{namespaced ? (
|
||||
SERVER_TOOLS.slice(0, 2).map((tool, index) => (
|
||||
<ToolChip
|
||||
key={tool.namespaced}
|
||||
label={tool.namespaced}
|
||||
active={called && index === 0}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty-belt"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="col-span-full rounded-md border border-dashed border-zinc-300 px-3 py-4 text-center text-xs text-zinc-500 dark:border-zinc-700 dark:text-zinc-400"
|
||||
>
|
||||
no MCP tools on the belt
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Shelf>
|
||||
</div>
|
||||
|
||||
<Shelf
|
||||
title="Call notebook"
|
||||
icon={called ? <Search size={15} /> : <CheckCircle2 size={15} />}
|
||||
active={current.active === "call" || current.active === "result"}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<motion.div
|
||||
animate={called && !returned ? { y: [0, -2, 0] } : { y: 0 }}
|
||||
transition={{ duration: 1, repeat: called && !returned ? Infinity : 0 }}
|
||||
className="break-all rounded-md border border-zinc-200 bg-zinc-50 p-3 font-mono text-[11px] leading-snug text-zinc-600 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
>
|
||||
{called ? "mcp__docs__search({ query })" : "waiting for a tool call"}
|
||||
</motion.div>
|
||||
<AnimatePresence>
|
||||
{returned && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="break-words rounded-md border border-emerald-200 bg-emerald-50 p-3 text-xs leading-snug text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200"
|
||||
>
|
||||
tool_result: 3 relevant docs found
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Shelf>
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={current.title}
|
||||
stepDescription={current.desc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
413
web/src/components/visualizations/s20-comprehensive.tsx
Normal file
413
web/src/components/visualizations/s20-comprehensive.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
Archive,
|
||||
Blocks,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Inbox,
|
||||
Network,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
import { StepControls } from "@/components/visualizations/shared/step-controls";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type StageId =
|
||||
| "intake"
|
||||
| "guardrails"
|
||||
| "route"
|
||||
| "execute"
|
||||
| "external"
|
||||
| "recover"
|
||||
| "append";
|
||||
|
||||
const STAGES: {
|
||||
id: StageId;
|
||||
label: string;
|
||||
detail: string;
|
||||
icon: ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
id: "intake",
|
||||
label: "Intake",
|
||||
detail: "request, memory, background notes",
|
||||
icon: <Inbox size={15} />,
|
||||
},
|
||||
{
|
||||
id: "guardrails",
|
||||
label: "Guardrails",
|
||||
detail: "permissions, hooks, policy",
|
||||
icon: <ShieldCheck size={15} />,
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
label: "Route",
|
||||
detail: "choose the right work surface",
|
||||
icon: <GitBranch size={15} />,
|
||||
},
|
||||
{
|
||||
id: "execute",
|
||||
label: "Execute",
|
||||
detail: "local tools, teams, worktrees",
|
||||
icon: <Wrench size={15} />,
|
||||
},
|
||||
{
|
||||
id: "external",
|
||||
label: "External",
|
||||
detail: "MCP toolboxes return results",
|
||||
icon: <Blocks size={15} />,
|
||||
},
|
||||
{
|
||||
id: "recover",
|
||||
label: "Recover",
|
||||
detail: "retry, compact, repair state",
|
||||
icon: <Sparkles size={15} />,
|
||||
},
|
||||
{
|
||||
id: "append",
|
||||
label: "Append",
|
||||
detail: "one transcript stays authoritative",
|
||||
icon: <FileText size={15} />,
|
||||
},
|
||||
];
|
||||
|
||||
const SURFACES = [
|
||||
{ label: "background", icon: <Clock3 size={14} />, text: "slow commands can finish later" },
|
||||
{ label: "team", icon: <Network size={14} />, text: "teammates work through mailboxes" },
|
||||
{ label: "worktree", icon: <GitBranch size={14} />, text: "risky edits stay isolated" },
|
||||
{ label: "MCP", icon: <Blocks size={14} />, text: "external tools are normalized" },
|
||||
];
|
||||
|
||||
const STEPS: {
|
||||
title: string;
|
||||
desc: string;
|
||||
stage: StageId;
|
||||
used: StageId[];
|
||||
packet: {
|
||||
request: string;
|
||||
carried: string[];
|
||||
decision: string;
|
||||
result: string;
|
||||
};
|
||||
transcript: string[];
|
||||
}[] = [
|
||||
{
|
||||
title: "A Turn Starts as a Packet",
|
||||
desc: "The comprehensive agent first gathers everything the model should see, instead of scattering context across hidden places.",
|
||||
stage: "intake",
|
||||
used: ["intake"],
|
||||
packet: {
|
||||
request: "Fix the web lesson visuals and verify the pages.",
|
||||
carried: ["recent messages", "relevant memory", "background notes"],
|
||||
decision: "build one model-visible input packet",
|
||||
result: "ready for a model call",
|
||||
},
|
||||
transcript: ["user request enters", "memory and notes are attached"],
|
||||
},
|
||||
{
|
||||
title: "Guardrails Check the Packet",
|
||||
desc: "Permissions and hooks are not separate side quests; they are the inspection gate before work happens.",
|
||||
stage: "guardrails",
|
||||
used: ["intake", "guardrails"],
|
||||
packet: {
|
||||
request: "Edit files, run build, open browser.",
|
||||
carried: ["permission mode", "hook output", "workspace rules"],
|
||||
decision: "allowed work continues; risky work asks first",
|
||||
result: "safe action envelope",
|
||||
},
|
||||
transcript: ["policy checked", "allowed actions are visible"],
|
||||
},
|
||||
{
|
||||
title: "The Agent Picks Work Surfaces",
|
||||
desc: "The model does not need every mechanism at once. It chooses the smallest surface that matches the job.",
|
||||
stage: "route",
|
||||
used: ["route", "execute", "external"],
|
||||
packet: {
|
||||
request: "Search code, patch UI, verify rendered pages.",
|
||||
carried: ["available tools", "team status", "MCP registry"],
|
||||
decision: "local edit first, external tools only when needed",
|
||||
result: "work split into clear lanes",
|
||||
},
|
||||
transcript: ["route: code search", "route: browser check", "route: no teammate needed"],
|
||||
},
|
||||
{
|
||||
title: "Work Runs in Bounded Places",
|
||||
desc: "Tools, teammates, and worktrees all produce small result cards, so parallel work does not become one unreadable chat log.",
|
||||
stage: "execute",
|
||||
used: ["execute", "route"],
|
||||
packet: {
|
||||
request: "Apply the patch and run the build.",
|
||||
carried: ["tool call", "worktree lane", "expected output"],
|
||||
decision: "execute, then return summarized results",
|
||||
result: "local evidence collected",
|
||||
},
|
||||
transcript: ["patch applied", "build output summarized"],
|
||||
},
|
||||
{
|
||||
title: "External Results Re-enter the Same Lane",
|
||||
desc: "MCP tools expand capability, but they still come back as ordinary tool results the agent can reason over.",
|
||||
stage: "external",
|
||||
used: ["external", "execute"],
|
||||
packet: {
|
||||
request: "Use an external source or tool if local context is missing.",
|
||||
carried: ["MCP tool name", "structured arguments", "returned artifact"],
|
||||
decision: "normalize external output before the next model step",
|
||||
result: "outside work is no longer special",
|
||||
},
|
||||
transcript: ["MCP result received", "result card appended"],
|
||||
},
|
||||
{
|
||||
title: "Recovery Keeps the Turn Understandable",
|
||||
desc: "Long context, command errors, and retries are handled as named recovery moves, not as mysterious branches.",
|
||||
stage: "recover",
|
||||
used: ["recover", "intake"],
|
||||
packet: {
|
||||
request: "If context or execution gets messy, repair before continuing.",
|
||||
carried: ["error text", "retry count", "compact summary"],
|
||||
decision: "retry once, compact old detail, keep the reason visible",
|
||||
result: "the turn remains legible",
|
||||
},
|
||||
transcript: ["error classified", "recovery note added", "work resumes"],
|
||||
},
|
||||
{
|
||||
title: "Everything Writes Back to One Transcript",
|
||||
desc: "The big lesson is boring in the best way: all mechanisms eventually append evidence to the same source of truth.",
|
||||
stage: "append",
|
||||
used: ["append", "intake"],
|
||||
packet: {
|
||||
request: "Report what changed and what was verified.",
|
||||
carried: ["tool evidence", "browser checks", "remaining risks"],
|
||||
decision: "answer from the transcript, not from memory alone",
|
||||
result: "next turn has a clean starting point",
|
||||
},
|
||||
transcript: ["tests pass", "visual checks recorded", "final answer drafted"],
|
||||
},
|
||||
];
|
||||
|
||||
function StageNode({
|
||||
stage,
|
||||
index,
|
||||
currentIndex,
|
||||
}: {
|
||||
stage: (typeof STAGES)[number];
|
||||
index: number;
|
||||
currentIndex: number;
|
||||
}) {
|
||||
const active = index === currentIndex;
|
||||
const done = index < currentIndex;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
animate={active ? { y: [0, -2, 0] } : { y: 0 }}
|
||||
transition={{ duration: 0.8, repeat: active ? Infinity : 0 }}
|
||||
className={cn(
|
||||
"min-w-0 rounded-lg border p-3 transition-colors",
|
||||
active
|
||||
? "border-blue-300 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950/35 dark:text-blue-200"
|
||||
: done
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/30 dark:text-emerald-200"
|
||||
: "border-zinc-200 bg-white text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md",
|
||||
active
|
||||
? "bg-blue-500 text-white"
|
||||
: done
|
||||
? "bg-emerald-500 text-white"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
)}
|
||||
>
|
||||
{done ? <CheckCircle2 size={15} /> : stage.icon}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="break-words text-sm font-semibold">
|
||||
{index + 1}. {stage.label}
|
||||
</div>
|
||||
<div className="break-words text-[11px] leading-snug opacity-80">{stage.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function PacketLine({
|
||||
label,
|
||||
value,
|
||||
tone = "zinc",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "zinc" | "blue" | "emerald";
|
||||
}) {
|
||||
const toneClass = {
|
||||
zinc: "border-zinc-200 bg-white text-zinc-700 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200",
|
||||
blue: "border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-900 dark:bg-blue-950/35 dark:text-blue-200",
|
||||
emerald:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/35 dark:text-emerald-200",
|
||||
}[tone];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
className={cn("min-w-0 rounded-md border px-3 py-2 shadow-sm", toneClass)}
|
||||
>
|
||||
<div className="font-mono text-[10px] uppercase tracking-normal opacity-70">{label}</div>
|
||||
<div className="mt-1 break-words text-sm font-medium leading-snug">{value}</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ComprehensiveVisualization({ title }: { title?: string }) {
|
||||
const vis = useSteppedVisualization({ totalSteps: STEPS.length, autoPlayInterval: 2800 });
|
||||
const step = STEPS[vis.currentStep];
|
||||
const currentStageIndex = STAGES.findIndex((stage) => stage.id === step.stage);
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || "Comprehensive Agent Turn"}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="grid gap-3 lg:grid-cols-[0.9fr_1.2fr]">
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
|
||||
<div className="mb-3 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<Bot size={16} />
|
||||
One-turn journey
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{STAGES.map((stage, index) => (
|
||||
<StageNode
|
||||
key={stage.id}
|
||||
stage={stage}
|
||||
index={index}
|
||||
currentIndex={currentStageIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70">
|
||||
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<Archive size={16} />
|
||||
Turn packet
|
||||
</div>
|
||||
<span className="w-fit rounded-md bg-white px-2 py-1 font-mono text-[11px] text-zinc-500 dark:bg-zinc-900 dark:text-zinc-300">
|
||||
step {vis.currentStep + 1}/{STEPS.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={step.title}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
className="space-y-3"
|
||||
>
|
||||
<PacketLine label="request" value={step.packet.request} tone="blue" />
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="rounded-md border border-zinc-200 bg-white px-3 py-2 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="font-mono text-[10px] uppercase tracking-normal text-zinc-500 dark:text-zinc-400">
|
||||
carried context
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{step.packet.carried.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className="max-w-full break-words rounded bg-zinc-100 px-2 py-1 text-[11px] text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<PacketLine label="decision" value={step.packet.decision} />
|
||||
</div>
|
||||
|
||||
<PacketLine label="result" value={step.packet.result} tone="emerald" />
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-3 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<FileText size={15} />
|
||||
Source-of-truth transcript
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{step.transcript.map((item) => (
|
||||
<motion.div
|
||||
key={item}
|
||||
layout
|
||||
initial={{ opacity: 0, x: 12 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -8 }}
|
||||
transition={{ duration: 0.22 }}
|
||||
className="break-words rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
|
||||
>
|
||||
{item}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{SURFACES.map((surface) => (
|
||||
<div
|
||||
key={surface.label}
|
||||
className="min-w-0 rounded-lg border border-zinc-200 bg-zinc-50 p-3 dark:border-zinc-700 dark:bg-zinc-800/70"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-zinc-100 text-zinc-500 dark:bg-zinc-900 dark:text-zinc-300">
|
||||
{surface.icon}
|
||||
</span>
|
||||
<span className="break-words">{surface.label}</span>
|
||||
</div>
|
||||
<div className="mt-2 break-words text-[11px] leading-snug text-zinc-500 dark:text-zinc-400">
|
||||
{surface.text}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
className="mt-4"
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={step.title}
|
||||
stepDescription={step.desc}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
301
web/src/components/visualizations/shared/mechanism-flow.tsx
Normal file
301
web/src/components/visualizations/shared/mechanism-flow.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useSteppedVisualization } from "@/hooks/useSteppedVisualization";
|
||||
import { useSvgPalette } from "@/hooks/useDarkMode";
|
||||
import { StepControls } from "./step-controls";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type NodeKind = "start" | "process" | "decision" | "store" | "external" | "end";
|
||||
|
||||
export interface MechanismNode {
|
||||
id: string;
|
||||
label: string;
|
||||
x: number;
|
||||
y: number;
|
||||
kind?: NodeKind;
|
||||
appearsAt?: number;
|
||||
}
|
||||
|
||||
export interface MechanismEdge {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
appearsAt?: number;
|
||||
}
|
||||
|
||||
export interface MechanismStep {
|
||||
title: string;
|
||||
description: string;
|
||||
focus?: string[];
|
||||
}
|
||||
|
||||
interface MechanismFlowProps {
|
||||
title?: string;
|
||||
fallbackTitle: string;
|
||||
nodes: MechanismNode[];
|
||||
edges: MechanismEdge[];
|
||||
steps: MechanismStep[];
|
||||
viewBox?: string;
|
||||
footer?: string[];
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 118;
|
||||
const NODE_HEIGHT = 42;
|
||||
const DIAMOND_SIZE = 54;
|
||||
|
||||
const KIND_COLORS: Record<NodeKind, string> = {
|
||||
start: "#3b82f6",
|
||||
process: "#10b981",
|
||||
decision: "#f59e0b",
|
||||
store: "#8b5cf6",
|
||||
external: "#ef4444",
|
||||
end: "#64748b",
|
||||
};
|
||||
|
||||
function bounds(node: MechanismNode) {
|
||||
const halfW = node.kind === "decision" ? DIAMOND_SIZE / 2 : NODE_WIDTH / 2;
|
||||
const halfH = node.kind === "decision" ? DIAMOND_SIZE / 2 : NODE_HEIGHT / 2;
|
||||
return {
|
||||
left: node.x - halfW,
|
||||
right: node.x + halfW,
|
||||
top: node.y - halfH,
|
||||
bottom: node.y + halfH,
|
||||
};
|
||||
}
|
||||
|
||||
function edgePath(from: MechanismNode, to: MechanismNode) {
|
||||
const a = bounds(from);
|
||||
const b = bounds(to);
|
||||
|
||||
if (Math.abs(from.x - to.x) < 12) {
|
||||
return `M ${from.x} ${a.bottom} L ${to.x} ${b.top}`;
|
||||
}
|
||||
|
||||
if (Math.abs(from.y - to.y) < 12) {
|
||||
const startX = to.x > from.x ? a.right : a.left;
|
||||
const endX = to.x > from.x ? b.left : b.right;
|
||||
const midX = (startX + endX) / 2;
|
||||
return `M ${startX} ${from.y} C ${midX} ${from.y}, ${midX} ${to.y}, ${endX} ${to.y}`;
|
||||
}
|
||||
|
||||
const startY = to.y > from.y ? a.bottom : a.top;
|
||||
const endY = to.y > from.y ? b.top : b.bottom;
|
||||
const control = Math.max(36, Math.abs(endY - startY) * 0.45);
|
||||
const c1 = startY + (endY > startY ? control : -control);
|
||||
const c2 = endY - (endY > startY ? control : -control);
|
||||
return `M ${from.x} ${startY} C ${from.x} ${c1}, ${to.x} ${c2}, ${to.x} ${endY}`;
|
||||
}
|
||||
|
||||
function labelPosition(from: MechanismNode, to: MechanismNode) {
|
||||
return {
|
||||
x: (from.x + to.x) / 2,
|
||||
y: (from.y + to.y) / 2 - 10,
|
||||
};
|
||||
}
|
||||
|
||||
function FlowNode({
|
||||
node,
|
||||
active,
|
||||
visible,
|
||||
}: {
|
||||
node: MechanismNode;
|
||||
active: boolean;
|
||||
visible: boolean;
|
||||
}) {
|
||||
const kind = node.kind ?? "process";
|
||||
const color = KIND_COLORS[kind];
|
||||
const lines = node.label.split("\n");
|
||||
|
||||
if (kind === "decision") {
|
||||
const half = DIAMOND_SIZE / 2;
|
||||
return (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, scale: 0.94 }}
|
||||
animate={{ opacity: visible ? 1 : 0.16, scale: active ? 1.04 : 1 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<polygon
|
||||
points={`${node.x},${node.y - half} ${node.x + half},${node.y} ${node.x},${node.y + half} ${node.x - half},${node.y}`}
|
||||
fill={active ? `${color}20` : "transparent"}
|
||||
stroke={color}
|
||||
strokeWidth={active ? 2.4 : 1.5}
|
||||
/>
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={line}
|
||||
x={node.x}
|
||||
y={node.y + (i - (lines.length - 1) / 2) * 12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={9}
|
||||
fontFamily="monospace"
|
||||
fontWeight={active ? 700 : 500}
|
||||
fill="currentColor"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.g
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: visible ? 1 : 0.16, y: 0, scale: active ? 1.03 : 1 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<rect
|
||||
x={node.x - NODE_WIDTH / 2}
|
||||
y={node.y - NODE_HEIGHT / 2}
|
||||
width={NODE_WIDTH}
|
||||
height={NODE_HEIGHT}
|
||||
rx={kind === "start" || kind === "end" ? NODE_HEIGHT / 2 : 6}
|
||||
fill={active ? `${color}1f` : "transparent"}
|
||||
stroke={color}
|
||||
strokeWidth={active ? 2.4 : 1.5}
|
||||
strokeDasharray={kind === "external" ? "6 3" : undefined}
|
||||
/>
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={line}
|
||||
x={node.x}
|
||||
y={node.y + (i - (lines.length - 1) / 2) * 12}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={10}
|
||||
fontFamily="monospace"
|
||||
fontWeight={active ? 700 : 500}
|
||||
fill="currentColor"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
|
||||
export function MechanismFlow({
|
||||
title,
|
||||
fallbackTitle,
|
||||
nodes,
|
||||
edges,
|
||||
steps,
|
||||
viewBox = "0 0 720 360",
|
||||
footer,
|
||||
}: MechanismFlowProps) {
|
||||
const vis = useSteppedVisualization({ totalSteps: steps.length, autoPlayInterval: 2300 });
|
||||
const step = steps[vis.currentStep];
|
||||
const palette = useSvgPalette();
|
||||
const focused = new Set(step.focus ?? []);
|
||||
|
||||
return (
|
||||
<section className="min-h-[500px] space-y-4">
|
||||
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title || fallbackTitle}
|
||||
</h2>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 bg-white p-4 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<svg viewBox={viewBox} className="w-full" aria-label={title || fallbackTitle}>
|
||||
<defs>
|
||||
<marker
|
||||
id={`mechanism-arrow-${fallbackTitle.replace(/\W/g, "-")}`}
|
||||
viewBox="0 0 10 10"
|
||||
refX="9"
|
||||
refY="5"
|
||||
markerWidth="5"
|
||||
markerHeight="5"
|
||||
orient="auto-start-reverse"
|
||||
>
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill={palette.arrowFill} />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{edges.map((edge) => {
|
||||
const from = nodes.find((node) => node.id === edge.from);
|
||||
const to = nodes.find((node) => node.id === edge.to);
|
||||
if (!from || !to) return null;
|
||||
const active = focused.has(edge.id) || focused.has(edge.from) || focused.has(edge.to);
|
||||
const visible = active || vis.currentStep >= (edge.appearsAt ?? 0);
|
||||
const label = labelPosition(from, to);
|
||||
|
||||
return (
|
||||
<motion.g
|
||||
key={edge.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: visible ? 1 : 0.12 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
>
|
||||
<path
|
||||
d={edgePath(from, to)}
|
||||
fill="none"
|
||||
stroke={active ? "#3b82f6" : palette.arrowFill}
|
||||
strokeWidth={active ? 2.4 : 1.3}
|
||||
markerEnd={`url(#mechanism-arrow-${fallbackTitle.replace(/\W/g, "-")})`}
|
||||
/>
|
||||
{edge.label && visible && (
|
||||
<text
|
||||
x={label.x}
|
||||
y={label.y}
|
||||
textAnchor="middle"
|
||||
fontSize={9}
|
||||
fontFamily="monospace"
|
||||
fill={active ? "#2563eb" : palette.labelFill}
|
||||
stroke={palette.bgSubtle}
|
||||
strokeWidth={4}
|
||||
paintOrder="stroke"
|
||||
>
|
||||
{edge.label}
|
||||
</text>
|
||||
)}
|
||||
</motion.g>
|
||||
);
|
||||
})}
|
||||
|
||||
{nodes.map((node) => {
|
||||
const active = focused.has(node.id);
|
||||
const visible = active || vis.currentStep >= (node.appearsAt ?? 0);
|
||||
return (
|
||||
<FlowNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
visible={visible}
|
||||
active={active}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{footer && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{footer.map((item) => (
|
||||
<span
|
||||
key={item}
|
||||
className={cn(
|
||||
"rounded-md px-2 py-1 font-mono text-xs",
|
||||
"bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400"
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<StepControls
|
||||
currentStep={vis.currentStep}
|
||||
totalSteps={vis.totalSteps}
|
||||
onPrev={vis.prev}
|
||||
onNext={vis.next}
|
||||
onReset={vis.reset}
|
||||
isPlaying={vis.isPlaying}
|
||||
onToggleAutoPlay={vis.toggleAutoPlay}
|
||||
stepTitle={step.title}
|
||||
stepDescription={step.description}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user