This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/web/src/layout.ts
Adam Moussa 34c5f1d75e feat(agent-team): cleaner retry-loop display + human-readable task history
Two dashboard-SPA refinements (frontend only):

1. Retry loops (plan<->review, build<->verify) no longer draw a backward arc over
   the forward edge (the 'circular arrows'). Loop-backs are excluded from the
   default render and from the dagre layout; instead the source node shows a small
   ↺ chip ('can send work back to ...'), and the actual return arc is drawn only
   when a selected task ACTUALLY looped it (computed from its timeline), highlighted
   on that task's path.

2. Q&A / Review Verdicts / Plan render human-readably instead of JSON blobs:
   react-markdown (no rehype-raw -> raw HTML escaped, XSS-safe) renders findings/
   answers/summary; verdicts as cards (badge + round + outcome), Q&A as per-turn
   cards (string + dict shapes), plan as summary + phase/step lists.

16 frontend tests pass (incl. loopback default-off/on-when-looped, markdown bold,
and a no-raw-HTML XSS guard); typecheck + build clean.
2026-06-24 12:42:55 -04:00

107 lines
3.6 KiB
TypeScript

// Auto-layout the pipeline graph with dagre, so adding a node in the backend
// graph needs no manual coordinates here — the map redraws itself.
import dagre from "@dagrejs/dagre";
import type { Edge, Node } from "reactflow";
import type { Topology, TopoNode } from "./api";
export const NODE_W = 184;
export const NODE_H = 72;
export interface RFData {
meta: TopoNode;
state: "idle" | "active" | "awaiting_human" | "parked";
count: number;
dimmed: boolean;
onPath: boolean;
// Loop-back targets this node can send work back to (e.g. review -> ["plan"]).
// Drives the small ↺ affordance on the node instead of an always-on back-arc.
loopBackTo: string[];
}
const FORWARD = new Set(["spine", "branch"]);
// Compute laid-out React Flow nodes + edges from the topology and live state.
// `onPath` marks the nodes a selected task has visited (highlighted); `dimmed`
// fades nodes outside the active task's path when one is selected.
//
// Loop-back edges (review->plan, verify->build) are NOT drawn by default — they
// overlapped the forward edge and read as ugly circles. Instead the source node
// shows a ↺ chip (loopBackTo), and an individual loop-back arc is drawn only when
// the selected task actually traversed it (its id is in `loopedEdges`).
export function layout(
topo: Topology,
liveNodes: Record<string, { state: RFData["state"]; count: number }>,
pathNodeIds: Set<string> | null,
loopedEdges: Set<string> | null = null,
): { nodes: Node<RFData>[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setGraph({ rankdir: "LR", nodesep: 36, ranksep: 72, marginx: 24, marginy: 24 });
g.setDefaultEdgeLabel(() => ({}));
// Lay out using ONLY forward edges, so the backward loop-backs don't distort
// the left-to-right ranking.
for (const n of topo.nodes) g.setNode(n.id, { width: NODE_W, height: NODE_H });
for (const e of topo.edges) {
if (FORWARD.has(e.kind)) g.setEdge(e.from, e.to);
}
dagre.layout(g);
// node id -> loop-back targets, from the topology's loopback edges.
const loopBackTo: Record<string, string[]> = {};
for (const e of topo.edges) {
if (e.kind === "loopback") (loopBackTo[e.from] ??= []).push(e.to);
}
const nodes: Node<RFData>[] = topo.nodes.map((meta) => {
const pos = g.node(meta.id);
const live = liveNodes[meta.id];
const onPath = pathNodeIds ? pathNodeIds.has(meta.id) : false;
return {
id: meta.id,
type: "pipeline",
position: { x: pos.x - NODE_W / 2, y: pos.y - NODE_H / 2 },
data: {
meta,
state: live?.state ?? "idle",
count: live?.count ?? 0,
dimmed: pathNodeIds != null && !onPath,
onPath,
loopBackTo: loopBackTo[meta.id] ?? [],
},
};
});
const edges: Edge[] = [];
for (const e of topo.edges) {
const id = `${e.from}->${e.to}`;
if (e.kind === "loopback") {
// Only draw a loop-back arc the selected task actually traversed.
if (!loopedEdges || !loopedEdges.has(id)) continue;
edges.push({
id,
source: e.from,
target: e.to,
animated: true,
style: { stroke: "#7a5cff", strokeWidth: 2, strokeDasharray: "5 4" },
label: e.to === "plan" ? "↺ revised" : "↺ retried",
labelStyle: { fill: "#9b8bff", fontWeight: 700 },
type: "smoothstep",
zIndex: 5,
});
continue;
}
edges.push({
id,
source: e.from,
target: e.to,
animated: false,
style: {
stroke: e.kind === "branch" ? "#3f7fd8" : "#39414f",
strokeWidth: 1.5,
},
type: "smoothstep",
});
}
return { nodes, edges };
}