From e16d49b52a393aa9a762edf57f93434a4bd7844e Mon Sep 17 00:00:00 2001 From: soryu Date: Thu, 5 Feb 2026 00:33:04 +0000 Subject: Replace custom DAG editor with React Flow library - Install @xyflow/react for smooth node-based UI - Create custom DefinitionNode and ContractNode components - Use React Flow's built-in drag-and-drop, edge drawing, pan/zoom - Removes laggy custom implementation Co-Authored-By: Claude Opus 4.5 --- .../frontend/src/components/chains/ChainEditor.tsx | 1696 +++++++------------- 1 file changed, 538 insertions(+), 1158 deletions(-) (limited to 'makima/frontend/src/components/chains/ChainEditor.tsx') diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx index a4c8a39..5b77170 100644 --- a/makima/frontend/src/components/chains/ChainEditor.tsx +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -1,8 +1,26 @@ -import { useState, useCallback, useMemo, useRef, useEffect } from "react"; +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, - ChainContractDetail, ChainContractDefinition, ChainDefinitionGraphResponse, AddContractDefinitionRequest, @@ -33,10 +51,172 @@ interface ChainEditorProps { onContractClick: (contractId: string) => void; } -// Node dimensions -const NODE_WIDTH = 180; +// Node dimensions for layout +const NODE_WIDTH = 200; const NODE_HEIGHT = 80; -const CANVAS_PADDING = 40; +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, @@ -46,34 +226,19 @@ export function ChainEditor({ 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); + const [selectedNodeId, setSelectedNodeId] = useState(null); - // Drag state - const [draggedNode, setDraggedNode] = useState(null); - const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); - const [localPositions, setLocalPositions] = useState>(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); + 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(() => { @@ -92,152 +257,167 @@ export function ChainEditor({ 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(); - - 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), - }); + // 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); + } } } - } 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), - }); - } - } + }, + [chain.id, onNodesChange] + ); - return positions; - }, [showDefinitions, definitionGraph?.nodes, graph?.nodes, localPositions]); + // Handle new edge connections + const handleConnect = useCallback( + async (connection: Connection) => { + if (!connection.source || !connection.target) return; - // Canvas dimensions - const canvasDimensions = useMemo(() => { - if (nodePositions.size === 0) { - return { width: 600, height: 400 }; - } + // Find the definitions + const sourceDef = definitions.find((d) => d.id === connection.source); + const targetDef = definitions.find((d) => d.id === connection.target); - 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); - } + if (!sourceDef || !targetDef) return; - return { - width: Math.max(600, maxX + CANVAS_PADDING), - height: Math.max(400, maxY + CANVAS_PADDING), - }; - }, [nodePositions]); + // 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], + }); - const handleNodeClick = useCallback((nodeId: string) => { - setSelectedNode(nodeId); - }, []); + // 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] + ); - const handleNodeDoubleClick = useCallback( - (nodeId: string) => { - // For definitions, we can't open a contract yet - if (showDefinitions) return; - onContractClick(nodeId); + // Handle node selection + const handleNodeClick = useCallback( + (_: React.MouseEvent, node: Node) => { + setSelectedNodeId(node.id); }, - [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" }; + // Handle node double-click (open contract) + const handleNodeDoubleClick = useCallback( + (_: React.MouseEvent, node: Node) => { + if (!showDefinitions) { + onContractClick(node.id); } - } - 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]); + }, + [showDefinitions, onContractClick] + ); - 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]); + // Handle pane click (deselect) + const handlePaneClick = useCallback(() => { + setSelectedNodeId(null); + }, []); - // Position for new definition (set from canvas right-click) - const [newDefinitionPosition, setNewDefinitionPosition] = useState<{ x: number; y: number } | null>(null); + // Find selected definition + const selectedDefinition = showDefinitions && selectedNodeId + ? definitions.find((d) => d.id === selectedNodeId) + : null; - // Find free space on the grid for new definitions + // Find free position for new definition const findFreePosition = useCallback(() => { if (!definitionGraph?.nodes || definitionGraph.nodes.length === 0) { return { x: 0, y: 0 }; } - // Get all occupied positions const occupied = new Set(); 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}`)) { @@ -246,243 +426,102 @@ export function ChainEditor({ } } - // 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) => { + 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 { - // 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); + await startChain(chain.id); + onRefresh(); } catch (err) { - console.error("Failed to add definition:", err); - setError(err instanceof Error ? err.message : "Failed to add definition"); + setError(err instanceof Error ? err.message : "Failed to start chain"); + } finally { + setIsStarting(false); } - }, [chain.id, newDefinitionPosition, findFreePosition]); + }, [chain.id, onRefresh]); - const handleDeleteDefinition = useCallback(async (definitionId: string) => { - if (!confirm("Are you sure you want to delete this definition?")) return; + const handleStopChain = useCallback(async () => { + if (!confirm("Are you sure you want to stop this chain?")) return; + setIsStopping(true); + setError(null); 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); + await stopChain(chain.id); + onRefresh(); } catch (err) { - console.error("Failed to delete definition:", err); - setError(err instanceof Error ? err.message : "Failed to delete definition"); + setError(err instanceof Error ? err.message : "Failed to stop chain"); + } finally { + setIsStopping(false); } - }, [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); - }, []); + }, [chain.id, onRefresh]); - // 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)); + 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, 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"); - } - } + await updateChainDefinition(chain.id, nodeId, { dependsOn: newDeps }); - 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"); - } - } + 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"); } - } - - 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]); + }, + [chain.id, definitions] + ); return (
@@ -494,7 +533,7 @@ export function ChainEditor({ onClick={onBack} className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" > - Back + ← Back

{chain.name}

@@ -511,7 +550,6 @@ export function ChainEditor({ > {chain.status} - {/* Chain control buttons */} {chain.status === "pending" && definitions.length > 0 && (
) : ( -
{ - 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 */} - - - - - - - - - - {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 ( - - ); - })} - {/* 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 ( - - ); - })()} - - - {/* 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 ( -
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) */} -
canEdit && handleDragStart(e, node.id)} - > - {/* Status indicator bar */} -
- {/* Content */} -
-
- - {node.name} - - {isCheckpoint ? ( - - ) : ( - - )} -
-
- - {node.isInstantiated ? status : isCheckpoint ? "checkpoint" : "definition"} - - - {node.contractType} - -
-
-
- {/* Edge drawing handle (connector dot at bottom) */} - {canEdit && (isHovered || isSelected) && !isDragging && ( -
handleEdgeDrawStart(e, node.id)} - title="Drag to create dependency" - /> - )} -
- ); - }) - : 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} - - )} -
-
-
-
- ); - })} -
+ + + )}
@@ -871,17 +655,9 @@ export function ChainEditor({ {selectedDefinition && ( setSelectedNode(null)} + onClose={() => setSelectedNodeId(null)} onDelete={handleDeleteDefinition} - /> - )} - {selectedContract && ( - setSelectedNode(null)} - onSelectContract={setSelectedNode} - onOpenContract={onContractClick} + onRemoveDependency={handleRemoveDependency} /> )}
@@ -892,18 +668,18 @@ export function ChainEditor({ {showDefinitions ? ( <> {definitions.length} definitions - {chain.status === "pending" && ( + {canEdit && ( <> | Drag nodes to reposition | - Drag from to link - | - Right-click for options + + Drag from to link + )} - {chain.status === "pending" && ( + {canEdit && (
- ); -} - -// 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(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 ( -
-
- -
-
- ); - } - - // Node context menu requires both nodeId and definition - if (!nodeId || !definition) return null; - - const dependencies = definition.dependsOnNames || []; - - return ( -
- {/* Header */} -
- {definition.name} -
- - {/* Actions */} -
- -
- - {/* Dependencies section */} - {dependencies.length > 0 && ( - <> -
-
- Dependencies -
-
- {dependencies.map((depName) => ( -
- {depName} - -
- ))} -
- - )} - - {/* Hint */} -
- - Drag from connector to create dependency - -
); } -// Trash Icon -function TrashIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -// Plus Icon -function PlusIcon({ className }: { className?: string }) { - return ( - - - - ); -} - +// 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 -

+

Definition Details

@@ -1151,65 +762,45 @@ function DefinitionDetailPanel({ -

{definition.description}

+

{definition.description}

)} - {/* Contract Type */} + {/* Type */}
- {definition.contractType} -
- - {/* Initial Phase */} -
- - - {definition.initialPhase || "plan"} - +

{definition.contractType}

{/* Dependencies */} - {definition.dependsOnNames && definition.dependsOnNames.length > 0 && ( + {dependencies.length > 0 && (
- {definition.dependsOnNames.map((depName) => ( - ( +
- {depName} - - ))} -
-
- )} - - {/* Tasks */} - {definition.tasks && definition.tasks.length > 0 && ( -
- -
- {definition.tasks.map((task, i) => ( -
- {task.name} + {dep} +
))}
)} - {/* Actions */} -
+ {/* Delete button */} +
-
-
- -
- {/* 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 */} -
- -
-
-
- ); -} - +// Add Definition Modal interface AddDefinitionModalProps { existingNames: string[]; onSubmit: (req: AddContractDefinitionRequest) => void; onCancel: () => void; } -function AddDefinitionModal({ - existingNames, - onSubmit, - onCancel, -}: AddDefinitionModalProps) { +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([]); - // 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"; @@ -1375,18 +835,9 @@ function AddDefinitionModal({ name: name.trim(), description: description.trim() || undefined, contractType, - initialPhase: isCheckpoint ? "execute" : initialPhase, // Checkpoints always start in execute + initialPhase: isCheckpoint ? "execute" : initialPhase, 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); }; @@ -1406,202 +857,131 @@ function AddDefinitionModal({
{/* Name */}
-
{/* Description */}
-