import { useState, useCallback, useEffect, useMemo } from "react"; import { ReactFlow, Node, Edge, Controls, Background, useNodesState, useEdgesState, addEdge, Connection, NodeProps, Handle, Position, BackgroundVariant, NodeChange, MarkerType, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import type { ChainWithContracts, ChainGraphResponse, ChainContractDefinition, ChainDefinitionGraphResponse, AddContractDefinitionRequest, } from "../../lib/api"; import { listChainDefinitions, createChainDefinition, updateChainDefinition, deleteChainDefinition, getChainDefinitionGraph, startChain, stopChain, } from "../../lib/api"; const statusColors: Record = { 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 for layout const NODE_WIDTH = 200; const NODE_HEIGHT = 80; const GRID_SPACING_X = 280; const GRID_SPACING_Y = 120; // Custom node component for definitions function DefinitionNode({ data, selected }: NodeProps) { const isCheckpoint = data.contractType === "checkpoint"; const status = data.isInstantiated ? data.contractStatus || "pending" : "pending"; const colors = getStatusColor(status, isCheckpoint); return (
{/* Top handle for incoming edges */} {/* Status indicator bar */}
{/* Content */}
{data.name} {isCheckpoint ? ( ) : ( )}
{data.isInstantiated ? status : isCheckpoint ? "checkpoint" : "definition"} {data.contractType}
{/* Bottom handle for outgoing edges */}
); } // Custom node for contracts (active chains) function ContractNode({ data, selected }: NodeProps) { const colors = getStatusColor(data.status); return (
{data.name}
{data.status} {data.phase && ( {data.phase} )}
); } const nodeTypes = { definition: DefinitionNode, contract: ContractNode, }; function getStatusColor(status: string, isCheckpoint = false) { 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" }; } } export function ChainEditor({ chain, graph, loading, onBack, onRefresh, onContractClick, }: ChainEditorProps) { const [definitions, setDefinitions] = useState([]); const [definitionGraph, setDefinitionGraph] = useState(null); const [showAddDefinition, setShowAddDefinition] = useState(false); const [isStarting, setIsStarting] = useState(false); const [isStopping, setIsStopping] = useState(false); const [error, setError] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const showDefinitions = chain.status === "pending" || chain.status === "archived"; const canEdit = chain.status === "pending"; // 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]); // Convert definitions/contracts to React Flow nodes and edges useEffect(() => { if (showDefinitions && definitionGraph) { const flowNodes: Node[] = definitionGraph.nodes.map((node) => ({ id: node.id, type: "definition", position: { x: (node.x || 0) * GRID_SPACING_X, y: (node.y || 0) * GRID_SPACING_Y, }, data: { name: node.name, contractType: node.contractType, isInstantiated: node.isInstantiated, contractStatus: node.contractStatus, }, draggable: canEdit, })); const flowEdges: Edge[] = definitionGraph.edges.map((edge, index) => ({ id: `${edge.from}-${edge.to}-${index}`, source: edge.from, target: edge.to, type: "smoothstep", animated: false, style: { stroke: "#3f6fb3", strokeWidth: 2 }, markerEnd: { type: MarkerType.ArrowClosed, color: "#75aafc" }, })); setNodes(flowNodes); setEdges(flowEdges); } else if (!showDefinitions && graph) { const flowNodes: Node[] = graph.nodes.map((node) => ({ id: node.contractId, type: "contract", position: { x: (node.x || 0) * GRID_SPACING_X, y: (node.y || 0) * GRID_SPACING_Y, }, data: { name: node.name, status: node.status, phase: node.phase, }, draggable: false, })); const flowEdges: Edge[] = graph.edges.map((edge, index) => ({ id: `${edge.from}-${edge.to}-${index}`, source: edge.from, target: edge.to, type: "smoothstep", animated: edge.from === selectedNodeId || edge.to === selectedNodeId, style: { stroke: "#3f6fb3", strokeWidth: 2 }, markerEnd: { type: MarkerType.ArrowClosed, color: "#75aafc" }, })); setNodes(flowNodes); setEdges(flowEdges); } }, [showDefinitions, definitionGraph, graph, canEdit, selectedNodeId, setNodes, setEdges]); // Handle node position changes (drag end) const handleNodesChange = useCallback( async (changes: NodeChange[]) => { onNodesChange(changes); // Save position changes to backend for (const change of changes) { if (change.type === "position" && change.dragging === false && change.position) { const gridX = Math.round(change.position.x / GRID_SPACING_X); const gridY = Math.round(change.position.y / GRID_SPACING_Y); try { await updateChainDefinition(chain.id, change.id, { editorX: gridX, editorY: gridY, }); } catch (err) { console.error("Failed to save position:", err); } } } }, [chain.id, onNodesChange] ); // Handle new edge connections const handleConnect = useCallback( async (connection: Connection) => { if (!connection.source || !connection.target) return; // Find the definitions const sourceDef = definitions.find((d) => d.id === connection.source); const targetDef = definitions.find((d) => d.id === connection.target); if (!sourceDef || !targetDef) return; // Add dependency: target depends on source const currentDeps = targetDef.dependsOnNames || []; if (!currentDeps.includes(sourceDef.name)) { try { await updateChainDefinition(chain.id, connection.target, { dependsOn: [...currentDeps, sourceDef.name], }); // Refresh const [defs, defGraph] = await Promise.all([ listChainDefinitions(chain.id), getChainDefinitionGraph(chain.id), ]); setDefinitions(defs); setDefinitionGraph(defGraph); } catch (err) { console.error("Failed to create dependency:", err); setError(err instanceof Error ? err.message : "Failed to create dependency"); } } }, [chain.id, definitions] ); // Handle node selection const handleNodeClick = useCallback( (_: React.MouseEvent, node: Node) => { setSelectedNodeId(node.id); }, [] ); // Handle node double-click (open contract) const handleNodeDoubleClick = useCallback( (_: React.MouseEvent, node: Node) => { if (!showDefinitions) { onContractClick(node.id); } }, [showDefinitions, onContractClick] ); // Handle pane click (deselect) const handlePaneClick = useCallback(() => { setSelectedNodeId(null); }, []); // Find selected definition const selectedDefinition = showDefinitions && selectedNodeId ? definitions.find((d) => d.id === selectedNodeId) : null; // Find free position for new definition const findFreePosition = useCallback(() => { if (!definitionGraph?.nodes || definitionGraph.nodes.length === 0) { return { x: 0, y: 0 }; } const occupied = new Set(); for (const node of definitionGraph.nodes) { occupied.add(`${node.x},${node.y}`); } for (let y = 0; y < 10; y++) { for (let x = 0; x < 10; x++) { if (!occupied.has(`${x},${y}`)) { return { x, y }; } } } 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 { const position = findFreePosition(); const reqWithPosition = { ...req, editorX: position.x, editorY: position.y }; await createChainDefinition(chain.id, reqWithPosition); const [defs, defGraph] = await Promise.all([ listChainDefinitions(chain.id), getChainDefinitionGraph(chain.id), ]); setDefinitions(defs); setDefinitionGraph(defGraph); setShowAddDefinition(false); } catch (err) { console.error("Failed to add definition:", err); setError(err instanceof Error ? err.message : "Failed to add definition"); } }, [chain.id, 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); const [defs, defGraph] = await Promise.all([ listChainDefinitions(chain.id), getChainDefinitionGraph(chain.id), ]); setDefinitions(defs); setDefinitionGraph(defGraph); setSelectedNodeId(null); } catch (err) { console.error("Failed to delete definition:", err); setError(err instanceof Error ? err.message : "Failed to delete definition"); } }, [chain.id] ); 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]); 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 }); const [defs, defGraph] = await Promise.all([ listChainDefinitions(chain.id), getChainDefinitionGraph(chain.id), ]); setDefinitions(defs); setDefinitionGraph(defGraph); } catch (err) { console.error("Failed to remove dependency:", err); setError(err instanceof Error ? err.message : "Failed to remove dependency"); } }, [chain.id, definitions] ); return (
{/* Header */}

{chain.name}

{chain.description && (

{chain.description}

)}
{chain.status} {chain.status === "pending" && definitions.length > 0 && ( )} {chain.status === "active" && ( )}
{error && (
{error}
)}
{/* Main content */}
{/* React Flow Canvas */}
{loading ? (

Loading graph...

) : nodes.length === 0 ? (

{showDefinitions ? "No contract definitions yet" : "No contracts in this chain yet"}

{showDefinitions ? "Add contract definitions to build your chain" : "Start the chain to create contracts from definitions"}

{showDefinitions && canEdit && ( )}
) : ( )}
{/* Detail panel */} {selectedDefinition && ( setSelectedNodeId(null)} onDelete={handleDeleteDefinition} onRemoveDependency={handleRemoveDependency} /> )}
{/* Footer with stats */}
{showDefinitions ? ( <> {definitions.length} definitions {canEdit && ( <> | Drag nodes to reposition | Drag from to link )} {canEdit && ( )} ) : ( <> {chain.contracts.length} contracts {chain.contracts.filter((c) => c.contractStatus === "completed").length}{" "} completed {chain.contracts.filter((c) => c.contractStatus === "active").length} active Double-click node to open contract )}
{/* Add Definition Modal */} {showAddDefinition && ( d.name)} onSubmit={handleAddDefinition} onCancel={() => setShowAddDefinition(false)} /> )}
); } // Detail panel for definitions interface DefinitionDetailPanelProps { definition: ChainContractDefinition; onClose: () => void; onDelete: (id: string) => void; onRemoveDependency: (nodeId: string, depName: string) => void; } function DefinitionDetailPanel({ definition, onClose, onDelete, onRemoveDependency, }: DefinitionDetailPanelProps) { const dependencies = definition.dependsOnNames || []; return (

Definition Details

{/* Name */}

{definition.name}

{/* Description */} {definition.description && (

{definition.description}

)} {/* Type */}

{definition.contractType}

{/* Dependencies */} {dependencies.length > 0 && (
{dependencies.map((dep) => (
{dep}
))}
)} {/* Delete button */}
); } // Add Definition Modal 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([]); 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, dependsOn: dependsOn.length > 0 ? dependsOn : undefined, }; onSubmit(req); }; const toggleDependency = (depName: string) => { setDependsOn((prev) => prev.includes(depName) ? prev.filter((d) => d !== depName) : [...prev, depName] ); }; return (

Add Contract Definition

{/* Name */}
setName(e.target.value)} className="w-full px-3 py-2 bg-[#050d18] border border-[rgba(117,170,252,0.3)] font-mono text-sm text-[#dbe7ff] focus:border-[#75aafc] focus:outline-none" placeholder="e.g., Research Phase" />
{/* Description */}