feat(frontend): topology compare/Pareto UI
ci / gates (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / sandbox-k8s (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / e2e (push) Has been cancelled

Add a Compare tab to /topologies: pick a task + roles + topology kinds, build a
graph per kind, run POST /api/topologies/compare, and render a leaderboard table
+ a quality/cost Pareto scatter (SVG, no deps). Wrap Build + Compare in a tabbed
TopologyWorkbench. e2e p8 extended to run a comparison end-to-end (build×N →
compare → leaderboard + Pareto). Full suite 39 green; build + TS clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 03:54:54 -07:00
co-authored by Claude Opus 4.8
parent 7648487a59
commit 654ec0f511
4 changed files with 254 additions and 2 deletions
@@ -1,5 +1,5 @@
import { PageChrome } from "@/components/global/PageChrome"; import { PageChrome } from "@/components/global/PageChrome";
import { TopologyExplorer } from "@/components/topology/TopologyExplorer"; import { TopologyWorkbench } from "@/components/topology/TopologyWorkbench";
import { fetchTopologyCatalog } from "@/lib/api/topology"; import { fetchTopologyCatalog } from "@/lib/api/topology";
// Topologies page: browse the catalog and build/visualize a topology. // Topologies page: browse the catalog and build/visualize a topology.
@@ -10,7 +10,7 @@ export default async function TopologiesPage() {
title="Topologies" title="Topologies"
description={`${catalog.length} organizational patterns to run your agents in`} description={`${catalog.length} organizational patterns to run your agents in`}
> >
<TopologyExplorer catalog={catalog} /> <TopologyWorkbench catalog={catalog} />
</PageChrome> </PageChrome>
); );
} }
@@ -0,0 +1,196 @@
"use client";
import { useState } from "react";
import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology";
interface CompareResult {
kind: string;
quality: number;
tokens: number;
turns: number;
on_pareto: boolean;
}
interface Comparison {
results: CompareResult[];
leaderboard: number[];
best_quality: number | null;
best_value: number | null;
}
const DEFAULT_KINDS = ["hierarchical", "pipeline", "swarm"];
/** Run one task across several topologies and show a leaderboard + Pareto. */
export function TopologyCompare({ catalog }: { catalog: CatalogEntry[] }) {
const [task, setTask] = useState("Draft a go-to-market launch plan.");
const [roles, setRoles] = useState("coordinator, researcher, analyst, writer");
const [selected, setSelected] = useState<Set<string>>(new Set(DEFAULT_KINDS));
const [cmp, setCmp] = useState<Comparison | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function toggle(kind: string) {
setSelected((s) => {
const next = new Set(s);
if (next.has(kind)) next.delete(kind);
else next.add(kind);
return next;
});
}
async function run() {
setBusy(true);
setError(null);
try {
const roleList = roles.split(",").map((r) => r.trim()).filter(Boolean);
const kinds = [...selected];
if (kinds.length === 0) throw new Error("Pick at least one topology");
const graphs: TopologyGraph[] = await Promise.all(
kinds.map(async (kind) => {
const res = await fetch("/api/topologies/build", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind, roles: roleList }),
});
if (!res.ok) throw new Error(`Build ${kind} failed`);
return (await res.json()) as TopologyGraph;
}),
);
const res = await fetch("/api/topologies/compare", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ task, graphs }),
});
if (!res.ok) throw new Error(`Compare failed (${res.status})`);
setCmp((await res.json()) as Comparison);
} catch (e) {
setError(e instanceof Error ? e.message : "Compare failed");
} finally {
setBusy(false);
}
}
return (
<div className="flex flex-col gap-4">
<label className="text-xs text-muted-foreground">
Task
<input
value={task}
onChange={(e) => setTask(e.target.value)}
className="mt-1 w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm text-foreground"
/>
</label>
<label className="text-xs text-muted-foreground">
Roles (comma-separated)
<input
value={roles}
onChange={(e) => setRoles(e.target.value)}
className="mt-1 w-full rounded-lg border border-border bg-transparent px-3 py-2 text-sm text-foreground"
/>
</label>
<div className="flex flex-wrap gap-1.5">
{catalog.map((c) => (
<button
key={c.kind}
type="button"
onClick={() => toggle(c.kind)}
aria-pressed={selected.has(c.kind)}
className={`rounded-full border px-3 py-1 text-xs capitalize transition-colors ${
selected.has(c.kind)
? "border-coral bg-coral/10 text-foreground"
: "border-border text-muted-foreground hover:bg-surface-warm/50"
}`}
>
{c.name.replace(/_/g, " ")}
</button>
))}
</div>
<div>
<button
type="button"
onClick={run}
disabled={busy}
className="rounded-lg bg-coral px-4 py-2 text-sm font-medium text-white transition-opacity disabled:opacity-50"
>
{busy ? "Running…" : "Run comparison"}
</button>
</div>
{error && <p className="text-sm text-coral">{error}</p>}
{cmp && <Results cmp={cmp} />}
</div>
);
}
function Results({ cmp }: { cmp: Comparison }) {
const fmt = (q: number) => `${Math.round(q * 100)}%`;
return (
<div className="flex flex-col gap-6">
<table className="w-full text-sm">
<thead className="text-xs text-muted-foreground">
<tr className="border-b border-border text-left">
<th className="py-2 font-medium">Topology</th>
<th className="py-2 text-right font-medium">Quality</th>
<th className="py-2 text-right font-medium">Tokens</th>
<th className="py-2 text-right font-medium">Turns</th>
<th className="py-2 text-right font-medium">Pareto</th>
</tr>
</thead>
<tbody>
{cmp.leaderboard.map((i) => {
const r = cmp.results[i];
return (
<tr key={r.kind} className="border-b border-border/50">
<td className="py-2 capitalize text-foreground">{r.kind.replace(/_/g, " ")}</td>
<td className="py-2 text-right text-foreground">{fmt(r.quality)}</td>
<td className="py-2 text-right text-muted-foreground">{r.tokens.toLocaleString()}</td>
<td className="py-2 text-right text-muted-foreground">{r.turns}</td>
<td className="py-2 text-right">{r.on_pareto ? "★" : ""}</td>
</tr>
);
})}
</tbody>
</table>
<ParetoChart results={cmp.results} />
</div>
);
}
function ParetoChart({ results }: { results: CompareResult[] }) {
const W = 560;
const H = 240;
const pad = 44;
const maxTokens = Math.max(...results.map((r) => r.tokens), 1);
const x = (t: number) => pad + (t / maxTokens) * (W - 2 * pad);
const y = (q: number) => H - pad - q * (H - 2 * pad);
return (
<div className="rounded-2xl border border-border bg-card p-4">
<svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full" role="img" aria-label="quality vs cost pareto">
{/* axes */}
<line x1={pad} y1={H - pad} x2={W - pad} y2={H - pad} stroke="#475569" strokeWidth={1} />
<line x1={pad} y1={pad} x2={pad} y2={H - pad} stroke="#475569" strokeWidth={1} />
<text x={W / 2} y={H - 10} textAnchor="middle" fontSize={11} fill="#94a3b8">
tokens →
</text>
<text x={14} y={H / 2} textAnchor="middle" fontSize={11} fill="#94a3b8" transform={`rotate(-90 14 ${H / 2})`}>
quality ↑
</text>
{results.map((r) => (
<g key={r.kind}>
<circle
cx={x(r.tokens)}
cy={y(r.quality)}
r={r.on_pareto ? 7 : 5}
fill={r.on_pareto ? "#f96565" : "#64748b"}
/>
<text x={x(r.tokens) + 9} y={y(r.quality) + 3} fontSize={10} fill="#94a3b8" className="capitalize">
{r.kind.replace(/_/g, " ")}
</text>
</g>
))}
</svg>
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import { useState } from "react";
import type { CatalogEntry } from "@/lib/api/topology";
import { TopologyCompare } from "./TopologyCompare";
import { TopologyExplorer } from "./TopologyExplorer";
const TABS = ["build", "compare"] as const;
type Tab = (typeof TABS)[number];
/** Tabbed topology workbench: Build (catalog + visualizer) and Compare
* (run a task across topologies → leaderboard + Pareto). */
export function TopologyWorkbench({ catalog }: { catalog: CatalogEntry[] }) {
const [tab, setTab] = useState<Tab>("build");
return (
<div className="flex flex-col gap-6">
<div role="tablist" className="flex gap-1 border-b border-border">
{TABS.map((t) => (
<button
key={t}
type="button"
role="tab"
aria-selected={tab === t}
onClick={() => setTab(t)}
className={`-mb-px border-b-2 px-3 py-2 text-sm capitalize transition-colors ${
tab === t
? "border-coral text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{t}
</button>
))}
</div>
{tab === "build" ? (
<TopologyExplorer catalog={catalog} />
) : (
<TopologyCompare catalog={catalog} />
)}
</div>
);
}
+12
View File
@@ -31,3 +31,15 @@ test("browse the topology catalog and build a graph", async ({ page }) => {
await page.getByRole("button", { name: "Build" }).click(); await page.getByRole("button", { name: "Build" }).click();
await expect(page.locator("svg[aria-label*='topology']")).toBeVisible(); await expect(page.locator("svg[aria-label*='topology']")).toBeVisible();
}); });
test("compare topologies shows a leaderboard and Pareto chart", async ({ page }) => {
await signIn(page);
await page.getByRole("link", { name: "Topologies" }).click();
await page.getByRole("tab", { name: /compare/i }).click();
await page.getByRole("button", { name: "Run comparison" }).click();
// Leaderboard table + Pareto chart render from the comparison result.
await expect(page.getByRole("cell", { name: /pipeline/i })).toBeVisible();
await expect(page.locator("svg[aria-label*='pareto']")).toBeVisible();
});