From c732dd048128808cd9f67f6e1176a5b565df5678 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 3 Feb 2026 23:49:08 +0000 Subject: Allow chain creation via web interface --- .../frontend/src/components/chains/ChainEditor.tsx | 696 ++++++++++++++++++--- .../frontend/src/components/chains/ChainList.tsx | 1 + 2 files changed, 607 insertions(+), 90 deletions(-) (limited to 'makima/frontend/src/components') diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx index 0dcabe1..9028c3e 100644 --- a/makima/frontend/src/components/chains/ChainEditor.tsx +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -1,14 +1,26 @@ -import { useState, useCallback, useMemo, useRef } from "react"; +import { useState, useCallback, useMemo, useRef, useEffect } from "react"; import type { ChainWithContracts, ChainGraphResponse, ChainContractDetail, + ChainContractDefinition, + ChainDefinitionGraphResponse, + AddContractDefinitionRequest, +} from "../../lib/api"; +import { + listChainDefinitions, + createChainDefinition, + deleteChainDefinition, + getChainDefinitionGraph, + startChain, + stopChain, } from "../../lib/api"; const statusColors: Record = { active: "text-green-400", completed: "text-blue-400", archived: "text-[#555]", + pending: "text-yellow-400", }; interface ChainEditorProps { @@ -36,20 +48,56 @@ export function ChainEditor({ const canvasRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [hoveredNode, setHoveredNode] = useState(null); + const [definitions, setDefinitions] = useState([]); + const [definitionGraph, setDefinitionGraph] = useState(null); + const [showAddDefinition, setShowAddDefinition] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [isStopping, setIsStopping] = useState(false); + const [error, setError] = useState(null); + + // Load definitions when chain changes + useEffect(() => { + async function loadDefinitions() { + try { + const [defs, defGraph] = await Promise.all([ + listChainDefinitions(chain.id), + getChainDefinitionGraph(chain.id), + ]); + setDefinitions(defs); + setDefinitionGraph(defGraph); + } catch (err) { + console.error("Failed to load definitions:", err); + } + } + loadDefinitions(); + }, [chain.id]); + + // Determine which view to show: definitions (pending chain) or contracts (active/completed) + const showDefinitions = chain.status === "pending" || chain.status === "archived"; + const currentGraph = showDefinitions ? definitionGraph : graph; // Use positions from graph nodes directly (x, y from server) const nodePositions = useMemo(() => { - if (!graph?.nodes) return new Map(); - const positions = new Map(); - 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), - }); + + if (showDefinitions && definitionGraph?.nodes) { + for (const node of definitionGraph.nodes) { + positions.set(node.id, { + x: CANVAS_PADDING + (node.x || 0) * (NODE_WIDTH + 60), + y: CANVAS_PADDING + (node.y || 0) * (NODE_HEIGHT + 40), + }); + } + } else if (!showDefinitions && graph?.nodes) { + for (const node of graph.nodes) { + positions.set(node.contractId, { + x: CANVAS_PADDING + (node.x || 0) * (NODE_WIDTH + 60), + y: CANVAS_PADDING + (node.y || 0) * (NODE_HEIGHT + 40), + }); + } } + return positions; - }, [graph?.nodes]); + }, [showDefinitions, definitionGraph?.nodes, graph?.nodes]); // Canvas dimensions const canvasDimensions = useMemo(() => { @@ -70,15 +118,17 @@ export function ChainEditor({ }; }, [nodePositions]); - const handleNodeClick = useCallback((contractId: string) => { - setSelectedNode(contractId); + const handleNodeClick = useCallback((nodeId: string) => { + setSelectedNode(nodeId); }, []); const handleNodeDoubleClick = useCallback( - (contractId: string) => { - onContractClick(contractId); + (nodeId: string) => { + // For definitions, we can't open a contract yet + if (showDefinitions) return; + onContractClick(nodeId); }, - [onContractClick] + [onContractClick, showDefinitions] ); const getStatusColor = (status: string) => { @@ -96,11 +146,76 @@ export function ChainEditor({ } }; - // Find selected contract from chain.contracts - const selectedContract = selectedNode + // Find selected item (definition or contract) + const selectedDefinition = showDefinitions && selectedNode + ? definitions.find((d) => d.id === selectedNode) + : null; + const selectedContract = !showDefinitions && selectedNode ? chain.contracts.find((c) => c.contractId === selectedNode) : null; + const handleStartChain = useCallback(async () => { + setIsStarting(true); + setError(null); + try { + await startChain(chain.id); + onRefresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start chain"); + } finally { + setIsStarting(false); + } + }, [chain.id, onRefresh]); + + const handleStopChain = useCallback(async () => { + if (!confirm("Are you sure you want to stop this chain?")) return; + setIsStopping(true); + setError(null); + try { + await stopChain(chain.id); + onRefresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to stop chain"); + } finally { + setIsStopping(false); + } + }, [chain.id, onRefresh]); + + const handleAddDefinition = useCallback(async (req: AddContractDefinitionRequest) => { + try { + await createChainDefinition(chain.id, req); + // Reload definitions + const [defs, defGraph] = await Promise.all([ + listChainDefinitions(chain.id), + getChainDefinitionGraph(chain.id), + ]); + setDefinitions(defs); + setDefinitionGraph(defGraph); + setShowAddDefinition(false); + } catch (err) { + console.error("Failed to add definition:", err); + setError(err instanceof Error ? err.message : "Failed to add definition"); + } + }, [chain.id]); + + const handleDeleteDefinition = useCallback(async (definitionId: string) => { + if (!confirm("Are you sure you want to delete this definition?")) return; + try { + await deleteChainDefinition(chain.id, definitionId); + // Reload definitions + const [defs, defGraph] = await Promise.all([ + listChainDefinitions(chain.id), + getChainDefinitionGraph(chain.id), + ]); + setDefinitions(defs); + setDefinitionGraph(defGraph); + setSelectedNode(null); + } catch (err) { + console.error("Failed to delete definition:", err); + setError(err instanceof Error ? err.message : "Failed to delete definition"); + } + }, [chain.id]); + return (
{/* Header */} @@ -128,6 +243,25 @@ export function ChainEditor({ > {chain.status} + {/* Chain control buttons */} + {chain.status === "pending" && definitions.length > 0 && ( + + )} + {chain.status === "active" && ( + + )}
+ {error && ( +
+ {error} +
+ )} {/* Main content */} @@ -146,15 +285,27 @@ export function ChainEditor({

Loading graph...

- ) : !graph || graph.nodes.length === 0 ? ( + ) : !currentGraph || currentGraph.nodes.length === 0 ? (

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

-

- Contracts will appear here once added via CLI or API +

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

+ {showDefinitions && ( + + )}
) : ( @@ -192,7 +343,7 @@ export function ChainEditor({ /> - {graph.edges.map((edge, index) => { + {currentGraph.edges.map((edge, index) => { const fromPos = nodePositions.get(edge.from); const toPos = nodePositions.get(edge.to); if (!fromPos || !toPos) return null; @@ -225,77 +376,156 @@ export function ChainEditor({ {/* Node layer */} - {graph.nodes.map((node) => { - const pos = nodePositions.get(node.contractId); - if (!pos) return null; + {showDefinitions && definitionGraph + ? definitionGraph.nodes.map((node) => { + const pos = nodePositions.get(node.id); + if (!pos) return null; + + const status = node.isInstantiated + ? node.contractStatus || "pending" + : "pending"; + const colors = getStatusColor(status); + const isSelected = selectedNode === node.id; + const isHovered = hoveredNode === node.id; + + return ( +
handleNodeClick(node.id)} + onMouseEnter={() => setHoveredNode(node.id)} + onMouseLeave={() => setHoveredNode(null)} + className={`absolute cursor-pointer transition-all duration-150 ${ + isSelected + ? "ring-2 ring-[#75aafc] ring-offset-2 ring-offset-[#050d18]" + : "" + }`} + style={{ + left: pos.x, + top: pos.y, + width: NODE_WIDTH, + height: NODE_HEIGHT, + transform: isHovered ? "scale(1.02)" : "scale(1)", + }} + > +
+ {/* Status indicator bar */} +
+ {/* Content */} +
+
+ + {node.name} + + +
+
+ + {node.isInstantiated ? status : "definition"} + + + {node.contractType} + +
+
+
+
+ ); + }) + : graph?.nodes.map((node) => { + const pos = nodePositions.get(node.contractId); + if (!pos) return null; - const colors = getStatusColor(node.status); - const isSelected = selectedNode === node.contractId; - const isHovered = hoveredNode === node.contractId; + 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 */} + return (
- {/* Content */} -
-
- - {node.name} - - -
-
- - {node.status} - - {node.phase && ( - - {node.phase} - - )} + 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)", + }} + > +
+ {/* Status indicator bar */} +
+ {/* Content */} +
+
+ + {node.name} + + +
+
+ + {node.status} + + {node.phase && ( + + {node.phase} + + )} +
+
-
-
- ); - })} + ); + })}
)}
{/* Detail panel */} + {selectedDefinition && ( + setSelectedNode(null)} + onDelete={handleDeleteDefinition} + /> + )} {selectedContract && (
- {chain.contracts.length} contracts - - {chain.contracts.filter((c) => c.contractStatus === "completed").length} completed - - - {chain.contracts.filter((c) => c.contractStatus === "active").length} active + {showDefinitions ? ( + <> + {definitions.length} definitions + + {chain.status === "pending" && ( + + )} + + ) : ( + <> + {chain.contracts.length} contracts + + {chain.contracts.filter((c) => c.contractStatus === "completed").length} completed + + + {chain.contracts.filter((c) => c.contractStatus === "active").length} active + + + Double-click node to open contract + + )} +
+
+ + {/* Add Definition Modal */} + {showAddDefinition && ( + d.name)} + onSubmit={handleAddDefinition} + onCancel={() => setShowAddDefinition(false)} + /> + )} +
+ ); +} + +interface DefinitionDetailPanelProps { + definition: ChainContractDefinition; + onClose: () => void; + onDelete: (id: string) => void; +} + +function DefinitionDetailPanel({ + definition, + onClose, + onDelete, +}: DefinitionDetailPanelProps) { + return ( +
+
+
+

+ Definition Details +

+ +
+
+ +
+ {/* Name */} +
+ +

{definition.name}

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

{definition.description}

+
+ )} + + {/* Contract Type */} +
+ + {definition.contractType} +
+ + {/* Initial Phase */} +
+ + + {definition.initialPhase || "plan"} - - Double-click node to open contract +
+ + {/* Dependencies */} + {definition.dependsOnNames && definition.dependsOnNames.length > 0 && ( +
+ +
+ {definition.dependsOnNames.map((depName) => ( + + {depName} + + ))} +
+
+ )} + + {/* Tasks */} + {definition.tasks && definition.tasks.length > 0 && ( +
+ +
+ {definition.tasks.map((task, i) => ( +
+ {task.name} +
+ ))} +
+
+ )} + + {/* Actions */} +
+
@@ -448,6 +817,153 @@ function ContractDetailPanel({ ); } +interface AddDefinitionModalProps { + existingNames: string[]; + onSubmit: (req: AddContractDefinitionRequest) => void; + onCancel: () => void; +} + +function AddDefinitionModal({ + existingNames, + onSubmit, + onCancel, +}: AddDefinitionModalProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [contractType, setContractType] = useState("simple"); + const [initialPhase, setInitialPhase] = useState("plan"); + const [dependsOn, setDependsOn] = useState([]); + + const handleSubmit = () => { + if (!name.trim()) return; + onSubmit({ + name: name.trim(), + description: description.trim() || undefined, + contractType, + initialPhase, + dependsOn: dependsOn.length > 0 ? dependsOn : undefined, + }); + }; + + const toggleDependency = (depName: string) => { + setDependsOn((prev) => + prev.includes(depName) ? prev.filter((d) => d !== depName) : [...prev, depName] + ); + }; + + return ( +
+
+

+ Add Contract Definition +

+ +
+ {/* Name */} +
+ + setName(e.target.value)} + placeholder="e.g., Research Phase" + className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]" + autoFocus + /> +
+ + {/* Description */} +
+ +