import React, { useEffect, useState, useCallback, useRef } from "react";
import ReactFlow, { Background, Controls, MiniMap } from "reactflow";
import "reactflow/dist/style.css";
/* ------------------------------------------------------------------ */
/* Node type → colour mapping */
/* ------------------------------------------------------------------ */
const NODE_COLOURS = {
agent: { border: "#ff7a3c", bg: "#20141a" },
router: { border: "#6c8cff", bg: "#141828" },
tool: { border: "#3a3b4d", bg: "#141821" },
tool_group: { border: "#3a3b4d", bg: "#141821" },
user: { border: "#4caf88", bg: "#14211a" },
output: { border: "#9c6cff", bg: "#1a1428" },
};
const DEFAULT_COLOUR = { border: "#3a3b4d", bg: "#141821" };
function colourFor(type) {
return NODE_COLOURS[type] || DEFAULT_COLOUR;
}
const STYLE_COLOURS = {
single_task: "#6c8cff",
react_loop: "#ff7a3c",
crew_pipeline: "#4caf88",
};
const STYLE_LABELS = {
single_task: "Dispatch",
react_loop: "ReAct Loop",
crew_pipeline: "Pipeline",
};
/* ------------------------------------------------------------------ */
/* TopologyCard — single clickable topology card */
/* ------------------------------------------------------------------ */
function TopologyCard({ topology, isActive, onClick }) {
const styleColor = STYLE_COLOURS[topology.execution_style] || "#9a9bb0";
const agentCount = topology.agents_used?.length || 0;
return (
);
}
const cardStyles = {
card: {
display: "flex",
flexDirection: "column",
gap: 4,
padding: "10px 12px",
borderRadius: 8,
border: "1px solid #1e1f30",
cursor: "pointer",
textAlign: "left",
minWidth: 170,
maxWidth: 200,
flexShrink: 0,
transition: "border-color 0.2s, background-color 0.2s",
},
cardTop: {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 6,
},
icon: {
fontSize: 18,
},
styleBadge: {
fontSize: 9,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.05em",
padding: "1px 6px",
borderRadius: 4,
border: "1px solid",
},
name: {
fontSize: 12,
fontWeight: 600,
lineHeight: 1.3,
},
desc: {
fontSize: 10,
color: "#71717A",
lineHeight: 1.3,
overflow: "hidden",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
},
agentCount: {
fontSize: 9,
color: "#52525B",
fontWeight: 600,
marginTop: 2,
},
};
/* ------------------------------------------------------------------ */
/* TopologyPanel — card grid grouped by category */
/* ------------------------------------------------------------------ */
function TopologyPanel({
topologies,
activeTopology,
autoMode,
autoResult,
onSelect,
onToggleAuto,
}) {
const systems = topologies.filter((t) => t.category === "system");
const pipelines = topologies.filter((t) => t.category === "pipeline");
return (
{/* Auto-detect toggle */}
{autoMode && autoResult && (
Detected: {autoResult.icon} {autoResult.name}
{autoResult.confidence != null && (
{" "}({Math.round(autoResult.confidence * 100)}%)
)}
)}
{/* System architectures */}
System Architectures
{systems.map((t) => (
onSelect(t.id)}
/>
))}
{/* Task pipelines */}
Task Pipelines
{pipelines.map((t) => (
onSelect(t.id)}
/>
))}
);
}
const panelStyles = {
root: {
padding: "8px 16px 12px",
borderBottom: "1px solid #1e1f30",
backgroundColor: "#08090e",
},
autoRow: {
display: "flex",
alignItems: "center",
gap: 10,
marginBottom: 10,
},
autoBtn: {
display: "flex",
alignItems: "center",
gap: 5,
padding: "4px 10px",
borderRadius: 6,
border: "1px solid #27272A",
background: "transparent",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
transition: "border-color 0.15s, color 0.15s",
},
autoHint: {
fontSize: 11,
color: "#9a9bb0",
},
section: {
marginBottom: 8,
},
sectionLabel: {
fontSize: 9,
fontWeight: 700,
textTransform: "uppercase",
letterSpacing: "0.08em",
color: "#52525B",
marginBottom: 6,
},
cardRow: {
display: "flex",
gap: 8,
overflowX: "auto",
scrollbarWidth: "none",
paddingBottom: 2,
},
};
/* ------------------------------------------------------------------ */
/* Main FlowViewer component */
/* ------------------------------------------------------------------ */
export default function FlowViewer() {
const [nodes, setNodes] = useState([]);
const [edges, setEdges] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Topology state
const [topologies, setTopologies] = useState([]);
const [activeTopology, setActiveTopology] = useState(null);
const [topologyMeta, setTopologyMeta] = useState(null);
// Auto-detection state
const [autoMode, setAutoMode] = useState(false);
const [autoResult, setAutoResult] = useState(null);
const [autoTestMessage, setAutoTestMessage] = useState("");
const initialLoadDone = useRef(false);
/* ---------- Load topology list on mount ---------- */
useEffect(() => {
(async () => {
try {
const [topoRes, prefRes] = await Promise.all([
fetch("/api/flow/topologies"),
fetch("/api/settings/topology"),
]);
if (topoRes.ok) {
const data = await topoRes.json();
setTopologies(data);
}
if (prefRes.ok) {
const { topology } = await prefRes.json();
if (topology) {
setActiveTopology(topology);
}
}
} catch (e) {
console.warn("Failed to load topologies:", e);
}
initialLoadDone.current = true;
})();
}, []);
/* ---------- Load graph when topology changes ---------- */
const loadGraph = useCallback(async (topologyId) => {
setLoading(true);
setError("");
try {
const url = topologyId
? `/api/flow/current?topology=${encodeURIComponent(topologyId)}`
: "/api/flow/current";
const res = await fetch(url);
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to load flow");
// Track topology metadata from response
if (data.topology_id) {
setTopologyMeta({
id: data.topology_id,
name: data.topology_name,
icon: data.topology_icon,
description: data.topology_description,
execution_style: data.execution_style,
agents_used: topologies.find((t) => t.id === data.topology_id)?.agents_used || [],
});
}
// Build ReactFlow nodes
const RFnodes = data.nodes.map((n, i) => {
const nodeType = n.type || "default";
const colour = colourFor(nodeType);
const d = n.data || {};
const label = d.label || n.label || n.id;
const description = d.description || n.description || "";
const model = d.model;
const mode = d.mode;
const pos = n.position || {
x: 50 + (i % 3) * 250,
y: 50 + Math.floor(i / 3) * 180,
};
return {
id: n.id,
data: {
label: (
{label}
{model && (
{model}
)}
{mode && (
{mode}
)}
{description}
),
},
position: pos,
type: "default",
style: {
borderRadius: 12,
padding: "12px 16px",
border: `2px solid ${colour.border}`,
background: colour.bg,
color: "#f5f5f7",
fontSize: 13,
minWidth: 180,
maxWidth: 220,
},
};
});
// Build ReactFlow edges
const RFedges = data.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
label: e.label,
animated: e.animated !== false,
style: { stroke: "#7a7b8e", strokeWidth: 2 },
labelStyle: { fill: "#c3c5dd", fontSize: 11, fontWeight: 500 },
labelBgStyle: { fill: "#101117", fillOpacity: 0.9 },
...(e.type === "bidirectional" && {
markerEnd: { type: "arrowclosed", color: "#7a7b8e" },
markerStart: { type: "arrowclosed", color: "#7a7b8e" },
animated: false,
style: { stroke: "#555670", strokeWidth: 1.5, strokeDasharray: "5 5" },
}),
}));
setNodes(RFnodes);
setEdges(RFedges);
} catch (e) {
console.error(e);
setError(e.message);
} finally {
setLoading(false);
}
}, [topologies]);
// Load graph whenever activeTopology changes
useEffect(() => {
loadGraph(activeTopology);
}, [activeTopology, loadGraph]);
/* ---------- Topology selection handler ---------- */
const handleTopologyChange = useCallback(
async (newTopologyId) => {
setActiveTopology(newTopologyId);
setAutoMode(false); // Manual selection disables auto
// Persist preference (fire-and-forget)
try {
await fetch("/api/settings/topology", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topology: newTopologyId }),
});
} catch (e) {
console.warn("Failed to save topology preference:", e);
}
},
[]
);
/* ---------- Auto-detection ---------- */
const handleToggleAuto = useCallback(() => {
setAutoMode((prev) => !prev);
if (!autoMode) {
setAutoResult(null);
}
}, [autoMode]);
const handleAutoClassify = useCallback(
async (message) => {
if (!message.trim()) return;
try {
const res = await fetch("/api/flow/classify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
});
if (!res.ok) return;
const data = await res.json();
const recommendedId = data.recommended_topology;
const topo = topologies.find((t) => t.id === recommendedId);
setAutoResult({
id: recommendedId,
name: topo?.name || recommendedId,
icon: topo?.icon || "",
confidence: data.confidence,
alternatives: data.alternatives || [],
});
setActiveTopology(recommendedId);
} catch (e) {
console.warn("Auto-classify failed:", e);
}
},
[topologies]
);
// Debounced auto-classify when test message changes
useEffect(() => {
if (!autoMode || !autoTestMessage.trim()) return;
const t = setTimeout(() => handleAutoClassify(autoTestMessage), 500);
return () => clearTimeout(t);
}, [autoTestMessage, autoMode, handleAutoClassify]);
/* ---------- Render ---------- */
const activeStyleColor = STYLE_COLOURS[topologyMeta?.execution_style] || "#9a9bb0";
return (
{/* Header */}
Agent Workflow
Visual view of the multi-agent system that GitPilot uses to
plan and apply changes to your repositories.
{topologyMeta && (
{topologyMeta.icon}
{topologyMeta.name}
{STYLE_LABELS[topologyMeta.execution_style] || topologyMeta.execution_style}
{topologyMeta.agents_used?.length || 0} agents
)}
{loading &&
Loading...}
{/* Topology selector panel */}
{topologies.length > 0 && (
)}
{/* Auto-detection test input (shown when auto mode is on) */}
{autoMode && (
Test auto-detection: type a task description to see which topology is recommended
setAutoTestMessage(e.target.value)}
style={autoInputStyles.input}
/>
{autoResult && autoResult.alternatives?.length > 0 && (
Alternatives:
{autoResult.alternatives.slice(0, 3).map((alt) => {
const altTopo = topologies.find((t) => t.id === alt.id);
return (
);
})}
)}
)}
{/* Description bar */}
{topologyMeta && topologyMeta.description && !autoMode && (
{topologyMeta.icon} {topologyMeta.description}
)}
{/* ReactFlow canvas */}
{error ? (
) : (
{
const border = node.style?.border || "";
if (border.includes("#ff7a3c")) return "#ff7a3c";
if (border.includes("#6c8cff")) return "#6c8cff";
if (border.includes("#4caf88")) return "#4caf88";
if (border.includes("#9c6cff")) return "#9c6cff";
return "#3a3b4d";
}}
maskColor="rgba(0, 0, 0, 0.6)"
/>
)}
);
}
const autoInputStyles = {
wrap: {
padding: "8px 16px 10px",
borderBottom: "1px solid #1e1f30",
backgroundColor: "#0c0d14",
},
label: {
fontSize: 10,
color: "#71717A",
marginBottom: 6,
},
input: {
width: "100%",
padding: "8px 12px",
borderRadius: 6,
border: "1px solid #27272A",
background: "#08090e",
color: "#e0e1f0",
fontSize: 12,
fontFamily: "monospace",
outline: "none",
boxSizing: "border-box",
},
altRow: {
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 6,
flexWrap: "wrap",
},
altBtn: {
padding: "2px 8px",
borderRadius: 4,
border: "1px solid #27272A",
background: "transparent",
color: "#9a9bb0",
fontSize: 10,
cursor: "pointer",
},
};