diff options
| author | soryu <soryu@soryu.co> | 2026-02-06 01:02:32 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-06 01:02:32 +0000 |
| commit | 8f725a7c64fbeb85ebeb59b54d2f774e9a0a59d6 (patch) | |
| tree | f66a7dd9190deb1a85b83da7302c85f7c171b69b | |
| parent | 0fb5083e1f453b6b8dab402014f333e13df75733 (diff) | |
| download | soryu-8f725a7c64fbeb85ebeb59b54d2f774e9a0a59d6.tar.gz soryu-8f725a7c64fbeb85ebeb59b54d2f774e9a0a59d6.zip | |
Fix: Directive page and remove chain page
| -rw-r--r-- | makima/frontend/src/components/NavStrip.tsx | 1 | ||||
| -rw-r--r-- | makima/frontend/src/components/chains/ChainEditor.tsx | 1278 | ||||
| -rw-r--r-- | makima/frontend/src/components/chains/ChainList.tsx | 191 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useChains.ts | 145 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useDirectives.ts | 2 | ||||
| -rw-r--r-- | makima/frontend/src/main.tsx | 17 | ||||
| -rw-r--r-- | makima/frontend/src/routes/chains.tsx | 496 | ||||
| -rw-r--r-- | makima/frontend/src/routes/directives.tsx | 4 |
8 files changed, 3 insertions, 2131 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index ece07e4..9bb7777 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -11,7 +11,6 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Directives", href: "/directives", requiresAuth: true }, - { label: "Chains", href: "/chains", requiresAuth: true }, { label: "Contracts", href: "/contracts", requiresAuth: true }, { label: "Board", href: "/workflow", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx deleted file mode 100644 index 6b9aa70..0000000 --- a/makima/frontend/src/components/chains/ChainEditor.tsx +++ /dev/null @@ -1,1278 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { - ReactFlow, - Node, - Edge, - Controls, - Background, - useNodesState, - useEdgesState, - Connection, - Handle, - Position, - BackgroundVariant, - NodeChange, - MarkerType, -} from "@xyflow/react"; -import "@xyflow/react/dist/style.css"; - -import type { - ChainWithContracts, - ChainGraphResponse, - ChainContractDefinition, - ChainDefinitionGraphResponse, - AddContractDefinitionRequest, - ChainRepository, - AddChainRepositoryRequest, -} from "../../lib/api"; -import { - listChainDefinitions, - createChainDefinition, - updateChainDefinition, - deleteChainDefinition, - getChainDefinitionGraph, - startChain, - stopChain, - listChainRepositories, - addChainRepository, - deleteChainRepository, - setChainRepositoryPrimary, -} from "../../lib/api"; - -const statusColors: Record<string, string> = { - active: "text-green-400", - completed: "text-blue-400", - archived: "text-[#555]", - pending: "text-yellow-400", -}; - -interface ChainEditorProps { - chain: ChainWithContracts; - graph: ChainGraphResponse | null; - loading: boolean; - onBack: () => void; - onRefresh: () => void; - onContractClick: (contractId: string) => void; -} - -// Node dimensions for layout -const NODE_WIDTH = 200; -const NODE_HEIGHT = 80; -const GRID_SPACING_X = 280; -const GRID_SPACING_Y = 120; - -// Custom node component for definitions -function DefinitionNodeComponent({ - data, - selected, -}: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: any; - selected?: boolean; -}) { - 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 ContractNodeComponent({ - data, - selected, -}: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: any; - selected?: boolean; -}) { - 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> - ); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const nodeTypes: Record<string, any> = { - definition: DefinitionNodeComponent, - contract: ContractNodeComponent, -}; - -function getStatusColor(status: string, isCheckpoint = false) { - if (isCheckpoint) { - switch (status) { - case "active": - return { bg: "#a78bfa", border: "#8b5cf6", text: "#5b21b6" }; - case "completed": - return { bg: "#818cf8", border: "#6366f1", text: "#3730a3" }; - case "pending": - return { bg: "#c4b5fd", border: "#a78bfa", text: "#6d28d9" }; - case "failed": - return { bg: "#ef4444", border: "#dc2626", text: "#991b1b" }; - default: - return { bg: "#a78bfa", border: "#8b5cf6", text: "#5b21b6" }; - } - } - switch (status) { - case "active": - return { bg: "#4ade80", border: "#22c55e", text: "#166534" }; - case "completed": - return { bg: "#60a5fa", border: "#3b82f6", text: "#1e40af" }; - case "pending": - return { bg: "#f59e0b", border: "#d97706", text: "#92400e" }; - case "blocked": - return { bg: "#ef4444", border: "#dc2626", text: "#991b1b" }; - default: - return { bg: "#6b7280", border: "#4b5563", text: "#374151" }; - } -} - -export function ChainEditor({ - chain, - graph, - loading, - onBack, - onRefresh, - onContractClick, -}: ChainEditorProps) { - const [definitions, setDefinitions] = useState<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); - const [repositories, setRepositories] = useState<ChainRepository[]>([]); - const [showAddRepo, setShowAddRepo] = useState(false); - - const [nodes, setNodes, onNodesChange] = useNodesState([] as Node[]); - const [edges, setEdges, onEdgesChange] = useEdgesState([] as Edge[]); - - const showDefinitions = chain.status === "pending" || chain.status === "archived"; - const canEdit = chain.status === "pending"; - - // Load definitions and repositories when chain changes - useEffect(() => { - async function loadData() { - try { - const [defs, defGraph, repos] = await Promise.all([ - listChainDefinitions(chain.id), - getChainDefinitionGraph(chain.id), - listChainRepositories(chain.id), - ]); - setDefinitions(defs); - setDefinitionGraph(defGraph); - setRepositories(repos); - } catch (err) { - console.error("Failed to load data:", err); - } - } - loadData(); - }, [chain.id]); - - // Convert definitions/contracts to React Flow nodes and edges - useEffect(() => { - if (showDefinitions && definitionGraph) { - const flowNodes: Node[] = definitionGraph.nodes.map((node) => ({ - id: node.id, - type: "definition", - position: { - x: (node.x || 0) * GRID_SPACING_X, - y: (node.y || 0) * GRID_SPACING_Y, - }, - data: { - name: node.name, - contractType: node.contractType, - isInstantiated: node.isInstantiated, - contractStatus: node.contractStatus, - }, - draggable: canEdit, - })); - - const flowEdges: Edge[] = definitionGraph.edges.map((edge, index) => ({ - id: `${edge.from}-${edge.to}-${index}`, - source: edge.from, - target: edge.to, - type: "smoothstep", - animated: false, - style: { stroke: "#3f6fb3", strokeWidth: 2 }, - markerEnd: { type: MarkerType.ArrowClosed, color: "#75aafc" }, - })); - - setNodes(flowNodes); - setEdges(flowEdges); - } else if (!showDefinitions && graph) { - const flowNodes: Node[] = graph.nodes.map((node) => ({ - id: node.contractId, - type: "contract", - position: { - x: (node.x || 0) * GRID_SPACING_X, - y: (node.y || 0) * GRID_SPACING_Y, - }, - data: { - name: node.name, - status: node.status, - phase: node.phase, - }, - draggable: false, - })); - - const flowEdges: Edge[] = graph.edges.map((edge, index) => ({ - id: `${edge.from}-${edge.to}-${index}`, - source: edge.from, - target: edge.to, - type: "smoothstep", - animated: edge.from === selectedNodeId || edge.to === selectedNodeId, - style: { stroke: "#3f6fb3", strokeWidth: 2 }, - markerEnd: { type: MarkerType.ArrowClosed, color: "#75aafc" }, - })); - - setNodes(flowNodes); - setEdges(flowEdges); - } - }, [showDefinitions, definitionGraph, graph, canEdit, selectedNodeId, setNodes, setEdges]); - - // Handle node position changes (drag end) - const handleNodesChange = useCallback( - async (changes: NodeChange[]) => { - onNodesChange(changes); - - // Save position changes to backend - for (const change of changes) { - if (change.type === "position" && change.dragging === false && change.position) { - const gridX = Math.round(change.position.x / GRID_SPACING_X); - const gridY = Math.round(change.position.y / GRID_SPACING_Y); - - try { - await updateChainDefinition(chain.id, change.id, { - editorX: gridX, - editorY: gridY, - }); - } catch (err) { - console.error("Failed to save position:", err); - } - } - } - }, - [chain.id, onNodesChange] - ); - - // Handle new edge connections - const handleConnect = useCallback( - async (connection: Connection) => { - if (!connection.source || !connection.target) return; - - // Find the definitions - const sourceDef = definitions.find((d) => d.id === connection.source); - const targetDef = definitions.find((d) => d.id === connection.target); - - if (!sourceDef || !targetDef) return; - - // Add dependency: target depends on source - const currentDeps = targetDef.dependsOnNames || []; - if (!currentDeps.includes(sourceDef.name)) { - try { - await updateChainDefinition(chain.id, connection.target, { - dependsOn: [...currentDeps, sourceDef.name], - }); - - // Refresh - const [defs, defGraph] = await Promise.all([ - listChainDefinitions(chain.id), - getChainDefinitionGraph(chain.id), - ]); - setDefinitions(defs); - setDefinitionGraph(defGraph); - } catch (err) { - console.error("Failed to create dependency:", err); - setError(err instanceof Error ? err.message : "Failed to create dependency"); - } - } - }, - [chain.id, definitions] - ); - - // Handle node selection - const handleNodeClick = useCallback( - (_: React.MouseEvent, node: Node) => { - setSelectedNodeId(node.id); - }, - [] - ); - - // Handle node double-click (open contract) - const handleNodeDoubleClick = useCallback( - (_: React.MouseEvent, node: Node) => { - if (!showDefinitions) { - onContractClick(node.id); - } - }, - [showDefinitions, onContractClick] - ); - - // Handle pane click (deselect) - const handlePaneClick = useCallback(() => { - setSelectedNodeId(null); - }, []); - - // Find selected definition - const selectedDefinition = showDefinitions && selectedNodeId - ? definitions.find((d) => d.id === selectedNodeId) - : null; - - // Find free position for new definition - const findFreePosition = useCallback(() => { - if (!definitionGraph?.nodes || definitionGraph.nodes.length === 0) { - return { x: 0, y: 0 }; - } - - const occupied = new Set<string>(); - for (const node of definitionGraph.nodes) { - occupied.add(`${node.x},${node.y}`); - } - - for (let y = 0; y < 10; y++) { - for (let x = 0; x < 10; x++) { - if (!occupied.has(`${x},${y}`)) { - return { x, y }; - } - } - } - - const maxY = Math.max(...definitionGraph.nodes.map((n) => n.y || 0)); - return { x: 0, y: maxY + 1 }; - }, [definitionGraph?.nodes]); - - const handleAddDefinition = useCallback( - async (req: AddContractDefinitionRequest) => { - try { - const position = findFreePosition(); - const reqWithPosition = { ...req, editorX: position.x, editorY: position.y }; - await createChainDefinition(chain.id, reqWithPosition); - - const [defs, defGraph] = await Promise.all([ - listChainDefinitions(chain.id), - getChainDefinitionGraph(chain.id), - ]); - setDefinitions(defs); - setDefinitionGraph(defGraph); - setShowAddDefinition(false); - } catch (err) { - console.error("Failed to add definition:", err); - setError(err instanceof Error ? err.message : "Failed to add definition"); - } - }, - [chain.id, findFreePosition] - ); - - const handleDeleteDefinition = useCallback( - async (definitionId: string) => { - if (!confirm("Are you sure you want to delete this definition?")) return; - try { - await deleteChainDefinition(chain.id, definitionId); - - const [defs, defGraph] = await Promise.all([ - listChainDefinitions(chain.id), - getChainDefinitionGraph(chain.id), - ]); - setDefinitions(defs); - setDefinitionGraph(defGraph); - setSelectedNodeId(null); - } catch (err) { - console.error("Failed to delete definition:", err); - setError(err instanceof Error ? err.message : "Failed to delete definition"); - } - }, - [chain.id] - ); - - const handleStartChain = useCallback(async () => { - setIsStarting(true); - setError(null); - try { - await startChain(chain.id); - onRefresh(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to start chain"); - } finally { - setIsStarting(false); - } - }, [chain.id, onRefresh]); - - const handleStopChain = useCallback(async () => { - if (!confirm("Are you sure you want to stop this chain?")) return; - setIsStopping(true); - setError(null); - try { - await stopChain(chain.id); - onRefresh(); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to stop chain"); - } finally { - setIsStopping(false); - } - }, [chain.id, onRefresh]); - - const handleRemoveDependency = useCallback( - async (nodeId: string, depName: string) => { - const def = definitions.find((d) => d.id === nodeId); - if (!def) return; - - const newDeps = (def.dependsOnNames || []).filter((d) => d !== depName); - try { - await updateChainDefinition(chain.id, nodeId, { dependsOn: newDeps }); - - const [defs, defGraph] = await Promise.all([ - listChainDefinitions(chain.id), - getChainDefinitionGraph(chain.id), - ]); - setDefinitions(defs); - setDefinitionGraph(defGraph); - } catch (err) { - console.error("Failed to remove dependency:", err); - setError(err instanceof Error ? err.message : "Failed to remove dependency"); - } - }, - [chain.id, definitions] - ); - - // Repository handlers - const handleAddRepository = useCallback( - async (req: AddChainRepositoryRequest) => { - try { - await addChainRepository(chain.id, req); - const repos = await listChainRepositories(chain.id); - setRepositories(repos); - setShowAddRepo(false); - } catch (err) { - console.error("Failed to add repository:", err); - setError(err instanceof Error ? err.message : "Failed to add repository"); - } - }, - [chain.id] - ); - - const handleDeleteRepository = useCallback( - async (repoId: string) => { - if (!confirm("Remove this repository from the chain?")) return; - try { - await deleteChainRepository(chain.id, repoId); - const repos = await listChainRepositories(chain.id); - setRepositories(repos); - } catch (err) { - console.error("Failed to delete repository:", err); - setError(err instanceof Error ? err.message : "Failed to delete repository"); - } - }, - [chain.id] - ); - - const handleSetPrimary = useCallback( - async (repoId: string) => { - try { - await setChainRepositoryPrimary(chain.id, repoId); - const repos = await listChainRepositories(chain.id); - setRepositories(repos); - } catch (err) { - console.error("Failed to set primary:", err); - setError(err instanceof Error ? err.message : "Failed to set primary repository"); - } - }, - [chain.id] - ); - - return ( - <div className="panel h-full flex flex-col"> - {/* Header */} - <div className="p-3 border-b border-[rgba(117,170,252,0.2)]"> - <div className="flex items-center justify-between"> - <div className="flex items-center gap-3"> - <button - onClick={onBack} - className="font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - ← Back - </button> - <div> - <h2 className="font-mono text-sm text-[#dbe7ff]">{chain.name}</h2> - {chain.description && ( - <p className="font-mono text-xs text-[#8b949e]">{chain.description}</p> - )} - </div> - </div> - <div className="flex items-center gap-2"> - <span - className={`font-mono text-[10px] uppercase ${ - statusColors[chain.status] || "text-[#555]" - }`} - > - {chain.status} - </span> - {chain.status === "pending" && definitions.length > 0 && ( - <button - onClick={handleStartChain} - disabled={isStarting} - className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-green-600 hover:bg-green-700 border border-green-500 transition-colors disabled:opacity-50" - > - {isStarting ? "Starting..." : "Start Chain"} - </button> - )} - {chain.status === "active" && ( - <button - onClick={handleStopChain} - disabled={isStopping} - className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-red-600 hover:bg-red-700 border border-red-500 transition-colors disabled:opacity-50" - > - {isStopping ? "Stopping..." : "Stop"} - </button> - )} - <button - onClick={onRefresh} - className="px-3 py-1 font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] border border-[#3f6fb3] hover:border-[#75aafc] transition-colors" - > - Refresh - </button> - </div> - </div> - {error && ( - <div className="mt-2 p-2 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-xs"> - {error} - </div> - )} - </div> - - {/* Repository section */} - <div className="px-3 py-2 border-b border-[rgba(117,170,252,0.2)] bg-[#0a1628]/50"> - <div className="flex items-center justify-between"> - <span className="font-mono text-[10px] text-[#8b949e] uppercase"> - Repositories ({repositories.length}) - </span> - {canEdit && ( - <button - onClick={() => setShowAddRepo(true)} - className="font-mono text-[10px] text-[#9bc3ff] hover:text-[#dbe7ff]" - > - + Add - </button> - )} - </div> - {repositories.length > 0 && ( - <div className="flex flex-wrap gap-2 mt-2"> - {repositories.map((repo) => ( - <div - key={repo.id} - className={`flex items-center gap-1 px-2 py-1 rounded font-mono text-xs ${ - repo.isPrimary - ? "bg-[#75aafc]/20 border border-[#75aafc]/50 text-[#75aafc]" - : "bg-[#1a2744] border border-[rgba(117,170,252,0.2)] text-[#9bc3ff]" - }`} - > - <RepoIcon className="w-3 h-3" /> - <span className="truncate max-w-[150px]" title={repo.name}> - {repo.name} - </span> - {repo.isPrimary && ( - <span className="text-[8px] uppercase ml-1">primary</span> - )} - {canEdit && !repo.isPrimary && ( - <button - onClick={() => handleSetPrimary(repo.id)} - className="ml-1 text-[8px] text-[#556677] hover:text-[#9bc3ff]" - title="Set as primary" - > - ★ - </button> - )} - {canEdit && ( - <button - onClick={() => handleDeleteRepository(repo.id)} - className="ml-1 text-[10px] text-[#556677] hover:text-red-400" - > - ✕ - </button> - )} - </div> - ))} - </div> - )} - {repositories.length === 0 && ( - <p className="font-mono text-[10px] text-[#556677] mt-1"> - No repositories attached. {canEdit && "Add one to use with contracts."} - </p> - )} - </div> - - {/* Main content */} - <div className="flex-1 flex min-h-0"> - {/* 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> - ) : 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"> - {showDefinitions - ? "No contract definitions yet" - : "No contracts in this chain yet"} - </p> - <p className="font-mono text-xs text-[#556677] mb-4"> - {showDefinitions - ? "Add contract definitions to build your chain" - : "Start the chain to create contracts from definitions"} - </p> - {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" - > - + Add Definition - </button> - )} - </div> - </div> - ) : ( - <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 }} - > - <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> - - {/* Detail panel */} - {selectedDefinition && ( - <DefinitionDetailPanel - definition={selectedDefinition} - onClose={() => setSelectedNodeId(null)} - onDelete={handleDeleteDefinition} - onRemoveDependency={handleRemoveDependency} - /> - )} - </div> - - {/* Footer with stats */} - <div className="p-3 border-t border-[rgba(117,170,252,0.2)] bg-[#0a1628]"> - <div className="flex items-center gap-4 font-mono text-[10px] text-[#8b949e]"> - {showDefinitions ? ( - <> - <span>{definitions.length} definitions</span> - {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="flex-1" /> - {canEdit && ( - <button - onClick={() => setShowAddDefinition(true)} - className="text-[#9bc3ff] hover:text-[#dbe7ff] transition-colors" - > - + Add Definition - </button> - )} - </> - ) : ( - <> - <span>{chain.contracts.length} contracts</span> - <span> - {chain.contracts.filter((c) => c.contractStatus === "completed").length}{" "} - completed - </span> - <span> - {chain.contracts.filter((c) => c.contractStatus === "active").length} active - </span> - <span className="flex-1" /> - <span>Double-click node to open contract</span> - </> - )} - </div> - </div> - - {/* Add Definition Modal */} - {showAddDefinition && ( - <AddDefinitionModal - existingNames={definitions.map((d) => d.name)} - onSubmit={handleAddDefinition} - onCancel={() => setShowAddDefinition(false)} - /> - )} - - {/* Add Repository Modal */} - {showAddRepo && ( - <AddRepositoryModal - onSubmit={handleAddRepository} - onCancel={() => setShowAddRepo(false)} - /> - )} - </div> - ); -} - -// 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> - <button - onClick={onClose} - className="font-mono text-xs text-[#8b949e] hover:text-[#dbe7ff]" - > - ✕ - </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]">{definition.name}</p> - </div> - - {/* Description */} - {definition.description && ( - <div> - <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> - Description - </label> - <p className="font-mono text-xs text-[#9bc3ff]">{definition.description}</p> - </div> - )} - - {/* Type */} - <div> - <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> - Type - </label> - <p className="font-mono text-xs text-[#9bc3ff]">{definition.contractType}</p> - </div> - - {/* Dependencies */} - {dependencies.length > 0 && ( - <div> - <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> - Dependencies - </label> - <div className="space-y-1"> - {dependencies.map((dep) => ( - <div - key={dep} - className="flex items-center justify-between bg-[rgba(117,170,252,0.1)] px-2 py-1 rounded" - > - <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> - )} - - {/* 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" - > - Delete Definition - </button> - </div> - </div> - </div> - ); -} - -// Add Definition Modal -interface AddDefinitionModalProps { - existingNames: string[]; - onSubmit: (req: AddContractDefinitionRequest) => void; - onCancel: () => void; -} - -function AddDefinitionModal({ existingNames, onSubmit, onCancel }: AddDefinitionModalProps) { - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [contractType, setContractType] = useState("simple"); - const [initialPhase, setInitialPhase] = useState("plan"); - const [dependsOn, setDependsOn] = useState<string[]>([]); - - const isCheckpoint = contractType === "checkpoint"; - - const handleSubmit = () => { - if (!name.trim()) return; - const req: AddContractDefinitionRequest = { - name: name.trim(), - description: description.trim() || undefined, - contractType, - initialPhase: isCheckpoint ? "execute" : initialPhase, - dependsOn: dependsOn.length > 0 ? dependsOn : undefined, - }; - onSubmit(req); - }; - - const toggleDependency = (depName: string) => { - setDependsOn((prev) => - prev.includes(depName) ? prev.filter((d) => d !== depName) : [...prev, depName] - ); - }; - - return ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> - <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> - <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> - Add Contract Definition - </h3> - - <div className="space-y-4"> - {/* Name */} - <div> - <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" - /> - </div> - - {/* Description */} - <div> - <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> - Description - </label> - <textarea - value={description} - onChange={(e) => setDescription(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 resize-none h-20" - placeholder="Optional description..." - /> - </div> - - {/* Type */} - <div> - <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-[#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="checkpoint">Checkpoint (validation)</option> - </select> - </div> - - {/* Initial Phase (not for checkpoints) */} - {!isCheckpoint && ( - <div> - <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-[#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> - </select> - </div> - )} - - {/* Dependencies */} - {existingNames.length > 0 && ( - <div> - <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> - Depends On - </label> - <div className="flex flex-wrap gap-2"> - {existingNames.map((depName) => ( - <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 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} 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} 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> - ); -} - -function RepoIcon({ className }: { className?: string }) { - return ( - <svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> - <path - strokeLinecap="round" - strokeLinejoin="round" - d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" - /> - </svg> - ); -} - -// Add Repository Modal -interface AddRepositoryModalProps { - onSubmit: (req: AddChainRepositoryRequest) => void; - onCancel: () => void; -} - -function AddRepositoryModal({ onSubmit, onCancel }: AddRepositoryModalProps) { - const [name, setName] = useState(""); - const [repositoryUrl, setRepositoryUrl] = useState(""); - const [localPath, setLocalPath] = useState(""); - const [sourceType, setSourceType] = useState<"remote" | "local">("remote"); - const [isPrimary, setIsPrimary] = useState(false); - - const handleSubmit = () => { - if (!name.trim()) return; - if (sourceType === "remote" && !repositoryUrl.trim()) return; - if (sourceType === "local" && !localPath.trim()) return; - - const req: AddChainRepositoryRequest = { - name: name.trim(), - sourceType, - isPrimary, - ...(sourceType === "remote" - ? { repositoryUrl: repositoryUrl.trim() } - : { localPath: localPath.trim() }), - }; - onSubmit(req); - }; - - return ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> - <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)]"> - <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4">Add Repository</h3> - - <div className="space-y-4"> - {/* Name */} - <div> - <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., Main Repository" - /> - </div> - - {/* Source Type */} - <div> - <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> - Source Type - </label> - <div className="flex gap-4"> - <label className="flex items-center gap-2 font-mono text-sm text-[#dbe7ff]"> - <input - type="radio" - checked={sourceType === "remote"} - onChange={() => setSourceType("remote")} - className="accent-[#75aafc]" - /> - Remote (URL) - </label> - <label className="flex items-center gap-2 font-mono text-sm text-[#dbe7ff]"> - <input - type="radio" - checked={sourceType === "local"} - onChange={() => setSourceType("local")} - className="accent-[#75aafc]" - /> - Local Path - </label> - </div> - </div> - - {/* Repository URL or Local Path */} - {sourceType === "remote" ? ( - <div> - <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> - Repository URL * - </label> - <input - type="text" - value={repositoryUrl} - onChange={(e) => setRepositoryUrl(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="https://github.com/user/repo" - /> - </div> - ) : ( - <div> - <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1"> - Local Path * - </label> - <input - type="text" - value={localPath} - onChange={(e) => setLocalPath(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="/path/to/local/repo" - /> - </div> - )} - - {/* Primary checkbox */} - <label className="flex items-center gap-2 font-mono text-sm text-[#dbe7ff]"> - <input - type="checkbox" - checked={isPrimary} - onChange={(e) => setIsPrimary(e.target.checked)} - className="accent-[#75aafc]" - /> - Set as primary repository - </label> - </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() || - (sourceType === "remote" && !repositoryUrl.trim()) || - (sourceType === "local" && !localPath.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 Repository - </button> - </div> - </div> - </div> - ); -} diff --git a/makima/frontend/src/components/chains/ChainList.tsx b/makima/frontend/src/components/chains/ChainList.tsx deleted file mode 100644 index e185efc..0000000 --- a/makima/frontend/src/components/chains/ChainList.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { useState, useCallback } from "react"; -import type { ChainSummary, ChainStatus } from "../../lib/api"; - -interface ChainListProps { - chains: ChainSummary[]; - loading: boolean; - onSelect: (chainId: string) => void; - onCreate: () => void; - selectedId?: string; - onArchive: (chain: ChainSummary) => void; -} - -const statusColors: Record<ChainStatus, string> = { - pending: "text-yellow-400", - active: "text-green-400", - completed: "text-blue-400", - archived: "text-[#555]", -}; - -export function ChainList({ - chains, - loading, - onSelect, - onCreate, - selectedId, - onArchive, -}: ChainListProps) { - const [filter, setFilter] = useState<ChainStatus | "all">("all"); - const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); - const [contextMenuChain, setContextMenuChain] = useState<ChainSummary | null>(null); - - const filteredChains = - filter === "all" - ? chains - : chains.filter((c) => c.status === filter); - - const handleContextMenu = useCallback( - (e: React.MouseEvent, chain: ChainSummary) => { - e.preventDefault(); - setContextMenuPosition({ x: e.clientX, y: e.clientY }); - setContextMenuChain(chain); - }, - [] - ); - - const closeContextMenu = useCallback(() => { - setContextMenuPosition(null); - setContextMenuChain(null); - }, []); - - const handleArchive = useCallback(() => { - if (contextMenuChain) { - onArchive(contextMenuChain); - closeContextMenu(); - } - }, [contextMenuChain, onArchive, closeContextMenu]); - - if (loading) { - return ( - <div className="panel h-full flex items-center justify-center"> - <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div> - </div> - ); - } - - return ( - <div className="panel h-full flex flex-col" onClick={closeContextMenu}> - {/* Header */} - <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]"> - <div className="flex items-center justify-between mb-3"> - <h2 className="font-mono text-sm text-[#75aafc] uppercase tracking-wider"> - Chains - </h2> - <button - onClick={onCreate} - className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" - > - + New - </button> - </div> - - {/* Filter tabs */} - <div className="flex gap-1"> - {(["all", "active", "completed", "archived"] as const).map((status) => ( - <button - key={status} - onClick={() => setFilter(status)} - className={` - px-2 py-1 font-mono text-[10px] uppercase tracking-wider transition-colors - ${ - filter === status - ? "bg-[rgba(117,170,252,0.1)] text-[#9bc3ff] border border-[rgba(117,170,252,0.3)]" - : "text-[#555] hover:text-[#75aafc]" - } - `} - > - {status} - </button> - ))} - </div> - </div> - - {/* Chain list */} - <div className="flex-1 overflow-y-auto"> - {filteredChains.length === 0 ? ( - <div className="p-4 text-center"> - <p className="font-mono text-sm text-[#555]"> - {filter === "all" - ? "No chains yet" - : `No ${filter} chains`} - </p> - </div> - ) : ( - <div className="divide-y divide-[rgba(117,170,252,0.15)]"> - {filteredChains.map((chain) => ( - <button - key={chain.id} - onClick={() => onSelect(chain.id)} - onContextMenu={(e) => handleContextMenu(e, chain)} - className={` - w-full text-left p-4 transition-colors - ${ - selectedId === chain.id - ? "bg-[rgba(117,170,252,0.1)]" - : "hover:bg-[rgba(117,170,252,0.05)]" - } - `} - > - <div className="flex items-start justify-between gap-2 mb-2"> - <h3 className="font-mono text-sm text-[#dbe7ff] truncate"> - {chain.name} - </h3> - <span - className={`text-[10px] font-mono uppercase shrink-0 ${ - statusColors[chain.status] - }`} - > - {chain.status} - </span> - </div> - - {chain.description && ( - <p className="font-mono text-xs text-[#555] mb-2 line-clamp-2"> - {chain.description} - </p> - )} - - <div className="flex items-center gap-4 text-[10px] font-mono text-[#555]"> - <span>{chain.contractCount} contracts</span> - <span>{chain.completedContractCount} completed</span> - {chain.loopEnabled && ( - <span className="text-amber-400"> - loop {chain.loopCurrentIteration || 0}/{chain.loopMaxIterations || "∞"} - </span> - )} - </div> - </button> - ))} - </div> - )} - </div> - - {/* Context Menu */} - {contextMenuPosition && contextMenuChain && ( - <div - className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg py-1 min-w-[150px]" - style={{ top: contextMenuPosition.y, left: contextMenuPosition.x }} - onClick={(e) => e.stopPropagation()} - > - <button - onClick={() => { - onSelect(contextMenuChain.id); - closeContextMenu(); - }} - className="w-full px-4 py-2 text-left font-mono text-xs text-[#dbe7ff] hover:bg-[rgba(117,170,252,0.1)] transition-colors" - > - View Details - </button> - {contextMenuChain.status !== "archived" && ( - <button - onClick={handleArchive} - className="w-full px-4 py-2 text-left font-mono text-xs text-red-400 hover:bg-red-400/10 transition-colors" - > - Archive - </button> - )} - </div> - )} - </div> - ); -} diff --git a/makima/frontend/src/hooks/useChains.ts b/makima/frontend/src/hooks/useChains.ts deleted file mode 100644 index 272847a..0000000 --- a/makima/frontend/src/hooks/useChains.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { - listChains, - getChain, - createChain, - updateChain, - archiveChain, - getChainGraph, - type ChainSummary, - type ChainWithContracts, - type ChainGraphResponse, - type ChainStatus, - type CreateChainRequest, - type UpdateChainRequest, -} from "../lib/api"; - -interface UseChainsResult { - chains: ChainSummary[]; - loading: boolean; - error: string | null; - refresh: () => Promise<void>; - createNewChain: (req: CreateChainRequest) => Promise<ChainWithContracts | null>; - updateExistingChain: ( - chainId: string, - req: UpdateChainRequest - ) => Promise<ChainWithContracts | null>; - archiveExistingChain: (chainId: string) => Promise<boolean>; - getChainById: (chainId: string) => Promise<ChainWithContracts | null>; - getGraph: (chainId: string) => Promise<ChainGraphResponse | null>; -} - -export function useChains(statusFilter?: ChainStatus): UseChainsResult { - const [chains, setChains] = useState<ChainSummary[]>([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState<string | null>(null); - - const fetchChains = useCallback(async () => { - setLoading(true); - setError(null); - try { - const response = await listChains(statusFilter); - setChains(response.chains); - } catch (err) { - console.error("Failed to fetch chains:", err); - setError(err instanceof Error ? err.message : "Failed to fetch chains"); - } finally { - setLoading(false); - } - }, [statusFilter]); - - useEffect(() => { - fetchChains(); - }, [fetchChains]); - - const createNewChain = useCallback( - async (req: CreateChainRequest): Promise<ChainWithContracts | null> => { - try { - const chain = await createChain(req); - // Refresh the list - await fetchChains(); - // Return the full chain with contracts - return await getChain(chain.id); - } catch (err) { - console.error("Failed to create chain:", err); - setError(err instanceof Error ? err.message : "Failed to create chain"); - return null; - } - }, - [fetchChains] - ); - - const updateExistingChain = useCallback( - async ( - chainId: string, - req: UpdateChainRequest - ): Promise<ChainWithContracts | null> => { - try { - await updateChain(chainId, req); - // Refresh the list - await fetchChains(); - // Return the updated chain - return await getChain(chainId); - } catch (err) { - console.error("Failed to update chain:", err); - setError(err instanceof Error ? err.message : "Failed to update chain"); - return null; - } - }, - [fetchChains] - ); - - const archiveExistingChain = useCallback( - async (chainId: string): Promise<boolean> => { - try { - await archiveChain(chainId); - // Refresh the list - await fetchChains(); - return true; - } catch (err) { - console.error("Failed to archive chain:", err); - setError(err instanceof Error ? err.message : "Failed to archive chain"); - return false; - } - }, - [fetchChains] - ); - - const getChainById = useCallback( - async (chainId: string): Promise<ChainWithContracts | null> => { - try { - return await getChain(chainId); - } catch (err) { - console.error("Failed to get chain:", err); - setError(err instanceof Error ? err.message : "Failed to get chain"); - return null; - } - }, - [] - ); - - const getGraph = useCallback( - async (chainId: string): Promise<ChainGraphResponse | null> => { - try { - return await getChainGraph(chainId); - } catch (err) { - console.error("Failed to get chain graph:", err); - setError(err instanceof Error ? err.message : "Failed to get chain graph"); - return null; - } - }, - [] - ); - - return { - chains, - loading, - error, - refresh: fetchChains, - createNewChain, - updateExistingChain, - archiveExistingChain, - getChainById, - getGraph, - }; -} diff --git a/makima/frontend/src/hooks/useDirectives.ts b/makima/frontend/src/hooks/useDirectives.ts index 6e1654f..7ae24a5 100644 --- a/makima/frontend/src/hooks/useDirectives.ts +++ b/makima/frontend/src/hooks/useDirectives.ts @@ -50,7 +50,7 @@ export function useDirectives(statusFilter?: DirectiveStatus): UseDirectivesResu setError(null); try { const response = await listDirectives(statusFilter); - setDirectives(response.directives); + setDirectives(response.directives ?? []); } catch (err) { console.error("Failed to fetch directives:", err); setError(err instanceof Error ? err.message : "Failed to fetch directives"); diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 5a1b98e..c90d292 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -12,7 +12,6 @@ import HomePage from "./routes/_index"; import ListenPage from "./routes/listen"; import FilesPage from "./routes/files"; import ContractsPage from "./routes/contracts"; -import ChainsPage from "./routes/chains"; import DirectivesPage from "./routes/directives"; import WorkflowPage from "./routes/workflow"; import MeshPage from "./routes/mesh"; @@ -74,22 +73,6 @@ createRoot(document.getElementById("root")!).render( } /> <Route - path="/chains" - element={ - <ProtectedRoute> - <ChainsPage /> - </ProtectedRoute> - } - /> - <Route - path="/chains/:id" - element={ - <ProtectedRoute> - <ChainsPage /> - </ProtectedRoute> - } - /> - <Route path="/directives" element={ <ProtectedRoute> diff --git a/makima/frontend/src/routes/chains.tsx b/makima/frontend/src/routes/chains.tsx deleted file mode 100644 index 9b33304..0000000 --- a/makima/frontend/src/routes/chains.tsx +++ /dev/null @@ -1,496 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { useParams, useNavigate } from "react-router"; -import { Masthead } from "../components/Masthead"; -import { ChainList } from "../components/chains/ChainList"; -import { ChainEditor } from "../components/chains/ChainEditor"; -import { useChains } from "../hooks/useChains"; -import { useAuth } from "../contexts/AuthContext"; -import type { - ChainSummary, - ChainWithContracts, - ChainGraphResponse, - CreateChainRequest, - AddChainRepositoryRequest, - RepositoryHistoryEntry, -} from "../lib/api"; -import { getRepositorySuggestions } from "../lib/api"; - -export default function ChainsPage() { - const { isAuthenticated, isAuthConfigured, isLoading: authLoading } = useAuth(); - const navigate = useNavigate(); - - // Redirect to login if not authenticated (when auth is configured) - useEffect(() => { - if (!authLoading && isAuthConfigured && !isAuthenticated) { - navigate("/login"); - } - }, [authLoading, isAuthConfigured, isAuthenticated, navigate]); - - // Show loading while checking auth - if (authLoading) { - return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex items-center justify-center"> - <p className="text-[#7788aa] font-mono text-sm">Loading...</p> - </main> - </div> - ); - } - - // Don't render if not authenticated (will redirect) - if (isAuthConfigured && !isAuthenticated) { - return null; - } - - return <ChainsPageContent />; -} - -function ChainsPageContent() { - const { id } = useParams<{ id: string }>(); - const navigate = useNavigate(); - const { - chains, - loading, - error, - createNewChain, - archiveExistingChain, - getChainById, - getGraph, - } = useChains(); - - const [chainDetail, setChainDetail] = useState<ChainWithContracts | null>(null); - const [chainGraph, setChainGraph] = useState<ChainGraphResponse | null>(null); - const [detailLoading, setDetailLoading] = useState(false); - const [isCreating, setIsCreating] = useState(false); - - // Load chain detail when ID changes - useEffect(() => { - if (id) { - setDetailLoading(true); - Promise.all([getChainById(id), getGraph(id)]).then(([chain, graph]) => { - setChainDetail(chain); - setChainGraph(graph); - setDetailLoading(false); - }); - } else { - setChainDetail(null); - setChainGraph(null); - } - }, [id, getChainById, getGraph]); - - const handleSelect = useCallback( - (chainId: string) => { - navigate(`/chains/${chainId}`); - }, - [navigate] - ); - - const handleBack = useCallback(() => { - navigate("/chains"); - }, [navigate]); - - const handleCreate = useCallback(() => { - setIsCreating(true); - }, []); - - const handleCreateSubmit = useCallback( - async (name: string, description: string, repositories: AddChainRepositoryRequest[]) => { - const data: CreateChainRequest = { - name: name.trim(), - description: description.trim() || undefined, - repositories: repositories.length > 0 ? repositories : undefined, - }; - - try { - const result = await createNewChain(data); - if (result) { - setIsCreating(false); - navigate(`/chains/${result.id}`); - } - } catch (err) { - console.error("Failed to create chain:", err); - } - }, - [createNewChain, navigate] - ); - - const handleCreateCancel = useCallback(() => { - setIsCreating(false); - }, []); - - const handleArchive = useCallback( - async (chain: ChainSummary) => { - if (confirm(`Are you sure you want to archive "${chain.name}"?`)) { - const success = await archiveExistingChain(chain.id); - if (success && chain.id === id) { - navigate("/chains"); - } - } - }, - [archiveExistingChain, id, navigate] - ); - - const handleRefresh = useCallback(async () => { - if (id) { - const [chain, graph] = await Promise.all([getChainById(id), getGraph(id)]); - setChainDetail(chain); - setChainGraph(graph); - } - }, [id, getChainById, getGraph]); - - const handleContractClick = useCallback( - (contractId: string) => { - navigate(`/contracts/${contractId}`); - }, - [navigate] - ); - - return ( - <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]"> - <Masthead showNav /> - <main className="flex-1 flex flex-col p-4 pt-2 gap-4 overflow-hidden"> - {error && ( - <div className="p-3 bg-red-400/10 border border-red-400/30 text-red-400 font-mono text-sm"> - {error} - </div> - )} - - {/* Create chain modal */} - {isCreating && ( - <CreateChainModal - onSubmit={handleCreateSubmit} - onCancel={handleCreateCancel} - /> - )} - - <div className="flex-1 grid grid-cols-[350px_1fr] gap-4 min-h-0"> - {/* Chain list */} - <ChainList - chains={chains} - loading={loading} - onSelect={handleSelect} - onCreate={handleCreate} - selectedId={id} - onArchive={handleArchive} - /> - - {/* Chain detail/editor or empty state */} - {chainDetail ? ( - <ChainEditor - chain={chainDetail} - graph={chainGraph} - loading={detailLoading} - onBack={handleBack} - onRefresh={handleRefresh} - onContractClick={handleContractClick} - /> - ) : ( - <div className="panel h-full flex items-center justify-center"> - <div className="text-center"> - <p className="font-mono text-sm text-[#555] mb-4"> - Select a chain or create a new one - </p> - <button - onClick={handleCreate} - className="px-4 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase" - > - + New Chain - </button> - </div> - </div> - )} - </div> - </main> - </div> - ); -} - -interface CreateChainModalProps { - onSubmit: (name: string, description: string, repositories: AddChainRepositoryRequest[]) => void; - onCancel: () => void; -} - -type RepoMode = "remote" | "local" | null; - -function CreateChainModal({ onSubmit, onCancel }: CreateChainModalProps) { - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [repositories, setRepositories] = useState<AddChainRepositoryRequest[]>([]); - - // Repository input state - const [repoMode, setRepoMode] = useState<RepoMode>(null); - const [repoName, setRepoName] = useState(""); - const [repoUrl, setRepoUrl] = useState(""); - const [repoPath, setRepoPath] = useState(""); - - // Suggestions - const [suggestions, setSuggestions] = useState<RepositoryHistoryEntry[]>([]); - const [showSuggestions, setShowSuggestions] = useState(false); - - // Load suggestions when mode changes - useEffect(() => { - if (repoMode) { - getRepositorySuggestions(repoMode, undefined, 10) - .then((res) => { - setSuggestions(res.entries); - setShowSuggestions(res.entries.length > 0); - }) - .catch(() => { - setSuggestions([]); - setShowSuggestions(false); - }); - } else { - setSuggestions([]); - setShowSuggestions(false); - } - }, [repoMode]); - - const applySuggestion = (suggestion: RepositoryHistoryEntry) => { - setRepoName(suggestion.name); - if (suggestion.repositoryUrl) setRepoUrl(suggestion.repositoryUrl); - if (suggestion.localPath) setRepoPath(suggestion.localPath); - setShowSuggestions(false); - }; - - const handleAddRepo = () => { - if (!repoName.trim()) return; - if (repoMode === "remote" && !repoUrl.trim()) return; - if (repoMode === "local" && !repoPath.trim()) return; - - const newRepo: AddChainRepositoryRequest = { - name: repoName.trim(), - sourceType: repoMode || "remote", - isPrimary: repositories.length === 0, // First one is primary - ...(repoMode === "remote" ? { repositoryUrl: repoUrl.trim() } : { localPath: repoPath.trim() }), - }; - - setRepositories([...repositories, newRepo]); - setRepoMode(null); - setRepoName(""); - setRepoUrl(""); - setRepoPath(""); - }; - - const handleRemoveRepo = (index: number) => { - const newRepos = repositories.filter((_, i) => i !== index); - // If we removed the primary, make the first one primary - if (newRepos.length > 0 && repositories[index]?.isPrimary) { - newRepos[0].isPrimary = true; - } - setRepositories(newRepos); - }; - - const handleSubmit = () => { - if (name.trim()) { - onSubmit(name.trim(), description.trim(), repositories); - } - }; - - return ( - <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> - <div className="w-full max-w-lg p-6 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] max-h-[90vh] overflow-y-auto"> - <h3 className="font-mono text-sm text-[#75aafc] uppercase mb-4"> - Create Chain - </h3> - - <div className="space-y-4"> - {/* Chain name */} - <div> - <label className="block font-mono text-xs text-[#8b949e] uppercase mb-1"> - Chain Name * - </label> - <input - type="text" - value={name} - onChange={(e) => setName(e.target.value)} - placeholder="e.g., Feature Implementation" - 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> - <textarea - value={description} - onChange={(e) => setDescription(e.target.value)} - placeholder="Describe what this chain accomplishes..." - 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" - /> - </div> - - {/* Repositories */} - <div> - <label className="block font-mono text-xs text-[#8b949e] uppercase mb-2"> - Repositories - </label> - - {/* Added repositories */} - {repositories.length > 0 && ( - <div className="space-y-2 mb-3"> - {repositories.map((repo, index) => ( - <div - key={index} - className="flex items-center gap-2 px-3 py-2 bg-[#0d1b2d] border border-[rgba(117,170,252,0.2)]" - > - <span className="font-mono text-[10px] text-[#556677] uppercase"> - {repo.sourceType === "remote" ? "URL" : "Local"} - </span> - <span className="font-mono text-xs text-[#dbe7ff] flex-1 truncate"> - {repo.name} - </span> - {repo.isPrimary && ( - <span className="font-mono text-[8px] text-[#75aafc] uppercase px-1 border border-[#75aafc]/30"> - primary - </span> - )} - <button - onClick={() => handleRemoveRepo(index)} - className="font-mono text-xs text-[#556677] hover:text-red-400" - > - ✕ - </button> - </div> - ))} - </div> - )} - - {/* Add repository form */} - {repoMode ? ( - <div className="p-3 border border-[rgba(117,170,252,0.3)] space-y-3"> - <div className="flex items-center justify-between"> - <span className="font-mono text-[10px] text-[#75aafc] uppercase"> - Add {repoMode === "remote" ? "Remote" : "Local"} Repository - </span> - {suggestions.length > 0 && ( - <button - onClick={() => setShowSuggestions(!showSuggestions)} - className="font-mono text-[10px] text-[#556677] hover:text-[#9bc3ff]" - > - {showSuggestions ? "Hide" : `${suggestions.length} suggestions`} - </button> - )} - </div> - - {/* Suggestions dropdown */} - {showSuggestions && suggestions.length > 0 && ( - <div className="border border-[rgba(117,170,252,0.2)] bg-[#0a1525] max-h-28 overflow-y-auto"> - {suggestions.map((s) => ( - <button - key={s.id} - onClick={() => applySuggestion(s)} - className="w-full text-left px-3 py-2 font-mono text-xs hover:bg-[rgba(117,170,252,0.1)] border-b border-[rgba(117,170,252,0.1)] last:border-b-0" - > - <div className="flex items-center justify-between"> - <span className="text-[#9bc3ff] truncate">{s.name}</span> - <span className="text-[10px] text-[#556677]">{s.useCount}×</span> - </div> - <div className="text-[10px] text-[#556677] truncate"> - {repoMode === "local" ? s.localPath : s.repositoryUrl} - </div> - </button> - ))} - </div> - )} - - <input - type="text" - value={repoName} - onChange={(e) => setRepoName(e.target.value)} - placeholder="Repository name" - className="w-full px-3 py-2 bg-[#050d18] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" - /> - - {repoMode === "remote" ? ( - <input - type="text" - value={repoUrl} - onChange={(e) => setRepoUrl(e.target.value)} - placeholder="https://github.com/owner/repo" - className="w-full px-3 py-2 bg-[#050d18] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" - /> - ) : ( - <input - type="text" - value={repoPath} - onChange={(e) => setRepoPath(e.target.value)} - placeholder="/path/to/repository" - className="w-full px-3 py-2 bg-[#050d18] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-xs focus:outline-none focus:border-[#75aafc]" - /> - )} - - <div className="flex gap-2"> - <button - onClick={() => setRepoMode(null)} - className="px-3 py-1.5 font-mono text-xs text-[#556677] hover:text-[#9bc3ff]" - > - Cancel - </button> - <button - onClick={handleAddRepo} - disabled={ - !repoName.trim() || - (repoMode === "remote" && !repoUrl.trim()) || - (repoMode === "local" && !repoPath.trim()) - } - className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] disabled:opacity-50" - > - Add - </button> - </div> - </div> - ) : ( - <div className="flex gap-2"> - <button - onClick={() => setRepoMode("remote")} - className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]" - > - + Remote - </button> - <button - onClick={() => setRepoMode("local")} - className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]" - > - + Local - </button> - </div> - )} - - {repositories.length === 0 && !repoMode && ( - <p className="font-mono text-[10px] text-[#556677] mt-2"> - Add repositories that contracts in this chain will work with - </p> - )} - </div> - - <p className="font-mono text-xs text-[#8b949e]"> - A chain links multiple contracts together in a DAG. Contracts depend on each - other and start automatically when dependencies complete. - </p> - - {/* 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" - > - Create - </button> - </div> - </div> - </div> - </div> - ); -} diff --git a/makima/frontend/src/routes/directives.tsx b/makima/frontend/src/routes/directives.tsx index 51fd57a..35e5703 100644 --- a/makima/frontend/src/routes/directives.tsx +++ b/makima/frontend/src/routes/directives.tsx @@ -769,7 +769,7 @@ function ChainTab({ directive, graph }: { directive: DirectiveWithProgress; grap // Build edges from dependencies const stepEdges: Edge[] = []; directive.steps.forEach((step) => { - step.dependsOn.forEach((depName) => { + (step.dependsOn ?? []).forEach((depName) => { const depStep = directive.steps.find((s) => s.name === depName); if (depStep) { stepEdges.push({ @@ -919,7 +919,7 @@ function ChainTab({ directive, graph }: { directive: DirectiveWithProgress; grap {step.description && ( <p className="font-mono text-xs text-[#556677] mt-1">{step.description}</p> )} - {step.dependsOn.length > 0 && ( + {step.dependsOn?.length > 0 && ( <div className="font-mono text-[10px] text-[#556677] mt-1"> Depends on: {step.dependsOn.join(", ")} </div> |
