import { useState, useCallback, useMemo, useRef, useEffect } from "react";
import type {
ChainWithContracts,
ChainGraphResponse,
ChainContractDetail,
ChainContractDefinition,
ChainDefinitionGraphResponse,
AddContractDefinitionRequest,
} from "../../lib/api";
import {
listChainDefinitions,
createChainDefinition,
updateChainDefinition,
deleteChainDefinition,
getChainDefinitionGraph,
startChain,
stopChain,
} from "../../lib/api";
const statusColors: Record<string, string> = {
active: "text-green-400",
completed: "text-blue-400",
archived: "text-[#555]",
pending: "text-yellow-400",
};
interface ChainEditorProps {
chain: ChainWithContracts;
graph: ChainGraphResponse | null;
loading: boolean;
onBack: () => void;
onRefresh: () => void;
onContractClick: (contractId: string) => void;
}
// Node dimensions
const NODE_WIDTH = 180;
const NODE_HEIGHT = 80;
const CANVAS_PADDING = 40;
export function ChainEditor({
chain,
graph,
loading,
onBack,
onRefresh,
onContractClick,
}: ChainEditorProps) {
const canvasRef = useRef<HTMLDivElement>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const [definitions, setDefinitions] = useState<ChainContractDefinition[]>([]);
const [definitionGraph, setDefinitionGraph] = useState<ChainDefinitionGraphResponse | null>(null);
const [showAddDefinition, setShowAddDefinition] = useState(false);
const [isStarting, setIsStarting] = useState(false);
const [isStopping, setIsStopping] = useState(false);
const [error, setError] = useState<string | null>(null);
// Drag state
const [draggedNode, setDraggedNode] = useState<string | null>(null);
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
const [localPositions, setLocalPositions] = useState<Map<string, { x: number; y: number }>>(new Map());
// Edge drawing state
const [edgeDrawing, setEdgeDrawing] = useState<{
fromNode: string;
toPoint: { x: number; y: number };
} | null>(null);
// Context menu state (nodeId is null for canvas context menu)
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
nodeId: string | null;
canvasPosition?: { gridX: number; gridY: number };
} | null>(null);
// Load definitions when chain changes
useEffect(() => {
async function loadDefinitions() {
try {
const [defs, defGraph] = await Promise.all([
listChainDefinitions(chain.id),
getChainDefinitionGraph(chain.id),
]);
setDefinitions(defs);
setDefinitionGraph(defGraph);
} catch (err) {
console.error("Failed to load definitions:", err);
}
}
loadDefinitions();
}, [chain.id]);
// Determine which view to show: definitions (pending chain) or contracts (active/completed)
const showDefinitions = chain.status === "pending" || chain.status === "archived";
const currentGraph = showDefinitions ? definitionGraph : graph;
// Use positions from graph nodes, with local overrides for dragging
const nodePositions = useMemo(() => {
const positions = new Map<string, { x: number; y: number }>();
if (showDefinitions && definitionGraph?.nodes) {
for (const node of definitionGraph.nodes) {
// Use local position if available (during drag), otherwise use server position
const localPos = localPositions.get(node.id);
if (localPos) {
positions.set(node.id, localPos);
} else {
positions.set(node.id, {
x: CANVAS_PADDING + (node.x || 0) * (NODE_WIDTH + 60),
y: CANVAS_PADDING + (node.y || 0) * (NODE_HEIGHT + 40),
});
}
}
} else if (!showDefinitions && graph?.nodes) {
for (const node of graph.nodes) {
positions.set(node.contractId, {
x: CANVAS_PADDING + (node.x || 0) * (NODE_WIDTH + 60),
y: CANVAS_PADDING + (node.y || 0) * (NODE_HEIGHT + 40),
});
}
}
return positions;
}, [showDefinitions, definitionGraph?.nodes, graph?.nodes, localPositions]);
// Canvas dimensions
const canvasDimensions = useMemo(() => {
if (nodePositions.size === 0) {
return { width: 600, height: 400 };
}
let maxX = 0;
let maxY = 0;
for (const pos of nodePositions.values()) {
maxX = Math.max(maxX, pos.x + NODE_WIDTH);
maxY = Math.max(maxY, pos.y + NODE_HEIGHT);
}
return {
width: Math.max(600, maxX + CANVAS_PADDING),
height: Math.max(400, maxY + CANVAS_PADDING),
};
}, [nodePositions]);
const handleNodeClick = useCallback((nodeId: string) => {
setSelectedNode(nodeId);
}, []);
const handleNodeDoubleClick = useCallback(
(nodeId: string) => {
// For definitions, we can't open a contract yet
if (showDefinitions) return;
onContractClick(nodeId);
},
[onContractClick, showDefinitions]
);
const getStatusColor = (status: string, isCheckpoint = false) => {
// Checkpoint contracts use purple/violet colors
if (isCheckpoint) {
switch (status) {
case "active":
return { bg: "#a78bfa", border: "#8b5cf6", text: "#5b21b6" };
case "completed":
return { bg: "#818cf8", border: "#6366f1", text: "#3730a3" };
case "pending":
return { bg: "#c4b5fd", border: "#a78bfa", text: "#6d28d9" };
case "failed":
return { bg: "#ef4444", border: "#dc2626", text: "#991b1b" };
default:
return { bg: "#a78bfa", border: "#8b5cf6", text: "#5b21b6" };
}
}
switch (status) {
case "active":
return { bg: "#4ade80", border: "#22c55e", text: "#166534" };
case "completed":
return { bg: "#60a5fa", border: "#3b82f6", text: "#1e40af" };
case "pending":
return { bg: "#f59e0b", border: "#d97706", text: "#92400e" };
case "blocked":
return { bg: "#ef4444", border: "#dc2626", text: "#991b1b" };
default:
return { bg: "#6b7280", border: "#4b5563", text: "#374151" };
}
};
// Find selected item (definition or contract)
const selectedDefinition = showDefinitions && selectedNode
? definitions.find((d) => d.id === selectedNode)
: null;
const selectedContract = !showDefinitions && selectedNode
? chain.contracts.find((c) => c.contractId === selectedNode)
: null;
const handleStartChain = useCallback(async () => {
setIsStarting(true);
setError(null);
try {
await startChain(chain.id);
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to start chain");
} finally {
setIsStarting(false);
}
}, [chain.id, onRefresh]);
const handleStopChain = useCallback(async () => {
if (!confirm("Are you sure you want to stop this chain?")) return;
setIsStopping(true);
setError(null);
try {
await stopChain(chain.id);
onRefresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to stop chain");
} finally {
setIsStopping(false);
}
}, [chain.id, onRefresh]);
// Position for new definition (set from canvas right-click)
const [newDefinitionPosition, setNewDefinitionPosition] = useState<{ x: number; y: number } | null>(null);
// Find free space on the grid for new definitions
const findFreePosition = useCallback(() => {
if (!definitionGraph?.nodes || definitionGraph.nodes.length === 0) {
return { x: 0, y: 0 };
}
// Get all occupied positions
const occupied = new Set<string>();
for (const node of definitionGraph.nodes) {
occupied.add(`${node.x},${node.y}`);
}
// Find first free position by scanning row by row
for (let y = 0; y < 10; y++) {
for (let x = 0; x < 10; x++) {
if (!occupied.has(`${x},${y}`)) {
return { x, y };
}
}
}
// Fallback: place at the end of the last row
const maxY = Math.max(...definitionGraph.nodes.map((n) => n.y || 0));
return { x: 0, y: maxY + 1 };
}, [definitionGraph?.nodes]);
const handleAddDefinition = useCallback(async (req: AddContractDefinitionRequest) => {
try {
// Use specified position or find free space
const position = newDefinitionPosition || findFreePosition();
const reqWithPosition = { ...req, editorX: position.x, editorY: position.y };
await createChainDefinition(chain.id, reqWithPosition);
// Reload definitions
const [defs, defGraph] = await Promise.all([
listChainDefinitions(chain.id),
getChainDefinitionGraph(chain.id),
]);
setDefinitions(defs);
setDefinitionGraph(defGraph);
setShowAddDefinition(false);
setNewDefinitionPosition(null);
} catch (err) {
console.error("Failed to add definition:", err);
setError(err instanceof Error ? err.message : "Failed to add definition");
}
}, [chain.id, newDefinitionPosition, findFreePosition]);
const handleDeleteDefinition = useCallback(async (definitionId: string) => {
if (!confirm("Are you sure you want to delete this definition?")) return;
try {
await deleteChainDefinition(chain.id, definitionId);
// Reload definitions
const [defs, defGraph] = await Promise.all([
listChainDefinitions(chain.id),
getChainDefinitionGraph(chain.id),
]);
setDefinitions(defs);
setDefinitionGraph(defGraph);
setSelectedNode(null);
} catch (err) {
console.error("Failed to delete definition:", err);
setError(err instanceof Error ? err.message : "Failed to delete definition");
}
}, [chain.id]);
// Refresh definitions helper
const refreshDefinitions = useCallback(async () => {
const [defs, defGraph] = await Promise.all([
listChainDefinitions(chain.id),
getChainDefinitionGraph(chain.id),
]);
setDefinitions(defs);
setDefinitionGraph(defGraph);
setLocalPositions(new Map()); // Clear local positions
}, [chain.id]);
// Context menu handlers
const handleContextMenu = useCallback((e: React.MouseEvent, nodeId: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, nodeId });
}, []);
// Canvas context menu (for creating new definitions)
const handleCanvasContextMenu = useCallback((e: React.MouseEvent) => {
if (!showDefinitions || chain.status !== "pending") return;
e.preventDefault();
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
// Calculate grid position from click
const clickX = e.clientX - canvasRect.left;
const clickY = e.clientY - canvasRect.top;
const gridX = Math.round((clickX - CANVAS_PADDING) / (NODE_WIDTH + 60));
const gridY = Math.round((clickY - CANVAS_PADDING) / (NODE_HEIGHT + 40));
setContextMenu({
x: e.clientX,
y: e.clientY,
nodeId: null,
canvasPosition: { gridX: Math.max(0, gridX), gridY: Math.max(0, gridY) },
});
}, [showDefinitions, chain.status]);
const closeContextMenu = useCallback(() => {
setContextMenu(null);
}, []);
// Handle creating definition at canvas position
const handleCreateAtPosition = useCallback((gridX: number, gridY: number) => {
setNewDefinitionPosition({ x: gridX, y: gridY });
setShowAddDefinition(true);
closeContextMenu();
}, [closeContextMenu]);
// Handle canvas click to close context menu
const handleCanvasClick = useCallback(() => {
if (contextMenu) {
closeContextMenu();
}
}, [contextMenu, closeContextMenu]);
// Drag handlers
const handleDragStart = useCallback((e: React.MouseEvent, nodeId: string) => {
if (!showDefinitions || chain.status !== "pending") return;
e.preventDefault();
const pos = nodePositions.get(nodeId);
if (!pos) return;
setDraggedNode(nodeId);
setDragOffset({
x: e.clientX - pos.x,
y: e.clientY - pos.y,
});
}, [showDefinitions, chain.status, nodePositions]);
const handleDragMove = useCallback((e: React.MouseEvent) => {
if (!draggedNode) return;
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
const newX = Math.max(CANVAS_PADDING, e.clientX - dragOffset.x);
const newY = Math.max(CANVAS_PADDING, e.clientY - dragOffset.y);
setLocalPositions(prev => {
const newMap = new Map(prev);
newMap.set(draggedNode, { x: newX, y: newY });
return newMap;
});
}, [draggedNode, dragOffset]);
const handleDragEnd = useCallback(async () => {
if (!draggedNode) return;
const localPos = localPositions.get(draggedNode);
if (localPos) {
// Convert pixel position back to grid coordinates
const gridX = Math.round((localPos.x - CANVAS_PADDING) / (NODE_WIDTH + 60));
const gridY = Math.round((localPos.y - CANVAS_PADDING) / (NODE_HEIGHT + 40));
try {
await updateChainDefinition(chain.id, draggedNode, {
editorX: gridX,
editorY: gridY,
});
await refreshDefinitions();
} catch (err) {
console.error("Failed to update position:", err);
setError(err instanceof Error ? err.message : "Failed to update position");
}
}
setDraggedNode(null);
}, [draggedNode, localPositions, chain.id, refreshDefinitions]);
// Edge drawing handlers
const handleEdgeDrawStart = useCallback((e: React.MouseEvent, nodeId: string) => {
if (!showDefinitions || chain.status !== "pending") return;
e.preventDefault();
e.stopPropagation();
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
const pos = nodePositions.get(nodeId);
if (!pos) return;
setEdgeDrawing({
fromNode: nodeId,
toPoint: {
x: pos.x + NODE_WIDTH / 2,
y: pos.y + NODE_HEIGHT,
},
});
}, [showDefinitions, chain.status, nodePositions]);
const handleEdgeDrawMove = useCallback((e: React.MouseEvent) => {
if (!edgeDrawing) return;
const canvasRect = canvasRef.current?.getBoundingClientRect();
if (!canvasRect) return;
setEdgeDrawing(prev => prev ? {
...prev,
toPoint: {
x: e.clientX - canvasRect.left,
y: e.clientY - canvasRect.top,
},
} : null);
}, [edgeDrawing]);
const handleEdgeDrawEnd = useCallback(async (targetNodeId: string | null) => {
if (!edgeDrawing) return;
if (targetNodeId && targetNodeId !== edgeDrawing.fromNode) {
// Find the target definition and its current dependencies
const fromDef = definitions.find(d => d.id === edgeDrawing.fromNode);
const toDef = definitions.find(d => d.id === targetNodeId);
if (fromDef && toDef) {
// Add dependency: targetNodeId depends on fromNode
const currentDeps = toDef.dependsOnNames || [];
if (!currentDeps.includes(fromDef.name)) {
try {
await updateChainDefinition(chain.id, targetNodeId, {
dependsOn: [...currentDeps, fromDef.name],
});
await refreshDefinitions();
} catch (err) {
console.error("Failed to create dependency:", err);
setError(err instanceof Error ? err.message : "Failed to create dependency");
}
}
}
}
setEdgeDrawing(null);
}, [edgeDrawing, definitions, chain.id, refreshDefinitions]);
// Handle removing a dependency via context menu
const handleRemoveDependency = useCallback(async (nodeId: string, depName: string) => {
const def = definitions.find(d => d.id === nodeId);
if (!def) return;
const newDeps = (def.dependsOnNames || []).filter(d => d !== depName);
try {
await updateChainDefinition(chain.id, nodeId, {
dependsOn: newDeps,
});
await refreshDefinitions();
closeContextMenu();
} catch (err) {
console.error("Failed to remove dependency:", err);
setError(err instanceof Error ? err.message : "Failed to remove dependency");
}
}, [definitions, chain.id, refreshDefinitions, closeContextMenu]);
return (
<div className="panel h-full flex flex-col">
{/* Header */}
<div className="p-3 border-b border-[rgba(117,170,252,0.2)]">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
>
Back
</button>
<div>
<h2 className="font-mono text-sm text-[#dbe7ff]">{chain.name}</h2>
{chain.description && (
<p className="font-mono text-xs text-[#8b949e]">{chain.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span
className={`font-mono text-[10px] uppercase ${
statusColors[chain.status] || "text-[#555]"
}`}
>
{chain.status}
</span>
{/* Chain control buttons */}
{chain.status === "pending" && definitions.length > 0 && (
<button
onClick={handleStartChain}
disabled={isStarting}
className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-green-600 hover:bg-green-700 border border-green-500 transition-colors disabled:opacity-50"
>
{isStarting ? "Starting..." : "Start Chain"}
</button>
)}
{chain.status === "active" && (
<button
onClick={handleStopChain}
disabled={isStopping}
className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-red-600 hover:bg-red-700 border border-red-500 transition-colors disabled:opacity-50"
>
{isStopping ? "Stopping..." : "Stop"}
</button>
)}
<button
onClick={onRefresh}
className="px-3 py-1 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] border border-[#3f6fb3] hover:border-[#75aafc] transition-colors"
>
Refresh
</button>
</div>
</div>
{error && (
<div className="mt-2 p-2 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs">
{error}
</div>
)}
</div>
{/* Main content */}
<div className="flex-1 flex min-h-0">
{/* DAG Canvas */}
<div className="flex-1 overflow-auto bg-[#050d18]">
{loading ? (
<div className="flex items-center justify-center h-full">
<p className="font-mono text-xs text-[#8b949e]">Loading graph...</p>
</div>
) : !currentGraph || currentGraph.nodes.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="font-mono text-sm text-[#8b949e] mb-2">
{showDefinitions
? "No contract definitions yet"
: "No contracts in this chain yet"}
</p>
<p className="font-mono text-xs text-[#556677] mb-4">
{showDefinitions
? "Add contract definitions to build your chain"
: "Start the chain to create contracts from definitions"}
</p>
{showDefinitions && (
<button
onClick={() => setShowAddDefinition(true)}
className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors"
>
+ Add Definition
</button>
)}
</div>
</div>
) : (
<div
ref={canvasRef}
className="relative"
style={{
width: canvasDimensions.width,
height: canvasDimensions.height,
minWidth: "100%",
minHeight: "100%",
cursor: draggedNode ? "grabbing" : edgeDrawing ? "crosshair" : "default",
}}
onClick={handleCanvasClick}
onContextMenu={handleCanvasContextMenu}
onMouseMove={(e) => {
if (draggedNode) handleDragMove(e);
if (edgeDrawing) handleEdgeDrawMove(e);
}}
onMouseUp={() => {
if (draggedNode) handleDragEnd();
if (edgeDrawing) handleEdgeDrawEnd(null);
}}
onMouseLeave={() => {
if (draggedNode) handleDragEnd();
if (edgeDrawing) setEdgeDrawing(null);
}}
>
{/* SVG layer for edges */}
<svg
className="absolute inset-0 pointer-events-none"
style={{
width: canvasDimensions.width,
height: canvasDimensions.height,
}}
>
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon
points="0 0, 10 3.5, 0 7"
fill="#75aafc"
opacity="0.6"
/>
</marker>
<marker
id="arrowhead-preview"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon
points="0 0, 10 3.5, 0 7"
fill="#f59e0b"
opacity="0.8"
/>
</marker>
</defs>
{currentGraph.edges.map((edge, index) => {
const fromPos = nodePositions.get(edge.from);
const toPos = nodePositions.get(edge.to);
if (!fromPos || !toPos) return null;
// Calculate edge path (from bottom of source to top of target)
const startX = fromPos.x + NODE_WIDTH / 2;
const startY = fromPos.y + NODE_HEIGHT;
const endX = toPos.x + NODE_WIDTH / 2;
const endY = toPos.y;
// Bezier control points for smooth curves
const midY = (startY + endY) / 2;
const isHighlighted =
hoveredNode === edge.from || hoveredNode === edge.to;
return (
<path
key={`${edge.from}-${edge.to}-${index}`}
d={`M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`}
fill="none"
stroke={isHighlighted ? "#75aafc" : "#3f6fb3"}
strokeWidth={isHighlighted ? 2 : 1.5}
strokeDasharray={isHighlighted ? "none" : "4 2"}
markerEnd="url(#arrowhead)"
opacity={isHighlighted ? 1 : 0.6}
/>
);
})}
{/* Edge drawing preview line */}
{edgeDrawing && (() => {
const fromPos = nodePositions.get(edgeDrawing.fromNode);
if (!fromPos) return null;
const startX = fromPos.x + NODE_WIDTH / 2;
const startY = fromPos.y + NODE_HEIGHT;
const endX = edgeDrawing.toPoint.x;
const endY = edgeDrawing.toPoint.y;
const midY = (startY + endY) / 2;
return (
<path
d={`M ${startX} ${startY} C ${startX} ${midY}, ${endX} ${midY}, ${endX} ${endY}`}
fill="none"
stroke="#f59e0b"
strokeWidth={2}
strokeDasharray="6 3"
markerEnd="url(#arrowhead-preview)"
opacity={0.8}
/>
);
})()}
</svg>
{/* Node layer */}
{showDefinitions && definitionGraph
? definitionGraph.nodes.map((node) => {
const pos = nodePositions.get(node.id);
if (!pos) return null;
const isCheckpoint = node.contractType === "checkpoint";
const status = node.isInstantiated
? node.contractStatus || "pending"
: "pending";
const colors = getStatusColor(status, isCheckpoint);
const isSelected = selectedNode === node.id;
const isHovered = hoveredNode === node.id;
const isDragging = draggedNode === node.id;
const canEdit = chain.status === "pending";
return (
<div
key={node.id}
onClick={() => handleNodeClick(node.id)}
onContextMenu={(e) => canEdit && handleContextMenu(e, node.id)}
onMouseEnter={() => !draggedNode && setHoveredNode(node.id)}
onMouseLeave={() => !draggedNode && setHoveredNode(null)}
onMouseUp={() => edgeDrawing && handleEdgeDrawEnd(node.id)}
className={`absolute ${
isDragging ? "z-50 shadow-lg" : "transition-all duration-150"
} ${
isSelected
? isCheckpoint
? "ring-2 ring-[#a78bfa] ring-offset-2 ring-offset-[#050d18]"
: "ring-2 ring-[#75aafc] ring-offset-2 ring-offset-[#050d18]"
: ""
}`}
style={{
left: pos.x,
top: pos.y,
width: NODE_WIDTH,
height: NODE_HEIGHT,
transform: isHovered && !isDragging ? "scale(1.02)" : "scale(1)",
cursor: isDragging ? "grabbing" : canEdit ? "grab" : "pointer",
}}
>
{/* Drag handle (entire node) */}
<div
className={`w-full h-full rounded-lg border-2 overflow-hidden ${
isCheckpoint ? "bg-[#0f0a1e]" : "bg-[#0a1628]"
}`}
style={{
borderColor: isSelected
? isCheckpoint
? "#a78bfa"
: "#75aafc"
: colors.border,
borderStyle: node.isInstantiated ? "solid" : "dashed",
}}
onMouseDown={(e) => canEdit && handleDragStart(e, node.id)}
>
{/* Status indicator bar */}
<div
className="h-1.5"
style={{ backgroundColor: colors.bg }}
/>
{/* Content */}
<div className="p-2">
<div className="flex items-center justify-between mb-1">
<span className="font-mono text-xs text-[#dbe7ff] truncate flex-1">
{node.name}
</span>
{isCheckpoint ? (
<CheckIcon className="w-4 h-4 text-[#a78bfa] opacity-70 flex-shrink-0 ml-1" />
) : (
<ChainIcon className="w-4 h-4 text-[#75aafc] opacity-50 flex-shrink-0 ml-1" />
)}
</div>
<div className="flex items-center justify-between">
<span
className="font-mono text-[10px] uppercase px-1.5 py-0.5 rounded"
style={{
color: colors.bg,
backgroundColor: `${colors.bg}20`,
}}
>
{node.isInstantiated ? status : isCheckpoint ? "checkpoint" : "definition"}
</span>
<span className="font-mono text-[10px] text-[#8b949e]">
{node.contractType}
</span>
</div>
</div>
</div>
{/* Edge drawing handle (connector dot at bottom) */}
{canEdit && (isHovered || isSelected) && !isDragging && (
<div
className="absolute left-1/2 -translate-x-1/2 -bottom-2 w-4 h-4 rounded-full bg-[#f59e0b] border-2 border-[#0a1628] cursor-crosshair hover:scale-125 transition-transform"
onMouseDown={(e) => handleEdgeDrawStart(e, node.id)}
title="Drag to create dependency"
/>
)}
</div>
);
})
: graph?.nodes.map((node) => {
const pos = nodePositions.get(node.contractId);
if (!pos) return null;
const colors = getStatusColor(node.status);
const isSelected = selectedNode === node.contractId;
const isHovered = hoveredNode === node.contractId;
return (
<div
key={node.contractId}
onClick={() => handleNodeClick(node.contractId)}
onDoubleClick={() => handleNodeDoubleClick(node.contractId)}
onMouseEnter={() => setHoveredNode(node.contractId)}
onMouseLeave={() => setHoveredNode(null)}
className={`absolute cursor-pointer transition-all duration-150 ${
isSelected
? "ring-2 ring-[#75aafc] ring-offset-2 ring-offset-[#050d18]"
: ""
}`}
style={{
left: pos.x,
top: pos.y,
width: NODE_WIDTH,
height: NODE_HEIGHT,
transform: isHovered ? "scale(1.02)" : "scale(1)",
}}
>
<div
className="w-full h-full rounded-lg border-2 bg-[#0a1628] overflow-hidden"
style={{
borderColor: isSelected ? "#75aafc" : colors.border,
}}
>
{/* Status indicator bar */}
<div
className="h-1.5"
style={{ backgroundColor: colors.bg }}
/>
{/* Content */}
<div className="p-2">
<div className="flex items-center justify-between mb-1">
<span className="font-mono text-xs text-[#dbe7ff] truncate flex-1">
{node.name}
</span>
<ChainIcon className="w-4 h-4 text-[#75aafc] opacity-50 flex-shrink-0 ml-1" />
</div>
<div className="flex items-center justify-between">
<span
className="font-mono text-[10px] uppercase px-1.5 py-0.5 rounded"
style={{
color: colors.bg,
backgroundColor: `${colors.bg}20`,
}}
>
{node.status}
</span>
{node.phase && (
<span className="font-mono text-[10px] text-[#8b949e]">
{node.phase}
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Detail panel */}
{selectedDefinition && (
<DefinitionDetailPanel
definition={selectedDefinition}
onClose={() => setSelectedNode(null)}
onDelete={handleDeleteDefinition}
/>
)}
{selectedContract && (
<ContractDetailPanel
contract={selectedContract}
allContracts={chain.contracts}
onClose={() => setSelectedNode(null)}
onSelectContract={setSelectedNode}
onOpenContract={onContractClick}
/>
)}
</div>
{/* Footer with stats */}
<div className="p-3 border-t border-[rgba(117,170,252,0.2)] bg-[#0a1628]">
<div className="flex items-center gap-4 font-mono text-[10px] text-[#8b949e]">
{showDefinitions ? (
<>
<span>{definitions.length} definitions</span>
{chain.status === "pending" && (
<>
<span className="text-[#556677]">|</span>
<span className="text-[#556677]">Drag nodes to reposition</span>
<span className="text-[#556677]">|</span>
<span className="text-[#556677]">Drag from <span className="text-[#f59e0b]">●</span> to link</span>
<span className="text-[#556677]">|</span>
<span className="text-[#556677]">Right-click for options</span>
</>
)}
<span className="flex-1" />
{chain.status === "pending" && (
<button
onClick={() => setShowAddDefinition(true)}
className="text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
>
+ Add Definition
</button>
)}
</>
) : (
<>
<span>{chain.contracts.length} contracts</span>
<span>
{chain.contracts.filter((c) => c.contractStatus === "completed").length} completed
</span>
<span>
{chain.contracts.filter((c) => c.contractStatus === "active").length} active
</span>
<span className="flex-1" />
<span>Double-click node to open contract</span>
</>
)}
</div>
</div>
{/* Add Definition Modal */}
{showAddDefinition && (
<AddDefinitionModal
existingNames={definitions.map((d) => d.name)}
onSubmit={handleAddDefinition}
onCancel={() => setShowAddDefinition(false)}
/>
)}
{/* Context Menu */}
{contextMenu && (
<ContextMenu
x={contextMenu.x}
y={contextMenu.y}
nodeId={contextMenu.nodeId}
canvasPosition={contextMenu.canvasPosition}
definition={contextMenu.nodeId ? definitions.find((d) => d.id === contextMenu.nodeId) : undefined}
allDefinitions={definitions}
onClose={closeContextMenu}
onDelete={handleDeleteDefinition}
onRemoveDependency={handleRemoveDependency}
onCreateAtPosition={handleCreateAtPosition}
/>
)}
</div>
);
}
// Context Menu Component
interface ContextMenuProps {
x: number;
y: number;
nodeId: string | null;
canvasPosition?: { gridX: number; gridY: number };
definition?: ChainContractDefinition;
allDefinitions: ChainContractDefinition[];
onClose: () => void;
onDelete: (id: string) => void;
onRemoveDependency: (nodeId: string, depName: string) => void;
onCreateAtPosition: (gridX: number, gridY: number) => void;
}
function ContextMenu({
x,
y,
nodeId,
canvasPosition,
definition,
allDefinitions: _allDefinitions,
onClose,
onDelete,
onRemoveDependency,
onCreateAtPosition,
}: ContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
// Close on escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
// Canvas context menu (no node selected)
if (!nodeId && canvasPosition) {
return (
<div
ref={menuRef}
className="fixed z-50 min-w-40 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-xl rounded-lg overflow-hidden"
style={{ left: x, top: y }}
>
<div className="py-1">
<button
onClick={() => onCreateAtPosition(canvasPosition.gridX, canvasPosition.gridY)}
className="w-full px-3 py-2 text-left font-mono text-xs text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] transition-colors flex items-center gap-2"
>
<PlusIcon className="w-3 h-3" />
Create Definition Here
</button>
</div>
</div>
);
}
// Node context menu requires both nodeId and definition
if (!nodeId || !definition) return null;
const dependencies = definition.dependsOnNames || [];
return (
<div
ref={menuRef}
className="fixed z-50 min-w-48 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-xl rounded-lg overflow-hidden"
style={{ left: x, top: y }}
>
{/* Header */}
<div className="px-3 py-2 border-b border-[rgba(117,170,252,0.2)] bg-[#0f1c32]">
<span className="font-mono text-xs text-[#dbe7ff]">{definition.name}</span>
</div>
{/* Actions */}
<div className="py-1">
<button
onClick={() => {
onDelete(nodeId);
onClose();
}}
className="w-full px-3 py-2 text-left font-mono text-xs text-red-400 hover:bg-red-400/10 transition-colors flex items-center gap-2"
>
<TrashIcon className="w-3 h-3" />
Delete Definition
</button>
</div>
{/* Dependencies section */}
{dependencies.length > 0 && (
<>
<div className="border-t border-[rgba(117,170,252,0.2)]" />
<div className="px-3 py-2">
<span className="font-mono text-[10px] text-[#8b949e] uppercase">Dependencies</span>
</div>
<div className="pb-1">
{dependencies.map((depName) => (
<div
key={depName}
className="px-3 py-1.5 flex items-center justify-between hover:bg-[rgba(117,170,252,0.1)] group"
>
<span className="font-mono text-xs text-[#9bc3ff]">{depName}</span>
<button
onClick={() => onRemoveDependency(nodeId, depName)}
className="font-mono text-[10px] text-red-400 opacity-0 group-hover:opacity-100 transition-opacity hover:underline"
>
Remove
</button>
</div>
))}
</div>
</>
)}
{/* Hint */}
<div className="border-t border-[rgba(117,170,252,0.2)] px-3 py-2">
<span className="font-mono text-[10px] text-[#556677]">
Drag from connector to create dependency
</span>
</div>
</div>
);
}
// Trash Icon
function TrashIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
);
}
// Plus Icon
function PlusIcon({ className }: { className?: string }) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
</svg>
);
}
interface DefinitionDetailPanelProps {
definition: ChainContractDefinition;
onClose: () => void;
onDelete: (id: string) => void;
}
function DefinitionDetailPanel({
definition,
onClose,
onDelete,
}: DefinitionDetailPanelProps) {
return (
<div className="w-72 border-l border-[rgba(117,170,252,0.2)] bg-[#0a1628] overflow-y-auto">
<div className="p-3 border-b border-[rgba(117,170,252,0.2)]">
<div className="flex items-center justify-between mb-2">
<h3 className="font-mono text-xs text-[#75aafc] uppercase">
Definition Details
</h3>
<button
onClick={onClose}
className="font-mono text-xs text-[#8b949e] hover:text-[#dbe7ff]"
>
Close
</button>
</div>
</div>
<div className="p-3 space-y-4">
{/* Name */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Name
</label>
<p className="font-mono text-sm text-[#dbe7ff]">{definition.name}</p>
</div>
{/* Description */}
{definition.description && (
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Description
</label>
<p className="font-mono text-xs text-[#dbe7ff]">{definition.description}</p>
</div>
)}
{/* Contract Type */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Contract Type
</label>
<span className="font-mono text-xs text-[#dbe7ff]">{definition.contractType}</span>
</div>
{/* Initial Phase */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Initial Phase
</label>
<span className="font-mono text-xs text-[#dbe7ff]">
{definition.initialPhase || "plan"}
</span>
</div>
{/* Dependencies */}
{definition.dependsOnNames && definition.dependsOnNames.length > 0 && (
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Depends On
</label>
<div className="space-y-1">
{definition.dependsOnNames.map((depName) => (
<span
key={depName}
className="block font-mono text-xs text-[#9bc3ff]"
>
{depName}
</span>
))}
</div>
</div>
)}
{/* Tasks */}
{definition.tasks && definition.tasks.length > 0 && (
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Tasks ({definition.tasks.length})
</label>
<div className="space-y-1">
{definition.tasks.map((task, i) => (
<div key={i} className="font-mono text-xs text-[#dbe7ff]">
{task.name}
</div>
))}
</div>
</div>
)}
{/* Actions */}
<div className="pt-2 border-t border-[rgba(117,170,252,0.2)]">
<button
onClick={() => onDelete(definition.id)}
className="w-full px-3 py-2 font-mono text-xs text-red-400 border border-red-400/30 hover:bg-red-400/10 transition-colors"
>
Delete Definition
</button>
</div>
</div>
</div>
);
}
interface ContractDetailPanelProps {
contract: ChainContractDetail;
allContracts: ChainContractDetail[];
onClose: () => void;
onSelectContract: (contractId: string) => void;
onOpenContract: (contractId: string) => void;
}
function ContractDetailPanel({
contract,
allContracts,
onClose,
onSelectContract,
onOpenContract,
}: ContractDetailPanelProps) {
return (
<div className="w-72 border-l border-[rgba(117,170,252,0.2)] bg-[#0a1628] overflow-y-auto">
<div className="p-3 border-b border-[rgba(117,170,252,0.2)]">
<div className="flex items-center justify-between mb-2">
<h3 className="font-mono text-xs text-[#75aafc] uppercase">
Contract Details
</h3>
<button
onClick={onClose}
className="font-mono text-xs text-[#8b949e] hover:text-[#dbe7ff]"
>
Close
</button>
</div>
</div>
<div className="p-3 space-y-4">
{/* Name */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Name
</label>
<p className="font-mono text-sm text-[#dbe7ff]">
{contract.contractName}
</p>
</div>
{/* Status */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Status
</label>
<span
className={`font-mono text-xs uppercase ${
statusColors[contract.contractStatus] || "text-[#555]"
}`}
>
{contract.contractStatus}
</span>
</div>
{/* Phase */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Phase
</label>
<span className="font-mono text-sm text-[#dbe7ff]">
{contract.contractPhase}
</span>
</div>
{/* Dependencies */}
{contract.dependsOn && contract.dependsOn.length > 0 && (
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Depends On
</label>
<div className="space-y-1">
{contract.dependsOn.map((depId) => {
const dep = allContracts.find((c) => c.contractId === depId);
return (
<button
key={depId}
onClick={() => onSelectContract(depId)}
className="block w-full text-left font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] truncate"
>
{dep?.contractName || depId}
</button>
);
})}
</div>
</div>
)}
{/* Order Index */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Order Index
</label>
<p className="font-mono text-xs text-[#dbe7ff]">
{contract.orderIndex}
</p>
</div>
{/* Created */}
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Created
</label>
<p className="font-mono text-xs text-[#dbe7ff]">
{new Date(contract.createdAt).toLocaleString()}
</p>
</div>
{/* Actions */}
<div className="pt-2 border-t border-[rgba(117,170,252,0.2)]">
<button
onClick={() => onOpenContract(contract.contractId)}
className="w-full px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors"
>
Open Contract
</button>
</div>
</div>
</div>
);
}
interface AddDefinitionModalProps {
existingNames: string[];
onSubmit: (req: AddContractDefinitionRequest) => void;
onCancel: () => void;
}
function AddDefinitionModal({
existingNames,
onSubmit,
onCancel,
}: AddDefinitionModalProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [contractType, setContractType] = useState("simple");
const [initialPhase, setInitialPhase] = useState("plan");
const [dependsOn, setDependsOn] = useState<string[]>([]);
// Checkpoint validation options
const [checkDeliverables, setCheckDeliverables] = useState(true);
const [runTests, setRunTests] = useState(false);
const [checkContent, setCheckContent] = useState("");
const [onFailure, setOnFailure] = useState<"block" | "retry" | "warn">("block");
const isCheckpoint = contractType === "checkpoint";
const handleSubmit = () => {
if (!name.trim()) return;
const req: AddContractDefinitionRequest = {
name: name.trim(),
description: description.trim() || undefined,
contractType,
initialPhase: isCheckpoint ? "execute" : initialPhase, // Checkpoints always start in execute
dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
};
// Add validation config for checkpoint contracts
if (isCheckpoint) {
req.validation = {
checkDeliverables,
runTests,
checkContent: checkContent.trim() || undefined,
onFailure,
};
}
onSubmit(req);
};
const toggleDependency = (depName: string) => {
setDependsOn((prev) =>
prev.includes(depName) ? prev.filter((d) => d !== depName) : [...prev, depName]
);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]">
<h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">
Add Contract Definition
</h3>
<div className="space-y-4">
{/* Name */}
<div>
<label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Research Phase"
className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
autoFocus
/>
</div>
{/* Description */}
<div>
<label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
Description (optional)
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What does this contract accomplish?"
rows={2}
className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none"
/>
</div>
{/* Contract Type */}
<div>
<label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
Contract Type
</label>
<select
value={contractType}
onChange={(e) => setContractType(e.target.value)}
className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
>
<option value="simple">Simple</option>
<option value="specification">Specification</option>
<option value="execute">Execute</option>
<option value="checkpoint">Checkpoint (Validation)</option>
</select>
{isCheckpoint && (
<p className="mt-1 font-mono text-[10px] text-[#a78bfa]">
Checkpoint contracts validate outputs before allowing downstream contracts to proceed.
</p>
)}
</div>
{/* Initial Phase - hidden for checkpoints */}
{!isCheckpoint && (
<div>
<label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
Initial Phase
</label>
<select
value={initialPhase}
onChange={(e) => setInitialPhase(e.target.value)}
className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
>
<option value="plan">Plan</option>
<option value="execute">Execute</option>
<option value="review">Review</option>
</select>
</div>
)}
{/* Checkpoint Validation Options */}
{isCheckpoint && (
<div className="p-3 bg-[#0f0a1e] border border-[#6366f1]/30 space-y-3">
<h4 className="font-mono text-xs text-[#a78bfa] uppercase">Validation Options</h4>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={checkDeliverables}
onChange={(e) => setCheckDeliverables(e.target.checked)}
className="accent-[#a78bfa]"
/>
<span className="font-mono text-xs text-[#dbe7ff]">Check required deliverables exist</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={runTests}
onChange={(e) => setRunTests(e.target.checked)}
className="accent-[#a78bfa]"
/>
<span className="font-mono text-xs text-[#dbe7ff]">Run test suite</span>
</label>
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
Custom Validation Instructions (optional)
</label>
<textarea
value={checkContent}
onChange={(e) => setCheckContent(e.target.value)}
placeholder="Additional criteria for Claude to check..."
rows={3}
className="w-full px-2 py-1.5 bg-[#0d1b2d] border border-[#6366f1]/50 text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#a78bfa] resize-none"
/>
</div>
<div>
<label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
On Failure
</label>
<select
value={onFailure}
onChange={(e) => setOnFailure(e.target.value as "block" | "retry" | "warn")}
className="w-full px-2 py-1.5 bg-[#0d1b2d] border border-[#6366f1]/50 text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#a78bfa]"
>
<option value="block">Block - Stop chain until fixed</option>
<option value="retry">Retry - Retry upstream contracts</option>
<option value="warn">Warn - Log warning but continue</option>
</select>
</div>
</div>
)}
{/* Dependencies */}
{existingNames.length > 0 && (
<div>
<label className="block font-mono text-xs text-[#8b949e] uppercase mb-1">
Depends On
</label>
<div className="space-y-1 max-h-32 overflow-y-auto">
{existingNames.map((depName) => (
<label key={depName} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={dependsOn.includes(depName)}
onChange={() => toggleDependency(depName)}
className="accent-[#75aafc]"
/>
<span className="font-mono text-xs text-[#dbe7ff]">{depName}</span>
</label>
))}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2 justify-end pt-2">
<button
onClick={onCancel}
className="px-4 py-2 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!name.trim()}
className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Add Definition
</button>
</div>
</div>
</div>
</div>
);
}
function ChainIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg
className={className}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
);
}