loops: repo picker + agent/team/org staffing + sidebar edit/delete
ci / gates (push) Successful in 6s
ci / rust (push) Failing after 12s
ci / frontend (push) Successful in 25s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

Adds the missing pieces the wizard needed and the sidebar controls
around it:

- LoopsWizard is now a 6-step flow (identity → repo → task/topology
  → triggers → repeat → assign agents) plus the existing secrets
  card. ResearchWizard picks up the same repo step and a hard gate
  when the workspace has zero agents.
- New LoopStaffingStep with three tabs — Individual / Team /
  Organization — that mix freely per loop; selections persist via
  new loop_agents / loop_teams / loop_orgs join tables (0035
  migration), each cascading on loop_id so hard-delete stays a
  single-row DELETE.
- Backend CreateLoopRequest / UpdateLoopRequest accept the three
  lists and apply_staffing does a transactional replace-all;
  list_loops / get_loop hydrate the lists via a flattened
  LoopWithStaffing response.
- LoopsList sidebar gains per-row enable/disable, edit (reopens the
  wizard prefilled with the current loop, PATCHes on submit), and
  delete with an inline confirm.
- NoAgentsGate blocks launching a loop or research topic from a
  workspace with no roster; the sidebar `+` buttons also disable
  with a tooltip pointing at the TEAM tier.

Not yet wired: the run driver still fills role slots from the
workspace-wide pool; teaching enqueue_iteration to prefer
loop_agents/loop_teams/loop_orgs is a follow-up.
This commit is contained in:
Omar Sobh
2026-07-07 22:09:06 -07:00
parent 2562541f5b
commit a6da19430f
25 changed files with 1781 additions and 156 deletions
+190 -32
View File
@@ -13,6 +13,8 @@ import { X } from "lucide-react";
import {
createLoop,
patchLoop,
type Loop,
type LoopCreated,
type LoopRepeatPolicy,
} from "@/lib/api/loops";
@@ -20,23 +22,61 @@ import {
// bearer.ts + @clerk/nextjs/server into the client bundle and breaks the
// production build. Call /api/topologies with plain fetch instead.
import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology";
import { RepoPicker, type PickedRepo } from "./RepoPicker";
import { NoAgentsGate } from "./NoAgentsGate";
import { fetchClaws } from "@/lib/api/team";
import type { Agent } from "@/lib/api/schemas";
import {
LoopStaffingStep,
type AgentSelection,
} from "./LoopStaffingStep";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function LoopsWizard({
initial,
agentsCount,
onClose,
onCreated,
onSaved,
}: {
/** When set, the wizard runs in edit mode — pre-fills state and PATCHes on submit. */
initial?: Loop;
/** Override for the roster gate. When omitted the wizard fetches /api/team/claws itself. */
agentsCount?: number;
onClose: () => void;
onCreated: (id: string) => void;
onSaved?: (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 editing = !!initial;
const [rosterCount, setRosterCount] = useState<number | null>(
agentsCount ?? null,
);
const [rosterAgents, setRosterAgents] = useState<Agent[]>([]);
const [selectedAgents, setSelectedAgents] = useState<AgentSelection[]>(
initial?.agents?.map((a) => ({
agent_id: a.agent_id,
role_slot: a.role_slot ?? "",
})) ?? [],
);
const [selectedTeamIds, setSelectedTeamIds] = useState<string[]>(
initial?.teams ?? [],
);
const [selectedOrgIds, setSelectedOrgIds] = useState<string[]>(
initial?.orgs ?? [],
);
const initialRepeat = readInitialRepeat(initial?.repeat_policy);
const initialTriggers = readInitialTriggers(initial?.triggers);
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5 | 6 | 7>(1);
const [title, setTitle] = useState(initial?.title ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [repo, setRepo] = useState<PickedRepo | null>(null);
const [task, setTask] = useState(initial?.task_template ?? "");
// When editing an existing loop, start in advanced mode so the caller sees
// the exact graph they saved. Builder mode would rebuild + overwrite it.
const [topologyMode, setTopologyMode] = useState<"builder" | "advanced">(
"builder",
editing ? "advanced" : "builder",
);
const [catalog, setCatalog] = useState<CatalogEntry[]>([]);
const [kind, setKind] = useState<string>("");
@@ -44,18 +84,26 @@ export function LoopsWizard({
const [builtGraph, setBuiltGraph] = useState<TopologyGraph | null>(null);
const [buildingGraph, setBuildingGraph] = useState(false);
const [graphText, setGraphText] = useState(
JSON.stringify({ nodes: [], edges: [] }, null, 2),
initial
? JSON.stringify(initial.graph, null, 2)
: JSON.stringify({ nodes: [], edges: [] }, null, 2),
);
const [cronEnabled, setCronEnabled] = useState(
initialTriggers.cron !== undefined,
);
const [cron, setCron] = useState(initialTriggers.cron ?? "0 */6 * * *");
const [onCompletion, setOnCompletion] = useState(
!!initialTriggers.on_completion,
);
const [webhookEnabled, setWebhookEnabled] = useState(
!!initialTriggers.webhook_enabled,
);
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" | "until">(
"infinite",
initialRepeat.kind,
);
const [iters, setIters] = useState(10);
const [untilEvent, setUntilEvent] = useState("ok");
const [untilWithin, setUntilWithin] = useState(20);
const [iters, setIters] = useState(initialRepeat.iters);
const [untilEvent, setUntilEvent] = useState(initialRepeat.untilEvent);
const [untilWithin, setUntilWithin] = useState(initialRepeat.untilWithin);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [buildError, setBuildError] = useState<string | null>(null);
@@ -81,6 +129,25 @@ export function LoopsWizard({
};
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const claws = await fetchClaws();
if (cancelled) return;
setRosterAgents(claws);
if (agentsCount === undefined) setRosterCount(claws.length);
} catch {
if (!cancelled && agentsCount === undefined) setRosterCount(0);
}
})();
return () => {
cancelled = true;
};
}, [agentsCount]);
const noAgents = rosterCount !== null && rosterCount === 0;
const selectedEntry = useMemo(
() => catalog.find((c) => c.kind === kind),
[catalog, kind],
@@ -135,9 +202,11 @@ export function LoopsWizard({
: isValidJson(graphText);
const canNext =
(step === 1 && title.trim().length > 0) ||
(step === 2 && task.trim().length > 0 && graphReady) ||
(step === 3 && (cronEnabled || onCompletion || webhookEnabled)) ||
step === 4;
step === 2 ||
(step === 3 && task.trim().length > 0 && graphReady) ||
(step === 4 && (cronEnabled || onCompletion || webhookEnabled)) ||
step === 5 ||
step === 6;
async function submit() {
setSubmitError(null);
@@ -169,7 +238,15 @@ export function LoopsWizard({
within_iters: Math.max(1, Math.floor(untilWithin)),
};
}
const out = await createLoop({
const staffing = {
agents: selectedAgents.map((s) => ({
agent_id: s.agent_id,
role_slot: s.role_slot.trim() || undefined,
})),
teams: selectedTeamIds,
orgs: selectedOrgIds,
};
const body = {
title: title.trim(),
description: description.trim(),
graph,
@@ -180,13 +257,21 @@ export function LoopsWizard({
...(webhookEnabled ? { webhook_enabled: true } : {}),
},
repeat_policy,
});
...(repo ? { repo } : {}),
...staffing,
};
if (editing && initial) {
await patchLoop(initial.id, body);
onSaved?.(initial.id);
return;
}
const out = await createLoop(body);
setCreated(out);
// If no webhook material, dismiss immediately.
if (!out.webhook_token) {
onCreated(out.id);
} else {
setStep(5);
setStep(7);
}
} catch (e) {
setSubmitError(e instanceof Error ? e.message : "create failed");
@@ -233,10 +318,10 @@ export function LoopsWizard({
}}
>
<span style={{ fontFamily: mono, fontSize: 10, color: "#5a5a62" }}>
{step <= 4 ? `STEP ${step} / 4` : "SECRETS"}
{step <= 6 ? `STEP ${step} / 6` : "SECRETS"}
</span>
<span style={{ flex: 1, fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>
{step <= 4 ? "New loop" : "Copy the webhook secret"}
{step <= 6 ? (editing ? "Edit loop" : "New loop") : "Copy the webhook secret"}
</span>
<button
type="button"
@@ -249,7 +334,10 @@ export function LoopsWizard({
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{step === 1 && (
{noAgents ? (
<NoAgentsGate what="loop" onDismiss={onClose} />
) : null}
{!noAgents && step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label style={labelStyle} htmlFor="loop-title">Title</label>
<input
@@ -270,7 +358,19 @@ export function LoopsWizard({
</div>
)}
{step === 2 && (
{!noAgents && step === 2 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Repository (optional)</span>
<p style={hintStyle}>
Pick a connected repo the loop should target. Task templates
and agents can reference {"{{repo.owner}}/{{repo.name}}"} once
a repo is chosen.
</p>
<RepoPicker value={repo} onChange={setRepo} optional />
</div>
)}
{!noAgents && step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label style={labelStyle} htmlFor="loop-task">Task template</label>
<textarea
@@ -327,7 +427,7 @@ export function LoopsWizard({
</div>
)}
{step === 3 && (
{!noAgents && step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={labelStyle}>Triggers (pick any combination)</span>
<TriggerToggle
@@ -360,7 +460,7 @@ export function LoopsWizard({
</div>
)}
{step === 4 && (
{!noAgents && step === 5 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={labelStyle}>Repeat policy</span>
<label style={radioRowStyle(repeatKind === "infinite")}>
@@ -467,7 +567,19 @@ export function LoopsWizard({
</div>
)}
{step === 5 && created && (
{!noAgents && step === 6 && (
<LoopStaffingStep
agents={rosterAgents}
selectedAgents={selectedAgents}
onAgentsChange={setSelectedAgents}
selectedTeamIds={selectedTeamIds}
onTeamsChange={setSelectedTeamIds}
selectedOrgIds={selectedOrgIds}
onOrgsChange={setSelectedOrgIds}
/>
)}
{!noAgents && step === 7 && 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
@@ -484,6 +596,7 @@ export function LoopsWizard({
)}
</div>
{!noAgents && (
<div
style={{
padding: 14,
@@ -493,20 +606,20 @@ export function LoopsWizard({
justifyContent: "space-between",
}}
>
{step <= 4 ? (
{step <= 6 ? (
<>
<button
type="button"
onClick={() => setStep((s) => ((s > 1 ? s - 1 : s) as 1 | 2 | 3 | 4 | 5))}
onClick={() => setStep((s) => ((s > 1 ? s - 1 : s) as 1 | 2 | 3 | 4 | 5 | 6 | 7))}
disabled={step === 1}
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
>
Back
</button>
{step < 4 ? (
{step < 6 ? (
<button
type="button"
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5))}
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5 | 6 | 7))}
disabled={!canNext}
style={{ ...primaryBtn, opacity: !canNext ? 0.4 : 1 }}
>
@@ -519,7 +632,13 @@ export function LoopsWizard({
disabled={submitting}
style={{ ...primaryBtn, opacity: submitting ? 0.6 : 1 }}
>
{submitting ? "Creating…" : "Create loop"}
{submitting
? editing
? "Saving…"
: "Creating…"
: editing
? "Save changes"
: "Create loop"}
</button>
)}
</>
@@ -536,6 +655,7 @@ export function LoopsWizard({
</>
)}
</div>
)}
</div>
</div>
);
@@ -856,6 +976,44 @@ function staffRoles(
return roles;
}
function readInitialTriggers(t: unknown): {
cron?: string;
on_completion?: boolean;
webhook_enabled?: boolean;
} {
if (!t || typeof t !== "object") return {};
const o = t as Record<string, unknown>;
return {
cron: typeof o.cron === "string" ? o.cron : undefined,
on_completion: !!o.on_completion,
webhook_enabled: !!o.webhook_enabled,
};
}
function readInitialRepeat(p: unknown): {
kind: "infinite" | "iters" | "until";
iters: number;
untilEvent: string;
untilWithin: number;
} {
const fallback = {
kind: "infinite" as const,
iters: 10,
untilEvent: "ok",
untilWithin: 20,
};
if (!p || typeof p !== "object") return fallback;
const o = p as Record<string, unknown>;
const kind = o.kind === "iters" || o.kind === "until" ? o.kind : "infinite";
return {
kind,
iters: typeof o.n === "number" ? o.n : fallback.iters,
untilEvent: typeof o.event === "string" ? o.event : fallback.untilEvent,
untilWithin:
typeof o.within_iters === "number" ? o.within_iters : fallback.untilWithin,
};
}
const labelStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,