loops UI: real list + canvas + 4-step wizard with webhook secrets card
ci / gates (push) Successful in 6s
ci / frontend (push) Successful in 34s
ci / rust (push) Successful in 2m38s
ci / publish (push) Successful in 40s
ci / e2e (push) Failing after 29m28s

Seventh and final commit of the Research + Loops arc. Replaces the
placeholder Loops surface from commit 5 with the full working UI.

New:
- lib/api/loops.ts — thin TypeScript client for every /api/loops
  endpoint (list, get, create, patch, delete, run-now, enable, disable).
- dashboard/LoopsWizard.tsx — 4-step modal:
    1. title + description
    2. task template + topology graph JSON (defaults to empty graph; a
       visual builder is future work)
    3. triggers — any combination of cron / on-completion / webhook,
       with the cron pattern input revealed inline when cron is on
    4. repeat policy — infinite or fixed-iterations
  When the webhook trigger is enabled, submit lands on a fifth SECRETS
  screen showing webhook_token + signing_key exactly once (never shown
  again by the backend), a copy-to-clipboard row for each, and the
  X-Loop-Signature: sha256=<HMAC-SHA256(key, body)> usage snippet.
- dashboard/LoopsList.tsx — replaces the stub. Loop cards with an
  enabled/disabled dot + a next-fire chip. Re-fetches on refreshKey.
- dashboard/LoopsCanvas.tsx — replaces the stub. Selected loop detail:
  header (title, enabled state, next-fire), triggers card, repeat
  policy, task template preview, topology graph JSON, and an action row
  (Run now / Enable-or-Disable / Delete with confirm).

Wired into Dashboard.tsx via a `loopsSel` / `loopsRefresh` state pair
matching the Research pattern. onDeleted clears the selection and bumps
the refresh key so the list drops the deleted card and the canvas
returns to its placeholder.

Closes the arc: all 5 tiers active, all state machines driveable from
the UI, and the loop scheduler + cron + webhook plumbing wired end-to-
end. Future work per the roadmap: visual topology builder, iteration
timeline in the canvas (via GET /api/topology-runs?loop_id=X), and the
"until" repeat policy UI.
This commit is contained in:
Omar Sobh
2026-07-06 07:22:39 -07:00
parent 568d3c4b78
commit d0adc78de4
5 changed files with 1137 additions and 77 deletions
@@ -0,0 +1,480 @@
"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.
import { useState } from "react";
import { X } from "lucide-react";
import {
createLoop,
type LoopCreated,
type LoopRepeatPolicy,
} from "@/lib/api/loops";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function LoopsWizard({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (id: string) => void;
}) {
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [task, setTask] = useState("");
const [graphText, setGraphText] = useState(
JSON.stringify({ nodes: [], edges: [] }, null, 2),
);
const [cronEnabled, setCronEnabled] = useState(false);
const [cron, setCron] = useState("0 */6 * * *");
const [onCompletion, setOnCompletion] = useState(false);
const [webhookEnabled, setWebhookEnabled] = useState(false);
const [repeatKind, setRepeatKind] = useState<"infinite" | "iters">("infinite");
const [iters, setIters] = useState(10);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [created, setCreated] = useState<LoopCreated | null>(null);
const canNext =
(step === 1 && title.trim().length > 0) ||
(step === 2 && task.trim().length > 0 && isValidJson(graphText)) ||
(step === 3 && (cronEnabled || onCompletion || webhookEnabled)) ||
step === 4;
async function submit() {
setSubmitError(null);
setSubmitting(true);
try {
let graph: unknown;
try {
graph = JSON.parse(graphText);
} catch {
setSubmitError("Graph is not valid JSON");
setSubmitting(false);
return;
}
const repeat_policy: LoopRepeatPolicy =
repeatKind === "infinite"
? { kind: "infinite" }
: { kind: "iters", n: Math.max(1, Math.floor(iters)) };
const out = await createLoop({
title: title.trim(),
description: description.trim(),
graph,
task_template: task.trim(),
triggers: {
...(cronEnabled ? { cron: cron.trim() } : {}),
...(onCompletion ? { on_completion: true } : {}),
...(webhookEnabled ? { webhook_enabled: true } : {}),
},
repeat_policy,
});
setCreated(out);
// If no webhook material, dismiss immediately.
if (!out.webhook_token) {
onCreated(out.id);
} else {
setStep(5);
}
} catch (e) {
setSubmitError(e instanceof Error ? e.message : "create failed");
} finally {
setSubmitting(false);
}
}
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.55)",
zIndex: 200,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 640,
maxHeight: "90vh",
background: "#0d0d10",
border: "1px solid rgba(255,255,255,.1)",
borderRadius: 14,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
<div
style={{
padding: "14px 18px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span style={{ fontFamily: mono, fontSize: 10, color: "#5a5a62" }}>
{step <= 4 ? `STEP ${step} / 4` : "SECRETS"}
</span>
<span style={{ flex: 1, fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>
{step <= 4 ? "New loop" : "Copy the webhook secret"}
</span>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={closeBtn}
>
<X aria-hidden size={16} />
</button>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label style={labelStyle} htmlFor="loop-title">Title</label>
<input
id="loop-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Nightly regression scan"
style={fieldStyle}
/>
<label style={labelStyle} htmlFor="loop-desc">Description</label>
<textarea
id="loop-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
style={fieldStyle}
/>
</div>
)}
{step === 2 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label style={labelStyle} htmlFor="loop-task">Task template</label>
<textarea
id="loop-task"
value={task}
onChange={(e) => setTask(e.target.value)}
rows={4}
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>
)}
{step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={labelStyle}>Triggers (pick any combination)</span>
<TriggerToggle
on={cronEnabled}
onToggle={setCronEnabled}
label="Cron schedule"
hint="Whenever the pattern fires; missed windows fire once and skip the backlog."
>
{cronEnabled && (
<input
value={cron}
onChange={(e) => setCron(e.target.value)}
placeholder="0 */6 * * *"
style={{ ...fieldStyle, fontFamily: mono }}
/>
)}
</TriggerToggle>
<TriggerToggle
on={onCompletion}
onToggle={setOnCompletion}
label="On previous completion"
hint="Enqueue the next iteration as soon as the previous one emits run_completed."
/>
<TriggerToggle
on={webhookEnabled}
onToggle={setWebhookEnabled}
label="Webhook"
hint="A public /webhooks/loops/:token endpoint. HMAC-SHA256 verified via X-Loop-Signature: sha256=…"
/>
</div>
)}
{step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={labelStyle}>Repeat policy</span>
<label style={radioRowStyle(repeatKind === "infinite")}>
<input
type="radio"
checked={repeatKind === "infinite"}
onChange={() => setRepeatKind("infinite")}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>Infinite</div>
<div style={hintStyle}>Loop until disabled or deleted.</div>
</div>
</label>
<label style={radioRowStyle(repeatKind === "iters")}>
<input
type="radio"
checked={repeatKind === "iters"}
onChange={() => setRepeatKind("iters")}
/>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>Fixed iterations</div>
<div style={hintStyle}>
Stop after N total iterations. Scheduler disables the loop when reached.
</div>
</div>
{repeatKind === "iters" && (
<input
type="number"
value={iters}
min={1}
onChange={(e) => setIters(parseInt(e.target.value, 10) || 1)}
style={{ ...fieldStyle, width: 90 }}
/>
)}
</label>
{submitError && (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
{submitError}
</p>
)}
</div>
)}
{step === 5 && created && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<p style={{ fontFamily: mono, fontSize: 12, color: "#ffb44a", lineHeight: 1.6 }}>
Save these now they&apos;re never shown again. To rotate, disable
the webhook trigger and re-enable it.
</p>
<SecretRow label="Webhook token" value={created.webhook_token ?? ""} />
<SecretRow label="Signing key" value={created.webhook_signing_key ?? ""} />
<p style={hintStyle}>
POST to <code style={{ color: "#f3f3f5" }}>/webhooks/loops/{created.webhook_token}</code>
{" "}with header <code style={{ color: "#f3f3f5" }}>X-Loop-Signature: sha256=&lt;hex&gt;</code>
{" "}where hex = HMAC-SHA256(signing_key, request_body).
</p>
</div>
)}
</div>
<div
style={{
padding: 14,
borderTop: "1px solid rgba(255,255,255,.06)",
display: "flex",
gap: 8,
justifyContent: "space-between",
}}
>
{step <= 4 ? (
<>
<button
type="button"
onClick={() => setStep((s) => ((s > 1 ? s - 1 : s) as 1 | 2 | 3 | 4 | 5))}
disabled={step === 1}
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
>
Back
</button>
{step < 4 ? (
<button
type="button"
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5))}
disabled={!canNext}
style={{ ...primaryBtn, opacity: !canNext ? 0.4 : 1 }}
>
Next
</button>
) : (
<button
type="button"
onClick={submit}
disabled={submitting}
style={{ ...primaryBtn, opacity: submitting ? 0.6 : 1 }}
>
{submitting ? "Creating…" : "Create loop"}
</button>
)}
</>
) : (
<>
<span />
<button
type="button"
onClick={() => onCreated(created!.id)}
style={primaryBtn}
>
Done
</button>
</>
)}
</div>
</div>
</div>
);
}
function TriggerToggle({
on,
onToggle,
label,
hint,
children,
}: {
on: boolean;
onToggle: (v: boolean) => void;
label: string;
hint: string;
children?: React.ReactNode;
}) {
return (
<div
style={{
padding: 12,
borderRadius: 10,
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
background: on ? "rgba(255,111,97,.06)" : "transparent",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<label style={{ display: "flex", alignItems: "flex-start", gap: 10, cursor: "pointer" }}>
<input
type="checkbox"
checked={on}
onChange={(e) => onToggle(e.target.checked)}
style={{ marginTop: 3 }}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{label}</div>
<div style={hintStyle}>{hint}</div>
</div>
</label>
{children}
</div>
);
}
function SecretRow({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<span style={labelStyle}>{label}</span>
<div style={{ display: "flex", gap: 6 }}>
<input
value={value}
readOnly
style={{ ...fieldStyle, fontFamily: mono, fontSize: 12, flex: 1 }}
/>
<button
type="button"
onClick={() => navigator.clipboard?.writeText(value)}
style={secondaryBtn}
>
Copy
</button>
</div>
</div>
);
}
function isValidJson(s: string) {
try {
JSON.parse(s);
return true;
} catch {
return false;
}
}
const labelStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,
color: "#b5b5bd",
};
const hintStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
lineHeight: 1.5,
};
const fieldStyle: React.CSSProperties = {
width: "100%",
padding: "10px 12px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "#0a0a0c",
color: "#f3f3f5",
outline: "none",
fontSize: 13,
};
const primaryBtn: React.CSSProperties = {
padding: "9px 16px",
borderRadius: 8,
border: 0,
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
color: "#2a0d0a",
fontSize: 13,
fontWeight: 700,
cursor: "pointer",
};
const secondaryBtn: React.CSSProperties = {
padding: "9px 16px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
};
const closeBtn: React.CSSProperties = {
width: 30,
height: 30,
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#cfcfd5",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
};
const radioRowStyle = (on: boolean): React.CSSProperties => ({
display: "flex",
alignItems: "center",
gap: 10,
padding: "12px",
borderRadius: 10,
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
background: on ? "rgba(255,111,97,.06)" : "transparent",
cursor: "pointer",
});