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 ? (
!!!
{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", }, };