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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user