diff options
| author | soryu <soryu@soryu.co> | 2026-02-05 00:33:04 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-05 00:33:04 +0000 |
| commit | e16d49b52a393aa9a762edf57f93434a4bd7844e (patch) | |
| tree | 42e8048e60bc63a68b8b788a543784c091872575 | |
| parent | c95fce0173386050cc865dfd18315f9a6397f505 (diff) | |
| download | soryu-e16d49b52a393aa9a762edf57f93434a4bd7844e.tar.gz soryu-e16d49b52a393aa9a762edf57f93434a4bd7844e.zip | |
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 <noreply@anthropic.com>
| -rw-r--r-- | makima/frontend/package-lock.json | 168 | ||||
| -rw-r--r-- | makima/frontend/package.json | 1 | ||||
| -rw-r--r-- | makima/frontend/src/components/chains/ChainEditor.tsx | 1696 |
3 files changed, 707 insertions, 1158 deletions
diff --git a/makima/frontend/package-lock.json b/makima/frontend/package-lock.json index cc842b1..38adfc4 100644 --- a/makima/frontend/package-lock.json +++ b/makima/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@supabase/supabase-js": "^2.90.1", + "@xyflow/react": "^12.10.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.1.0", @@ -1791,6 +1792,15 @@ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", @@ -1817,6 +1827,12 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, "node_modules/@types/d3-shape": { "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", @@ -1835,6 +1851,25 @@ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1915,6 +1950,38 @@ "node": ">=0.10.0" } }, + "node_modules/@xyflow/react": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", + "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.74", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.74", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", + "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -2035,6 +2102,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2092,6 +2165,28 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -2142,6 +2237,16 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "peer": true, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -2183,6 +2288,41 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3341,6 +3481,34 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/makima/frontend/package.json b/makima/frontend/package.json index 9293f65..3b908aa 100644 --- a/makima/frontend/package.json +++ b/makima/frontend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@supabase/supabase-js": "^2.90.1", + "@xyflow/react": "^12.10.0", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router": "^7.1.0", 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 ( + <div + className={`rounded-lg border-2 overflow-hidden ${ + isCheckpoint ? "bg-[#0f0a1e]" : "bg-[#0a1628]" + } ${selected ? "ring-2 ring-offset-2 ring-offset-[#050d18]" : ""} ${ + selected ? (isCheckpoint ? "ring-[#a78bfa]" : "ring-[#75aafc]") : "" + }`} + style={{ + width: NODE_WIDTH, + height: NODE_HEIGHT, + borderColor: selected + ? isCheckpoint + ? "#a78bfa" + : "#75aafc" + : colors.border, + borderStyle: data.isInstantiated ? "solid" : "dashed", + }} + > + {/* Top handle for incoming edges */} + <Handle + type="target" + position={Position.Top} + className="!bg-[#75aafc] !w-3 !h-3 !border-2 !border-[#0a1628]" + /> + + {/* 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"> + {data.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`, + }} + > + {data.isInstantiated ? status : isCheckpoint ? "checkpoint" : "definition"} + </span> + <span className="font-mono text-[10px] text-[#8b949e]"> + {data.contractType} + </span> + </div> + </div> + + {/* Bottom handle for outgoing edges */} + <Handle + type="source" + position={Position.Bottom} + className="!bg-[#f59e0b] !w-3 !h-3 !border-2 !border-[#0a1628]" + /> + </div> + ); +} + +// Custom node for contracts (active chains) +function ContractNode({ data, selected }: NodeProps) { + const colors = getStatusColor(data.status); + + return ( + <div + className={`rounded-lg border-2 bg-[#0a1628] overflow-hidden ${ + selected ? "ring-2 ring-[#75aafc] ring-offset-2 ring-offset-[#050d18]" : "" + }`} + style={{ + width: NODE_WIDTH, + height: NODE_HEIGHT, + borderColor: selected ? "#75aafc" : colors.border, + }} + > + <Handle + type="target" + position={Position.Top} + className="!bg-[#75aafc] !w-3 !h-3 !border-2 !border-[#0a1628]" + /> + + <div className="h-1.5" style={{ backgroundColor: colors.bg }} /> + + <div className="p-2"> + <div className="flex items-center justify-between mb-1"> + <span className="font-mono text-xs text-[#dbe7ff] truncate flex-1"> + {data.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`, + }} + > + {data.status} + </span> + {data.phase && ( + <span className="font-mono text-[10px] text-[#8b949e]">{data.phase}</span> + )} + </div> + </div> + + <Handle + type="source" + position={Position.Bottom} + className="!bg-[#f59e0b] !w-3 !h-3 !border-2 !border-[#0a1628]" + /> + </div> + ); +} + +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<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); + const [selectedNodeId, setSelectedNodeId] = 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); + 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<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), - }); + // 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<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}`)) { @@ -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 ( <div className="panel h-full flex flex-col"> @@ -494,7 +533,7 @@ export function ChainEditor({ onClick={onBack} className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" > - Back + ← Back </button> <div> <h2 className="font-mono text-sm text-[#dbe7ff]">{chain.name}</h2> @@ -511,7 +550,6 @@ export function ChainEditor({ > {chain.status} </span> - {/* Chain control buttons */} {chain.status === "pending" && definitions.length > 0 && ( <button onClick={handleStartChain} @@ -547,13 +585,13 @@ export function ChainEditor({ {/* Main content */} <div className="flex-1 flex min-h-0"> - {/* DAG Canvas */} - <div className="flex-1 overflow-auto bg-[#050d18]"> + {/* React Flow Canvas */} + <div className="flex-1 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 ? ( + ) : 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"> @@ -566,7 +604,7 @@ export function ChainEditor({ ? "Add contract definitions to build your chain" : "Start the chain to create contracts from definitions"} </p> - {showDefinitions && ( + {showDefinitions && canEdit && ( <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" @@ -577,293 +615,39 @@ export function ChainEditor({ </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); + <ReactFlow + nodes={nodes} + edges={edges} + onNodesChange={handleNodesChange} + onEdgesChange={onEdgesChange} + onConnect={canEdit ? handleConnect : undefined} + onNodeClick={handleNodeClick} + onNodeDoubleClick={handleNodeDoubleClick} + onPaneClick={handlePaneClick} + nodeTypes={nodeTypes} + fitView + fitViewOptions={{ padding: 0.2 }} + minZoom={0.5} + maxZoom={2} + defaultEdgeOptions={{ + type: "smoothstep", + style: { stroke: "#3f6fb3", strokeWidth: 2 }, + markerEnd: { type: MarkerType.ArrowClosed, color: "#75aafc" }, }} + connectionLineStyle={{ stroke: "#f59e0b", strokeWidth: 2 }} + proOptions={{ hideAttribution: true }} > - {/* 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> + <Background + variant={BackgroundVariant.Dots} + gap={20} + size={1} + color="#1a2744" + /> + <Controls + className="!bg-[#0a1628] !border-[rgba(117,170,252,0.3)] !rounded-lg" + showInteractive={false} + /> + </ReactFlow> )} </div> @@ -871,17 +655,9 @@ export function ChainEditor({ {selectedDefinition && ( <DefinitionDetailPanel definition={selectedDefinition} - onClose={() => setSelectedNode(null)} + onClose={() => setSelectedNodeId(null)} onDelete={handleDeleteDefinition} - /> - )} - {selectedContract && ( - <ContractDetailPanel - contract={selectedContract} - allContracts={chain.contracts} - onClose={() => setSelectedNode(null)} - onSelectContract={setSelectedNode} - onOpenContract={onContractClick} + onRemoveDependency={handleRemoveDependency} /> )} </div> @@ -892,18 +668,18 @@ export function ChainEditor({ {showDefinitions ? ( <> <span>{definitions.length} definitions</span> - {chain.status === "pending" && ( + {canEdit && ( <> <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="text-[#556677]"> + Drag from <span className="text-[#f59e0b]">●</span> to link + </span> </> )} <span className="flex-1" /> - {chain.status === "pending" && ( + {canEdit && ( <button onClick={() => setShowAddDefinition(true)} className="text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" @@ -916,7 +692,8 @@ export function ChainEditor({ <> <span>{chain.contracts.length} contracts</span> <span> - {chain.contracts.filter((c) => c.contractStatus === "completed").length} completed + {chain.contracts.filter((c) => c.contractStatus === "completed").length}{" "} + completed </span> <span> {chain.contracts.filter((c) => c.contractStatus === "active").length} active @@ -936,202 +713,36 @@ export function ChainEditor({ 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> - ); -} - +// 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 ( <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> + <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> @@ -1151,65 +762,45 @@ function DefinitionDetailPanel({ <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> + <p className="font-mono text-xs text-[#9bc3ff]">{definition.description}</p> </div> )} - {/* Contract Type */} + {/* Type */} <div> <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> - Contract Type + 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> + <p className="font-mono text-xs text-[#9bc3ff]">{definition.contractType}</p> </div> {/* Dependencies */} - {definition.dependsOnNames && definition.dependsOnNames.length > 0 && ( + {dependencies.length > 0 && ( <div> <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> - Depends On + Dependencies </label> <div className="space-y-1"> - {definition.dependsOnNames.map((depName) => ( - <span - key={depName} - className="block font-mono text-xs text-[#9bc3ff]" + {dependencies.map((dep) => ( + <div + key={dep} + className="flex items-center justify-between bg-[rgba(117,170,252,0.1)] px-2 py-1 rounded" > - {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} + <span className="font-mono text-xs text-[#9bc3ff]">{dep}</span> + <button + onClick={() => onRemoveDependency(definition.id, dep)} + className="font-mono text-[10px] text-red-400 hover:text-red-300" + > + ✕ + </button> </div> ))} </div> </div> )} - {/* Actions */} - <div className="pt-2 border-t border-[rgba(117,170,252,0.2)]"> + {/* Delete button */} + <div className="pt-4 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" @@ -1222,150 +813,19 @@ function DefinitionDetailPanel({ ); } -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> - ); -} - +// 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<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"; @@ -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({ <div className="space-y-4"> {/* Name */} <div> - <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> - Name + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Name * </label> <input type="text" value={name} onChange={(e) => 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" - 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 className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> + Description </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" + 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 resize-none h-20" + placeholder="Optional description..." /> </div> - {/* Contract Type */} + {/* Type */} <div> - <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + <label className="block font-mono text-[10px] 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]" + 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" > <option value="simple">Simple</option> - <option value="specification">Specification</option> - <option value="execute">Execute</option> - <option value="checkpoint">Checkpoint (Validation)</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 */} + {/* Initial Phase (not for checkpoints) */} {!isCheckpoint && ( <div> - <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> + <label className="block font-mono text-[10px] 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]" + 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" > <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"> + <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> Depends On </label> - <div className="space-y-1 max-h-32 overflow-y-auto"> + <div className="flex flex-wrap gap-2"> {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> + <button + key={depName} + type="button" + onClick={() => toggleDependency(depName)} + className={`px-2 py-1 font-mono text-xs border transition-colors ${ + dependsOn.includes(depName) + ? "bg-[#75aafc]/20 border-[#75aafc] text-[#75aafc]" + : "border-[rgba(117,170,252,0.3)] text-[#8b949e] hover:border-[#75aafc]" + }`} + > + {depName} + </button> ))} </div> </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> + {/* Actions */} + <div className="flex justify-end gap-2 mt-6"> + <button + onClick={onCancel} + className="px-4 py-2 font-mono text-xs text-[#8b949e] border border-[rgba(117,170,252,0.3)] hover:border-[#75aafc] 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" + > + Add Definition + </button> </div> </div> </div> ); } +// Icons 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 className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" + /> </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 className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" + /> </svg> ); } |
