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
@@ -15,6 +15,8 @@ import {
wizardRefine,
type OutcomeKind,
} from "@/lib/api/research";
import { RepoPicker, type PickedRepo } from "./RepoPicker";
import { NoAgentsGate } from "./NoAgentsGate";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
@@ -48,8 +50,9 @@ export function ResearchWizard({
onClose: () => void;
onCreated: (topicId: string) => void;
}) {
const [step, setStep] = useState<1 | 2 | 3 | 4>(1);
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1);
const [prompt, setPrompt] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null);
const [outcome, setOutcome] = useState<OutcomeKind>("spec");
const [refining, setRefining] = useState(false);
const [refineError, setRefineError] = useState<string | null>(null);
@@ -67,6 +70,7 @@ export function ResearchWizard({
prompt,
outcome_kind: outcome,
prior_description: prior,
...(repo ? { repo } : {}),
});
setTitle(out.suggested_title);
setDescription(out.description);
@@ -89,6 +93,7 @@ export function ResearchWizard({
agent_id: s.agent_id,
role_slot: s.role_slot.trim() || undefined,
})),
...(repo ? { repo } : {}),
});
onCreated(id);
} catch (e) {
@@ -100,9 +105,10 @@ export function ResearchWizard({
const canNext =
(step === 1 && prompt.trim().length > 0) ||
(step === 2 && title.trim().length > 0 && description.trim().length > 0) ||
step === 3 ||
step === 4;
step === 2 ||
(step === 3 && title.trim().length > 0 && description.trim().length > 0) ||
step === 4 ||
step === 5;
return (
<div
@@ -143,7 +149,7 @@ export function ResearchWizard({
}}
>
<span style={{ fontFamily: mono, fontSize: 10, color: "#5a5a62" }}>
STEP {step} / 4
STEP {step} / 5
</span>
<span style={{ flex: 1, fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>
New research topic
@@ -171,7 +177,10 @@ export function ResearchWizard({
{/* Body */}
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{step === 1 && (
{agents.length === 0 ? (
<NoAgentsGate what="research topic" onDismiss={onClose} />
) : null}
{agents.length > 0 && step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label htmlFor="topic-prompt" style={{ fontFamily: mono, fontSize: 11, color: "#b5b5bd" }}>
Topic prompt
@@ -191,7 +200,18 @@ export function ResearchWizard({
</div>
)}
{step === 2 && (
{agents.length > 0 && step === 2 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Repository (optional)</span>
<p style={hintStyle}>
Anchor the topic to a connected repo the refine step will
use it as context, and downstream outcomes can cite it.
</p>
<RepoPicker value={repo} onChange={setRepo} optional />
</div>
)}
{agents.length > 0 && step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{!title && !refining ? (
<button
@@ -247,7 +267,7 @@ export function ResearchWizard({
</div>
)}
{step === 3 && (
{agents.length > 0 && step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Outcome kind</span>
{OUTCOMES.map((o) => (
@@ -279,7 +299,7 @@ export function ResearchWizard({
</div>
)}
{step === 4 && (
{agents.length > 0 && step === 5 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Assign agents</span>
<p style={hintStyle}>Zero or more. Each optional role slot (&quot;lead&quot;, &quot;critic&quot;) groups avatars on the canvas.</p>
@@ -346,6 +366,7 @@ export function ResearchWizard({
</div>
{/* Footer */}
{agents.length > 0 && (
<div
style={{
padding: 14,
@@ -357,16 +378,16 @@ export function ResearchWizard({
>
<button
type="button"
onClick={() => setStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3 | 4) : s))}
onClick={() => setStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3 | 4 | 5) : s))}
disabled={step === 1}
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
>
Back
</button>
{step < 4 ? (
{step < 5 ? (
<button
type="button"
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4))}
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5))}
disabled={!canNext || refining}
style={{ ...primaryBtn, opacity: !canNext || refining ? 0.4 : 1 }}
>
@@ -383,6 +404,7 @@ export function ResearchWizard({
</button>
)}
</div>
)}
</div>
</div>
);