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