Merge pull request #59 from Sea-Haven-Industries/feature/agent-team-dashboard-polish
feat(agent-team): cleaner retry-loop display + human-readable task history
This commit is contained in:
commit
0301b8e4e7
11 changed files with 1513 additions and 38 deletions
1174
agent-team/web/package-lock.json
generated
1174
agent-team/web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,7 @@
|
||||||
"@dagrejs/dagre": "1.1.4",
|
"@dagrejs/dagre": "1.1.4",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
|
"react-markdown": "9.0.1",
|
||||||
"reactflow": "11.11.4"
|
"reactflow": "11.11.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,22 @@ export default function App() {
|
||||||
return new Set(detail.timeline.map((s) => s.to_phase));
|
return new Set(detail.timeline.map((s) => s.to_phase));
|
||||||
}, [selectedId, detail]);
|
}, [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 (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<TopBar
|
<TopBar
|
||||||
|
|
@ -111,6 +127,7 @@ export default function App() {
|
||||||
topology={topology}
|
topology={topology}
|
||||||
state={state}
|
state={state}
|
||||||
pathNodeIds={pathNodeIds}
|
pathNodeIds={pathNodeIds}
|
||||||
|
loopedEdges={loopedEdges}
|
||||||
onNodeClick={toggleNodeFilter}
|
onNodeClick={toggleNodeFilter}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type { RFData } from "../layout";
|
||||||
// live task count; gated (P3/inert) nodes render dimmed with a tag. ARIA role +
|
// live task count; gated (P3/inert) nodes render dimmed with a tag. ARIA role +
|
||||||
// label keep the map reachable for screen readers / keyboard users.
|
// label keep the map reachable for screen readers / keyboard users.
|
||||||
function MapNodeImpl({ data }: NodeProps<RFData>) {
|
function MapNodeImpl({ data }: NodeProps<RFData>) {
|
||||||
const { meta, state, count, dimmed, onPath } = data;
|
const { meta, state, count, dimmed, onPath, loopBackTo } = data;
|
||||||
const cls = [
|
const cls = [
|
||||||
"node",
|
"node",
|
||||||
`state-${state}`,
|
`state-${state}`,
|
||||||
|
|
@ -16,6 +16,7 @@ function MapNodeImpl({ data }: NodeProps<RFData>) {
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
const canLoop = loopBackTo.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -32,6 +33,15 @@ function MapNodeImpl({ data }: NodeProps<RFData>) {
|
||||||
⛋
|
⛋
|
||||||
</span>
|
</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>
|
||||||
<div className="node-agent">{meta.agent}</div>
|
<div className="node-agent">{meta.agent}</div>
|
||||||
{count > 0 && (
|
{count > 0 && (
|
||||||
|
|
|
||||||
15
agent-team/web/src/components/Markdown.tsx
Normal file
15
agent-team/web/src/components/Markdown.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -16,16 +16,24 @@ interface Props {
|
||||||
topology: Topology;
|
topology: Topology;
|
||||||
state: StateResponse | null;
|
state: StateResponse | null;
|
||||||
pathNodeIds: Set<string> | null;
|
pathNodeIds: Set<string> | null;
|
||||||
|
loopedEdges: Set<string> | null;
|
||||||
onNodeClick: (nodeId: string) => void;
|
onNodeClick: (nodeId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The center pipeline map. Nodes are dagre-laid from the topology; live state
|
// 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
|
// colors them; clicking a node filters the task list. Multiple trees (processes
|
||||||
// off intake) lay out as separate lanes automatically.
|
// off intake) lay out as separate lanes automatically. Loop-back arcs appear
|
||||||
export function PipelineMap({ topology, state, pathNodeIds, onNodeClick }: Props) {
|
// only for a selected task that actually looped (loopedEdges).
|
||||||
|
export function PipelineMap({
|
||||||
|
topology,
|
||||||
|
state,
|
||||||
|
pathNodeIds,
|
||||||
|
loopedEdges,
|
||||||
|
onNodeClick,
|
||||||
|
}: Props) {
|
||||||
const { nodes, edges } = useMemo(
|
const { nodes, edges } = useMemo(
|
||||||
() => layout(topology, state?.nodes ?? {}, pathNodeIds),
|
() => layout(topology, state?.nodes ?? {}, pathNodeIds, loopedEdges),
|
||||||
[topology, state, pathNodeIds],
|
[topology, state, pathNodeIds, loopedEdges],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleNodeClick: NodeMouseHandler = (_evt, node) => onNodeClick(node.id);
|
const handleNodeClick: NodeMouseHandler = (_evt, node) => onNodeClick(node.id);
|
||||||
|
|
|
||||||
|
|
@ -50,4 +50,63 @@ describe("TaskDrawer", () => {
|
||||||
);
|
);
|
||||||
expect(container).toBeEmptyDOMElement();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { TaskDetail } from "../api";
|
import type { TaskDetail } from "../api";
|
||||||
|
import { Markdown } from "./Markdown";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
detail: TaskDetail | null;
|
detail: TaskDetail | null;
|
||||||
|
|
@ -83,19 +84,19 @@ export function TaskDrawer({ detail, loading, error, onClose }: Props) {
|
||||||
{detail.qa_history.length > 0 && (
|
{detail.qa_history.length > 0 && (
|
||||||
<details className="drawer-section" open>
|
<details className="drawer-section" open>
|
||||||
<summary>Q&A ({detail.qa_history.length})</summary>
|
<summary>Q&A ({detail.qa_history.length})</summary>
|
||||||
<pre className="json">{JSON.stringify(detail.qa_history, null, 2)}</pre>
|
<QASection entries={detail.qa_history} />
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
{detail.review_verdicts.length > 0 && (
|
{detail.review_verdicts.length > 0 && (
|
||||||
<details className="drawer-section">
|
<details className="drawer-section">
|
||||||
<summary>Review verdicts ({detail.review_verdicts.length})</summary>
|
<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>
|
</details>
|
||||||
)}
|
)}
|
||||||
{detail.plan && (
|
{detail.plan && (
|
||||||
<details className="drawer-section">
|
<details className="drawer-section">
|
||||||
<summary>Plan</summary>
|
<summary>Plan</summary>
|
||||||
<pre className="json">{JSON.stringify(detail.plan, null, 2)}</pre>
|
<PlanSection plan={detail.plan} />
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
@ -107,3 +108,113 @@ export function TaskDrawer({ detail, loading, error, onClose }: Props) {
|
||||||
</section>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,24 +22,41 @@ const TOPO: Topology = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("layout", () => {
|
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);
|
const { nodes, edges } = layout(TOPO, {}, null);
|
||||||
expect(nodes).toHaveLength(4);
|
expect(nodes).toHaveLength(4);
|
||||||
expect(edges).toHaveLength(4);
|
// 3 forward (spine) edges; the review->plan loopback is NOT drawn by default.
|
||||||
// every node has a numeric position assigned by dagre
|
expect(edges).toHaveLength(3);
|
||||||
|
expect(edges.find((e) => e.id === "review->plan")).toBeUndefined();
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
expect(typeof n.position.x).toBe("number");
|
expect(typeof n.position.x).toBe("number");
|
||||||
expect(typeof n.position.y).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", () => {
|
it("applies live state + count to the matching node", () => {
|
||||||
const { nodes } = layout(TOPO, { clarify: { state: "awaiting_human", count: 2 } }, null);
|
const { nodes } = layout(TOPO, { clarify: { state: "awaiting_human", count: 2 } }, null);
|
||||||
const clarify = nodes.find((n) => n.id === "clarify")!;
|
const clarify = nodes.find((n) => n.id === "clarify")!;
|
||||||
expect(clarify.data.state).toBe("awaiting_human");
|
expect(clarify.data.state).toBe("awaiting_human");
|
||||||
expect(clarify.data.count).toBe(2);
|
expect(clarify.data.count).toBe(2);
|
||||||
const plan = nodes.find((n) => n.id === "plan")!;
|
expect(nodes.find((n) => n.id === "plan")!.data.state).toBe("idle");
|
||||||
expect(plan.data.state).toBe("idle");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks the selected task's path and dims the rest", () => {
|
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.onPath).toBe(false);
|
||||||
expect(nodes.find((n) => n.id === "review")!.data.dimmed).toBe(true);
|
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,24 +13,45 @@ export interface RFData {
|
||||||
count: number;
|
count: number;
|
||||||
dimmed: boolean;
|
dimmed: boolean;
|
||||||
onPath: 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.
|
// Compute laid-out React Flow nodes + edges from the topology and live state.
|
||||||
// `onPath` marks the nodes a selected task has visited (highlighted); `dimmed`
|
// `onPath` marks the nodes a selected task has visited (highlighted); `dimmed`
|
||||||
// fades nodes outside the active task's path when one is selected.
|
// 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(
|
export function layout(
|
||||||
topo: Topology,
|
topo: Topology,
|
||||||
liveNodes: Record<string, { state: RFData["state"]; count: number }>,
|
liveNodes: Record<string, { state: RFData["state"]; count: number }>,
|
||||||
pathNodeIds: Set<string> | null,
|
pathNodeIds: Set<string> | null,
|
||||||
|
loopedEdges: Set<string> | null = null,
|
||||||
): { nodes: Node<RFData>[]; edges: Edge[] } {
|
): { nodes: Node<RFData>[]; edges: Edge[] } {
|
||||||
const g = new dagre.graphlib.Graph();
|
const g = new dagre.graphlib.Graph();
|
||||||
g.setGraph({ rankdir: "LR", nodesep: 36, ranksep: 72, marginx: 24, marginy: 24 });
|
g.setGraph({ rankdir: "LR", nodesep: 36, ranksep: 72, marginx: 24, marginy: 24 });
|
||||||
g.setDefaultEdgeLabel(() => ({}));
|
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 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);
|
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 nodes: Node<RFData>[] = topo.nodes.map((meta) => {
|
||||||
const pos = g.node(meta.id);
|
const pos = g.node(meta.id);
|
||||||
const live = liveNodes[meta.id];
|
const live = liveNodes[meta.id];
|
||||||
|
|
@ -45,27 +66,42 @@ export function layout(
|
||||||
count: live?.count ?? 0,
|
count: live?.count ?? 0,
|
||||||
dimmed: pathNodeIds != null && !onPath,
|
dimmed: pathNodeIds != null && !onPath,
|
||||||
onPath,
|
onPath,
|
||||||
|
loopBackTo: loopBackTo[meta.id] ?? [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const edges: Edge[] = topo.edges.map((e) => {
|
const edges: Edge[] = [];
|
||||||
const loop = e.kind === "loopback";
|
for (const e of topo.edges) {
|
||||||
return {
|
const id = `${e.from}->${e.to}`;
|
||||||
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,
|
source: e.from,
|
||||||
target: e.to,
|
target: e.to,
|
||||||
animated: false,
|
animated: false,
|
||||||
style: {
|
style: {
|
||||||
stroke: loop ? "#7a5cff" : e.kind === "branch" ? "#3f7fd8" : "#39414f",
|
stroke: e.kind === "branch" ? "#3f7fd8" : "#39414f",
|
||||||
strokeWidth: 1.5,
|
strokeWidth: 1.5,
|
||||||
strokeDasharray: loop ? "5 4" : undefined,
|
|
||||||
},
|
},
|
||||||
label: loop ? "↺" : undefined,
|
|
||||||
labelStyle: { fill: "#9b8bff", fontWeight: 700 },
|
|
||||||
type: "smoothstep",
|
type: "smoothstep",
|
||||||
};
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
return { nodes, edges };
|
return { nodes, edges };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,3 +176,52 @@ body {
|
||||||
.json { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
.json { background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||||
padding: .6rem; font-size: .72rem; overflow-x: auto; max-height: 16rem;
|
padding: .6rem; font-size: .72rem; overflow-x: auto; max-height: 16rem;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
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; }
|
||||||
|
|
|
||||||
Reference in a new issue