loops: repo picker + agent/team/org staffing + sidebar edit/delete
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:
@@ -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'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,
|
||||
|
||||
Reference in New Issue
Block a user