From cf0a25af1d2834bfe6c5ea892ce5769936e5a673 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 3 Feb 2026 22:01:29 +0000 Subject: Add makima chain mechanism --- makima/frontend/src/components/NavStrip.tsx | 1 + .../frontend/src/components/chains/ChainEditor.tsx | 468 +++++++++++++++++++++ .../frontend/src/components/chains/ChainList.tsx | 205 +++++++++ 3 files changed, 674 insertions(+) create mode 100644 makima/frontend/src/components/chains/ChainEditor.tsx create mode 100644 makima/frontend/src/components/chains/ChainList.tsx (limited to 'makima/frontend/src/components') 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(null); + const [selectedNode, setSelectedNode] = useState(null); + const [hoveredNode, setHoveredNode] = useState(null); + + // Use positions from graph nodes directly (x, y from server) + const nodePositions = useMemo(() => { + if (!graph?.nodes) return new Map(); + + const positions = new Map(); + for (const node of graph.nodes) { + positions.set(node.contractId, { + x: CANVAS_PADDING + (node.x || 0) * (NODE_WIDTH + 60), + y: CANVAS_PADDING + (node.y || 0) * (NODE_HEIGHT + 40), + }); + } + 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 ( +
+ {/* Header */} +
+
+
+ +
+

{chain.chain.name}

+ {chain.chain.description && ( +

{chain.chain.description}

+ )} +
+
+
+ + {chain.chain.status} + + +
+
+
+ + {/* Main content */} +
+ {/* DAG Canvas */} +
+ {loading ? ( +
+

Loading graph...

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

+ No contracts in this chain yet +

+

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

+
+
+ ) : ( +
+ {/* SVG layer for edges */} + + + + + + + {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 ( + + ); + })} + + + {/* 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 ( +
handleNodeClick(node.contractId)} + onDoubleClick={() => handleNodeDoubleClick(node.contractId)} + onMouseEnter={() => setHoveredNode(node.contractId)} + onMouseLeave={() => setHoveredNode(null)} + className={`absolute cursor-pointer transition-all duration-150 ${ + isSelected ? "ring-2 ring-[#75aafc] ring-offset-2 ring-offset-[#050d18]" : "" + }`} + style={{ + left: pos.x, + top: pos.y, + width: NODE_WIDTH, + height: NODE_HEIGHT, + transform: isHovered ? "scale(1.02)" : "scale(1)", + }} + > +
+ {/* Status indicator bar */} +
+ {/* Content */} +
+
+ + {node.name} + + +
+
+ + {node.status} + + {node.phase && ( + + {node.phase} + + )} +
+
+
+
+ ); + })} +
+ )} +
+ + {/* Detail panel */} + {selectedContract && ( + setSelectedNode(null)} + onSelectContract={setSelectedNode} + onOpenContract={onContractClick} + /> + )} +
+ + {/* Footer with stats */} +
+
+ {chain.contracts.length} contracts + + {chain.contracts.filter((c) => c.contractStatus === "completed").length} completed + + + {chain.contracts.filter((c) => c.contractStatus === "active").length} active + + + Double-click node to open contract +
+
+
+ ); +} + +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 ( +
+
+
+

+ Contract Details +

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

+ {contract.contractName} +

+
+ + {/* Status */} +
+ + + {contract.contractStatus} + +
+ + {/* Phase */} +
+ + + {contract.contractPhase} + +
+ + {/* Dependencies */} + {contract.dependsOn && contract.dependsOn.length > 0 && ( +
+ +
+ {contract.dependsOn.map((depId) => { + const dep = allContracts.find((c) => c.contractId === depId); + return ( + + ); + })} +
+
+ )} + + {/* Order Index */} +
+ +

+ {contract.orderIndex} +

+
+ + {/* Created */} +
+ +

+ {new Date(contract.createdAt).toLocaleString()} +

+
+ + {/* Actions */} +
+ +
+
+
+ ); +} + +function ChainIcon({ className }: { className?: string }) { + return ( + + + + + ); +} 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("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 ( + + + + ); + case "completed": + return ( + + + + ); + case "archived": + return ( + + + + + + ); + default: + return null; + } + }; + + return ( +
+ {/* Header */} +
+
+

Chains

+ +
+ + {/* Status filter */} +
+ {(["all", "active", "completed", "archived"] as const).map((status) => ( + + ))} +
+
+ + {/* Chain list */} +
+ {loading ? ( +
+

Loading chains...

+
+ ) : filteredChains.length === 0 ? ( +
+

+ {statusFilter === "all" ? "No chains yet" : `No ${statusFilter} chains`} +

+
+ ) : ( +
+ {filteredChains.map((chain) => ( +
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)]" + }`} + > +
+ + {chain.name} + + + {getStatusIcon(chain.status)} + {chain.status} + +
+ {chain.description && ( +

+ {chain.description} +

+ )} +
+ {chain.contractCount} contracts + + {new Date(chain.updatedAt).toLocaleDateString()} + +
+
+ ))} +
+ )} +
+ + {/* Context menu */} + {contextMenu && ( +
+ + {contextMenu.chain.status !== "archived" && ( + + )} +
+ )} +
+ ); +} -- cgit v1.2.3 From 78087b37d25ba0b0f955c0f8a13d73f3014f707e Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 3 Feb 2026 22:29:44 +0000 Subject: Reorganize makima navbar --- makima/frontend/src/components/NavStrip.tsx | 2 +- .../frontend/src/components/chains/ChainEditor.tsx | 22 ++- .../frontend/src/components/chains/ChainList.tsx | 187 ++++++++++----------- 3 files changed, 97 insertions(+), 114 deletions(-) (limited to 'makima/frontend/src/components') diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx index 5937982..4f6cf32 100644 --- a/makima/frontend/src/components/NavStrip.tsx +++ b/makima/frontend/src/components/NavStrip.tsx @@ -10,8 +10,8 @@ interface NavLink { const NAV_LINKS: NavLink[] = [ { label: "Listen", href: "/listen" }, - { label: "Contracts", href: "/contracts", 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 }, { label: "History", href: "/history", requiresAuth: true }, diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx index 9077d19..92bd496 100644 --- a/makima/frontend/src/components/chains/ChainEditor.tsx +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -5,6 +5,12 @@ import type { ChainContractDetail, } from "../../lib/api"; +const statusColors: Record = { + active: "text-green-400", + completed: "text-blue-400", + archived: "text-[#555]", +}; + interface ChainEditorProps { chain: ChainWithContracts; graph: ChainGraphResponse | null; @@ -116,12 +122,8 @@ export function ChainEditor({
{chain.chain.status} @@ -371,12 +373,8 @@ function ContractDetailPanel({ Status {contract.contractStatus} diff --git a/makima/frontend/src/components/chains/ChainList.tsx b/makima/frontend/src/components/chains/ChainList.tsx index eda79d7..befccd2 100644 --- a/makima/frontend/src/components/chains/ChainList.tsx +++ b/makima/frontend/src/components/chains/ChainList.tsx @@ -10,6 +10,12 @@ interface ChainListProps { onArchive: (chain: ChainSummary) => void; } +const statusColors: Record = { + active: "text-green-400", + completed: "text-blue-400", + archived: "text-[#555]", +}; + export function ChainList({ chains, loading, @@ -18,101 +24,74 @@ export function ChainList({ selectedId, onArchive, }: ChainListProps) { - const [statusFilter, setStatusFilter] = useState("all"); - const [contextMenu, setContextMenu] = useState<{ - chain: ChainSummary; - x: number; - y: number; - } | null>(null); + const [filter, setFilter] = useState("all"); + const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); + const [contextMenuChain, setContextMenuChain] = useState(null); - const filteredChains = chains.filter((chain) => - statusFilter === "all" ? true : chain.status === statusFilter - ); + const filteredChains = + filter === "all" + ? chains + : chains.filter((c) => c.status === filter); const handleContextMenu = useCallback( (e: React.MouseEvent, chain: ChainSummary) => { e.preventDefault(); - setContextMenu({ chain, x: e.clientX, y: e.clientY }); + setContextMenuPosition({ x: e.clientX, y: e.clientY }); + setContextMenuChain(chain); }, [] ); const closeContextMenu = useCallback(() => { - setContextMenu(null); + setContextMenuPosition(null); + setContextMenuChain(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"; + if (contextMenuChain) { + onArchive(contextMenuChain); + closeContextMenu(); } - }; + }, [contextMenuChain, onArchive, closeContextMenu]); - const getStatusIcon = (status: ChainStatus) => { - switch (status) { - case "active": - return ( - - - - ); - case "completed": - return ( - - - - ); - case "archived": - return ( - - - - - - ); - default: - return null; - } - }; + if (loading) { + return ( +
+
Loading...
+
+ ); + } return (
{/* Header */} -
+
-

Chains

+

+ Chains +

- {/* Status filter */} + {/* Filter tabs */}
{(["all", "active", "completed", "archived"] as const).map((status) => ( @@ -122,78 +101,84 @@ export function ChainList({ {/* Chain list */}
- {loading ? ( -
-

Loading chains...

-
- ) : filteredChains.length === 0 ? ( -
-

- {statusFilter === "all" ? "No chains yet" : `No ${statusFilter} chains`} + {filteredChains.length === 0 ? ( +

+

+ {filter === "all" + ? "No chains yet" + : `No ${filter} chains`}

) : ( -
+
{filteredChains.map((chain) => ( -
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)]" - }`} + 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)]" + } + `} > -
- +
+

{chain.name} - +

- {getStatusIcon(chain.status)} {chain.status}
+ {chain.description && ( -

+

{chain.description}

)} -
+ +
{chain.contractCount} contracts - - {new Date(chain.updatedAt).toLocaleDateString()} - + {chain.completedContractCount} completed + {chain.loopEnabled && ( + + loop {chain.loopCurrentIteration || 0}/{chain.loopMaxIterations || "∞"} + + )}
-
+ ))}
)}
- {/* Context menu */} - {contextMenu && ( + {/* Context Menu */} + {contextMenuPosition && contextMenuChain && (
e.stopPropagation()} > - {contextMenu.chain.status !== "archived" && ( + {contextMenuChain.status !== "archived" && ( -- cgit v1.2.3 From dcbf8c834626870a43b633b099f409d69d4f9b87 Mon Sep 17 00:00:00 2001 From: soryu Date: Tue, 3 Feb 2026 22:35:19 +0000 Subject: Fix: FE type error --- makima/frontend/src/components/chains/ChainEditor.tsx | 10 +++++----- makima/frontend/src/lib/api.ts | 5 ++--- makima/frontend/src/routes/chains.tsx | 2 +- 3 files changed, 8 insertions(+), 9 deletions(-) (limited to 'makima/frontend/src/components') diff --git a/makima/frontend/src/components/chains/ChainEditor.tsx b/makima/frontend/src/components/chains/ChainEditor.tsx index 92bd496..0dcabe1 100644 --- a/makima/frontend/src/components/chains/ChainEditor.tsx +++ b/makima/frontend/src/components/chains/ChainEditor.tsx @@ -114,19 +114,19 @@ export function ChainEditor({ Back
-

{chain.chain.name}

- {chain.chain.description && ( -

{chain.chain.description}

+

{chain.name}

+ {chain.description && ( +

{chain.description}

)}
- {chain.chain.status} + {chain.status}