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
@@ -0,0 +1,356 @@
"use client";
// The "Assign agents" step for the loops wizard. Three tabs:
// Individual — pick specific agents (with optional role slots)
// Team — pick one or more teams; the run driver expands each team
// to its members at fire time
// Org — pick one or more orgs; expanded transitively
//
// The three selections aren't mutually exclusive — you can mix a team with a
// couple of specialist agents. Backend stores them in loop_agents /
// loop_teams / loop_orgs and merges at run time.
import { useEffect, useMemo, useState } from "react";
import { Building2, User, Users } from "lucide-react";
import type { Agent } from "@/lib/api/schemas";
import { fetchOrgs, fetchTeams, type GroupSummary } from "@/lib/api/structure";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export interface AgentSelection {
agent_id: string;
role_slot: string;
}
export function LoopStaffingStep({
agents,
selectedAgents,
onAgentsChange,
selectedTeamIds,
onTeamsChange,
selectedOrgIds,
onOrgsChange,
}: {
agents: Agent[];
selectedAgents: AgentSelection[];
onAgentsChange: (v: AgentSelection[]) => void;
selectedTeamIds: string[];
onTeamsChange: (v: string[]) => void;
selectedOrgIds: string[];
onOrgsChange: (v: string[]) => void;
}) {
const [mode, setMode] = useState<"individual" | "team" | "org">("individual");
const [teams, setTeams] = useState<GroupSummary[] | null>(null);
const [orgs, setOrgs] = useState<GroupSummary[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const [t, o] = await Promise.all([fetchTeams(), fetchOrgs()]);
if (cancelled) return;
setTeams(t);
setOrgs(o);
} catch (e) {
if (!cancelled)
setLoadError(e instanceof Error ? e.message : "load failed");
}
})();
return () => {
cancelled = true;
};
}, []);
const totalPicked =
selectedAgents.length + selectedTeamIds.length + selectedOrgIds.length;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span style={labelStyle}>Assign agents</span>
<span style={hintStyle}>
{totalPicked === 0
? "None picked yet"
: `${totalPicked} selection${totalPicked === 1 ? "" : "s"}`}
</span>
</div>
<p style={hintStyle}>
Mix and match: pick individual agents, entire teams, or whole
organizations. At fire time the run driver expands teams and orgs
into their members and merges with any explicit agents you added.
</p>
<div style={pillRowStyle}>
<ModePill
active={mode === "individual"}
onClick={() => setMode("individual")}
icon={<User aria-hidden size={12} />}
label="Individual"
count={selectedAgents.length}
/>
<ModePill
active={mode === "team"}
onClick={() => setMode("team")}
icon={<Users aria-hidden size={12} />}
label="Team"
count={selectedTeamIds.length}
/>
<ModePill
active={mode === "org"}
onClick={() => setMode("org")}
icon={<Building2 aria-hidden size={12} />}
label="Organization"
count={selectedOrgIds.length}
/>
</div>
{mode === "individual" && (
<AgentList
agents={agents}
selected={selectedAgents}
onChange={onAgentsChange}
/>
)}
{mode === "team" && (
<GroupList
items={teams}
selectedIds={selectedTeamIds}
onChange={onTeamsChange}
empty="No teams in this workspace yet — create one from the TEAMS tier first."
/>
)}
{mode === "org" && (
<GroupList
items={orgs}
selectedIds={selectedOrgIds}
onChange={onOrgsChange}
empty="No organizations in this workspace yet — create one from the ORGS tier first."
/>
)}
{loadError && (
<p style={{ ...hintStyle, color: "#ff8a7a" }}>{loadError}</p>
)}
</div>
);
}
function AgentList({
agents,
selected,
onChange,
}: {
agents: Agent[];
selected: AgentSelection[];
onChange: (v: AgentSelection[]) => void;
}) {
if (agents.length === 0) {
return <p style={hintStyle}>No agents in this workspace yet.</p>;
}
return (
<div style={listStyle}>
{agents.map((a) => {
const chosen = selected.find((s) => s.agent_id === a.id);
return (
<div
key={a.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${chosen ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
background: chosen ? "rgba(255,111,97,.06)" : "transparent",
}}
>
<input
type="checkbox"
checked={!!chosen}
onChange={(e) => {
if (e.target.checked)
onChange([...selected, { agent_id: a.id, role_slot: "" }]);
else onChange(selected.filter((s) => s.agent_id !== a.id));
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{a.name}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
{a.job_title || "—"}
</div>
</div>
{chosen && (
<input
value={chosen.role_slot}
onChange={(e) =>
onChange(
selected.map((s) =>
s.agent_id === a.id
? { ...s, role_slot: e.target.value }
: s,
),
)
}
placeholder="role slot (optional)"
style={{ ...fieldStyle, width: 180, padding: "6px 10px" }}
/>
)}
</div>
);
})}
</div>
);
}
function GroupList({
items,
selectedIds,
onChange,
empty,
}: {
items: GroupSummary[] | null;
selectedIds: string[];
onChange: (v: string[]) => void;
empty: string;
}) {
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
if (items === null) return <p style={hintStyle}>Loading</p>;
if (items.length === 0) return <p style={hintStyle}>{empty}</p>;
return (
<div style={listStyle}>
{items.map((g) => {
const on = selectedSet.has(g.id);
return (
<label
key={g.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 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",
}}
>
<input
type="checkbox"
checked={on}
onChange={(e) => {
if (e.target.checked) onChange([...selectedIds, g.id]);
else onChange(selectedIds.filter((x) => x !== g.id));
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{g.name}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
{g.kind} · {g.status}
</div>
</div>
</label>
);
})}
</div>
);
}
function ModePill({
active,
onClick,
icon,
label,
count,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
count: number;
}) {
return (
<button
type="button"
onClick={onClick}
style={{
padding: "6px 12px",
borderRadius: 6,
background: active ? "rgba(255,138,122,.12)" : "transparent",
color: active ? "#ffbfb3" : "#8a8a92",
fontSize: 11,
fontFamily: mono,
fontWeight: 700,
letterSpacing: ".08em",
textTransform: "uppercase",
border: 0,
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
{icon}
{label}
{count > 0 && (
<span
style={{
padding: "1px 6px",
borderRadius: 5,
background: active ? "rgba(255,138,122,.2)" : "rgba(255,255,255,.06)",
fontSize: 10,
}}
>
{count}
</span>
)}
</button>
);
}
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 pillRowStyle: React.CSSProperties = {
display: "inline-flex",
gap: 2,
padding: 2,
borderRadius: 8,
background: "rgba(255,255,255,.04)",
border: "1px solid rgba(255,255,255,.06)",
alignSelf: "flex-start",
};
const listStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 6,
maxHeight: 320,
overflow: "auto",
paddingRight: 2,
};