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,397 @@
"use client";
// Shared repo picker used by the Loop + Research wizards. Fetches the
// workspace's already-connected repos from /api/repos (+ /api/repos/connections
// for the provider label) and lets the user pick one — or skip when optional.
//
// Emits a compact PickedRepo the caller can send in its create body; the
// backend can persist the id later without another round-trip.
import { useEffect, useMemo, useState } from "react";
import { GitBranch, Search, X } from "lucide-react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export interface PickedRepo {
repo_id: string;
connection_id: string;
provider: string;
owner: string;
name: string;
default_branch: string | null;
}
interface RepoSummary {
id: string;
connection_id: string;
provider: string;
owner: string;
name: string;
private: boolean;
description: string | null;
default_branch: string | null;
stars: number;
forks: number;
updated_at: string | null;
}
interface ConnectionSummary {
id: string;
provider: string;
owner: string | null;
base_url?: string | null;
label?: string;
}
export function RepoPicker({
value,
onChange,
optional = false,
emptyHint,
}: {
value: PickedRepo | null;
onChange: (v: PickedRepo | null) => void;
optional?: boolean;
emptyHint?: string;
}) {
const [repos, setRepos] = useState<RepoSummary[] | null>(null);
const [connections, setConnections] = useState<ConnectionSummary[]>([]);
const [error, setError] = useState<string | null>(null);
const [query, setQuery] = useState("");
useEffect(() => {
let cancelled = false;
async function load() {
try {
const [rRes, cRes] = await Promise.all([
fetch("/api/repos"),
fetch("/api/repos/connections"),
]);
if (cancelled) return;
if (rRes.ok) setRepos(await rRes.json());
else setRepos([]);
if (cRes.ok) setConnections(await cRes.json());
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "load failed");
setRepos([]);
}
}
}
void load();
return () => {
cancelled = true;
};
}, []);
const connLabel = useMemo(() => {
const m = new Map<string, string>();
for (const c of connections) {
m.set(c.id, c.label || c.owner || c.provider);
}
return m;
}, [connections]);
const filtered = useMemo(() => {
if (!repos) return [];
const q = query.trim().toLowerCase();
if (!q) return repos;
return repos.filter(
(r) =>
r.name.toLowerCase().includes(q) ||
r.owner.toLowerCase().includes(q) ||
(r.description ?? "").toLowerCase().includes(q),
);
}, [repos, query]);
const grouped = useMemo(() => groupByOwner(filtered), [filtered]);
if (repos === null) {
return (
<p style={hintStyle}>Loading repositories</p>
);
}
if (repos.length === 0) {
return (
<div
style={{
padding: 14,
borderRadius: 10,
border: "1px dashed rgba(255,255,255,.14)",
background: "rgba(255,255,255,.02)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ fontFamily: mono, fontSize: 12, color: "#cfcfd5" }}>
No repositories connected yet.
</div>
<div style={hintStyle}>
{emptyHint ??
"Connect a GitHub/Gitea provider from the REPOS tier first, then come back."}
</div>
{optional ? (
<button
type="button"
onClick={() => onChange(null)}
style={{ ...secondaryBtn, alignSelf: "flex-start" }}
>
Continue without a repo
</button>
) : null}
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{value && (
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: "1px solid rgba(255,111,97,.5)",
background: "rgba(255,111,97,.08)",
}}
>
<GitBranch aria-hidden size={14} style={{ color: "#ffbfb3" }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontFamily: mono,
fontSize: 12,
color: "#f3f3f5",
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{value.owner}/{value.name}
</div>
<div style={hintStyle}>
{value.provider}
{value.default_branch ? ` · ${value.default_branch}` : ""}
{connLabel.get(value.connection_id)
? ` · ${connLabel.get(value.connection_id)}`
: ""}
</div>
</div>
<button
type="button"
onClick={() => onChange(null)}
aria-label="Clear selection"
style={{
width: 26,
height: 26,
borderRadius: 6,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X aria-hidden size={12} />
</button>
</div>
)}
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "#0a0a0c",
}}
>
<Search aria-hidden size={13} style={{ color: "#8a8a92" }} />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Filter by owner, name, or description…"
style={{
flex: 1,
border: 0,
outline: "none",
background: "transparent",
color: "#f3f3f5",
fontSize: 13,
}}
/>
{optional ? (
<button
type="button"
onClick={() => onChange(null)}
style={{
...secondaryBtn,
padding: "4px 10px",
fontSize: 11,
}}
>
None
</button>
) : null}
</div>
<div
style={{
maxHeight: 320,
overflow: "auto",
border: "1px solid rgba(255,255,255,.06)",
borderRadius: 10,
background: "rgba(255,255,255,.02)",
}}
>
{grouped.length === 0 ? (
<p style={{ ...hintStyle, padding: 12 }}>No repos match the filter.</p>
) : (
grouped.map(([owner, list]) => (
<div key={owner}>
<div
style={{
padding: "6px 12px",
fontFamily: mono,
fontSize: 10,
letterSpacing: ".1em",
textTransform: "uppercase",
color: "#8a8a92",
background: "rgba(255,255,255,.03)",
borderBottom: "1px solid rgba(255,255,255,.05)",
position: "sticky",
top: 0,
}}
>
{owner}
</div>
{list.map((r) => {
const active = value?.repo_id === r.id;
return (
<button
key={r.id}
type="button"
onClick={() =>
onChange({
repo_id: r.id,
connection_id: r.connection_id,
provider: r.provider,
owner: r.owner,
name: r.name,
default_branch: r.default_branch,
})
}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
textAlign: "left",
padding: "8px 12px",
background: active
? "rgba(255,138,122,.08)"
: "transparent",
border: 0,
borderBottom: "1px solid rgba(255,255,255,.04)",
cursor: "pointer",
color: "#eaeaee",
}}
>
<GitBranch
aria-hidden
size={13}
style={{ color: active ? "#ffbfb3" : "#5a5a62" }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: active ? 700 : 500,
color: "#f3f3f5",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{r.name}
</div>
{r.description ? (
<div
style={{
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{r.description}
</div>
) : null}
</div>
<span
style={{
fontFamily: mono,
fontSize: 10,
color: "#5a5a62",
flexShrink: 0,
}}
>
{r.default_branch ?? "—"}
</span>
</button>
);
})}
</div>
))
)}
</div>
{error && (
<p style={{ ...hintStyle, color: "#ff8a7a" }}>{error}</p>
)}
</div>
);
}
function groupByOwner(repos: RepoSummary[]): [string, RepoSummary[]][] {
const map = new Map<string, RepoSummary[]>();
for (const r of repos) {
const key = r.owner || "(unknown)";
const bucket = map.get(key) ?? [];
bucket.push(r);
map.set(key, bucket);
}
const out = Array.from(map.entries());
out.sort(([a], [b]) => a.localeCompare(b));
for (const [, arr] of out) arr.sort((x, y) => x.name.localeCompare(y.name));
return out;
}
const hintStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
lineHeight: 1.5,
};
const secondaryBtn: React.CSSProperties = {
padding: "6px 12px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 12,
fontWeight: 600,
cursor: "pointer",
};