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.
This commit is contained in:
Adam Moussa 2026-06-23 21:14:04 -04:00
parent 7d53d6b1f3
commit 34c5f1d75e
11 changed files with 1513 additions and 38 deletions

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,7 @@
"@dagrejs/dagre": "1.1.4",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-markdown": "9.0.1",
"reactflow": "11.11.4"
},
"devDependencies": {

View file

@ -89,6 +89,22 @@ export default function App() {
return new Set(detail.timeline.map((s) => s.to_phase));
}, [selectedId, detail]);
// Loop-back edges the selected task actually traversed: a loopback src->dst is
// "looped" if dst appears in the timeline AFTER src (a real back-step, since
// dst is earlier in the spine). These — and only these — get a drawn arc.
const loopedEdges = useMemo(() => {
if (!selectedId || !detail?.ok || !topology) return null;
const order = detail.timeline.map((s) => s.to_phase);
const looped = new Set<string>();
for (const e of topology.edges) {
if (e.kind !== "loopback") continue;
const i = order.indexOf(e.from);
if (i === -1) continue;
if (order.indexOf(e.to, i + 1) !== -1) looped.add(`${e.from}->${e.to}`);
}
return looped;
}, [selectedId, detail, topology]);
return (
<div className="app">
<TopBar
@ -111,6 +127,7 @@ export default function App() {
topology={topology}
state={state}
pathNodeIds={pathNodeIds}
loopedEdges={loopedEdges}
onNodeClick={toggleNodeFilter}
/>
) : (

View file

@ -6,7 +6,7 @@ import type { RFData } from "../layout";
// live task count; gated (P3/inert) nodes render dimmed with a tag. ARIA role +
// label keep the map reachable for screen readers / keyboard users.
function MapNodeImpl({ data }: NodeProps<RFData>) {
const { meta, state, count, dimmed, onPath } = data;
const { meta, state, count, dimmed, onPath, loopBackTo } = data;
const cls = [
"node",
`state-${state}`,
@ -16,6 +16,7 @@ function MapNodeImpl({ data }: NodeProps<RFData>) {
]
.filter(Boolean)
.join(" ");
const canLoop = loopBackTo.length > 0;
return (
<div
@ -32,6 +33,15 @@ function MapNodeImpl({ data }: NodeProps<RFData>) {
⛋
</span>
)}
{canLoop && (
<span
className="loop-chip"
title={`can send work back to ${loopBackTo.join(", ")}`}
aria-label={`can send work back to ${loopBackTo.join(", ")}`}
>
↺
</span>
)}
</div>
<div className="node-agent">{meta.agent}</div>
{count > 0 && (

View file

@ -0,0 +1,15 @@
import ReactMarkdown from "react-markdown";
// Safe markdown renderer for agent-authored text (Q&A answers, review findings,
// plan summaries). react-markdown builds a React element tree and does NOT render
// raw HTML by default — we deliberately pass NO rehype-raw plugin, so any HTML in
// the (semi-trusted) agent text is escaped to text, never executed. Links are
// rendered but constrained to safe protocols by react-markdown's default
// urlTransform. Keeps the XSS-safe posture established for the dashboard.
export function Markdown({ children }: { children: string }) {
return (
<div className="md">
<ReactMarkdown>{children}</ReactMarkdown>
</div>
);
}

View file

@ -16,16 +16,24 @@ interface Props {
topology: Topology;
state: StateResponse | null;
pathNodeIds: Set<string> | null;
loopedEdges: Set<string> | null;
onNodeClick: (nodeId: string) => void;
}
// The center pipeline map. Nodes are dagre-laid from the topology; live state
// colors them; clicking a node filters the task list. Multiple trees (processes
// off intake) lay out as separate lanes automatically.
export function PipelineMap({ topology, state, pathNodeIds, onNodeClick }: Props) {
// off intake) lay out as separate lanes automatically. Loop-back arcs appear
// only for a selected task that actually looped (loopedEdges).
export function PipelineMap({
topology,
state,
pathNodeIds,
loopedEdges,
onNodeClick,
}: Props) {
const { nodes, edges } = useMemo(
() => layout(topology, state?.nodes ?? {}, pathNodeIds),
[topology, state, pathNodeIds],
() => layout(topology, state?.nodes ?? {}, pathNodeIds, loopedEdges),
[topology, state, pathNodeIds, loopedEdges],
);
const handleNodeClick: NodeMouseHandler = (_evt, node) => onNodeClick(node.id);

View file

@ -50,4 +50,63 @@ describe("TaskDrawer", () => {
);
expect(container).toBeEmptyDOMElement();
});
it("renders review findings as markdown (bold, not raw **)", () => {
const d: TaskDetail = {
...DETAIL,
review_verdicts: [
{
verdict: "request_changes",
round_index: 1,
outcome: "loop_back",
findings: "**Missing phase** and\n1. run the tests",
},
],
};
render(<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />);
expect(screen.getByText("Missing phase").tagName).toBe("STRONG");
expect(screen.getByText("run the tests").closest("li")).not.toBeNull();
expect(screen.getByText("request changes")).toBeInTheDocument();
});
it("renders the plan as summary + phase/step lists", () => {
const d: TaskDetail = {
...DETAIL,
plan: {
summary: "Add a smoke test",
phases: [{ name: "Recon", steps: ["read conftest", "read test_graph"] }],
},
};
render(<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />);
expect(screen.getByText("Recon")).toBeInTheDocument();
expect(screen.getByText("read conftest").closest("li")).not.toBeNull();
});
it("renders both string and dict Q&A entries", () => {
const d: TaskDetail = {
...DETAIL,
qa_history: ["plain answer text", { turn: 1, answer: "dict answer text" }],
};
render(<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />);
expect(screen.getByText("plain answer text")).toBeInTheDocument();
expect(screen.getByText("dict answer text")).toBeInTheDocument();
});
it("does NOT render raw HTML embedded in agent text (no XSS)", () => {
const d: TaskDetail = {
...DETAIL,
review_verdicts: [
{
verdict: "approve",
round_index: 1,
outcome: "build",
findings: 'before <img src=x onerror="alert(1)"> after',
},
],
};
const { container } = render(
<TaskDrawer detail={d} loading={false} error={null} onClose={() => {}} />,
);
expect(container.querySelector("img")).toBeNull();
});
});

View file

@ -1,4 +1,5 @@
import type { TaskDetail } from "../api";
import { Markdown } from "./Markdown";
interface Props {
detail: TaskDetail | null;
@ -83,19 +84,19 @@ export function TaskDrawer({ detail, loading, error, onClose }: Props) {
{detail.qa_history.length > 0 && (
<details className="drawer-section" open>
<summary>Q&amp;A ({detail.qa_history.length})</summary>
<pre className="json">{JSON.stringify(detail.qa_history, null, 2)}</pre>
<QASection entries={detail.qa_history} />
</details>
)}
{detail.review_verdicts.length > 0 && (
<details className="drawer-section">
<summary>Review verdicts ({detail.review_verdicts.length})</summary>
<pre className="json">{JSON.stringify(detail.review_verdicts, null, 2)}</pre>
<VerdictsSection entries={detail.review_verdicts} />
</details>
)}
{detail.plan && (
<details className="drawer-section">
<summary>Plan</summary>
<pre className="json">{JSON.stringify(detail.plan, null, 2)}</pre>
<PlanSection plan={detail.plan} />
</details>
)}
</>
@ -107,3 +108,113 @@ export function TaskDrawer({ detail, loading, error, onClose }: Props) {
</section>
);
}
function asText(v: unknown): string {
return typeof v === "string" ? v : v == null ? "" : JSON.stringify(v, null, 2);
}
// Q&A: each entry is the answer text for a turn (a string), or an older
// {turn, question_id, answer} dict. Render each as a turn card with the answer
// as markdown so numbered sub-answers / bold render readably.
function QASection({ entries }: { entries: unknown[] }) {
return (
<div className="qa">
{entries.map((entry, i) => {
const obj =
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : null;
const turn = obj && "turn" in obj ? Number(obj.turn) : i;
const answer = obj && "answer" in obj ? asText(obj.answer) : asText(entry);
return (
<div className="qa-card" key={i}>
<div className="qa-turn">Turn {turn + 1}</div>
<Markdown>{answer}</Markdown>
</div>
);
})}
</div>
);
}
// Review verdicts: each {verdict, round_index, outcome, findings} → a card with a
// verdict badge + round + outcome header and the findings rendered as markdown.
function VerdictsSection({ entries }: { entries: unknown[] }) {
return (
<div className="verdicts">
{entries.map((entry, i) => {
const v =
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : {};
const verdict = asText(v.verdict) || "verdict";
const round = "round_index" in v ? Number(v.round_index) : i + 1;
const outcome = asText(v.outcome);
const findings = asText(v.findings);
const cls = verdict.replace(/[^a-z_]/gi, "_").toLowerCase();
return (
<div className="verdict-card" key={i}>
<div className="verdict-head">
<span className="verdict-round">Round {round}</span>
<span className={`badge ${cls}`}>{verdict.replace(/_/g, " ")}</span>
{outcome && <span className="verdict-outcome">{outcome.replace(/_/g, " ")}</span>}
</div>
{findings && <Markdown>{findings}</Markdown>}
</div>
);
})}
</div>
);
}
// Plan: {summary, phases:[{name, steps:[...]}]} → summary (markdown) + a phase
// list with nested steps. Any extra scalar keys render as a small label/value
// list; an unrecognized shape falls back to a collapsed JSON view.
function PlanSection({ plan }: { plan: Record<string, unknown> }) {
const summary = asText(plan.summary);
const phases = Array.isArray(plan.phases) ? plan.phases : null;
const extra = Object.entries(plan).filter(
([k, val]) =>
k !== "summary" && k !== "phases" && (typeof val !== "object" || val === null),
);
if (!summary && !phases) {
return (
<pre className="json">{JSON.stringify(plan, null, 2)}</pre>
);
}
return (
<div className="plan">
{summary && <Markdown>{summary}</Markdown>}
{phases && (
<ol className="plan-phases">
{phases.map((p, i) => {
const ph =
p && typeof p === "object" ? (p as Record<string, unknown>) : {};
const name = asText(ph.name) || `Phase ${i + 1}`;
const steps = Array.isArray(ph.steps) ? ph.steps : [];
return (
<li className="plan-phase" key={i}>
<div className="plan-phase-name">{name}</div>
{steps.length > 0 && (
<ul className="plan-steps">
{steps.map((s, j) => (
<li key={j}>{asText(s)}</li>
))}
</ul>
)}
</li>
);
})}
</ol>
)}
{extra.length > 0 && (
<dl className="plan-extra">
{extra.map(([k, val]) => (
<div className="plan-extra-row" key={k}>
<dt>{k}</dt>
<dd>{asText(val)}</dd>
</div>
))}
</dl>
)}
</div>
);
}

View file

@ -22,24 +22,41 @@ const TOPO: Topology = {
};
describe("layout", () => {
it("produces a laid-out node per topology node", () => {
it("renders only forward edges by default (no loop-back circles)", () => {
const { nodes, edges } = layout(TOPO, {}, null);
expect(nodes).toHaveLength(4);
expect(edges).toHaveLength(4);
// every node has a numeric position assigned by dagre
// 3 forward (spine) edges; the review->plan loopback is NOT drawn by default.
expect(edges).toHaveLength(3);
expect(edges.find((e) => e.id === "review->plan")).toBeUndefined();
for (const n of nodes) {
expect(typeof n.position.x).toBe("number");
expect(typeof n.position.y).toBe("number");
}
});
it("populates loopBackTo on the loop-back source node (drives the ↺ chip)", () => {
const { nodes } = layout(TOPO, {}, null);
expect(nodes.find((n) => n.id === "review")!.data.loopBackTo).toEqual(["plan"]);
expect(nodes.find((n) => n.id === "plan")!.data.loopBackTo).toEqual([]);
});
it("draws a loop-back arc ONLY when the selected task looped it", () => {
const looped = new Set(["review->plan"]);
const { edges } = layout(TOPO, {}, new Set(["plan", "review"]), looped);
const loop = edges.find((e) => e.id === "review->plan");
expect(loop).toBeDefined();
expect(loop!.style?.strokeDasharray).toBeTruthy();
// A different looped set leaves it undrawn.
const { edges: none } = layout(TOPO, {}, new Set(["plan"]), new Set());
expect(none.find((e) => e.id === "review->plan")).toBeUndefined();
});
it("applies live state + count to the matching node", () => {
const { nodes } = layout(TOPO, { clarify: { state: "awaiting_human", count: 2 } }, null);
const clarify = nodes.find((n) => n.id === "clarify")!;
expect(clarify.data.state).toBe("awaiting_human");
expect(clarify.data.count).toBe(2);
const plan = nodes.find((n) => n.id === "plan")!;
expect(plan.data.state).toBe("idle");
expect(nodes.find((n) => n.id === "plan")!.data.state).toBe("idle");
});
it("marks the selected task's path and dims the rest", () => {
@ -50,10 +67,4 @@ describe("layout", () => {
expect(nodes.find((n) => n.id === "review")!.data.onPath).toBe(false);
expect(nodes.find((n) => n.id === "review")!.data.dimmed).toBe(true);
});
it("styles loopback edges distinctly (dashed)", () => {
const { edges } = layout(TOPO, {}, null);
const loop = edges.find((e) => e.id === "review->plan")!;
expect(loop.style?.strokeDasharray).toBeTruthy();
});
});

View file

@ -13,24 +13,45 @@ export interface RFData {
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) g.setEdge(e.from, e.to);
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];
@ -45,27 +66,42 @@ export function layout(
count: live?.count ?? 0,
dimmed: pathNodeIds != null && !onPath,
onPath,
loopBackTo: loopBackTo[meta.id] ?? [],
},
};
});
const edges: Edge[] = topo.edges.map((e) => {
const loop = e.kind === "loopback";
return {
id: `${e.from}->${e.to}`,
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: loop ? "#7a5cff" : e.kind === "branch" ? "#3f7fd8" : "#39414f",
stroke: e.kind === "branch" ? "#3f7fd8" : "#39414f",
strokeWidth: 1.5,
strokeDasharray: loop ? "5 4" : undefined,
},
label: loop ? "↺" : undefined,
labelStyle: { fill: "#9b8bff", fontWeight: 700 },
type: "smoothstep",
};
});
});
}
return { nodes, edges };
}

View file

@ -176,3 +176,52 @@ body {
.json { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: .6rem; font-size: .72rem; overflow-x: auto; max-height: 16rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* --- Loop-back affordance on a node ------------------------------------- */
.loop-chip { margin-left: .35rem; color: var(--accent); font-weight: 700;
font-size: .9rem; cursor: help; }
.badge.request_changes { background: var(--park-bg); color: var(--park-fg); }
.badge.approve, .badge.approved, .badge.pass { background: var(--ok-bg); color: var(--ok-fg); }
/* --- Markdown content (Q&A / verdicts / plan) --------------------------- */
.md { font-size: .82rem; color: var(--text); overflow-wrap: anywhere; }
.md > :first-child { margin-top: 0; }
.md > :last-child { margin-bottom: 0; }
.md p { margin: .4rem 0; }
.md ul, .md ol { margin: .4rem 0; padding-left: 1.2rem; }
.md li { margin: .15rem 0; }
.md strong { color: #fff; font-weight: 700; }
.md h1, .md h2, .md h3, .md h4 { font-size: .9rem; margin: .6rem 0 .3rem;
color: #9fb4d6; }
.md code { background: var(--bg); border: 1px solid var(--border); border-radius: 4px;
padding: 0 .25rem; font-size: .92em;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.md pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
padding: .5rem; overflow-x: auto; }
.md pre code { border: 0; padding: 0; background: none; }
.md a { color: var(--done-fg); }
.md blockquote { margin: .4rem 0; padding-left: .7rem; border-left: 2px solid var(--border-2);
color: var(--text-dim); }
/* --- Q&A cards --------------------------------------------------------- */
.qa-card, .verdict-card { background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; padding: .55rem .65rem; margin-bottom: .6rem; }
.qa-turn { font-size: .72rem; font-weight: 700; color: var(--text-dim);
text-transform: uppercase; letter-spacing: .04em; margin-bottom: .3rem; }
/* --- Verdict cards ----------------------------------------------------- */
.verdict-head { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
margin-bottom: .35rem; }
.verdict-round { font-size: .72rem; font-weight: 700; color: var(--text-dim); }
.verdict-outcome { font-size: .72rem; color: var(--text-dim); }
/* --- Plan -------------------------------------------------------------- */
.plan-phases { margin: .5rem 0 0; padding-left: 1.1rem; }
.plan-phase { margin-bottom: .5rem; }
.plan-phase-name { font-weight: 700; font-size: .84rem; }
.plan-steps { margin: .2rem 0 0; padding-left: 1.1rem; }
.plan-steps li { font-size: .8rem; margin: .15rem 0; }
.plan-extra { margin: .6rem 0 0; font-size: .78rem; }
.plan-extra-row { display: flex; gap: .5rem; }
.plan-extra dt { color: var(--text-dim); min-width: 5rem; }
.plan-extra dd { margin: 0; }