import { useState, useCallback, useMemo, useRef, useEffect } from "react"; import type { ChainWithContracts, ChainGraphResponse, ChainContractDetail, ChainContractDefinition, ChainDefinitionGraphResponse, AddContractDefinitionRequest, } from "../../lib/api"; import { listChainDefinitions, createChainDefinition, 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 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(null); const [selectedNode, setSelectedNode] = useState(null); const [hoveredNode, setHoveredNode] = useState(null); 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); // 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 directly (x, y from server) const nodePositions = useMemo(() => { const positions = new Map(); if (showDefinitions && definitionGraph?.nodes) { for (const node of definitionGraph.nodes) { 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]); // 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) => { 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]); const handleAddDefinition = useCallback(async (req: AddContractDefinitionRequest) => { try { await createChainDefinition(chain.id, req); // Reload definitions 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]); 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]); return (
{/* Header */}

{chain.name}

{chain.description && (

{chain.description}

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

Loading graph...

) : !currentGraph || currentGraph.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 && ( )}
) : (
{/* SVG layer for edges */} {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 ( ); })} {/* Node layer */} {showDefinitions && definitionGraph ? definitionGraph.nodes.map((node) => { const pos = nodePositions.get(node.id); if (!pos) return null; const status = node.isInstantiated ? node.contractStatus || "pending" : "pending"; const colors = getStatusColor(status); const isSelected = selectedNode === node.id; const isHovered = hoveredNode === node.id; return (
handleNodeClick(node.id)} onMouseEnter={() => setHoveredNode(node.id)} 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)", }} >
{/* Status indicator bar */}
{/* Content */}
{node.name}
{node.isInstantiated ? status : "definition"} {node.contractType}
); }) : 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 (
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)", }} >
{/* Status indicator bar */}
{/* Content */}
{node.name}
{node.status} {node.phase && ( {node.phase} )}
); })}
)}
{/* Detail panel */} {selectedDefinition && ( setSelectedNode(null)} onDelete={handleDeleteDefinition} /> )} {selectedContract && ( setSelectedNode(null)} onSelectContract={setSelectedNode} onOpenContract={onContractClick} /> )}
{/* Footer with stats */}
{showDefinitions ? ( <> {definitions.length} definitions {chain.status === "pending" && ( )} ) : ( <> {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)} /> )}
); } interface DefinitionDetailPanelProps { definition: ChainContractDefinition; onClose: () => void; onDelete: (id: string) => void; } function DefinitionDetailPanel({ definition, onClose, onDelete, }: DefinitionDetailPanelProps) { return (

Definition Details

{/* Name */}

{definition.name}

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

{definition.description}

)} {/* Contract Type */}
{definition.contractType}
{/* Initial Phase */}
{definition.initialPhase || "plan"}
{/* Dependencies */} {definition.dependsOnNames && definition.dependsOnNames.length > 0 && (
{definition.dependsOnNames.map((depName) => ( {depName} ))}
)} {/* Tasks */} {definition.tasks && definition.tasks.length > 0 && (
{definition.tasks.map((task, i) => (
{task.name}
))}
)} {/* Actions */}
); } 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 (

Contract Details

{/* Name */}

{contract.contractName}

{/* Status */}
{contract.contractStatus}
{/* Phase */}
{contract.contractPhase}
{/* Dependencies */} {contract.dependsOn && contract.dependsOn.length > 0 && (
{contract.dependsOn.map((depId) => { const dep = allContracts.find((c) => c.contractId === depId); return ( ); })}
)} {/* Order Index */}

{contract.orderIndex}

{/* Created */}

{new Date(contract.createdAt).toLocaleString()}

{/* Actions */}
); } 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 handleSubmit = () => { if (!name.trim()) return; onSubmit({ name: name.trim(), description: description.trim() || undefined, contractType, initialPhase, dependsOn: dependsOn.length > 0 ? dependsOn : undefined, }); }; 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)} 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 />
{/* Description */}