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