summaryrefslogtreecommitdiff
path: root/makima/frontend/src
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-03 23:19:40 +0000
committersoryu <soryu@soryu.co>2026-02-03 23:19:40 +0000
commitbfa3af9ef16fd5e255bdb606a99a5ebb535ba7cc (patch)
tree53da855b4ca61a5c0856fc15112daa7a3748c637 /makima/frontend/src
parent1ce281adb89683a5fccfd153706383b14b944f32 (diff)
parentdcbf8c834626870a43b633b099f409d69d4f9b87 (diff)
downloadsoryu-bfa3af9ef16fd5e255bdb606a99a5ebb535ba7cc.tar.gz
soryu-bfa3af9ef16fd5e255bdb606a99a5ebb535ba7cc.zip
fix: Resolve merge conflict in server/mod.rsmakima/discuss-contract-feature
Combine imports from both branches - include both chains and contract_discuss handlers. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'makima/frontend/src')
-rw-r--r--makima/frontend/src/components/NavStrip.tsx1
-rw-r--r--makima/frontend/src/components/chains/ChainEditor.tsx466
-rw-r--r--makima/frontend/src/components/chains/ChainList.tsx190
-rw-r--r--makima/frontend/src/hooks/useChains.ts145
-rw-r--r--makima/frontend/src/lib/api.ts235
-rw-r--r--makima/frontend/src/main.tsx17
-rw-r--r--makima/frontend/src/routes/chains.tsx283
7 files changed, 1337 insertions, 0 deletions
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index fb95c7f..4f6cf32 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -10,6 +10,7 @@ interface NavLink {
const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
+ { 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
new file mode 100644
index 0000000..0dcabe1
--- /dev/null
+++ b/makima/frontend/src/components/chains/ChainEditor.tsx
@@ -0,0 +1,466 @@
+import { useState, useCallback, useMemo, useRef } from "react";
+import type {
+ ChainWithContracts,
+ ChainGraphResponse,
+ ChainContractDetail,
+} from "../../lib/api";
+
+const statusColors: Record<string, string> = {
+ active: "text-green-400",
+ completed: "text-blue-400",
+ archived: "text-[#555]",
+};
+
+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.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>
+ <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={`font-mono text-xs uppercase ${
+ statusColors[contract.contractStatus] || "text-[#555]"
+ }`}
+ >
+ {contract.contractStatus}
+ </span>
+ </div>
+
+ {/* Phase */}
+ <div>
+ <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+ Phase
+ </label>
+ <span className="font-mono text-sm text-[#dbe7ff]">
+ {contract.contractPhase}
+ </span>
+ </div>
+
+ {/* Dependencies */}
+ {contract.dependsOn && contract.dependsOn.length > 0 && (
+ <div>
+ <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+ Depends On
+ </label>
+ <div className="space-y-1">
+ {contract.dependsOn.map((depId) => {
+ const dep = allContracts.find((c) => c.contractId === depId);
+ return (
+ <button
+ key={depId}
+ onClick={() => onSelectContract(depId)}
+ className="block w-full text-left font-mono text-xs text-[#9bc3ff] hover:text-[#dbe7ff] truncate"
+ >
+ {dep?.contractName || depId}
+ </button>
+ );
+ })}
+ </div>
+ </div>
+ )}
+
+ {/* Order Index */}
+ <div>
+ <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+ Order Index
+ </label>
+ <p className="font-mono text-xs text-[#dbe7ff]">
+ {contract.orderIndex}
+ </p>
+ </div>
+
+ {/* Created */}
+ <div>
+ <label className="block font-mono text-[10px] text-[#8b949e] uppercase mb-1">
+ Created
+ </label>
+ <p className="font-mono text-xs text-[#dbe7ff]">
+ {new Date(contract.createdAt).toLocaleString()}
+ </p>
+ </div>
+
+ {/* Actions */}
+ <div className="pt-2 border-t border-[rgba(117,170,252,0.2)]">
+ <button
+ onClick={() => onOpenContract(contract.contractId)}
+ className="w-full px-3 py-2 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors"
+ >
+ Open Contract
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+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..befccd2
--- /dev/null
+++ b/makima/frontend/src/components/chains/ChainList.tsx
@@ -0,0 +1,190 @@
+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> = {
+ 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
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 f507c7a..bdaedf9 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -3002,3 +3002,238 @@ 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 (chain fields are flattened via serde(flatten)) */
+export interface ChainWithContracts extends 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..23484b4
--- /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.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>
+ );
+}