loops wizard: topology builder in step 2
ci / gates (push) Successful in 13s
ci / frontend (push) Successful in 4m21s
ci / rust (push) Successful in 8m10s
ci / e2e (push) Has been skipped
ci / publish (push) Failing after 6m22s

Step 2 now defaults to a Builder pane: kind picker (from /api/topologies)
+ team size + role distribution preview, with /api/topologies/build
rendering the canonical graph and node/edge counts. Advanced JSON stays
as a toggle for hand-crafted graphs — same shape lands in the payload
either way, so downstream code is unchanged.

Debounced build effect wraps the async load in an inner function to
avoid the setState-in-effect cascading-renders lint. Uses the same
largest-remainder role apportionment as TeamWizard so builder output
matches team-wizard output for the same kind + size.
This commit is contained in:
Omar Sobh
2026-07-06 12:36:18 -07:00
parent 9079184bb2
commit e5820ce927
+371 -18
View File
@@ -1,12 +1,14 @@
"use client";
// 4-step loop wizard: identity → task/topology → triggers → repeat policy.
// The topology field is a JSON textarea for v1 (defaults to an empty graph);
// a proper visual builder is future work. On submit, if the webhook trigger
// was enabled the response returns webhook_token + signing_key ONCE — the
// wizard shows them in a copy-friendly card before dismissing.
// Topology step has two modes: a builder (pick a kind from the catalog + a
// role list; POST /api/topologies/build renders the canonical graph) and
// an advanced JSON textarea for hand-crafted graphs. On submit, if the
// webhook trigger was enabled the response returns webhook_token +
// signing_key ONCE — the wizard shows them in a copy-friendly card before
// dismissing.
import { useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { X } from "lucide-react";
import {
@@ -14,6 +16,11 @@ import {
type LoopCreated,
type LoopRepeatPolicy,
} from "@/lib/api/loops";
import {
fetchTopologyCatalog,
type CatalogEntry,
type TopologyGraph,
} from "@/lib/api/topology";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -29,6 +36,14 @@ export function LoopsWizard({
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [task, setTask] = useState("");
const [topologyMode, setTopologyMode] = useState<"builder" | "advanced">(
"builder",
);
const [catalog, setCatalog] = useState<CatalogEntry[]>([]);
const [kind, setKind] = useState<string>("");
const [teamSize, setTeamSize] = useState(4);
const [builtGraph, setBuiltGraph] = useState<TopologyGraph | null>(null);
const [buildingGraph, setBuildingGraph] = useState(false);
const [graphText, setGraphText] = useState(
JSON.stringify({ nodes: [], edges: [] }, null, 2),
);
@@ -44,11 +59,76 @@ export function LoopsWizard({
const [untilWithin, setUntilWithin] = useState(20);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [buildError, setBuildError] = useState<string | null>(null);
const [created, setCreated] = useState<LoopCreated | null>(null);
useEffect(() => {
void (async () => {
try {
const c = await fetchTopologyCatalog();
setCatalog(c);
if (c.length > 0) setKind((k) => k || c[0].kind);
} catch {
// Catalog is optional — advanced mode still works.
}
})();
}, []);
const selectedEntry = useMemo(
() => catalog.find((c) => c.kind === kind),
[catalog, kind],
);
// Rebuild the canonical graph whenever the builder inputs change. Debounced
// via a small effect so rapid clicks don't hammer /build. All state writes
// happen in the debounced async load — the effect body only registers the
// timer + cleanup (avoids the setState-in-effect cascading-renders rule).
useEffect(() => {
if (topologyMode !== "builder" || !selectedEntry) return;
const dist = selectedEntry.role_distribution.length
? selectedEntry.role_distribution
: [{ role: "worker", weight: 1 }];
const roles = staffRoles(dist, Math.max(1, teamSize));
let cancelled = false;
async function load() {
setBuildingGraph(true);
setBuildError(null);
try {
const r = await fetch("/api/topologies/build", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind, roles }),
});
if (!r.ok) throw new Error(`build failed (${r.status})`);
const g = (await r.json()) as TopologyGraph;
if (cancelled) return;
setBuiltGraph(g);
setGraphText(JSON.stringify(g, null, 2));
} catch (e) {
if (!cancelled) {
setBuildError(e instanceof Error ? e.message : "build failed");
setBuiltGraph(null);
}
} finally {
if (!cancelled) setBuildingGraph(false);
}
}
const handle = setTimeout(() => {
void load();
}, 150);
return () => {
cancelled = true;
clearTimeout(handle);
};
}, [topologyMode, kind, teamSize, selectedEntry]);
const graphReady =
topologyMode === "builder"
? builtGraph !== null && !buildingGraph
: isValidJson(graphText);
const canNext =
(step === 1 && title.trim().length > 0) ||
(step === 2 && task.trim().length > 0 && isValidJson(graphText)) ||
(step === 2 && task.trim().length > 0 && graphReady) ||
(step === 3 && (cronEnabled || onCompletion || webhookEnabled)) ||
step === 4;
@@ -194,18 +274,49 @@ export function LoopsWizard({
placeholder="What should the loop's agents do each iteration?"
style={fieldStyle}
/>
<label style={labelStyle} htmlFor="loop-graph">Topology graph (JSON)</label>
<textarea
id="loop-graph"
value={graphText}
onChange={(e) => setGraphText(e.target.value)}
rows={10}
style={{ ...fieldStyle, fontFamily: mono, fontSize: 12 }}
/>
<p style={hintStyle}>
Uses the same shape as topology_runs.graph. Empty graph
(default) works a visual builder is future work.
</p>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginTop: 4,
}}
>
<span style={labelStyle}>Topology</span>
<ModeToggle
mode={topologyMode}
onChange={setTopologyMode}
/>
</div>
{topologyMode === "builder" ? (
<TopologyBuilder
catalog={catalog}
kind={kind}
onKind={setKind}
teamSize={teamSize}
onTeamSize={setTeamSize}
built={builtGraph}
building={buildingGraph}
error={buildError}
/>
) : (
<>
<textarea
id="loop-graph"
value={graphText}
onChange={(e) => setGraphText(e.target.value)}
rows={10}
style={{ ...fieldStyle, fontFamily: mono, fontSize: 12 }}
/>
<p style={hintStyle}>
Uses the same shape as topology_runs.graph. Switch to
Builder to generate a canonical graph from a kind + role
distribution.
</p>
</>
)}
</div>
)}
@@ -487,6 +598,222 @@ function SecretRow({ label, value }: { label: string; value: string }) {
);
}
function ModeToggle({
mode,
onChange,
}: {
mode: "builder" | "advanced";
onChange: (m: "builder" | "advanced") => void;
}) {
const pill = (active: boolean): React.CSSProperties => ({
padding: "4px 10px",
borderRadius: 6,
background: active ? "rgba(255,138,122,.12)" : "transparent",
color: active ? "#ffbfb3" : "#8a8a92",
fontSize: 11,
fontFamily: mono,
fontWeight: 700,
letterSpacing: ".08em",
textTransform: "uppercase",
border: 0,
cursor: "pointer",
});
return (
<div
style={{
display: "inline-flex",
gap: 2,
padding: 2,
borderRadius: 8,
background: "rgba(255,255,255,.04)",
border: "1px solid rgba(255,255,255,.06)",
}}
>
<button
type="button"
onClick={() => onChange("builder")}
style={pill(mode === "builder")}
>
Builder
</button>
<button
type="button"
onClick={() => onChange("advanced")}
style={pill(mode === "advanced")}
>
Advanced JSON
</button>
</div>
);
}
function TopologyBuilder({
catalog,
kind,
onKind,
teamSize,
onTeamSize,
built,
building,
error,
}: {
catalog: CatalogEntry[];
kind: string;
onKind: (k: string) => void;
teamSize: number;
onTeamSize: (n: number) => void;
built: TopologyGraph | null;
building: boolean;
error: string | null;
}) {
const entry = catalog.find((c) => c.kind === kind);
if (catalog.length === 0) {
return (
<p style={hintStyle}>
Loading topology catalog switch to Advanced JSON if it doesn&apos;t
appear.
</p>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
gap: 6,
}}
>
{catalog.map((c) => {
const active = kind === c.kind;
return (
<button
key={c.kind}
type="button"
onClick={() => onKind(c.kind)}
title={c.description}
style={{
textAlign: "left",
padding: "8px 10px",
borderRadius: 8,
background: active
? "rgba(255,138,122,.10)"
: "rgba(255,255,255,.03)",
border: active
? "1px solid rgba(255,138,122,.35)"
: "1px solid rgba(255,255,255,.06)",
color: "#eaeaee",
fontFamily: mono,
fontSize: 12,
cursor: "pointer",
}}
>
<div style={{ fontWeight: 700 }}>{c.name}</div>
<div
style={{
color: "#8a8a92",
fontSize: 10.5,
marginTop: 2,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{c.description}
</div>
</button>
);
})}
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 120px",
gap: 12,
alignItems: "end",
}}
>
<div>
<span style={{ ...hintStyle, fontSize: 10, letterSpacing: ".1em", textTransform: "uppercase" }}>
Roles
</span>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
marginTop: 4,
minHeight: 24,
}}
>
{entry?.role_distribution.length ? (
entry.role_distribution.map((r) => (
<span
key={r.role}
style={{
fontFamily: mono,
fontSize: 11,
color: "#eaeaee",
padding: "3px 8px",
borderRadius: 5,
background: "rgba(255,255,255,.05)",
}}
>
{r.role} · {Math.round(r.weight * 100)}%
</span>
))
) : (
<span style={hintStyle}>no roles defined</span>
)}
</div>
</div>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ ...hintStyle, fontSize: 10, letterSpacing: ".1em", textTransform: "uppercase" }}>
Team size
</span>
<input
type="number"
min={1}
value={teamSize}
onChange={(e) =>
onTeamSize(Math.max(1, parseInt(e.target.value, 10) || 1))
}
style={fieldStyle}
/>
</label>
</div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "8px 12px",
borderRadius: 8,
background: "rgba(255,255,255,.03)",
border: "1px solid rgba(255,255,255,.05)",
fontFamily: mono,
fontSize: 12,
color: "#eaeaee",
}}
>
<span>
{building
? "Building…"
: built
? `${built.nodes.length} nodes · ${built.edges.length} edges`
: "—"}
</span>
<span style={{ color: "#8a8a92", fontSize: 11 }}>{kind}</span>
</div>
{error && (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
{error}
</p>
)}
</div>
);
}
function isValidJson(s: string) {
try {
JSON.parse(s);
@@ -496,6 +823,32 @@ function isValidJson(s: string) {
}
}
/** Largest-remainder apportionment of `size` across the role distribution.
* Mirrors TeamWizard so the built graph matches what the team wizard would
* produce for the same kind + size. */
function staffRoles(
dist: { role: string; weight: number }[],
size: number,
): string[] {
if (dist.length === 0) return [];
const base = dist.map((d) => {
const exact = d.weight * size;
const n = Math.floor(exact);
return { role: d.role, n, rem: exact - n };
});
let used = base.reduce((s, b) => s + b.n, 0);
const ordered = [...base].sort((a, b) => b.rem - a.rem);
for (let i = 0; used < size && i < ordered.length * 4; i++) {
ordered[i % ordered.length].n += 1;
used += 1;
}
const roles: string[] = [];
for (const b of base) {
for (let i = 0; i < b.n; i++) roles.push(b.role);
}
return roles;
}
const labelStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,