diff options
Diffstat (limited to 'makima/frontend/src')
| -rw-r--r-- | makima/frontend/src/components/NavStrip.tsx | 1 | ||||
| -rw-r--r-- | makima/frontend/src/components/chains/ChainEditor.tsx | 468 | ||||
| -rw-r--r-- | makima/frontend/src/components/chains/ChainList.tsx | 205 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useChains.ts | 145 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 236 | ||||
| -rw-r--r-- | makima/frontend/src/main.tsx | 17 | ||||
| -rw-r--r-- | makima/frontend/src/routes/chains.tsx | 283 |
7 files changed, 1355 insertions, 0 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index fb95c7f..5937982 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -11,6 +11,7 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, { label: "Contracts", href: "/contracts", requiresAuth: true }, + { label: "Chains", href: "/chains", requiresAuth: true }, { label: "Board", href: "/workflow", requiresAuth: true }, { label: "Mesh", href: "/mesh", requiresAuth: true }, { label: "History", href: "/history", requiresAuth: true }, diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx new file mode 100644 index 0000000..9077d19 --- /dev/null +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -0,0 +1,468 @@ +import { useState, useCallback, useMemo, useRef } from "react"; +import type { + ChainWithContracts, + ChainGraphResponse, + ChainContractDetail, +} from "../../lib/api"; + +interface ChainEditorProps { + chain: ChainWithContracts; + graph: ChainGraphResponse | null; + loading: boolean; + onBack: () => void; + onRefresh: () => void; + onContractClick: (contractId: string) => void; +} + +// Node dimensions +const NODE_WIDTH = 180; +const NODE_HEIGHT = 80; +const CANVAS_PADDING = 40; + +export function ChainEditor({ + chain, + graph, + loading, + onBack, + onRefresh, + onContractClick, +}: ChainEditorProps) { + const canvasRef = useRef<HTMLDivElement>(null); + const [selectedNode, setSelectedNode] = useState<string | null>(null); + const [hoveredNode, setHoveredNode] = useState<string | null>(null); + + // Use positions from graph nodes directly (x, y from server) + const nodePositions = useMemo(() => { + if (!graph?.nodes) return new Map<string, { x: number; y: number }>(); + + const positions = new Map<string, { x: number; y: number }>(); + 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]); + + // Canvas dimensions + const canvasDimensions = useMemo(() => { + if (nodePositions.size === 0) { + return { width: 600, height: 400 }; + } + + let maxX = 0; + let maxY = 0; + for (const pos of nodePositions.values()) { + maxX = Math.max(maxX, pos.x + NODE_WIDTH); + maxY = Math.max(maxY, pos.y + NODE_HEIGHT); + } + + return { + width: Math.max(600, maxX + CANVAS_PADDING), + height: Math.max(400, maxY + CANVAS_PADDING), + }; + }, [nodePositions]); + + const handleNodeClick = useCallback((contractId: string) => { + setSelectedNode(contractId); + }, []); + + const handleNodeDoubleClick = useCallback( + (contractId: string) => { + onContractClick(contractId); + }, + [onContractClick] + ); + + const getStatusColor = (status: string) => { + switch (status) { + case "active": + return { bg: "#4ade80", border: "#22c55e", text: "#166534" }; + case "completed": + return { bg: "#60a5fa", border: "#3b82f6", text: "#1e40af" }; + case "pending": + return { bg: "#f59e0b", border: "#d97706", text: "#92400e" }; + case "blocked": + return { bg: "#ef4444", border: "#dc2626", text: "#991b1b" }; + default: + return { bg: "#6b7280", border: "#4b5563", text: "#374151" }; + } + }; + + // Find selected contract from chain.contracts + const selectedContract = selectedNode + ? chain.contracts.find((c) => c.contractId === selectedNode) + : null; + + 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.chain.name}</h2> + {chain.chain.description && ( + <p className="font-mono text-xs text-[#8b949e]">{chain.chain.description}</p> + )} + </div> + </div> + <div className="flex items-center gap-2"> + <span + className={`px-2 py-1 font-mono text-[10px] uppercase rounded ${ + chain.chain.status === "active" + ? "text-[#4ade80] bg-[#4ade80]/10" + : chain.chain.status === "completed" + ? "text-[#60a5fa] bg-[#60a5fa]/10" + : "text-[#6b7280] bg-[#6b7280]/10" + }`} + > + {chain.chain.status} + </span> + <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> + </div> + + {/* Main content */} + <div className="flex-1 flex min-h-0"> + {/* DAG Canvas */} + <div className="flex-1 overflow-auto bg-[#050d18]"> + {loading ? ( + <div className="flex items-center justify-center h-full"> + <p className="font-mono text-xs text-[#8b949e]">Loading graph...</p> + </div> + ) : !graph || graph.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"> + No contracts in this chain yet + </p> + <p className="font-mono text-xs text-[#556677]"> + Contracts will appear here once added via CLI or API + </p> + </div> + </div> + ) : ( + <div + ref={canvasRef} + className="relative" + style={{ + width: canvasDimensions.width, + height: canvasDimensions.height, + minWidth: "100%", + minHeight: "100%", + }} + > + {/* 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> + </defs> + {graph.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} + /> + ); + })} + </svg> + + {/* Node layer */} + {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> + )} + </div> + + {/* Detail panel */} + {selectedContract && ( + <ContractDetailPanel + contract={selectedContract} + allContracts={chain.contracts} + onClose={() => setSelectedNode(null)} + onSelectContract={setSelectedNode} + onOpenContract={onContractClick} + /> + )} + </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]"> + <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> + </div> + ); +} + +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={`inline-block px-2 py-1 font-mono text-xs uppercase rounded ${ + contract.contractStatus === "active" + ? "text-[#4ade80] bg-[#4ade80]/10" + : contract.contractStatus === "completed" + ? "text-[#60a5fa] bg-[#60a5fa]/10" + : "text-[#6b7280] bg-[#6b7280]/10" + }`} + > + {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> + ); +} + +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> + ); +} diff --git a/makima/frontend/src/components/chains/ChainList.tsx b/makima/frontend/src/components/chains/ChainList.tsx new file mode 100644 index 0000000..eda79d7 --- /dev/null +++ b/makima/frontend/src/components/chains/ChainList.tsx @@ -0,0 +1,205 @@ +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; +} + +export function ChainList({ + chains, + loading, + onSelect, + onCreate, + selectedId, + onArchive, +}: ChainListProps) { + const [statusFilter, setStatusFilter] = useState<ChainStatus | "all">("all"); + const [contextMenu, setContextMenu] = useState<{ + chain: ChainSummary; + x: number; + y: number; + } | null>(null); + + const filteredChains = chains.filter((chain) => + statusFilter === "all" ? true : chain.status === statusFilter + ); + + const handleContextMenu = useCallback( + (e: React.MouseEvent, chain: ChainSummary) => { + e.preventDefault(); + setContextMenu({ chain, x: e.clientX, y: e.clientY }); + }, + [] + ); + + const closeContextMenu = useCallback(() => { + setContextMenu(null); + }, []); + + const handleArchive = useCallback(() => { + if (contextMenu) { + onArchive(contextMenu.chain); + setContextMenu(null); + } + }, [contextMenu, onArchive]); + + const getStatusColor = (status: ChainStatus) => { + switch (status) { + case "active": + return "text-[#4ade80] bg-[#4ade80]/10"; + case "completed": + return "text-[#60a5fa] bg-[#60a5fa]/10"; + case "archived": + return "text-[#6b7280] bg-[#6b7280]/10"; + default: + return "text-[#8b949e] bg-[#8b949e]/10"; + } + }; + + const getStatusIcon = (status: ChainStatus) => { + switch (status) { + case "active": + return ( + <svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor"> + <circle cx="12" cy="12" r="4" /> + </svg> + ); + case "completed": + return ( + <svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"> + <polyline points="20 6 9 17 4 12" /> + </svg> + ); + case "archived": + return ( + <svg className="w-3 h-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> + <path d="M21 8v13H3V8" /> + <path d="M1 3h22v5H1z" /> + <path d="M10 12h4" /> + </svg> + ); + default: + return null; + } + }; + + return ( + <div className="panel h-full flex flex-col" onClick={closeContextMenu}> + {/* Header */} + <div className="p-3 border-b border-[rgba(117,170,252,0.2)]"> + <div className="flex items-center justify-between mb-3"> + <h2 className="font-mono text-sm text-[#75aafc] uppercase">Chains</h2> + <button + onClick={onCreate} + className="px-3 py-1 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors" + > + + New + </button> + </div> + + {/* Status filter */} + <div className="flex gap-1"> + {(["all", "active", "completed", "archived"] as const).map((status) => ( + <button + key={status} + onClick={() => setStatusFilter(status)} + className={`px-2 py-1 font-mono text-[10px] uppercase transition-colors ${ + statusFilter === status + ? "bg-[#0f3c78] text-[#dbe7ff] border border-[#75aafc]" + : "bg-[#0d1b2d] text-[#8b949e] border border-[#3f6fb3] hover:border-[#75aafc]" + }`} + > + {status} + </button> + ))} + </div> + </div> + + {/* Chain list */} + <div className="flex-1 overflow-y-auto"> + {loading ? ( + <div className="flex items-center justify-center h-32"> + <p className="font-mono text-xs text-[#8b949e]">Loading chains...</p> + </div> + ) : filteredChains.length === 0 ? ( + <div className="flex items-center justify-center h-32"> + <p className="font-mono text-xs text-[#8b949e]"> + {statusFilter === "all" ? "No chains yet" : `No ${statusFilter} chains`} + </p> + </div> + ) : ( + <div className="divide-y divide-[rgba(117,170,252,0.1)]"> + {filteredChains.map((chain) => ( + <div + key={chain.id} + onClick={() => onSelect(chain.id)} + onContextMenu={(e) => handleContextMenu(e, chain)} + className={`p-3 cursor-pointer transition-colors ${ + selectedId === chain.id + ? "bg-[rgba(117,170,252,0.15)]" + : "hover:bg-[rgba(117,170,252,0.05)]" + }`} + > + <div className="flex items-center justify-between mb-1"> + <span className="font-mono text-sm text-[#dbe7ff] truncate"> + {chain.name} + </span> + <span + className={`flex items-center gap-1 px-1.5 py-0.5 font-mono text-[10px] uppercase rounded ${getStatusColor( + chain.status + )}`} + > + {getStatusIcon(chain.status)} + {chain.status} + </span> + </div> + {chain.description && ( + <p className="font-mono text-xs text-[#8b949e] truncate mb-1"> + {chain.description} + </p> + )} + <div className="flex items-center gap-3 font-mono text-[10px] text-[#556677]"> + <span>{chain.contractCount} contracts</span> + <span> + {new Date(chain.updatedAt).toLocaleDateString()} + </span> + </div> + </div> + ))} + </div> + )} + </div> + + {/* Context menu */} + {contextMenu && ( + <div + className="fixed z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] shadow-lg py-1" + style={{ top: contextMenu.y, left: contextMenu.x }} + > + <button + onClick={() => { + onSelect(contextMenu.chain.id); + setContextMenu(null); + }} + className="w-full px-4 py-2 text-left font-mono text-xs text-[#dbe7ff] hover:bg-[rgba(117,170,252,0.1)]" + > + View Details + </button> + {contextMenu.chain.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" + > + Archive + </button> + )} + </div> + )} + </div> + ); +} diff --git a/makima/frontend/src/hooks/useChains.ts b/makima/frontend/src/hooks/useChains.ts new file mode 100644 index 0000000..272847a --- /dev/null +++ b/makima/frontend/src/hooks/useChains.ts @@ -0,0 +1,145 @@ +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/lib/api.ts b/makima/frontend/src/lib/api.ts index f148d76..445537c 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -2948,3 +2948,239 @@ export async function listTaskPatches(taskId: string, contractId: string): Promi } return res.json(); } + +// ============================================================================= +// Chain Types and API +// ============================================================================= + +/** Chain status */ +export type ChainStatus = "active" | "completed" | "archived"; + +/** Chain summary for list view */ +export interface ChainSummary { + id: string; + name: string; + description: string | null; + status: ChainStatus; + contractCount: number; + completedContractCount: number; + loopEnabled: boolean; + loopCurrentIteration: number | null; + loopMaxIterations: number | null; + createdAt: string; + updatedAt: string; +} + +/** Full chain with contracts */ +export interface Chain { + id: string; + ownerId: string; + name: string; + description: string | null; + status: ChainStatus; + loopEnabled: boolean; + loopMaxIterations: number | null; + loopCurrentIteration: number | null; + loopProgressCheck: string | null; + repositoryUrl: string | null; + localPath: string | null; + version: number; + createdAt: string; + updatedAt: string; +} + +/** Contract detail within a chain */ +export interface ChainContractDetail { + id: string; + chainId: string; + contractId: string; + contractName: string; + contractStatus: string; + contractPhase: string; + dependsOn: string[]; + orderIndex: number; + editorX: number | null; + editorY: number | null; + createdAt: string; +} + +/** Chain with contracts */ +export interface ChainWithContracts { + chain: Chain; + contracts: ChainContractDetail[]; +} + +/** Node in chain graph visualization */ +export interface ChainGraphNode { + id: string; + contractId: string; + name: string; + status: string; + phase: string; + x: number; + y: number; +} + +/** Edge in chain graph */ +export interface ChainGraphEdge { + from: string; + to: string; +} + +/** Chain graph response */ +export interface ChainGraphResponse { + chainId: string; + chainName: string; + chainStatus: string; + nodes: ChainGraphNode[]; + edges: ChainGraphEdge[]; +} + +/** Chain event */ +export interface ChainEvent { + id: string; + chainId: string; + eventType: string; + contractId: string | null; + eventData: Record<string, unknown> | null; + createdAt: string; +} + +/** Chain list response */ +export interface ChainListResponse { + chains: ChainSummary[]; + total: number; +} + +/** Create chain request */ +export interface CreateChainRequest { + name: string; + description?: string; + repositoryUrl?: string; + localPath?: string; + loopEnabled?: boolean; + loopMaxIterations?: number; + loopProgressCheck?: string; + contracts?: CreateChainContractRequest[]; +} + +/** Create chain contract request */ +export interface CreateChainContractRequest { + name: string; + description?: string; + contractType?: string; + initialPhase?: string; + phases?: string[]; + dependsOn?: string[]; + tasks?: { name: string; plan: string }[]; + deliverables?: { id: string; name: string; priority?: string }[]; + editorX?: number; + editorY?: number; +} + +/** Update chain request */ +export interface UpdateChainRequest { + name?: string; + description?: string; + status?: ChainStatus; + loopEnabled?: boolean; + loopMaxIterations?: number; + loopProgressCheck?: string; + version?: number; +} + +/** List chains */ +export async function listChains( + status?: ChainStatus, + limit = 50, + offset = 0 +): Promise<ChainListResponse> { + const params = new URLSearchParams(); + if (status) params.set("status", status); + params.set("limit", String(limit)); + params.set("offset", String(offset)); + + const res = await authFetch(`${API_BASE}/api/v1/chains?${params}`); + if (!res.ok) { + throw new Error(`Failed to list chains: ${res.statusText}`); + } + return res.json(); +} + +/** Get chain by ID */ +export async function getChain(chainId: string): Promise<ChainWithContracts> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}`); + if (!res.ok) { + throw new Error(`Failed to get chain: ${res.statusText}`); + } + return res.json(); +} + +/** Create a new chain */ +export async function createChain(req: CreateChainRequest): Promise<Chain> { + const res = await authFetch(`${API_BASE}/api/v1/chains`, { + method: "POST", + body: JSON.stringify(req), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to create chain: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** Update a chain */ +export async function updateChain( + chainId: string, + req: UpdateChainRequest +): Promise<Chain> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}`, { + method: "PUT", + body: JSON.stringify(req), + }); + if (!res.ok) { + const errorText = await res.text(); + throw new Error(`Failed to update chain: ${errorText || res.statusText}`); + } + return res.json(); +} + +/** Archive a chain */ +export async function archiveChain(chainId: string): Promise<{ archived: boolean }> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}`, { + method: "DELETE", + }); + if (!res.ok) { + throw new Error(`Failed to archive chain: ${res.statusText}`); + } + return res.json(); +} + +/** Get chain contracts */ +export async function getChainContracts( + chainId: string +): Promise<ChainContractDetail[]> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/contracts`); + if (!res.ok) { + throw new Error(`Failed to get chain contracts: ${res.statusText}`); + } + return res.json(); +} + +/** Get chain graph for visualization */ +export async function getChainGraph(chainId: string): Promise<ChainGraphResponse> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/graph`); + if (!res.ok) { + throw new Error(`Failed to get chain graph: ${res.statusText}`); + } + return res.json(); +} + +/** Get chain events */ +export async function getChainEvents(chainId: string): Promise<ChainEvent[]> { + const res = await authFetch(`${API_BASE}/api/v1/chains/${chainId}/events`); + if (!res.ok) { + throw new Error(`Failed to get chain events: ${res.statusText}`); + } + return res.json(); +} diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx index 50fffe4..a7ba1a3 100644 --- a/makima/frontend/src/main.tsx +++ b/makima/frontend/src/main.tsx @@ -12,6 +12,7 @@ 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 WorkflowPage from "./routes/workflow"; import MeshPage from "./routes/mesh"; import HistoryPage from "./routes/history"; @@ -72,6 +73,22 @@ createRoot(document.getElementById("root")!).render( } /> <Route + path="/chains" + element={ + <ProtectedRoute> + <ChainsPage /> + </ProtectedRoute> + } + /> + <Route + path="/chains/:id" + element={ + <ProtectedRoute> + <ChainsPage /> + </ProtectedRoute> + } + /> + <Route path="/contracts/:id/files/:fileId" element={ <ProtectedRoute> diff --git a/makima/frontend/src/routes/chains.tsx b/makima/frontend/src/routes/chains.tsx new file mode 100644 index 0000000..f01d5a6 --- /dev/null +++ b/makima/frontend/src/routes/chains.tsx @@ -0,0 +1,283 @@ +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, +} 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) => { + const data: CreateChainRequest = { + name: name.trim(), + description: description.trim() || undefined, + }; + + try { + const result = await createNewChain(data); + if (result) { + setIsCreating(false); + navigate(`/chains/${result.chain.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) => void; + onCancel: () => void; +} + +function CreateChainModal({ onSubmit, onCancel }: CreateChainModalProps) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + const handleSubmit = () => { + if (name.trim()) { + onSubmit(name.trim(), description.trim()); + } + }; + + 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"> + 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={3} + 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> + + <p className="font-mono text-xs text-[#8b949e]"> + A chain links multiple contracts together in a directed acyclic graph (DAG). + Contracts can depend on each other, and dependent contracts start automatically + when their 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> + ); +} |
