loops wizard: auto-provision team in-wizard (parity with research)
ci / gates (push) Successful in 5s
ci / rust (push) Failing after 9s
ci / frontend (push) Failing after 17s
ci / e2e (push) Skipped
ci / publish (push) Skipped

The LoopsWizard hard-blocked on an empty roster via NoAgentsGate — new
users had to bounce to the Agents page first. Match ResearchWizard's
flow: remove the top-level gate, add an auto-provision panel to the
staffing step (6) that derives 3-5 roles from the loop's task via
Claude Sonnet 5 and preselects the returned agents into
selectedAgents so the existing submit path publishes them onto the
loop with zero extra clicks.

Existing rosters still work — the hand-pick panel now sits below the
auto-provision card and only renders when the workspace has agents and
no team was auto-provisioned this session.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-18 16:17:57 -07:00
co-authored by Claude Opus 4.7
parent 657b666219
commit 9cab32c354
+131 -21
View File
@@ -23,7 +23,10 @@ import {
// production build. Call /api/topologies with plain fetch instead. // production build. Call /api/topologies with plain fetch instead.
import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology"; import type { CatalogEntry, TopologyGraph } from "@/lib/api/topology";
import { RepoPicker, type PickedRepo } from "./RepoPicker"; import { RepoPicker, type PickedRepo } from "./RepoPicker";
import { NoAgentsGate } from "./NoAgentsGate"; import {
autoProvisionTeam,
type AutoProvisionResponse,
} from "@/lib/api/research";
// Do NOT import @/lib/api/team here — it transitively pulls bearer.ts // Do NOT import @/lib/api/team here — it transitively pulls bearer.ts
// (next/headers, server-only) into the client bundle and breaks the // (next/headers, server-only) into the client bundle and breaks the
// production build. Call /api/team/claws with plain fetch instead. // production build. Call /api/team/claws with plain fetch instead.
@@ -39,7 +42,7 @@ const mono =
export function LoopsWizard({ export function LoopsWizard({
initial, initial,
agentsCount, agentsCount: _agentsCount,
onClose, onClose,
onCreated, onCreated,
onSaved, onSaved,
@@ -53,9 +56,6 @@ export function LoopsWizard({
onSaved?: (id: string) => void; onSaved?: (id: string) => void;
}) { }) {
const editing = !!initial; const editing = !!initial;
const [rosterCount, setRosterCount] = useState<number | null>(
agentsCount ?? null,
);
const [rosterAgents, setRosterAgents] = useState<Agent[]>([]); const [rosterAgents, setRosterAgents] = useState<Agent[]>([]);
const [selectedAgents, setSelectedAgents] = useState<AgentSelection[]>( const [selectedAgents, setSelectedAgents] = useState<AgentSelection[]>(
initial?.agents?.map((a) => ({ initial?.agents?.map((a) => ({
@@ -155,17 +155,53 @@ export function LoopsWizard({
const claws = (await r.json()) as Agent[]; const claws = (await r.json()) as Agent[];
if (cancelled) return; if (cancelled) return;
setRosterAgents(claws); setRosterAgents(claws);
if (agentsCount === undefined) setRosterCount(claws.length);
} catch { } catch {
if (!cancelled && agentsCount === undefined) setRosterCount(0); // Roster is optional — auto-provision covers empty rosters.
} }
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [agentsCount]); }, []);
const noAgents = rosterCount !== null && rosterCount === 0; // Roster-empty state no longer blocks the wizard — step 6 offers
// auto-provision so a new user can pick a topology + kick off a team
// without visiting the Agents page first (parity with ResearchWizard).
// Auto-provision state: mirrors ResearchWizard's `runAutoProvision`
// flow. On success we preselect the returned agents so submit flows
// through the existing `agents:` submit path.
const [autoTeam, setAutoTeam] = useState<AutoProvisionResponse | null>(null);
const [autoProvisioning, setAutoProvisioning] = useState(false);
const [autoProvisionError, setAutoProvisionError] = useState<string | null>(
null,
);
async function runAutoProvision() {
setAutoProvisionError(null);
setAutoProvisioning(true);
try {
const r = await autoProvisionTeam({
title: title.trim() || task.trim().slice(0, 60) || "Loop team",
description: task.trim(),
outcome_kind: "loop",
topology_kind: kind,
model: "claude-sonnet-5",
});
setAutoTeam(r);
setSelectedAgents(
r.agents.map((a) => ({
agent_id: a.agent_id,
role_slot: a.role_slot,
})),
);
} catch (e) {
setAutoProvisionError(
e instanceof Error ? e.message : "auto-provision failed",
);
} finally {
setAutoProvisioning(false);
}
}
const selectedEntry = useMemo( const selectedEntry = useMemo(
() => catalog.find((c) => c.kind === kind), () => catalog.find((c) => c.kind === kind),
@@ -363,10 +399,7 @@ export function LoopsWizard({
</div> </div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}> <div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{noAgents ? ( {step === 1 && (
<NoAgentsGate what="loop" onDismiss={onClose} />
) : null}
{!noAgents && step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label style={labelStyle} htmlFor="loop-title">Title</label> <label style={labelStyle} htmlFor="loop-title">Title</label>
<input <input
@@ -387,7 +420,7 @@ export function LoopsWizard({
</div> </div>
)} )}
{!noAgents && step === 2 && ( {step === 2 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Repository (optional)</span> <span style={labelStyle}>Repository (optional)</span>
<p style={hintStyle}> <p style={hintStyle}>
@@ -399,7 +432,7 @@ export function LoopsWizard({
</div> </div>
)} )}
{!noAgents && step === 3 && ( {step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}> <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<label style={labelStyle} htmlFor="loop-task">Task template</label> <label style={labelStyle} htmlFor="loop-task">Task template</label>
@@ -499,7 +532,7 @@ export function LoopsWizard({
</div> </div>
)} )}
{!noAgents && step === 4 && ( {step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={labelStyle}>Triggers (pick any combination)</span> <span style={labelStyle}>Triggers (pick any combination)</span>
<TriggerToggle <TriggerToggle
@@ -564,7 +597,7 @@ export function LoopsWizard({
</div> </div>
)} )}
{!noAgents && step === 5 && ( {step === 5 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={labelStyle}>Repeat policy</span> <span style={labelStyle}>Repeat policy</span>
<label style={radioRowStyle(repeatKind === "infinite")}> <label style={radioRowStyle(repeatKind === "infinite")}>
@@ -671,7 +704,83 @@ export function LoopsWizard({
</div> </div>
)} )}
{!noAgents && step === 6 && ( {step === 6 && (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<div
style={{
padding: 12,
borderRadius: 8,
border: `1px solid ${autoTeam ? "rgba(95,208,138,.35)" : "rgba(94,200,216,.35)"}`,
background: autoTeam
? "rgba(95,208,138,.06)"
: "rgba(94,200,216,.06)",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 12,
color: autoTeam ? "#83e6a5" : "#7cd6e0",
}}
>
{autoTeam
? `Team ready · ${autoTeam.agents.length} agents · ${autoTeam.topology_kind}`
: "No team yet? Auto-provision one from the loop's task."}
</span>
{!autoTeam && (
<p style={hintStyle}>
We ask Claude Sonnet 5 to derive 3–5 roles from the task
above, then create each agent + wire them into this loop.
</p>
)}
{autoTeam ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{autoTeam.agents.map((a) => (
<span
key={a.agent_id}
style={{
fontFamily: mono,
fontSize: 11,
padding: "3px 8px",
borderRadius: 999,
border: "1px solid rgba(255,255,255,.14)",
background: "rgba(255,255,255,.04)",
color: "#d7d7db",
}}
>
{a.role_slot} · {a.display_name}
</span>
))}
</div>
) : (
<button
type="button"
onClick={runAutoProvision}
disabled={autoProvisioning || task.trim().length === 0}
style={{
...primaryBtn,
opacity:
autoProvisioning || task.trim().length === 0 ? 0.5 : 1,
}}
>
{autoProvisioning
? "Provisioning…"
: "Auto-provision team"}
</button>
)}
{autoProvisionError && (
<p style={{ ...hintStyle, color: "#ff8a7a" }}>
{autoProvisionError}
</p>
)}
</div>
{rosterAgents.length > 0 && !autoTeam ? (
<>
<span style={labelStyle}>Or hand-pick from your roster</span>
<LoopStaffingStep <LoopStaffingStep
agents={rosterAgents} agents={rosterAgents}
selectedAgents={selectedAgents} selectedAgents={selectedAgents}
@@ -681,9 +790,12 @@ export function LoopsWizard({
selectedOrgIds={selectedOrgIds} selectedOrgIds={selectedOrgIds}
onOrgsChange={setSelectedOrgIds} onOrgsChange={setSelectedOrgIds}
/> />
</>
) : null}
</div>
)} )}
{!noAgents && step === 7 && created && ( {step === 7 && created && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<p style={{ fontFamily: mono, fontSize: 12, color: "#ffb44a", lineHeight: 1.6 }}> <p style={{ fontFamily: mono, fontSize: 12, color: "#ffb44a", lineHeight: 1.6 }}>
Save these now — they&apos;re never shown again. To rotate, disable Save these now — they&apos;re never shown again. To rotate, disable
@@ -700,7 +812,6 @@ export function LoopsWizard({
)} )}
</div> </div>
{!noAgents && (
<div <div
style={{ style={{
padding: 14, padding: 14,
@@ -759,7 +870,6 @@ export function LoopsWizard({
</> </>
)} )}
</div> </div>
)}
</div> </div>
{pickerOpen ? ( {pickerOpen ? (
<ResearchArtifactPicker <ResearchArtifactPicker