| import React, { useEffect, useState, useCallback, useRef } from "react"; |
| import ReactFlow, { Background, Controls, MiniMap } from "reactflow"; |
| import "reactflow/dist/style.css"; |
|
|
| |
| |
| |
| 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", |
| }; |
|
|
| |
| |
| |
| function TopologyCard({ topology, isActive, onClick }) { |
| const styleColor = STYLE_COLOURS[topology.execution_style] || "#9a9bb0"; |
| const agentCount = topology.agents_used?.length || 0; |
|
|
| return ( |
| <button |
| type="button" |
| onClick={onClick} |
| style={{ |
| ...cardStyles.card, |
| borderColor: isActive ? styleColor : "#1e1f30", |
| backgroundColor: isActive ? `${styleColor}0D` : "#0c0d14", |
| }} |
| > |
| <div style={cardStyles.cardTop}> |
| <span style={cardStyles.icon}>{topology.icon}</span> |
| <span |
| style={{ |
| ...cardStyles.styleBadge, |
| color: styleColor, |
| borderColor: `${styleColor}40`, |
| }} |
| > |
| {STYLE_LABELS[topology.execution_style] || topology.execution_style} |
| </span> |
| </div> |
| <div |
| style={{ |
| ...cardStyles.name, |
| color: isActive ? "#f5f5f7" : "#c3c5dd", |
| }} |
| > |
| {topology.name} |
| </div> |
| <div style={cardStyles.desc}>{topology.description}</div> |
| <div style={cardStyles.agentCount}> |
| {agentCount} agent{agentCount !== 1 ? "s" : ""} |
| </div> |
| </button> |
| ); |
| } |
|
|
| 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, |
| }, |
| }; |
|
|
| |
| |
| |
| 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 ( |
| <div style={panelStyles.root}> |
| {/* Auto-detect toggle */} |
| <div style={panelStyles.autoRow}> |
| <button |
| type="button" |
| onClick={onToggleAuto} |
| style={{ |
| ...panelStyles.autoBtn, |
| borderColor: autoMode ? "#ff7a3c" : "#27272A", |
| color: autoMode ? "#ff7a3c" : "#71717A", |
| backgroundColor: autoMode ? "rgba(255, 122, 60, 0.06)" : "transparent", |
| }} |
| > |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> |
| <circle cx="12" cy="12" r="10" /> |
| <path d="M12 6v6l4 2" /> |
| </svg> |
| Auto |
| </button> |
| {autoMode && autoResult && ( |
| <span style={panelStyles.autoHint}> |
| Detected: {autoResult.icon} {autoResult.name} |
| {autoResult.confidence != null && ( |
| <span style={{ opacity: 0.6 }}> |
| {" "}({Math.round(autoResult.confidence * 100)}%) |
| </span> |
| )} |
| </span> |
| )} |
| </div> |
| |
| {/* System architectures */} |
| <div style={panelStyles.section}> |
| <div style={panelStyles.sectionLabel}>System Architectures</div> |
| <div style={panelStyles.cardRow}> |
| {systems.map((t) => ( |
| <TopologyCard |
| key={t.id} |
| topology={t} |
| isActive={activeTopology === t.id} |
| onClick={() => onSelect(t.id)} |
| /> |
| ))} |
| </div> |
| </div> |
| |
| {/* Task pipelines */} |
| <div style={panelStyles.section}> |
| <div style={panelStyles.sectionLabel}>Task Pipelines</div> |
| <div style={panelStyles.cardRow}> |
| {pipelines.map((t) => ( |
| <TopologyCard |
| key={t.id} |
| topology={t} |
| isActive={activeTopology === t.id} |
| onClick={() => onSelect(t.id)} |
| /> |
| ))} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| 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, |
| }, |
| }; |
|
|
| |
| |
| |
| export default function FlowViewer() { |
| const [nodes, setNodes] = useState([]); |
| const [edges, setEdges] = useState([]); |
| const [loading, setLoading] = useState(false); |
| const [error, setError] = useState(""); |
|
|
| |
| const [topologies, setTopologies] = useState([]); |
| const [activeTopology, setActiveTopology] = useState(null); |
| const [topologyMeta, setTopologyMeta] = useState(null); |
|
|
| |
| const [autoMode, setAutoMode] = useState(false); |
| const [autoResult, setAutoResult] = useState(null); |
| const [autoTestMessage, setAutoTestMessage] = useState(""); |
|
|
| const initialLoadDone = useRef(false); |
|
|
| |
| 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; |
| })(); |
| }, []); |
|
|
| |
| 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"); |
|
|
| |
| 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 || [], |
| }); |
| } |
|
|
| |
| 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: ( |
| <div style={{ textAlign: "center" }}> |
| <div style={{ fontWeight: 600, marginBottom: 2 }}> |
| {label} |
| </div> |
| {model && ( |
| <div style={{ fontSize: 9, color: "#6c8cff", marginBottom: 2, fontFamily: "monospace" }}> |
| {model} |
| </div> |
| )} |
| {mode && ( |
| <div |
| style={{ |
| fontSize: 9, |
| color: mode === "read-only" ? "#4caf88" : mode === "git-ops" ? "#9c6cff" : "#ff7a3c", |
| marginBottom: 2, |
| }} |
| > |
| {mode} |
| </div> |
| )} |
| <div style={{ fontSize: 10, color: "#9a9bb0", maxWidth: 160, lineHeight: 1.3 }}> |
| {description} |
| </div> |
| </div> |
| ), |
| }, |
| 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, |
| }, |
| }; |
| }); |
|
|
| |
| 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]); |
|
|
| |
| useEffect(() => { |
| loadGraph(activeTopology); |
| }, [activeTopology, loadGraph]); |
|
|
| |
| const handleTopologyChange = useCallback( |
| async (newTopologyId) => { |
| setActiveTopology(newTopologyId); |
| setAutoMode(false); |
| |
| 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); |
| } |
| }, |
| [] |
| ); |
|
|
| |
| 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] |
| ); |
|
|
| |
| useEffect(() => { |
| if (!autoMode || !autoTestMessage.trim()) return; |
| const t = setTimeout(() => handleAutoClassify(autoTestMessage), 500); |
| return () => clearTimeout(t); |
| }, [autoTestMessage, autoMode, handleAutoClassify]); |
|
|
| |
| const activeStyleColor = STYLE_COLOURS[topologyMeta?.execution_style] || "#9a9bb0"; |
|
|
| return ( |
| <div className="flow-root"> |
| {/* Header */} |
| <div className="flow-header"> |
| <div> |
| <h1>Agent Workflow</h1> |
| <p> |
| Visual view of the multi-agent system that GitPilot uses to |
| plan and apply changes to your repositories. |
| </p> |
| </div> |
| <div style={{ display: "flex", alignItems: "center", gap: 10 }}> |
| {topologyMeta && ( |
| <div style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12, color: "#9a9bb0" }}> |
| <span style={{ fontSize: 18 }}>{topologyMeta.icon}</span> |
| <span style={{ fontWeight: 600, color: "#e0e1f0" }}>{topologyMeta.name}</span> |
| <span |
| style={{ |
| padding: "2px 8px", |
| borderRadius: 6, |
| border: `1px solid ${activeStyleColor}40`, |
| color: activeStyleColor, |
| fontSize: 10, |
| fontWeight: 700, |
| textTransform: "uppercase", |
| }} |
| > |
| {STYLE_LABELS[topologyMeta.execution_style] || topologyMeta.execution_style} |
| </span> |
| <span>{topologyMeta.agents_used?.length || 0} agents</span> |
| </div> |
| )} |
| {loading && <span style={{ fontSize: 11, color: "#6c8cff" }}>Loading...</span>} |
| </div> |
| </div> |
| |
| {/* Topology selector panel */} |
| {topologies.length > 0 && ( |
| <TopologyPanel |
| topologies={topologies} |
| activeTopology={activeTopology} |
| autoMode={autoMode} |
| autoResult={autoResult} |
| onSelect={handleTopologyChange} |
| onToggleAuto={handleToggleAuto} |
| /> |
| )} |
| |
| {/* Auto-detection test input (shown when auto mode is on) */} |
| {autoMode && ( |
| <div style={autoInputStyles.wrap}> |
| <div style={autoInputStyles.label}> |
| Test auto-detection: type a task description to see which topology is recommended |
| </div> |
| <input |
| type="text" |
| placeholder='e.g. "Fix the 403 error in auth middleware" or "Add a REST endpoint for users"' |
| value={autoTestMessage} |
| onChange={(e) => setAutoTestMessage(e.target.value)} |
| style={autoInputStyles.input} |
| /> |
| {autoResult && autoResult.alternatives?.length > 0 && ( |
| <div style={autoInputStyles.altRow}> |
| <span style={{ color: "#52525B", fontSize: 10 }}>Alternatives:</span> |
| {autoResult.alternatives.slice(0, 3).map((alt) => { |
| const altTopo = topologies.find((t) => t.id === alt.id); |
| return ( |
| <button |
| key={alt.id} |
| type="button" |
| style={autoInputStyles.altBtn} |
| onClick={() => handleTopologyChange(alt.id)} |
| > |
| {altTopo?.icon} {altTopo?.name || alt.id} |
| <span style={{ opacity: 0.5 }}> |
| {alt.confidence != null ? ` ${Math.round(alt.confidence * 100)}%` : ""} |
| </span> |
| </button> |
| ); |
| })} |
| </div> |
| )} |
| </div> |
| )} |
| |
| {/* Description bar */} |
| {topologyMeta && topologyMeta.description && !autoMode && ( |
| <div |
| style={{ |
| padding: "8px 16px", |
| fontSize: 12, |
| color: "#9a9bb0", |
| background: "#0a0b12", |
| borderBottom: "1px solid #1e1f30", |
| }} |
| > |
| {topologyMeta.icon} {topologyMeta.description} |
| </div> |
| )} |
| |
| {/* ReactFlow canvas */} |
| <div className="flow-canvas"> |
| {error ? ( |
| <div className="flow-error"> |
| <div className="error-icon">!!!</div> |
| <div className="error-text">{error}</div> |
| </div> |
| ) : ( |
| <ReactFlow nodes={nodes} edges={edges} fitView> |
| <Background color="#272832" gap={16} /> |
| <MiniMap |
| nodeColor={(node) => { |
| 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)" |
| /> |
| <Controls /> |
| </ReactFlow> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| 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", |
| }, |
| }; |
|
|