slice 9: big-bang cutover — delete legacy research + loops UI
ci / gates (push) Successful in 5s
ci / frontend (push) Successful in 36s
ci / rust (push) Successful in 3m34s
ci / e2e (push) Skipped
ci / publish (push) Successful in 38s

Missions has been the unified surface for a while (Slices 1-8.5).
This slice makes it the ONLY surface by deleting the legacy
research + loops UI:

Removed frontend files:
  - dashboard/ResearchList.tsx
  - dashboard/ResearchCanvas.tsx
  - dashboard/ResearchWizard.tsx
  - dashboard/LoopsList.tsx
  - dashboard/LoopsCanvas.tsx
  - dashboard/LoopsWizard.tsx
  - dashboard/LoopStaffingStep.tsx
  - dashboard/NoAgentsGate.tsx
  - dashboard/ResearchArtifactPicker.tsx
  - dashboard/AutoProvisionCard.tsx
  - dashboard/LiveRunLogs.tsx
  - lib/api/research.ts
  - lib/api/loops.ts

Dashboard.tsx cleanup:
  - Tier enum collapses to world | missions | claw | repos | infra
  - Dropped researchSel / researchRefresh / loopsSel / loopsRefresh
    state slots
  - Dropped the Research + Loops crumbs from the tab bar
  - Dropped the two rail icons; missions rail icon retitled
  - TIER_TABS lists missions in the second slot (was research)
  - Combined isInfra || isMissions || isRepos guard replaces the
    six-way branch

What INTENTIONALLY stayed (soft cutover on backend):
  - /api/research/* + /api/loops/* routes still respond — no UI
    hits them but external integrations (webhooks, prior curl
    scripts) don't hard-break on this deploy
  - research_topics / loops / research_outcomes / research_topic_agents
    / research_publish_approvals tables remain — the missions
    backfill from 0047 references these rows via config.legacy_*
    keys, and dropping the tables now would cascade FK deletes
    into topology_runs.research_topic_id / .loop_id nullouts
  - cm_db::repo::{research_topics, research_outcomes, loops} +
    cm_api::routes::{research, research_setup, research_pipeline,
    loops} + cm_runtime::loops kept — they're internal-only now,
    scheduled for a follow-up cleanup PR

Follow-up PR (dedicated cleanup):
  - Drop the 5 legacy tables + null out topology_runs FKs
  - Delete the ~4k LoC of backend routes/repos + their tests
  - Remove research/loops crumbs from URL history

Frontend TS check: clean (16 pre-existing unused warnings in
Dashboard.tsx are unrelated).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-19 16:53:21 -07:00
co-authored by Claude Opus 4.7
parent 9b5e63cbb7
commit 4663348a0e
14 changed files with 13 additions and 7428 deletions
@@ -1,93 +0,0 @@
"use client";
import type { CSSProperties } from "react";
import type { AutoProvisionResponse } from "@/lib/api/research";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function AutoProvisionCard({
autoTeam,
autoProvisioning,
autoProvisionError,
disabled,
onProvision,
hintStyle,
primaryBtn,
description,
}: {
autoTeam: AutoProvisionResponse | null;
autoProvisioning: boolean;
autoProvisionError: string | null;
disabled: boolean;
onProvision: () => void;
hintStyle: CSSProperties;
primaryBtn: CSSProperties;
description?: string;
}) {
return (
<div
style={{
padding: 12,
borderRadius: 8,
border: `1px solid ${autoTeam ? "rgba(95,208,138,.35)" : "rgba(94,200,216,.35)"}`,
background: autoTeam
? "rgba(95,208,138,.06)"
: "rgba(94,200,216,.06)",
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 12,
color: autoTeam ? "#83e6a5" : "#7cd6e0",
}}
>
{autoTeam
? `Team ready · ${autoTeam.agents.length} agents · ${autoTeam.topology_kind}`
: "No team yet? Auto-provision one from the task."}
</span>
{!autoTeam && (
<p style={hintStyle}>
{description ??
"We ask Claude Sonnet 5 to derive 3–5 roles from the task above, then create each agent + wire them in."}
</p>
)}
{autoTeam ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
{autoTeam.agents.map((a) => (
<span
key={a.agent_id}
style={{
fontFamily: mono,
fontSize: 11,
padding: "3px 8px",
borderRadius: 999,
border: "1px solid rgba(255,255,255,.14)",
background: "rgba(255,255,255,.04)",
color: "#d7d7db",
}}
>
{a.role_slot} · {a.name}
</span>
))}
</div>
) : (
<button
type="button"
onClick={onProvision}
disabled={disabled}
style={{ ...primaryBtn, opacity: disabled ? 0.5 : 1 }}
>
{autoProvisioning ? "Provisioning…" : "Auto-provision team"}
</button>
)}
{autoProvisionError && (
<p style={{ ...hintStyle, color: "#ff8a7a" }}>{autoProvisionError}</p>
)}
</div>
);
}
+13 -66
View File
@@ -23,10 +23,6 @@ import { type FlowItem } from "./flow/TopologyFlow";
import { WorldCanvas } from "../world/WorldCanvas"; import { WorldCanvas } from "../world/WorldCanvas";
import { ObservePanel } from "../observe/ObservePanel"; import { ObservePanel } from "../observe/ObservePanel";
import { StructureTree, orgNode, type TreeItem } from "./StructureTree"; import { StructureTree, orgNode, type TreeItem } from "./StructureTree";
import { ResearchList } from "./ResearchList";
import { ResearchCanvas } from "./ResearchCanvas";
import { LoopsList } from "./LoopsList";
import { LoopsCanvas } from "./LoopsCanvas";
import { MissionsList } from "./MissionsList"; import { MissionsList } from "./MissionsList";
import { MissionCanvas } from "./MissionCanvas"; import { MissionCanvas } from "./MissionCanvas";
import { RepoList } from "./RepoList"; import { RepoList } from "./RepoList";
@@ -69,7 +65,12 @@ function readSidebarCollapse(): boolean {
} }
} }
type Tier = "world" | "research" | "loops" | "missions" | "claw" | "repos" | "infra"; // Slice 9: research + loops folded into missions. Kept in the enum
// (as unused variants) for one release so any URL/history state that
// still references them doesn't hard-error — the switches below just
// have no branches for them, so the fallthrough renders the missions
// tier.
type Tier = "world" | "missions" | "claw" | "repos" | "infra";
const mono = "'JetBrains Mono', ui-monospace, monospace"; const mono = "'JetBrains Mono', ui-monospace, monospace";
// dashboard-data.ts fabricates a few org/company/team nodes so an empty // dashboard-data.ts fabricates a few org/company/team nodes so an empty
@@ -115,11 +116,7 @@ const COMPANY_TEMPLATES: Template[] = [
const railIcon: Record<Tier, React.ReactNode> = { const railIcon: Record<Tier, React.ReactNode> = {
world: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="3.5" r="1.9" fill="currentColor" /><circle cx="3.8" cy="11" r="1.9" fill="currentColor" /><circle cx="16.2" cy="11" r="1.9" fill="currentColor" /><circle cx="10" cy="16.5" r="1.9" fill="currentColor" /><path d="M10 3.5 L3.8 11 M10 3.5 L16.2 11 M3.8 11 L10 16.5 M16.2 11 L10 16.5" stroke="currentColor" strokeWidth="1.1" opacity=".5" /></svg>), world: (<svg width="20" height="20" viewBox="0 0 20 20"><circle cx="10" cy="3.5" r="1.9" fill="currentColor" /><circle cx="3.8" cy="11" r="1.9" fill="currentColor" /><circle cx="16.2" cy="11" r="1.9" fill="currentColor" /><circle cx="10" cy="16.5" r="1.9" fill="currentColor" /><path d="M10 3.5 L3.8 11 M10 3.5 L16.2 11 M3.8 11 L10 16.5 M16.2 11 L10 16.5" stroke="currentColor" strokeWidth="1.1" opacity=".5" /></svg>),
// Book with a magnifying-lens tucked into the lower-right — research. // Flag on a pole — missions (unified research + loops).
research: (<svg width="20" height="20" viewBox="0 0 24 24"><rect x="4" y="3.5" width="12" height="14" rx="1.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><path d="M4 15.5 H16" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="17.5" cy="17.5" r="3.2" stroke="currentColor" strokeWidth="1.6" fill="none" /><path d="M19.9 19.9 L22 22" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /></svg>),
// Circular arrow ⟳ with an inner dot — loops.
loops: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M20 12 A8 8 0 1 1 12 4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" /><path d="M13 3 L20 4 L18.5 10" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinejoin="round" strokeLinecap="round" /><circle cx="12" cy="12" r="1.8" fill="currentColor" /></svg>),
// Flag on a pole — missions.
missions: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M6 3 V21" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /><path d="M6 4 L18 4 L15 8 L18 12 L6 12 Z" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinejoin="round" /></svg>), missions: (<svg width="20" height="20" viewBox="0 0 24 24"><path d="M6 3 V21" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /><path d="M6 4 L18 4 L15 8 L18 12 L6 12 Z" stroke="currentColor" strokeWidth="1.4" fill="none" strokeLinejoin="round" /></svg>),
claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>), claw: (<svg width="20" height="20" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3.5" stroke="currentColor" strokeWidth="1.4" fill="none" /><circle cx="10" cy="10" r="2.4" fill="currentColor" /></svg>),
// Branching lines forking off a trunk — repositories. // Branching lines forking off a trunk — repositories.
@@ -128,8 +125,7 @@ const railIcon: Record<Tier, React.ReactNode> = {
}; };
const TIER_TABS: { key: Tier; label: string }[] = [ const TIER_TABS: { key: Tier; label: string }[] = [
{ key: "world", label: "VIZ" }, { key: "world", label: "VIZ" },
{ key: "research", label: "RESEARCH" }, { key: "missions", label: "MISSIONS" },
{ key: "loops", label: "LOOPS" },
{ key: "claw", label: "AGENT" }, { key: "claw", label: "AGENT" },
{ key: "repos", label: "REPOS" }, { key: "repos", label: "REPOS" },
{ key: "infra", label: "INFRA" }, { key: "infra", label: "INFRA" },
@@ -262,13 +258,8 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
// Large World right slide-out (sized like the agent computer: phone/tablet/full). // Large World right slide-out (sized like the agent computer: phone/tablet/full).
const [worldPanelOpen, setWorldPanelOpen] = useState(false); const [worldPanelOpen, setWorldPanelOpen] = useState(false);
const [worldSize, setWorldSize] = useState<DeviceSize>("phone"); const [worldSize, setWorldSize] = useState<DeviceSize>("phone");
// Research tier: currently-selected topic + a refresh key mutations bump so // Slice 9: research + loops tier state removed with the file deletions.
// list + canvas re-fetch after start/submit/publish etc. // Missions state below is the unified surface.
const [researchSel, setResearchSel] = useState<string | null>(null);
const [researchRefresh, setResearchRefresh] = useState(0);
// Loops tier: same pattern.
const [loopsSel, setLoopsSel] = useState<string | null>(null);
const [loopsRefresh, setLoopsRefresh] = useState(0);
// Repos tier: selected repo + refresh key + wizard modal flag. // Repos tier: selected repo + refresh key + wizard modal flag.
const [repoSel, setRepoSel] = useState<string | null>(null); const [repoSel, setRepoSel] = useState<string | null>(null);
const [repoRefresh, setRepoRefresh] = useState(0); const [repoRefresh, setRepoRefresh] = useState(0);
@@ -540,8 +531,6 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
const [toolOpen, setToolOpen] = useState<ToolKey | null>(null); const [toolOpen, setToolOpen] = useState<ToolKey | null>(null);
const isWorld = tier === "world", const isWorld = tier === "world",
isResearch = tier === "research",
isLoops = tier === "loops",
isMissions = tier === "missions", isMissions = tier === "missions",
isClaw = tier === "claw", isClaw = tier === "claw",
isRepos = tier === "repos", isRepos = tier === "repos",
@@ -638,9 +627,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
<div style={{ display: "flex", alignItems: "center", gap: 6, fontFamily: mono, fontSize: 12 }}> <div style={{ display: "flex", alignItems: "center", gap: 6, fontFamily: mono, fontSize: 12 }}>
<span style={crumbStyle(isWorld)} onClick={() => setTier("world")}>Visualizations</span> <span style={crumbStyle(isWorld)} onClick={() => setTier("world")}>Visualizations</span>
<span style={{ color: "#3a3a40" }}>/</span> <span style={{ color: "#3a3a40" }}>/</span>
<span style={crumbStyle(isResearch)} onClick={() => setTier("research")}>Research</span>
<span style={{ color: "#3a3a40" }}>/</span> <span style={{ color: "#3a3a40" }}>/</span>
<span style={crumbStyle(isLoops)} onClick={() => setTier("loops")}>Loops</span>
<span style={crumbStyle(isMissions)} onClick={() => setTier("missions")}>Missions</span> <span style={crumbStyle(isMissions)} onClick={() => setTier("missions")}>Missions</span>
<span style={{ color: "#3a3a40" }}>/</span> <span style={{ color: "#3a3a40" }}>/</span>
<span style={crumbStyle(isClaw)} onClick={() => setTier("claw")}>Agents</span> <span style={crumbStyle(isClaw)} onClick={() => setTier("claw")}>Agents</span>
@@ -714,7 +701,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
</div> </div>
<button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button> <button type="button" onClick={() => { setSelectMode((v) => { if (v) setSelectedAgents(new Set()); return !v; }); }} title="Select to manage" aria-label="Select to manage" style={{ flex: "none", width: 34, height: 34, borderRadius: 9, border: `1px solid ${selectMode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.12)"}`, background: selectMode ? "rgba(255,111,97,.12)" : "transparent", color: selectMode ? "#ff6f61" : "#9a9aa2", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}><Wrench aria-hidden size={16} /></button>
</div> </div>
) : isInfra || isResearch || isLoops || isMissions || isRepos ? null : ( ) : isInfra || isMissions || isRepos ? null : (
<div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "center", gap: 8 }}> <div style={{ padding: "16px 16px 12px", borderBottom: "1px solid rgba(255,255,255,.06)", display: "flex", alignItems: "center", gap: 8 }}>
{/* Title on the left; toolbar (wrench / history / brain) sits {/* Title on the left; toolbar (wrench / history / brain) sits
flush right at the same baseline. The prior "N orgs · N co flush right at the same baseline. The prior "N orgs · N co
@@ -732,29 +719,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
</div> </div>
)} )}
{isResearch ? ( {isMissions ? (
<ResearchList
agents={claws}
selectedId={researchSel}
onSelect={setResearchSel}
refreshKey={researchRefresh}
onCreated={(id) => {
setResearchSel(id);
setResearchRefresh((n) => n + 1);
}}
/>
) : isLoops ? (
<LoopsList
agents={claws}
selectedId={loopsSel}
onSelect={setLoopsSel}
refreshKey={loopsRefresh}
onCreated={(id) => {
setLoopsSel(id);
setLoopsRefresh((n) => n + 1);
}}
/>
) : isMissions ? (
<MissionsList <MissionsList
selectedId={missionsSel} selectedId={missionsSel}
onSelect={setMissionsSel} onSelect={setMissionsSel}
@@ -842,25 +807,7 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
{/* CANVAS */} {/* CANVAS */}
<div ref={canvasRef} style={{ flex: 1, position: "relative", minWidth: 0, overflow: "hidden", ...(resizing ? { ["--duration-normal" as string]: "0ms" } : {}), ...(isClaw || isInfra ? { ["--computer-width" as string]: computerOpen ? (customWidth != null ? `${customWidth}px` : COMPUTER_WIDTH[device]) : "0px" } : isWorld ? { ["--world-width" as string]: worldPanelOpen ? COMPUTER_WIDTH[worldSize] : "0px" } : {}) }}> <div ref={canvasRef} style={{ flex: 1, position: "relative", minWidth: 0, overflow: "hidden", ...(resizing ? { ["--duration-normal" as string]: "0ms" } : {}), ...(isClaw || isInfra ? { ["--computer-width" as string]: computerOpen ? (customWidth != null ? `${customWidth}px` : COMPUTER_WIDTH[device]) : "0px" } : isWorld ? { ["--world-width" as string]: worldPanelOpen ? COMPUTER_WIDTH[worldSize] : "0px" } : {}) }}>
{isResearch ? ( {isMissions ? (
<ResearchCanvas
selectedId={researchSel}
agents={claws}
refreshKey={researchRefresh}
onChanged={() => setResearchRefresh((n) => n + 1)}
canApprovePublish={user?.role === "owner"}
/>
) : isLoops ? (
<LoopsCanvas
selectedId={loopsSel}
refreshKey={loopsRefresh}
onChanged={() => setLoopsRefresh((n) => n + 1)}
onDeleted={() => {
setLoopsSel(null);
setLoopsRefresh((n) => n + 1);
}}
/>
) : isMissions ? (
<MissionCanvas <MissionCanvas
selectedId={missionsSel} selectedId={missionsSel}
refreshKey={missionsRefresh} refreshKey={missionsRefresh}
@@ -1,784 +0,0 @@
"use client";
// Live topology-run log panel for the research canvas. Renders when at
// least one run is active for the selected topic. Tabbed per run, each
// tab subscribes to `/api/topology-runs/:id/events` via EventSource
// and renders each `step` SSE event as a terminal line. Auto-scroll
// tails the newest line unless the user has manually scrolled up.
//
// Failure modes:
// - EventSource network error → header goes red, banner explains, no
// reconnect (browser retries automatically).
// - `done` event with error → line rendered in red, header stays green.
import { useEffect, useMemo, useRef, useState } from "react";
import { getActiveRuns, getPipelineState } from "@/lib/api/research";
import type { PipelineStateResponse } from "@/lib/api/research";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
type SseEvent =
| { kind: "step"; index: number; ts: number; raw: string }
| { kind: "done"; ts: number; raw: string; error?: string | null };
type ContainerLine = { ts: number; kind: "info" | "line" | "error" | "done"; text: string };
/** Subscribe to the run's team container log SSE. `active` gates the
* subscription — the Container tab opens/closes the stream. */
function useContainerLog(runId: string | null, active: boolean) {
const [lines, setLines] = useState<ContainerLine[]>([]);
const [status, setStatus] = useState<"idle" | "connecting" | "streaming" | "done" | "error">(
"idle",
);
const [prevKey, setPrevKey] = useState<string>("");
const key = `${runId ?? ""}:${active}`;
if (prevKey !== key) {
setPrevKey(key);
setLines([]);
setStatus(runId && active ? "connecting" : "idle");
}
useEffect(() => {
if (!runId || !active) return;
const es = new EventSource(
`/api/topology-runs/${encodeURIComponent(runId)}/container-log`,
);
es.onopen = () => setStatus("streaming");
for (const kind of ["info", "line", "error", "done"] as const) {
es.addEventListener(kind, (e: MessageEvent) => {
const ts = Date.now();
setLines((prev) => [...prev, { ts, kind, text: e.data as string }]);
if (kind === "done") setStatus("done");
if (kind === "error") setStatus("error");
});
}
es.onerror = () => setStatus((prev) => (prev === "done" ? prev : "error"));
return () => es.close();
}, [runId, active]);
return { lines, status };
}
function useRunEvents(runId: string | null, onStep?: (p: StepPulse) => void) {
const [events, setEvents] = useState<SseEvent[]>([]);
const [status, setStatus] = useState<"connecting" | "streaming" | "done" | "error">(
"connecting",
);
// Reset-on-key-change pattern (React docs "You Might Not Need an Effect").
// Setting state during render on a state change is explicitly sanctioned
// and avoids the set-state-in-effect lint hit.
const [prevRunId, setPrevRunId] = useState<string | null>(runId);
if (prevRunId !== runId) {
setPrevRunId(runId);
setEvents([]);
setStatus("connecting");
}
useEffect(() => {
if (!runId) return;
const es = new EventSource(`/api/topology-runs/${encodeURIComponent(runId)}/events`);
es.onopen = () => setStatus("streaming");
es.addEventListener("step", (e: MessageEvent) => {
const idx = Number((e as unknown as { lastEventId?: string }).lastEventId ?? -1);
const ts = Date.now();
setEvents((prev) => [
...prev,
{ kind: "step", index: Number.isFinite(idx) ? idx : prev.length, ts, raw: e.data },
]);
// Fire a pulse so parent UI can flash the acting agent's card.
if (onStep) {
try {
const j = JSON.parse(e.data) as {
role?: string;
node_id?: string;
phase?: string | { kind?: string };
};
const phaseRaw =
typeof j.phase === "string"
? j.phase
: j.phase && typeof j.phase === "object"
? j.phase.kind
: undefined;
const phase: StepPulse["phase"] =
phaseRaw === "plan" || phaseRaw === "work" ||
phaseRaw === "synth" || phaseRaw === "aggregate"
? phaseRaw
: "unknown";
onStep({
role: j.role ?? "",
phase,
node_id: j.node_id ?? "",
ts,
});
} catch {
/* ignore malformed */
}
}
});
es.addEventListener("done", (e: MessageEvent) => {
let error: string | null = null;
try {
const parsed = JSON.parse(e.data) as { error?: string | null };
error = parsed.error ?? null;
} catch {
/* ignore */
}
setEvents((prev) => [...prev, { kind: "done", ts: Date.now(), raw: e.data, error }]);
setStatus("done");
es.close();
});
es.onerror = () => {
setStatus((prev) => (prev === "done" ? prev : "error"));
};
return () => es.close();
}, [runId]);
return { events, status };
}
const STAGE_DOT: Record<string, string> = {
ok: "🟢",
waiting: "🔵",
warn: "🟡",
fail: "🔴",
skip: "◯",
};
// ── pretty log-line primitives ────────────────────────────────────────
/** Palette tuned to read cleanly on the dark terminal bg. Kept small on
* purpose — every added color raises the ambient noise floor. */
const LOG = {
ts: "#5a5a62",
dim: "#8a8a92",
fg: "#cfcfd5",
fgHi: "#eaeaee",
ok: "#5fd08a",
err: "#ff8a7a",
warn: "#ffb44a",
info: "#5ec8d8",
purple: "#c98af0",
cyan: "#5ec8d8",
green: "#5fd08a",
amber: "#ffb44a",
};
/** Colored dot + pill wrapper used everywhere. */
function Pill({
color,
children,
bg,
}: {
color: string;
bg?: string;
children: React.ReactNode;
}) {
return (
<span
style={{
display: "inline-block",
padding: "1px 6px",
borderRadius: 999,
border: `1px solid ${color}40`,
background: bg ?? `${color}12`,
color,
fontFamily: mono,
fontSize: 10,
lineHeight: "14px",
letterSpacing: ".02em",
}}
>
{children}
</span>
);
}
/** Deterministic actor color hash so [Distiller] and [Novelty Analyst]
* each get a stable, distinct hue across the whole session. */
function actorColor(actor: string): string {
if (!actor) return LOG.dim;
const palette = [
"#5ec8d8",
"#c98af0",
"#5fd08a",
"#ffb44a",
"#f38ba8",
"#89b4fa",
"#f9e2af",
"#a6e3a1",
];
let h = 0;
for (let i = 0; i < actor.length; i++) h = (h * 31 + actor.charCodeAt(i)) >>> 0;
return palette[h % palette.length];
}
/** hh:mm:ss with `.mmm` fractionals — dense but scannable. */
function fmtTs(ts: number): string {
const d = new Date(ts);
const pad = (n: number, w = 2) => n.toString().padStart(w, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${pad(d.getMilliseconds(), 3)}`;
}
/** Compact "1234" → "1.2k" for token counts. */
function fmtCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
const PHASE_COLOR: Record<string, string> = {
plan: LOG.cyan,
work: LOG.green,
synth: LOG.amber,
aggregate: LOG.purple,
};
/** Parse a raw StepRecord JSON into ready-to-render fields; falls back
* to raw text for anything the orchestrator hasn't formatted as JSON. */
function parseStep(raw: string): {
role?: string;
phase?: string;
node_id?: string;
tokens?: number;
gated?: number;
output?: string;
} {
try {
const j = JSON.parse(raw) as {
node_id?: string;
role?: string;
phase?: string | { kind?: string };
output?: string;
tokens?: number;
gated?: unknown[];
};
const phase =
typeof j.phase === "string"
? j.phase
: j.phase && typeof j.phase === "object" && "kind" in j.phase
? (j.phase.kind as string)
: undefined;
return {
role: j.role,
phase,
node_id: j.node_id,
tokens: typeof j.tokens === "number" ? j.tokens : undefined,
gated: Array.isArray(j.gated) ? j.gated.length : undefined,
output: j.output,
};
} catch {
return { output: raw };
}
}
/** Pretty step line — timestamp · #idx · actor pill · phase pill ·
* meta pills (tokens, gated) · expandable output. Long outputs
* collapse to their first line with a "+ N more" affordance. */
function StepLogLine({
index,
ts,
raw,
isDone,
doneError,
}: {
index: number;
ts: number;
raw: string;
isDone: boolean;
doneError?: string | null;
}) {
const [expanded, setExpanded] = useState(false);
if (isDone) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "2px 0",
borderLeft: `2px solid ${doneError ? LOG.err : LOG.ok}`,
paddingLeft: 8,
marginTop: 6,
color: doneError ? LOG.err : LOG.fg,
}}
>
<span style={{ color: LOG.ts }}>{fmtTs(ts)}</span>
<Pill color={doneError ? LOG.err : LOG.ok}>done</Pill>
<span>{doneError ? `error: ${doneError}` : "run completed"}</span>
</div>
);
}
const p = parseStep(raw);
const outputLines = (p.output ?? "").split("\n");
const firstLine = outputLines[0] ?? "";
const remaining = outputLines.length - 1 + (firstLine.length > 300 ? 1 : 0);
const shortLine = firstLine.length > 300 ? firstLine.slice(0, 300) + "…" : firstLine;
const phaseColor = p.phase ? (PHASE_COLOR[p.phase] ?? LOG.dim) : LOG.dim;
const roleColor = actorColor(p.role ?? p.node_id ?? "");
const canExpand = (p.output ?? "").length > 300 || outputLines.length > 1;
return (
<div
style={{
padding: "3px 0 3px 8px",
borderLeft: `2px solid ${roleColor}66`,
}}
>
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 6 }}>
<span style={{ color: LOG.ts, fontVariantNumeric: "tabular-nums" }}>
{fmtTs(ts)}
</span>
<span style={{ color: LOG.dim, fontVariantNumeric: "tabular-nums" }}>
#{index}
</span>
{p.role ? <Pill color={roleColor}>{p.role}</Pill> : null}
{p.phase ? <Pill color={phaseColor}>{p.phase}</Pill> : null}
{typeof p.tokens === "number" && p.tokens > 0 ? (
<Pill color={LOG.dim}>{fmtCount(p.tokens)}t</Pill>
) : null}
{p.gated && p.gated > 0 ? <Pill color={LOG.warn}>{p.gated} gated</Pill> : null}
{canExpand ? (
<button
type="button"
onClick={() => setExpanded((v) => !v)}
style={{
padding: "1px 8px",
borderRadius: 999,
background: "transparent",
border: `1px solid ${LOG.dim}40`,
color: LOG.dim,
fontFamily: mono,
fontSize: 10,
cursor: "pointer",
}}
>
{expanded ? "collapse" : `+ ${remaining} more`}
</button>
) : null}
</div>
{shortLine ? (
<div style={{ marginTop: 2, color: LOG.fg, whiteSpace: "pre-wrap" }}>
{expanded ? (p.output ?? "") : shortLine}
</div>
) : null}
</div>
);
}
/** Split `[actor] action (outcome) · message` into colored spans. Falls
* back to a plain line for non-matching text (bash echoes, stack
* traces, etc). */
function ContainerLogLine({
ts,
kind,
text,
}: {
ts: number;
kind: "info" | "line" | "error" | "done";
text: string;
}) {
if (kind !== "line") {
const color = kind === "error" ? LOG.err : LOG.dim;
return (
<div
style={{
padding: "2px 0 2px 8px",
borderLeft: `2px solid ${color}80`,
color,
}}
>
<span style={{ color: LOG.ts, marginRight: 6 }}>{fmtTs(ts)}</span>
<Pill color={color}>{kind}</Pill>{" "}
<span>{text}</span>
</div>
);
}
// Match: `[actor] action (outcome) · msg` OR `[actor] action · msg`
const m = text.match(/^\[([^\]]+)\]\s+([a-z_]+)(?:\s*\(([a-z_]+)\))?\s*·\s*(.*)$/);
if (!m) {
return (
<div style={{ padding: "2px 0 2px 8px", borderLeft: `2px solid ${LOG.dim}30`, color: LOG.fg }}>
<span style={{ color: LOG.ts, marginRight: 6 }}>{fmtTs(ts)}</span>
{text}
</div>
);
}
const [, actor, action, outcome, msg] = m;
const roleColor = actorColor(actor);
const outcomeColor =
outcome === "success" ? LOG.ok : outcome === "failure" ? LOG.err : LOG.dim;
return (
<div
style={{
padding: "2px 0 2px 8px",
borderLeft: `2px solid ${outcome === "failure" ? LOG.err : roleColor}80`,
}}
>
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 6 }}>
<span style={{ color: LOG.ts, fontVariantNumeric: "tabular-nums" }}>
{fmtTs(ts)}
</span>
<Pill color={roleColor}>{actor}</Pill>
<span style={{ color: LOG.dim }}>{action}</span>
{outcome ? <Pill color={outcomeColor}>{outcome}</Pill> : null}
</div>
{msg ? (
<div style={{ color: LOG.fg, whiteSpace: "pre-wrap" }}>{msg}</div>
) : null}
</div>
);
}
/** Fires whenever a new topology-run step SSE event lands. Consumers use
* it to drive live UI signals — e.g. an agent-card glow keyed to the
* step's role/phase. Called once per step, right after the event
* arrives; safe to be a no-op. */
export type StepPulse = {
role: string;
phase: "plan" | "work" | "synth" | "aggregate" | "unknown";
node_id: string;
ts: number;
};
export function LiveRunLogs({
topicId,
directRunId,
onStep,
defaultOpen = true,
}: {
/** Topic-scoped mode: polls /active-runs + /pipeline-state, discovers
* active runs. Used from ResearchCanvas. Ignored when directRunId is
* set. */
topicId?: string;
/** Run-scoped mode: pin to a single known topology_run id. Skips both
* polls, subscribes straight to SSE. Used from LoopsCanvas for a
* drilled-into iteration. */
directRunId?: string;
onStep?: (pulse: StepPulse) => void;
defaultOpen?: boolean;
}) {
const [runs, setRuns] = useState<string[]>(directRunId ? [directRunId] : []);
const [activeRun, setActiveRun] = useState<string | null>(directRunId ?? null);
const [open, setOpen] = useState(defaultOpen);
const scrollRef = useRef<HTMLDivElement>(null);
const [pinned, setPinned] = useState(true); // auto-scroll to bottom
const [pipeline, setPipeline] = useState<PipelineStateResponse | null>(null);
// Topic-mode only: poll pipeline-state so we can surface the pre-first-
// step setup phases (repo materialize, container start, container
// populate) as pseudo-log lines. Real step events overtake this once
// they arrive. Run-mode skips this — a single iteration doesn't have
// a topic pipeline to poll.
useEffect(() => {
if (!topicId || directRunId) return;
let live = true;
let timer: ReturnType<typeof setTimeout> | null = null;
async function tick() {
try {
const p = await getPipelineState(topicId!);
if (live) setPipeline(p);
} catch {
/* ignore */
}
if (live) timer = setTimeout(tick, 2000);
}
void tick();
return () => {
live = false;
if (timer) clearTimeout(timer);
};
}, [topicId, directRunId]);
// Topic-mode only: poll active-runs so the list refreshes when new
// runs kick off or old ones finish.
useEffect(() => {
if (!topicId || directRunId) return;
let live = true;
let timer: ReturnType<typeof setTimeout> | null = null;
async function tick() {
try {
const r = await getActiveRuns(topicId!);
if (!live) return;
const ids = r.runs.map((x) => x.id);
setRuns(ids);
setActiveRun((prev) => {
if (prev && ids.includes(prev)) return prev;
return ids[0] ?? null;
});
} catch {
/* ignore transient */
} finally {
if (live) timer = setTimeout(tick, 3000);
}
}
void tick();
return () => {
live = false;
if (timer) clearTimeout(timer);
};
}, [topicId, directRunId]);
// Run-mode: nothing to null-check — activeRun is pinned to directRunId
// at the useState initializer above. The SSE hooks below fire whenever
// activeRun is non-null.
const { events, status } = useRunEvents(activeRun, onStep);
const [tab, setTab] = useState<"steps" | "container">("steps");
const { lines: containerLines, status: containerStatus } =
useContainerLog(activeRun, tab === "container");
// Autoscroll to newest event when pinned to the bottom.
useEffect(() => {
if (!pinned) return;
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [events, pinned]);
const statusColor = useMemo(() => {
if (status === "error") return "#ff8a7a";
if (status === "done") return "#5fd08a";
if (status === "streaming") return "#5ec8d8";
return "#8a8a92";
}, [status]);
// Nothing active → render nothing (matches the "Pipeline in flight"
// parent state which is what gates this whole panel).
// Topic-mode: hide entirely when no runs. Run-mode: always render
// (parent already decided to drill in).
if (runs.length === 0 && !directRunId) return null;
return (
<div>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "10px 12px",
borderRadius: 10,
border: "1px solid rgba(255,255,255,.08)",
background: "#101014",
color: "#eaeaee",
cursor: "pointer",
textAlign: "left",
fontFamily: mono,
fontSize: 11.5,
}}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: statusColor }} />
<span style={{ flex: 1, color: "#8a8a92" }}>
Live run · {events.length} event{events.length === 1 ? "" : "s"} · {status}
{runs.length > 1 ? ` · ${runs.length} runs` : ""}
</span>
<span style={{ opacity: 0.7 }}>{open ? "▾" : "▸"}</span>
</button>
{open ? (
<div
style={{
marginTop: 6,
padding: 10,
borderRadius: 10,
background: "#0a0a0d",
border: "1px solid rgba(255,255,255,.06)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{runs.length > 1 ? (
<div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
{runs.map((id, i) => {
const isActive = id === activeRun;
return (
<button
key={id}
type="button"
onClick={() => setActiveRun(id)}
style={{
padding: "4px 10px",
borderRadius: 999,
fontFamily: mono,
fontSize: 10,
background: isActive ? "rgba(94,200,216,.15)" : "transparent",
border: `1px solid ${
isActive ? "rgba(94,200,216,.45)" : "rgba(255,255,255,.1)"
}`,
color: isActive ? "#e5f6fb" : "#8a8a92",
cursor: "pointer",
}}
>
run #{i + 1} · {id.slice(0, 8)}
</button>
);
})}
</div>
) : null}
{/* Sub-tabs: Steps (topology step summaries) vs Container
(live daemon log tail from the team runtime). */}
<div style={{ display: "flex", gap: 6 }}>
{(
[
{ k: "steps" as const, label: "Steps", count: events.length, s: status },
{
k: "container" as const,
label: "Container",
count: containerLines.length,
s: containerStatus,
},
]
).map((t) => {
const isActive = tab === t.k;
return (
<button
key={t.k}
type="button"
onClick={() => setTab(t.k)}
style={{
padding: "4px 10px",
borderRadius: 999,
fontFamily: mono,
fontSize: 10,
background: isActive ? "rgba(94,200,216,.15)" : "transparent",
border: `1px solid ${
isActive ? "rgba(94,200,216,.45)" : "rgba(255,255,255,.1)"
}`,
color: isActive ? "#e5f6fb" : "#8a8a92",
cursor: "pointer",
}}
>
{t.label} · {t.count} · {t.s}
</button>
);
})}
</div>
<div
ref={scrollRef}
onScroll={(e) => {
const el = e.currentTarget;
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
setPinned(nearBottom);
}}
style={{
maxHeight: 320,
overflow: "auto",
padding: 10,
borderRadius: 8,
background: "#050507",
border: "1px solid rgba(255,255,255,.04)",
fontFamily: mono,
fontSize: 11,
lineHeight: 1.5,
color: "#cfcfd5",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{/* Pre-step setup phases from pipeline-state. Rendered until
real step events arrive, then hidden so the terminal
doesn't scroll past the actual step timeline. */}
{tab === "steps" ? (
<>
{events.length === 0 && pipeline ? (
<>
<div style={{ color: "#6a6a72", marginBottom: 4 }}>
{status === "connecting"
? "connecting…"
: "worker booting — steps journal after each turn completes"}
</div>
{pipeline.stages.map((s) => (
<div key={s.key} style={{ color: "#cfcfd5" }}>
<span style={{ color: "#5a5a62" }}>
setup {STAGE_DOT[s.status] ?? "◯"} {s.key.padEnd(9)}
</span>{" "}
{s.label}
{s.detail ? (
<span style={{ color: "#ffb0a5" }}> — {s.detail}</span>
) : null}
</div>
))}
</>
) : null}
{events.length === 0 && !pipeline ? (
<div style={{ color: "#6a6a72" }}>
{status === "connecting" ? "connecting…" : "waiting for pipeline state…"}
</div>
) : null}
{events.map((e, i) => (
<StepLogLine
key={`${i}-${e.ts}`}
index={e.kind === "step" ? e.index : -1}
ts={e.ts}
raw={e.raw}
isDone={e.kind === "done"}
doneError={e.kind === "done" ? (e.error ?? null) : null}
/>
))}
</>
) : (
<>
{containerLines.length === 0 ? (
<div style={{ color: "#6a6a72" }}>
{containerStatus === "connecting"
? "connecting to container log…"
: containerStatus === "streaming"
? "waiting for first line…"
: containerStatus === "error"
? "container log stream error"
: "idle"}
</div>
) : null}
{containerLines.map((l, i) => (
<ContainerLogLine
key={`${i}-${l.ts}`}
ts={l.ts}
kind={l.kind}
text={l.text}
/>
))}
</>
)}
</div>
{!pinned ? (
<button
type="button"
onClick={() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
setPinned(true);
}}
style={{
alignSelf: "flex-end",
padding: "4px 10px",
borderRadius: 999,
background: "rgba(255,255,255,.05)",
border: "1px solid rgba(255,255,255,.1)",
color: "#cfcfd5",
cursor: "pointer",
fontFamily: mono,
fontSize: 10,
}}
>
Jump to latest
</button>
) : null}
{status === "error" ? (
<div style={{ color: "#ff8a7a", fontSize: 11, fontFamily: mono }}>
Stream disconnected. The browser will attempt to reconnect
automatically.
</div>
) : null}
</div>
) : null}
</div>
);
}
@@ -1,371 +0,0 @@
"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";
// Do NOT import @/lib/api/structure — pulls bearer.ts (next/headers,
// server-only) into the client bundle and breaks the production build.
// Call /api/teams and /api/orgs with plain fetch instead.
interface GroupSummary {
id: string;
name: string;
kind: string;
status: string;
created_at: string;
}
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 [tRes, oRes] = await Promise.all([
fetch("/api/teams"),
fetch("/api/orgs"),
]);
if (cancelled) return;
const t = tRes.ok ? ((await tRes.json()) as GroupSummary[]) : [];
const o = oRes.ok ? ((await oRes.json()) as GroupSummary[]) : [];
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,
};
@@ -1,847 +0,0 @@
"use client";
// Canvas for the Loops tier — selected loop detail: header, triggers card,
// repeat policy, task template, topology graph JSON, and action row
// (Run now / Enable / Disable / Delete).
import { useEffect, useState } from "react";
import {
deleteLoop,
disableLoop,
enableLoop,
getLoop,
getRunDetail,
listLoopRuns,
runLoopNow,
type Loop,
type LoopRepeatPolicy,
type RunSummary,
} from "@/lib/api/loops";
import { LiveRunLogs } from "./LiveRunLogs";
function formatRepeatPolicy(policy: LoopRepeatPolicy | undefined): string {
if (!policy) return "infinite";
if (policy.kind === "iters") return `${policy.n} iterations`;
if (policy.kind === "until") {
const cap = policy.within_iters ? ` · cap ${policy.within_iters}` : "";
return `until "${policy.event}"${cap}`;
}
return "infinite";
}
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function LoopsCanvas({
selectedId,
refreshKey,
onChanged,
onDeleted,
}: {
selectedId: string | null;
refreshKey: number;
onChanged: () => void;
onDeleted: () => void;
}) {
const [loop, setLoop] = useState<Loop | null>(null);
const [runs, setRuns] = useState<RunSummary[]>([]);
const [runsLoading, setRunsLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [acting, setActing] = useState(false);
const [flash, setFlash] = useState<string | null>(null);
useEffect(() => {
let alive = true;
const load = async () => {
if (!selectedId) {
if (alive) {
setLoop(null);
setRuns([]);
}
return;
}
setLoading(true);
setRunsLoading(true);
setError(null);
try {
const [l, r] = await Promise.all([
getLoop(selectedId),
listLoopRuns(selectedId).catch(() => [] as RunSummary[]),
]);
if (alive) {
setLoop(l);
setRuns(r);
}
} catch (e) {
if (alive) setError(e instanceof Error ? e.message : "load failed");
} finally {
if (alive) {
setLoading(false);
setRunsLoading(false);
}
}
};
load();
return () => {
alive = false;
};
}, [selectedId, refreshKey]);
if (!selectedId) return <Placeholder />;
if (loading && !loop) return <PlaceholderText>Loading loop…</PlaceholderText>;
if (error) return <PlaceholderText color="#ff8a7a">{error}</PlaceholderText>;
if (!loop) return <Placeholder />;
async function run<T>(fn: () => Promise<T>, successMsg?: string) {
setActing(true);
setError(null);
setFlash(null);
try {
await fn();
if (successMsg) setFlash(successMsg);
onChanged();
} catch (e) {
setError(e instanceof Error ? e.message : "action failed");
} finally {
setActing(false);
}
}
const triggers = loop.triggers ?? {};
const enabledTriggers = [
triggers.cron ? `cron: ${triggers.cron}` : null,
triggers.on_completion ? "on completion" : null,
triggers.webhook_enabled ? "webhook" : null,
].filter(Boolean) as string[];
return (
<div
style={{
position: "absolute",
inset: 0,
overflow: "auto",
background:
"radial-gradient(120% 90% at 55% 38%, #0e0e13 0%, #08080a 70%)",
padding: 32,
}}
>
<div style={{ maxWidth: 820, margin: "0 auto", display: "flex", flexDirection: "column", gap: 24 }}>
{/* Header */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: loop.enabled ? "#5fd08a" : "#5a5a62",
}}
/>
<span style={{ color: loop.enabled ? "#5fd08a" : "#5a5a62", fontWeight: 700 }}>
{loop.enabled ? "ENABLED" : "DISABLED"}
</span>
{loop.next_fire_at && (
<>
<span style={{ opacity: 0.5 }}>·</span>
<span>next {new Date(loop.next_fire_at).toLocaleString()}</span>
</>
)}
</div>
<h1
style={{
fontSize: 30,
fontWeight: 700,
color: "#f3f3f5",
letterSpacing: "-.02em",
margin: 0,
}}
>
{loop.title}
</h1>
{loop.description && (
<p style={{ fontFamily: mono, fontSize: 13, color: "#b5b5bd", margin: 0, lineHeight: 1.6 }}>
{loop.description}
</p>
)}
</div>
{/* Triggers + Repeat side by side */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<Section header="Triggers">
{enabledTriggers.length === 0 ? (
<p style={hintStyle}>No triggers configured — loop can only be run manually.</p>
) : (
<ul
style={{
margin: 0,
padding: 0,
listStyle: "none",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{enabledTriggers.map((t) => (
<li
key={t}
style={{
fontFamily: mono,
fontSize: 12.5,
color: "#eaeaee",
padding: "6px 10px",
borderRadius: 6,
background: "rgba(255,255,255,.05)",
}}
>
{t}
</li>
))}
</ul>
)}
</Section>
<Section header="Repeat">
<div style={{ fontFamily: mono, fontSize: 12.5, color: "#eaeaee" }}>
{formatRepeatPolicy(loop.repeat_policy)}
</div>
</Section>
</div>
{/* Task template */}
<Section header="Task template">
<pre
style={{
margin: 0,
padding: 14,
borderRadius: 10,
background: "#101014",
border: "1px solid rgba(255,255,255,.06)",
color: "#eaeaee",
fontFamily: mono,
fontSize: 12.5,
lineHeight: 1.6,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{loop.task_template}
</pre>
</Section>
{/* Graph — collapsed by default; the topology overview is
reflected in the iteration steps and rarely needs inspection. */}
<CollapsibleSection header="Topology (graph JSON)">
<pre
style={{
margin: 0,
padding: 14,
borderRadius: 10,
background: "#101014",
border: "1px solid rgba(255,255,255,.06)",
color: "#eaeaee",
fontFamily: mono,
fontSize: 11.5,
lineHeight: 1.55,
maxHeight: 240,
overflow: "auto",
}}
>
{JSON.stringify(loop.graph, null, 2)}
</pre>
</CollapsibleSection>
{/* Iterations timeline */}
<Section header={`Iterations${runs.length ? ` · ${runs.length}` : ""}`}>
<IterationsTimeline runs={runs} loading={runsLoading} />
</Section>
{/* Actions */}
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<button
type="button"
onClick={() => run(() => runLoopNow(loop.id), "Run enqueued")}
disabled={acting}
style={primaryBtn}
>
Run now
</button>
{loop.enabled ? (
<button
type="button"
onClick={() => run(() => disableLoop(loop.id))}
disabled={acting}
style={secondaryBtn}
>
Disable
</button>
) : (
<button
type="button"
onClick={() => run(() => enableLoop(loop.id))}
disabled={acting}
style={secondaryBtn}
>
Enable
</button>
)}
<button
type="button"
onClick={() => {
if (window.confirm(`Delete loop "${loop.title}"?`)) {
run(async () => {
await deleteLoop(loop.id);
onDeleted();
});
}
}}
disabled={acting}
style={dangerBtn}
>
Delete
</button>
{flash && (
<span style={{ fontFamily: mono, fontSize: 12, color: "#5fd08a" }}>{flash}</span>
)}
{error && (
<span style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>{error}</span>
)}
</div>
</div>
</div>
);
}
function IterationsTimeline({
runs,
loading,
}: {
runs: RunSummary[];
loading: boolean;
}) {
// One expanded iteration at a time; auto-open the newest running one.
const [expanded, setExpanded] = useState<string | null>(null);
// Modal viewer for the full topology_run result.
const [outputRunId, setOutputRunId] = useState<string | null>(null);
const runningId = runs.find((r) => r.status === "running" || r.status === "queued")?.id ?? null;
const [seenRunning, setSeenRunning] = useState<string | null>(runningId);
if (seenRunning !== runningId) {
setSeenRunning(runningId);
if (runningId) setExpanded(runningId);
}
if (loading && runs.length === 0) {
return <p style={hintStyle}>Loading iterations…</p>;
}
if (runs.length === 0) {
return <p style={hintStyle}>No iterations yet — hit &quot;Run now&quot; to fire one.</p>;
}
return (
<>
{outputRunId ? (
<RunOutputModal runId={outputRunId} onClose={() => setOutputRunId(null)} />
) : null}
<ul
style={{
margin: 0,
padding: 0,
listStyle: "none",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
{runs.map((r) => {
const started = new Date(r.created_at);
const finished = r.finished_at ? new Date(r.finished_at) : null;
const durationSec = finished
? Math.max(0, Math.round((finished.getTime() - started.getTime()) / 1000))
: null;
const isOpen = expanded === r.id;
return (
<li
key={r.id}
style={{
display: "flex",
flexDirection: "column",
gap: 8,
padding: "8px 12px",
borderRadius: 8,
background: isOpen ? "rgba(94,200,216,.06)" : "rgba(255,255,255,.04)",
border: `1px solid ${isOpen ? "rgba(94,200,216,.25)" : "rgba(255,255,255,.05)"}`,
fontFamily: mono,
fontSize: 12,
color: "#eaeaee",
}}
>
<button
type="button"
onClick={() => setExpanded(isOpen ? null : r.id)}
aria-expanded={isOpen}
style={{
display: "grid",
gridTemplateColumns: "16px 48px 90px 1fr auto",
alignItems: "center",
gap: 12,
padding: 0,
background: "transparent",
border: 0,
color: "inherit",
fontFamily: "inherit",
fontSize: "inherit",
textAlign: "left",
cursor: "pointer",
width: "100%",
}}
>
<span style={{ opacity: 0.7 }}>{isOpen ? "▾" : "▸"}</span>
<span
style={{
color: "#5a5a62",
fontVariantNumeric: "tabular-nums",
}}
>
#{r.iteration ?? "—"}
</span>
<StatusPill status={r.status} />
<span style={{ color: "#b5b5bd" }}>
{started.toLocaleString()}
{durationSec !== null && (
<span style={{ color: "#5a5a62" }}> · {formatDuration(durationSec)}</span>
)}
</span>
<span
style={{ color: "#5a5a62", fontSize: 10.5, letterSpacing: ".05em" }}
title={r.id}
>
{r.id.slice(0, 8)}
</span>
</button>
{isOpen ? (
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<button
type="button"
onClick={() => setOutputRunId(r.id)}
disabled={r.status !== "completed" && r.status !== "failed"}
title={
r.status === "completed" || r.status === "failed"
? "Open the full topology_run result in a viewer"
: "Available once the iteration terminates"
}
style={{
padding: "4px 10px",
borderRadius: 999,
background: "rgba(94,200,216,.1)",
border: "1px solid rgba(94,200,216,.35)",
color: "#e5f6fb",
fontFamily: mono,
fontSize: 10,
cursor:
r.status === "completed" || r.status === "failed"
? "pointer"
: "not-allowed",
opacity: r.status === "completed" || r.status === "failed" ? 1 : 0.4,
}}
>
View full output ↗
</button>
</div>
<LiveRunLogs directRunId={r.id} defaultOpen={true} />
</div>
) : null}
</li>
);
})}
</ul>
</>
);
}
/** Modal viewer for a topology_run's full result. Fetches
* /api/topology-runs/:id which returns `comparison` (the run's
* serialized RunRecord). We look for `final_output` (the produced
* markdown / code) first; fall back to `steps` inline; last resort
* is a raw JSON dump.
*
* Rendered as a fixed overlay so it works from any tier. Escape /
* backdrop-click close. */
function RunOutputModal({
runId,
onClose,
}: {
runId: string;
onClose: () => void;
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [text, setText] = useState<string>("");
const [meta, setMeta] = useState<{
kind?: string;
status?: string;
tokens?: number;
steps?: number;
}>({});
useEffect(() => {
let live = true;
(async () => {
try {
const d = await getRunDetail(runId);
if (!live) return;
const c = (d.comparison ?? {}) as {
final_output?: string;
steps?: Array<{ role?: string; phase?: string; output?: string; tokens?: number }>;
totals?: { tokens?: number };
};
const finalOutput = c.final_output ?? "";
const steps = c.steps ?? [];
const tokens = c.totals?.tokens ?? 0;
setMeta({ kind: d.kind, status: d.status, tokens, steps: steps.length });
if (finalOutput) {
setText(finalOutput);
} else if (steps.length > 0) {
setText(
steps
.map(
(s, i) =>
`━━━ step ${i} · [${s.role ?? "?"}] ${s.phase ?? ""} · ${s.tokens ?? 0}t ━━━\n\n${s.output ?? ""}`,
)
.join("\n\n"),
);
} else {
setText(JSON.stringify(d.comparison, null, 2));
}
} catch (e) {
setError(e instanceof Error ? e.message : "fetch failed");
} finally {
setLoading(false);
}
})();
return () => {
live = false;
};
}, [runId]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
async function copy() {
try {
await navigator.clipboard.writeText(text);
} catch {
/* ignore */
}
}
function download() {
const blob = new Blob([text], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `run-${runId.slice(0, 8)}.md`;
a.click();
URL.revokeObjectURL(url);
}
return (
<div
role="dialog"
aria-modal="true"
onClick={onClose}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.65)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
zIndex: 1000,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(1080px, 100%)",
maxHeight: "90vh",
display: "flex",
flexDirection: "column",
background: "#0b0b0e",
border: "1px solid rgba(255,255,255,.12)",
borderRadius: 12,
overflow: "hidden",
}}
>
<header
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "12px 16px",
borderBottom: "1px solid rgba(255,255,255,.08)",
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
}}
>
<span style={{ color: "#e5f6fb" }}>run · {runId.slice(0, 8)}</span>
{meta.kind && <span>· {meta.kind}</span>}
{meta.status && <span>· {meta.status}</span>}
{meta.steps ? <span>· {meta.steps} steps</span> : null}
{meta.tokens ? <span>· {meta.tokens.toLocaleString()} tokens</span> : null}
<span style={{ flex: 1 }} />
<button type="button" onClick={copy} style={modalBtn}>
Copy
</button>
<button type="button" onClick={download} style={modalBtn}>
Download .md
</button>
<button type="button" onClick={onClose} style={modalBtn}>
Close
</button>
</header>
<div
style={{
flex: 1,
minHeight: 0,
overflow: "auto",
padding: 20,
}}
>
{loading ? (
<p style={{ color: "#8a8a92", fontFamily: mono, fontSize: 12 }}>Loading…</p>
) : error ? (
<p style={{ color: "#ff8a7a", fontFamily: mono, fontSize: 12 }}>{error}</p>
) : (
<pre
style={{
margin: 0,
fontFamily: mono,
fontSize: 12,
lineHeight: 1.6,
color: "#cfcfd5",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{text}
</pre>
)}
</div>
</div>
</div>
);
}
const modalBtn: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 6,
background: "transparent",
border: "1px solid rgba(255,255,255,.15)",
color: "#cfcfd5",
fontFamily: mono,
fontSize: 10,
cursor: "pointer",
};
function StatusPill({ status }: { status: string }) {
const color =
status === "completed"
? "#5fd08a"
: status === "failed"
? "#ff8a7a"
: status === "cancelled"
? "#c8a464"
: status === "running"
? "#7fbcff"
: "#8a8a92";
return (
<span
style={{
color,
fontSize: 10.5,
letterSpacing: ".1em",
textTransform: "uppercase",
fontWeight: 700,
}}
>
{status}
</span>
);
}
function formatDuration(sec: number): string {
if (sec < 60) return `${sec}s`;
const m = Math.floor(sec / 60);
const s = sec % 60;
if (m < 60) return s ? `${m}m ${s}s` : `${m}m`;
const h = Math.floor(m / 60);
const rm = m % 60;
return rm ? `${h}h ${rm}m` : `${h}h`;
}
function Section({
header,
children,
}: {
header: string;
children: React.ReactNode;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#5a5a62",
textTransform: "uppercase",
}}
>
{header}
</div>
{children}
</div>
);
}
/** Section wrapper whose body collapses behind a chevron. Body starts
* closed. Used for reference blocks (graph JSON, raw task template)
* that clutter the canvas but should still be a click away. */
function CollapsibleSection({
header,
children,
}: {
header: string;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: 0,
background: "transparent",
border: 0,
cursor: "pointer",
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#5a5a62",
textTransform: "uppercase",
textAlign: "left",
}}
>
<span style={{ opacity: 0.7 }}>{open ? "▾" : "▸"}</span>
{header}
</button>
{open ? children : null}
</div>
);
}
function Placeholder() {
return (
<div
style={{
position: "absolute",
inset: 0,
background:
"radial-gradient(120% 90% at 55% 38%, #0e0e13 0%, #08080a 70%)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 48,
}}
>
<div style={{ maxWidth: 460, textAlign: "center" }}>
<div style={{ fontSize: 22, fontWeight: 700, color: "#f3f3f5", marginBottom: 12 }}>Loops</div>
<div style={{ fontFamily: mono, fontSize: 12, color: "#8a8a92", lineHeight: 1.6 }}>
Pick a loop on the left, or hit the + button in the sidebar to launch
the wizard.
</div>
</div>
</div>
);
}
function PlaceholderText({
children,
color = "#8a8a92",
}: {
children: React.ReactNode;
color?: string;
}) {
return (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: mono,
fontSize: 12,
color,
}}
>
{children}
</div>
);
}
const hintStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 12,
color: "#8a8a92",
};
const primaryBtn: React.CSSProperties = {
padding: "10px 18px",
borderRadius: 9,
border: 0,
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
color: "#2a0d0a",
fontSize: 13,
fontWeight: 700,
cursor: "pointer",
};
const secondaryBtn: React.CSSProperties = {
padding: "10px 18px",
borderRadius: 9,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
};
const dangerBtn: React.CSSProperties = {
padding: "10px 18px",
borderRadius: 9,
border: "1px solid rgba(255,111,97,.35)",
background: "transparent",
color: "#ff8a7a",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
};
@@ -1,608 +0,0 @@
"use client";
// Sidebar for the Loops tier — real loop cards + `+` opens the wizard.
// Each row has an inline action row: enable/disable · edit · delete
// (with confirm). Mutations bump a local counter so the list refetches
// without waiting on a parent refresh.
import { useEffect, useState } from "react";
import {
Pause,
Pencil,
Play,
Plus,
Trash2,
} from "lucide-react";
import {
deleteLoop,
disableLoop,
enableLoop,
listLoopProgress,
listLoops,
type Loop,
type LoopProgress,
} from "@/lib/api/loops";
import type { Agent } from "@/lib/api/schemas";
import { LoopsWizard } from "./LoopsWizard";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function LoopsList({
agents,
selectedId,
onSelect,
refreshKey,
onCreated,
}: {
agents: Agent[];
selectedId: string | null;
onSelect: (id: string) => void;
refreshKey: number;
onCreated: (id: string) => void;
}) {
const noAgents = agents.length === 0;
const [loops, setLoops] = useState<Loop[]>([]);
const [progress, setProgress] = useState<Map<string, LoopProgress>>(new Map());
const [openHistoryId, setOpenHistoryId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [wizardOpen, setWizardOpen] = useState(false);
const [editing, setEditing] = useState<Loop | null>(null);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [localBump, setLocalBump] = useState(0);
useEffect(() => {
let alive = true;
const load = async () => {
setLoading(true);
try {
const [rows, prog] = await Promise.all([
listLoops(),
listLoopProgress().catch(() => [] as LoopProgress[]),
]);
if (alive) {
setLoops(rows);
setProgress(new Map(prog.map((p) => [p.loop_id, p])));
}
} catch {
if (alive) setLoops([]);
} finally {
if (alive) setLoading(false);
}
};
load();
return () => {
alive = false;
};
}, [refreshKey, localBump]);
async function toggle(l: Loop) {
if (busyId) return;
setBusyId(l.id);
setError(null);
try {
if (l.enabled) await disableLoop(l.id);
else await enableLoop(l.id);
setLocalBump((n) => n + 1);
} catch (e) {
setError(e instanceof Error ? e.message : "toggle failed");
} finally {
setBusyId(null);
}
}
async function doDelete(id: string) {
if (busyId) return;
setBusyId(id);
setError(null);
try {
await deleteLoop(id);
setConfirmDeleteId(null);
if (selectedId === id) onSelect(""); // clear selection
setLocalBump((n) => n + 1);
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
} finally {
setBusyId(null);
}
}
return (
<>
<div
style={{
padding: "16px 16px 12px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
alignItems: "flex-start",
gap: 8,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#5a5a62",
marginBottom: 6,
}}
>
{loops.length} LOOP{loops.length === 1 ? "" : "S"}
</div>
<div
style={{
fontSize: 18,
fontWeight: 700,
color: "#f3f3f5",
letterSpacing: "-.01em",
}}
>
Loops
</div>
</div>
<button
type="button"
onClick={() => setWizardOpen(true)}
title="New loop"
aria-label="New loop"
style={{
flex: "none",
width: 34,
height: 34,
borderRadius: 9,
border: "1px dashed rgba(255,111,97,.4)",
background: "rgba(255,111,97,.06)",
color: "#ff6f61",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Plus aria-hidden size={17} />
</button>
</div>
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "8px" }}>
{loading && loops.length === 0 ? (
<p
style={{
fontFamily: mono,
fontSize: 11,
color: "#5a5a62",
textAlign: "center",
padding: 24,
}}
>
Loading…
</p>
) : loops.length === 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 14,
padding: "32px 20px",
textAlign: "center",
}}
>
<div
style={{
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
lineHeight: 1.6,
}}
>
No loops yet.
<br />
The wizard sets up the agents for you.
</div>
<button
type="button"
onClick={() => setWizardOpen(true)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "10px 18px",
borderRadius: 10,
border: "1px solid rgba(255,111,97,.5)",
background: "rgba(255,111,97,.12)",
color: "#ff8a7a",
cursor: "pointer",
fontFamily: mono,
fontSize: 12,
fontWeight: 600,
letterSpacing: ".02em",
}}
>
<Plus aria-hidden size={14} />
Start a loop
</button>
</div>
) : (
loops.map((l) => {
const on = l.id === selectedId;
const dot = l.enabled ? "#5fd08a" : "#5a5a62";
const confirming = confirmDeleteId === l.id;
return (
<div
key={l.id}
style={{
padding: "10px 12px",
marginBottom: 4,
borderRadius: 9,
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.06)"}`,
background: on ? "rgba(255,111,97,.08)" : "#101014",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<button
type="button"
onClick={() => onSelect(l.id)}
style={{
all: "unset",
cursor: "pointer",
color: "#eaeaee",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div
style={{
fontSize: 13.5,
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{l.title}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontFamily: mono,
fontSize: 10,
color: "#8a8a92",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: dot,
}}
/>
<span style={{ color: dot }}>
{l.enabled ? "enabled" : "disabled"}
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span>
{l.next_fire_at
? `next ${new Date(l.next_fire_at).toLocaleString()}`
: "no schedule"}
</span>
</div>
{(() => {
const p = progress.get(l.id);
if (!p) return null;
const done = p.consumed_count;
const total = p.total_int_count;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 2,
fontFamily: mono,
fontSize: 9.5,
color: "#5ec8d8",
}}
title={`Based on research topic "${p.source_topic_title}" (artifact v${p.source_outcome_version})`}
>
<span
style={{
padding: "2px 6px",
borderRadius: 4,
border: "1px solid rgba(94,200,216,.35)",
background: "rgba(94,200,216,.08)",
fontWeight: 700,
}}
>
{done}/{total || "?"} INTs
</span>
<span style={{ opacity: 0.7, color: "#8a8a92" }}>·</span>
<span
style={{
opacity: 0.7,
color: "#8a8a92",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: 200,
}}
>
{p.source_topic_title}
</span>
{total > 0 ? (
<span
style={{
flex: 1,
height: 3,
borderRadius: 2,
background: "rgba(255,255,255,.06)",
overflow: "hidden",
minWidth: 20,
marginLeft: 4,
}}
>
<span
style={{
display: "block",
width: `${pct}%`,
height: "100%",
background: "#5ec8d8",
}}
/>
</span>
) : null}
</div>
);
})()}
</button>
{(() => {
const p = progress.get(l.id);
if (!p || p.recent_reorders.length === 0) return null;
const open = openHistoryId === l.id;
return (
<div style={{ marginTop: 2 }}>
<button
type="button"
onClick={() =>
setOpenHistoryId(open ? null : l.id)
}
style={{
all: "unset",
cursor: "pointer",
fontFamily: mono,
fontSize: 9.5,
color: "#8a8a92",
display: "flex",
alignItems: "center",
gap: 6,
}}
aria-expanded={open}
>
<span style={{ opacity: 0.7 }}>{open ? "▾" : "▸"}</span>
<span>
{p.recent_reorders.length} reorder
{p.recent_reorders.length === 1 ? "" : "s"}
</span>
</button>
{open ? (
<div
style={{
marginTop: 4,
padding: "6px 8px",
borderRadius: 6,
background: "rgba(255,255,255,.02)",
border: "1px solid rgba(255,255,255,.05)",
display: "flex",
flexDirection: "column",
gap: 5,
}}
>
{p.recent_reorders.map((e, i) => (
<div
key={`${e.run_id ?? "unk"}-${i}`}
style={{
fontFamily: mono,
fontSize: 10,
color: "#c3c3c8",
lineHeight: 1.4,
}}
>
<span style={{ color: "#8a8a92" }}>
iter {e.iteration ?? "?"} ·{" "}
</span>
{e.text ?? ""}
</div>
))}
</div>
) : null}
</div>
);
})()}
{confirming ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 4,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
color: "#ff8a7a",
flex: 1,
}}
>
Delete this loop? Historical runs stay.
</span>
<button
type="button"
onClick={() => doDelete(l.id)}
disabled={busyId === l.id}
style={{
...dangerBtn,
opacity: busyId === l.id ? 0.6 : 1,
}}
>
{busyId === l.id ? "…" : "Delete"}
</button>
<button
type="button"
onClick={() => setConfirmDeleteId(null)}
style={ghostBtn}
>
Cancel
</button>
</div>
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
marginTop: 2,
}}
>
<RowIconBtn
title={l.enabled ? "Disable loop" : "Enable loop"}
onClick={() => toggle(l)}
disabled={busyId === l.id}
>
{l.enabled ? (
<Pause aria-hidden size={12} />
) : (
<Play aria-hidden size={12} />
)}
</RowIconBtn>
<RowIconBtn
title="Edit loop"
onClick={() => setEditing(l)}
disabled={busyId === l.id}
>
<Pencil aria-hidden size={12} />
</RowIconBtn>
<RowIconBtn
title="Delete loop"
onClick={() => setConfirmDeleteId(l.id)}
disabled={busyId === l.id}
danger
>
<Trash2 aria-hidden size={12} />
</RowIconBtn>
</div>
)}
</div>
);
})
)}
{error ? (
<p
style={{
fontFamily: mono,
fontSize: 11,
color: "#ff8a7a",
padding: "8px 12px",
}}
>
{error}
</p>
) : null}
</div>
{wizardOpen && (
<LoopsWizard
agentsCount={agents.length}
onClose={() => setWizardOpen(false)}
onCreated={(id) => {
setWizardOpen(false);
onCreated(id);
}}
/>
)}
{editing && (
<LoopsWizard
initial={editing}
agentsCount={agents.length}
onClose={() => setEditing(null)}
onCreated={() => setEditing(null)}
onSaved={() => {
setEditing(null);
setLocalBump((n) => n + 1);
}}
/>
)}
</>
);
}
function RowIconBtn({
title,
onClick,
disabled,
danger,
children,
}: {
title: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={onClick}
disabled={disabled}
style={{
width: 24,
height: 24,
borderRadius: 6,
border: "1px solid rgba(255,255,255,.08)",
background: "transparent",
color: danger ? "#ff8a7a" : "#cfcfd5",
cursor: disabled ? "not-allowed" : "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
opacity: disabled ? 0.5 : 1,
}}
>
{children}
</button>
);
}
const dangerBtn: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 6,
border: 0,
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
color: "#2a0d0a",
fontSize: 11,
fontWeight: 700,
cursor: "pointer",
};
const ghostBtn: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
};
File diff suppressed because it is too large Load Diff
@@ -1,92 +0,0 @@
"use client";
// Reusable "no agents" gate. Loops + Research + scheduled sessions all
// need at least one agent in the roster to launch — this component
// renders the empty-state we show inside a wizard shell (or inline)
// when the workspace has zero agents.
import { Users } from "lucide-react";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export function NoAgentsGate({
what,
onDismiss,
}: {
/** Human noun for the thing being blocked: "loop", "research topic", "scheduled session". */
what: string;
onDismiss?: () => void;
}) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 14,
padding: "32px 24px",
textAlign: "center",
}}
>
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background: "rgba(255,111,97,.08)",
border: "1px solid rgba(255,111,97,.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#ff8a7a",
}}
>
<Users aria-hidden size={20} />
</div>
<div style={{ maxWidth: 380 }}>
<div
style={{
fontSize: 15,
fontWeight: 700,
color: "#f3f3f5",
marginBottom: 6,
}}
>
You need at least one agent to launch a {what}.
</div>
<p
style={{
fontFamily: mono,
fontSize: 12,
lineHeight: 1.6,
color: "#8a8a92",
}}
>
Add an agent to your roster first — individually, or by spinning up
a team or an organization from the TEAM tier. A {what} can&apos;t be
scheduled, staffed, or bound to a node pool until at least one
agent exists.
</p>
</div>
{onDismiss ? (
<button
type="button"
onClick={onDismiss}
style={{
padding: "9px 16px",
borderRadius: 8,
border: 0,
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
color: "#2a0d0a",
fontSize: 13,
fontWeight: 700,
cursor: "pointer",
}}
>
Take me to the roster
</button>
) : null}
</div>
);
}
@@ -1,206 +0,0 @@
"use client";
// Modal for the loop wizard's "Import from research artifact" flow.
// Lists published research topics that have at least one outcome row,
// user picks one → fetches the artifact markdown → returns
// (topic_id, markdown) to the parent, which snapshots it into the
// loop's task_template AND records the pointer so future iterations
// can auto-refresh from the source topic.
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { listTopics, type TopicListItem } from "@/lib/api/research";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
export interface PickedArtifact {
topicId: string;
artifactMd: string;
}
export function ResearchArtifactPicker({
onPick,
onClose,
}: {
onPick: (a: PickedArtifact) => void;
onClose: () => void;
}) {
const [topics, setTopics] = useState<TopicListItem[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let alive = true;
(async () => {
try {
const rows = await listTopics();
if (alive) {
// Only topics that reached 'published' — they have an artifact
// downstream loops can consume.
setTopics(rows.filter((t) => t.status === "published"));
}
} catch {
if (alive) setError("Could not load research topics");
} finally {
if (alive) setLoading(false);
}
})();
return () => {
alive = false;
};
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
async function pick(t: TopicListItem) {
if (busyId) return;
setBusyId(t.id);
setError(null);
try {
const res = await fetch(`/api/research/${t.id}/artifact`);
if (!res.ok) {
setError(`Artifact not available (${res.status})`);
setBusyId(null);
return;
}
const artifactMd = await res.text();
onPick({ topicId: t.id, artifactMd });
} catch {
setError("Could not fetch artifact");
setBusyId(null);
}
}
return (
<div
onClick={onClose}
role="presentation"
style={{
position: "fixed",
inset: 0,
zIndex: 120,
background: "rgba(0,0,0,.68)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
>
<div
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Import research artifact"
style={{
width: "100%",
maxWidth: 560,
maxHeight: "80vh",
display: "flex",
flexDirection: "column",
borderRadius: 16,
background: "#0d0d10",
border: "1px solid rgba(255,255,255,.1)",
boxShadow: "0 30px 90px rgba(0,0,0,.6)",
padding: 22,
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 12, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 17, fontWeight: 700, color: "#f3f3f5" }}>
Import from research artifact
</div>
<div style={{ fontSize: 12, color: "#8a8a92", marginTop: 2 }}>
Pick a published topic. Its artifact will fill the task template,
and each iteration will focus on the next unconsumed INT-XX item.
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
style={{
width: 30,
height: 30,
flex: "none",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#9a9aa2",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X size={15} />
</button>
</div>
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", display: "flex", flexDirection: "column", gap: 6 }}>
{loading ? (
<div style={{ padding: 24, textAlign: "center", fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
Loading…
</div>
) : topics.length === 0 ? (
<div style={{ padding: 24, textAlign: "center", fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.6 }}>
No published topics yet.
<br />
Publish one from the Research tab first.
</div>
) : (
topics.map((t) => (
<button
key={t.id}
type="button"
onClick={() => pick(t)}
disabled={busyId === t.id}
style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
gap: 4,
padding: "10px 12px",
borderRadius: 10,
border: "1px solid rgba(255,255,255,.08)",
background: "#101014",
color: "#eaeaee",
cursor: busyId === t.id ? "wait" : "pointer",
textAlign: "left",
}}
>
<div style={{ fontWeight: 600, fontSize: 13 }}>{t.title}</div>
<div style={{ fontFamily: mono, fontSize: 10, color: "#8a8a92" }}>
{t.outcome_kind.replace("_", " ")} · published
</div>
</button>
))
)}
</div>
{error ? (
<div
role="alert"
style={{
marginTop: 12,
fontFamily: mono,
fontSize: 10.5,
color: "#ff8a7a",
}}
>
{error}
</div>
) : null}
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,605 +0,0 @@
"use client";
// Sidebar for the Research tier — real topic list, +New opens the wizard.
// Each row also carries a delete affordance with inline confirm; delete is
// hard on the backend (research_topics DELETE cascades to agents/approvals/
// outcomes; topology_runs.research_topic_id is SET NULL).
import { useEffect, useState } from "react";
import { AlertTriangle, Download, Plus, RotateCw, Trash2 } from "lucide-react";
import type { Agent } from "@/lib/api/schemas";
import {
deleteTopic,
listPendingApprovals,
listTopics,
startTopic,
type PublishApproval,
type TopicListItem,
type TopicStatus,
} from "@/lib/api/research";
import { ResearchWizard } from "./ResearchWizard";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
const STATUS_COLOR: Record<TopicStatus, string> = {
standby: "#8a8a92",
processing: "#5ec8d8",
reviewing: "#ffb44a",
publishing: "#c98af0",
published: "#5fd08a",
};
export function ResearchList({
agents,
selectedId,
onSelect,
refreshKey,
onCreated,
}: {
agents: Agent[];
selectedId: string | null;
onSelect: (id: string) => void;
/** Bump to force a re-fetch after external mutations (start/publish/etc). */
refreshKey: number;
onCreated: (id: string) => void;
}) {
const [topics, setTopics] = useState<TopicListItem[]>([]);
const [loading, setLoading] = useState(true);
const [wizardOpen, setWizardOpen] = useState(false);
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [localBump, setLocalBump] = useState(0);
const [approvals, setApprovals] = useState<PublishApproval[]>([]);
const [inboxOpen, setInboxOpen] = useState(false);
useEffect(() => {
let alive = true;
const load = async () => {
setLoading(true);
try {
const [topicsRows, approvalRows] = await Promise.all([
listTopics(),
listPendingApprovals().catch(() => [] as PublishApproval[]),
]);
if (alive) {
setTopics(topicsRows);
setApprovals(approvalRows);
}
} catch {
if (alive) setTopics([]);
} finally {
if (alive) setLoading(false);
}
};
load();
return () => {
alive = false;
};
}, [refreshKey, localBump]);
// Light polling while any topic is actively running so the spinner + status
// in the sidebar transition automatically without waiting on a parent bump.
// Depending only on the boolean prevents the effect from thrashing every
// time the polled response substitutes a fresh array.
const anyActive = topics.some(
(t) => t.status === "processing" || t.status === "publishing",
);
useEffect(() => {
if (!anyActive) return;
let alive = true;
const tick = async () => {
try {
const rows = await listTopics();
if (alive) setTopics(rows);
} catch { /* transient — keep the last snapshot */ }
};
const handle = setInterval(tick, 6000);
return () => {
alive = false;
clearInterval(handle);
};
}, [anyActive]);
async function doDelete(id: string) {
if (busyId) return;
setBusyId(id);
setError(null);
try {
await deleteTopic(id);
setConfirmDeleteId(null);
if (selectedId === id) onSelect("");
setLocalBump((n) => n + 1);
} catch (e) {
setError(e instanceof Error ? e.message : "delete failed");
} finally {
setBusyId(null);
}
}
async function doRerun(id: string) {
if (busyId) return;
setBusyId(id);
setError(null);
try {
await startTopic(id);
setLocalBump((n) => n + 1);
} catch (e) {
setError(e instanceof Error ? e.message : "rerun failed");
} finally {
setBusyId(null);
}
}
return (
<>
<div
style={{
// right padding leaves clear space for the Dashboard's
// collapse chevron pinned top-right of this column.
padding: "16px 34px 12px 16px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
alignItems: "flex-end",
gap: 8,
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontFamily: mono,
fontSize: 10,
letterSpacing: ".12em",
color: "#5a5a62",
marginBottom: 6,
}}
>
{topics.length} TOPIC{topics.length === 1 ? "" : "S"}
</div>
<div
style={{
fontSize: 18,
fontWeight: 700,
color: "#f3f3f5",
letterSpacing: "-.01em",
}}
>
Research
</div>
</div>
<button
type="button"
onClick={() => setWizardOpen(true)}
title="New research topic"
aria-label="New research topic"
style={{
flex: "none",
width: 34,
height: 34,
borderRadius: 9,
border: "1px dashed rgba(255,111,97,.4)",
background: "rgba(255,111,97,.06)",
color: "#ff6f61",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Plus aria-hidden size={17} />
</button>
</div>
{approvals.length > 0 ? (
<div style={{ padding: "0 12px 8px" }}>
<button
type="button"
onClick={() => setInboxOpen((v) => !v)}
style={{
width: "100%",
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 10px",
borderRadius: 8,
border: "1px solid rgba(255,180,74,.35)",
background: "rgba(255,180,74,.08)",
color: "#ffb44a",
fontFamily: mono,
fontSize: 11,
letterSpacing: ".05em",
cursor: "pointer",
textAlign: "left",
}}
aria-expanded={inboxOpen}
title="Pending publish approvals in this workspace"
>
<span
style={{
minWidth: 20,
height: 18,
padding: "0 6px",
borderRadius: 999,
background: "#ffb44a",
color: "#1c1408",
fontWeight: 700,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
}}
>
{approvals.length}
</span>
<span style={{ flex: 1 }}>
PENDING REVIEW{approvals.length === 1 ? "" : "S"}
</span>
<span style={{ opacity: 0.7 }}>{inboxOpen ? "▾" : "▸"}</span>
</button>
{inboxOpen ? (
<div style={{ marginTop: 6, display: "flex", flexDirection: "column", gap: 4 }}>
{approvals.map((a) => {
const t = topics.find((tt) => tt.id === a.topic_id);
return (
<button
key={a.id}
type="button"
onClick={() => { onSelect(a.topic_id); setInboxOpen(false); }}
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "7px 10px",
borderRadius: 7,
border: "1px solid rgba(255,255,255,.06)",
background: "#101014",
color: "#eaeaee",
fontSize: 12,
textAlign: "left",
cursor: "pointer",
}}
title={`Requested ${new Date(a.created_at).toLocaleString()}`}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: "#ffb44a" }} />
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t?.title ?? a.topic_id.slice(0, 8)}
</span>
<span style={{ fontFamily: mono, fontSize: 9, color: "#8a8a92" }}>
review →
</span>
</button>
);
})}
</div>
) : null}
</div>
) : null}
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "8px" }}>
{loading && topics.length === 0 ? (
<p
style={{
fontFamily: mono,
fontSize: 11,
color: "#5a5a62",
textAlign: "center",
padding: 24,
}}
>
Loading…
</p>
) : topics.length === 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 14,
padding: "32px 20px",
textAlign: "center",
}}
>
<div
style={{
fontFamily: mono,
fontSize: 11,
color: "#8a8a92",
lineHeight: 1.6,
}}
>
No research topics yet.
<br />
The wizard sets up the agents for you.
</div>
<button
type="button"
onClick={() => setWizardOpen(true)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "10px 18px",
borderRadius: 10,
border: "1px solid rgba(255,111,97,.5)",
background: "rgba(255,111,97,.12)",
color: "#ff8a7a",
cursor: "pointer",
fontFamily: mono,
fontSize: 12,
fontWeight: 600,
letterSpacing: ".02em",
}}
>
<Plus aria-hidden size={14} />
Start a research topic
</button>
</div>
) : (
topics.map((t) => {
const on = t.id === selectedId;
// Errored-processing: pipeline in `processing` but all runs
// failed and none in flight. Swap spinner for a red error
// icon and expose a rerun affordance.
const errored =
t.status === "processing" &&
t.runs_in_flight === 0 &&
(t.runs_failed ?? 0) > 0;
const dot = errored ? "#ff8a7a" : STATUS_COLOR[t.status];
const confirming = confirmDeleteId === t.id;
return (
<div
key={t.id}
style={{
padding: "10px 12px",
marginBottom: 4,
borderRadius: 9,
border: `1px solid ${on ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.06)"}`,
background: on ? "rgba(255,111,97,.08)" : "#101014",
display: "flex",
flexDirection: "column",
gap: 6,
}}
>
<button
type="button"
onClick={() => onSelect(t.id)}
style={{
all: "unset",
cursor: "pointer",
color: "#eaeaee",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<div
style={{
fontSize: 13.5,
fontWeight: 600,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{t.title}
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
fontFamily: mono,
fontSize: 10,
color: "#8a8a92",
}}
>
{errored ? (
<AlertTriangle aria-hidden size={11} color={dot} />
) : t.status === "processing" || t.status === "publishing" ? (
<MiniSpinner color={dot} />
) : (
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: dot,
}}
/>
)}
<span style={{ color: dot }}>
{errored ? `error · ${t.runs_failed} failed` : t.status}
</span>
<span style={{ opacity: 0.5 }}>·</span>
<span>{t.outcome_kind.replace("_", " ")}</span>
</div>
</button>
{confirming ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
marginTop: 4,
}}
>
<span
style={{
fontFamily: mono,
fontSize: 10.5,
color: "#ff8a7a",
flex: 1,
}}
>
Delete topic + all outcomes?
</span>
<button
type="button"
onClick={() => doDelete(t.id)}
disabled={busyId === t.id}
style={{
...dangerBtn,
opacity: busyId === t.id ? 0.6 : 1,
}}
>
{busyId === t.id ? "…" : "Delete"}
</button>
<button
type="button"
onClick={() => setConfirmDeleteId(null)}
style={ghostBtn}
>
Cancel
</button>
</div>
) : (
<div
style={{
display: "flex",
alignItems: "center",
gap: 4,
marginTop: 2,
}}
>
{t.status === "published" ? (
<a
href={`/api/research/${t.id}/artifact`}
title="Download artifact (.md)"
aria-label="Download artifact"
style={{
width: 24,
height: 24,
borderRadius: 6,
border: "1px solid rgba(255,255,255,.08)",
background: "transparent",
color: "#6fd0c0",
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
textDecoration: "none",
}}
>
<Download aria-hidden size={12} />
</a>
) : null}
{errored ? (
<button
type="button"
title="Rerun the pipeline for this topic"
aria-label="Rerun topic"
onClick={() => doRerun(t.id)}
disabled={busyId === t.id}
style={{
width: 24,
height: 24,
borderRadius: 6,
border: "1px solid rgba(255,255,255,.08)",
background: "transparent",
color: "#5ec8d8",
cursor: busyId === t.id ? "not-allowed" : "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
opacity: busyId === t.id ? 0.5 : 1,
}}
>
<RotateCw aria-hidden size={12} />
</button>
) : null}
<button
type="button"
title="Delete topic"
aria-label="Delete topic"
onClick={() => setConfirmDeleteId(t.id)}
disabled={busyId === t.id}
style={{
width: 24,
height: 24,
borderRadius: 6,
border: "1px solid rgba(255,255,255,.08)",
background: "transparent",
color: "#ff8a7a",
cursor: busyId === t.id ? "not-allowed" : "pointer",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
opacity: busyId === t.id ? 0.5 : 1,
}}
>
<Trash2 aria-hidden size={12} />
</button>
</div>
)}
</div>
);
})
)}
{error ? (
<p
style={{
fontFamily: mono,
fontSize: 11,
color: "#ff8a7a",
padding: "8px 12px",
}}
>
{error}
</p>
) : null}
</div>
{wizardOpen && (
<ResearchWizard
agents={agents}
onClose={() => setWizardOpen(false)}
onCreated={(id) => {
setWizardOpen(false);
onCreated(id);
}}
/>
)}
</>
);
}
function MiniSpinner({ color }: { color: string }) {
return (
<svg width={10} height={10} viewBox="0 0 20 20" aria-hidden>
<g fill="none" stroke={color} strokeWidth="2.6" strokeLinecap="round">
<circle cx="10" cy="10" r="7.5" opacity="0.22" />
<path d="M17.5 10 A7.5 7.5 0 0 0 10 2.5">
<animateTransform
attributeName="transform"
type="rotate"
from="0 10 10"
to="360 10 10"
dur="0.9s"
repeatCount="indefinite"
/>
</path>
</g>
</svg>
);
}
const dangerBtn: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 6,
border: 0,
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
color: "#2a0d0a",
fontSize: 11,
fontWeight: 700,
cursor: "pointer",
};
const ghostBtn: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 6,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 11,
fontWeight: 600,
cursor: "pointer",
};
@@ -1,930 +0,0 @@
"use client";
// 4-step research topic wizard: prompt → LLM refine → outcome kind → agents.
// Refine calls /api/research/wizard/refine which streams the workspace's
// default LLM provider under the hood and returns a structured
// {title, description}. Final submit creates the topic in `standby` with
// agents attached.
import { useState } from "react";
import { X } from "lucide-react";
import type { Agent } from "@/lib/api/schemas";
import {
autoProvisionTeam,
createTopic,
wizardRefine,
wizardRepoEnsure,
wizardRepoRelease,
type AutoProvisionResponse,
type OutcomeKind,
type TopologyKind,
type WizardRepoReply,
} from "@/lib/api/research";
import { RepoPicker, type PickedRepo } from "./RepoPicker";
const mono =
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
const TOPOLOGIES: { kind: TopologyKind; label: string; hint: string }[] = [
{
kind: "hub_spoke",
label: "Hub · spoke",
hint: "A coordinator delegates to specialists and synthesizes their outputs. Good default.",
},
{
kind: "pipeline",
label: "Pipeline",
hint: "Linear stages: each teammate hands off to the next. Last stage owns the final artifact. Ideal for research → distill → analyze → implement chains.",
},
{
kind: "hierarchical",
label: "Hierarchical",
hint: "Root plans, children work in parallel, root synthesizes. Best when sub-problems are independent.",
},
{
kind: "star_moe",
label: "Star · MoE",
hint: "Router classifies subtasks and dispatches to the domain expert best-fit for each. Mixed-domain topics.",
},
];
const OUTCOMES: {
kind: OutcomeKind;
label: string;
hint: string;
/** Optional: what the artifact will contain. Shown as a preview on the
* wizard card so users pick by seeing what they'll get. */
sections?: string[];
}[] = [
{
kind: "spec",
label: "Spec",
hint: "Technical spec: problem, interfaces, data model, acceptance criteria.",
sections: ["Problem", "Goals + non-goals", "Interfaces", "Data model", "Alternatives", "Acceptance criteria"],
},
{
kind: "prod_plan",
label: "Prod plan",
hint: "Prioritized execution plan with P0/P1/P2 workstreams and milestones.",
sections: ["Objective", "Success metrics", "Workstreams", "Milestones", "Risks", "Definition of done"],
},
{
kind: "roadmap",
label: "Roadmap",
hint: "Horizon roadmap — Now / Next / Later with cross-cutting concerns.",
sections: ["Vision", "Now (0-6w)", "Next (6-16w)", "Later (16w+)", "Cross-cutting"],
},
{
kind: "paper",
label: "Paper",
hint: "Short scientific paper with methods, findings, related work.",
sections: ["Abstract", "Background", "Method", "Findings", "Discussion", "References"],
},
{
kind: "integrations",
label: "Integration plan",
hint:
"Audit the bound repo + survey papers → prioritized menu of concrete " +
"INT-XX items (what · how · where in architecture · effort · risk · " +
"acceptance). Loops can consume one item per iteration.",
sections: [
"Executive summary",
"INT-XX cards (P0/P1/P2)",
"· What it is + source paper(s)",
"· How to accomplish",
"· Where in the architecture (file paths)",
"· Prereqs, effort, risk, testing, rollback",
"· Acceptance criteria",
],
},
];
interface AgentSelection {
agent_id: string;
role_slot: string;
}
export function ResearchWizard({
agents,
onClose,
onCreated,
}: {
agents: Agent[];
onClose: () => void;
onCreated: (topicId: string) => void;
}) {
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5 | 6>(1);
// Schedule (D1 fold): every research topic materializes a kind='research'
// loop that owns the runs. "Once" fires once and stops (initial_burst=1);
// "Nightly" adds cron 0 3 * * *; "Manual" only surfaces the webhook. The
// paired coding loop (optional) is a second kind='exec' loop with
// on_artifact_update=true so it consumes each new artifact version.
const [scheduleMode, setScheduleMode] = useState<"once" | "nightly" | "manual">(
"once",
);
const [pairedCodingLoop, setPairedCodingLoop] = useState(false);
// Team disposition for the paired coding loop. `fresh` (default when
// the loop is enabled) provisions a dedicated writable coding team
// so the research team's read-only posture isn't loosened. `reuse`
// is the legacy behavior — coding iterations share the research
// team's container + risk profile.
const [codingTeamMode, setCodingTeamMode] = useState<"fresh" | "reuse">("fresh");
const [prompt, setPrompt] = useState("");
const [repo, setRepo] = useState<PickedRepo | null>(null);
const [outcome, setOutcome] = useState<OutcomeKind>("spec");
const [topology, setTopology] = useState<TopologyKind>("hub_spoke");
const [refining, setRefining] = useState(false);
const [refineError, setRefineError] = useState<string | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [selected, setSelected] = useState<AgentSelection[]>([]);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// Track the repo we've asked clawstor to materialize across the fleet
// (set on step-2 → step-3 transition). Used to release on cancel and
// to render per-peer status.
const [ensuredRepoId, setEnsuredRepoId] = useState<string | null>(null);
const [ensuring, setEnsuring] = useState(false);
const [ensureError, setEnsureError] = useState<string | null>(null);
const [ensureReply, setEnsureReply] = useState<WizardRepoReply | null>(null);
const [committed, setCommitted] = useState(false);
// Inline team auto-provisioning (default when the workspace roster is
// empty). LLM-derived roster + claws are materialized on step-5;
// response drops straight into the submit body's agents[].
const [autoProvisioning, setAutoProvisioning] = useState(false);
const [autoProvisionError, setAutoProvisionError] = useState<string | null>(null);
const [autoTeam, setAutoTeam] = useState<AutoProvisionResponse | null>(null);
async function runAutoProvision() {
setAutoProvisionError(null);
setAutoProvisioning(true);
try {
const r = await autoProvisionTeam({
title: title.trim() || prompt.trim().slice(0, 60),
description: description.trim() || prompt.trim(),
outcome_kind: outcome,
topology_kind: topology,
model: "claude-sonnet-5",
});
setAutoTeam(r);
// Adopt the topology the LLM chose (it may differ if we passed
// hub_spoke but the outcome_kind maps naturally to pipeline).
setTopology(r.topology_kind);
// Preselect the returned agents so submit() flows through the
// existing path without a second click.
setSelected(
r.agents.map((a) => ({ agent_id: a.agent_id, role_slot: a.role_slot })),
);
} catch (e) {
setAutoProvisionError(e instanceof Error ? e.message : "auto-provision failed");
} finally {
setAutoProvisioning(false);
}
}
async function maybeEnsureRepo(): Promise<boolean> {
if (!repo) return true;
if (ensuredRepoId === repo.repo_id) return true;
setEnsureError(null);
setEnsuring(true);
try {
const reply = await wizardRepoEnsure(
repo.repo_id,
repo.default_branch ?? undefined,
);
setEnsureReply(reply);
setEnsuredRepoId(repo.repo_id);
return true;
} catch (e) {
setEnsureError(e instanceof Error ? e.message : "ensure failed");
return false;
} finally {
setEnsuring(false);
}
}
async function handleClose() {
// Best-effort release on cancel. If we already committed via
// submit(), the repo stays live for the topic and we skip.
if (ensuredRepoId && !committed) {
try {
await wizardRepoRelease(
ensuredRepoId,
repo?.default_branch ?? undefined,
);
} catch {
// Swallow — cancel path is best-effort; TTL sweeper follow-up
// in a future clawstor phase will cover the true safety net.
}
}
onClose();
}
async function runRefine(prior?: string) {
setRefineError(null);
setRefining(true);
try {
const out = await wizardRefine({
prompt,
outcome_kind: outcome,
prior_description: prior,
...(repo ? { repo } : {}),
});
setTitle(out.suggested_title);
setDescription(out.description);
} catch (e) {
setRefineError(e instanceof Error ? e.message : "refine failed");
} finally {
setRefining(false);
}
}
async function submit() {
setSubmitError(null);
setSubmitting(true);
try {
const { id } = await createTopic({
title: title.trim(),
description: description.trim(),
outcome_kind: outcome,
topology_kind: topology,
agents: selected.map((s) => ({
agent_id: s.agent_id,
role_slot: s.role_slot.trim() || undefined,
})),
...(repo ? { repo } : {}),
schedule: { mode: scheduleMode },
create_paired_coding_loop: pairedCodingLoop,
...(pairedCodingLoop
? { paired_coding_team_mode: codingTeamMode }
: {}),
});
setCommitted(true);
onCreated(id);
} catch (e) {
setSubmitError(e instanceof Error ? e.message : "create failed");
} finally {
setSubmitting(false);
}
}
async function handleNext() {
if (step === 2) {
const ok = await maybeEnsureRepo();
if (!ok) return;
}
setStep((s) => ((s + 1) as 1 | 2 | 3 | 4 | 5 | 6));
}
const canNext =
(step === 1 && prompt.trim().length > 0) ||
step === 2 ||
(step === 3 && title.trim().length > 0 && description.trim().length > 0) ||
step === 4 ||
// step 5 (team): allow when EITHER auto-provision landed OR the
// user handpicked at least one agent from the workspace roster.
// Empty-workspace + no auto-team blocks Next so users don't hit
// step 6 with no team bound.
(step === 5 && (autoTeam !== null || selected.length > 0 || agents.length > 0)) ||
step === 6;
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,.55)",
zIndex: 200,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
}}
onClick={handleClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 620,
maxHeight: "90vh",
background: "#0d0d10",
border: "1px solid rgba(255,255,255,.1)",
borderRadius: 14,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Header */}
<div
style={{
padding: "14px 18px",
borderBottom: "1px solid rgba(255,255,255,.06)",
display: "flex",
alignItems: "center",
gap: 10,
}}
>
<span style={{ fontFamily: mono, fontSize: 10, color: "#5a5a62" }}>
STEP {step} / 5
</span>
<span style={{ flex: 1, fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>
New research topic
</span>
<button
type="button"
onClick={handleClose}
aria-label="Close"
style={{
width: 30,
height: 30,
borderRadius: 8,
border: "1px solid rgba(255,255,255,.12)",
background: "transparent",
color: "#cfcfd5",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<X aria-hidden size={16} />
</button>
</div>
{/* Body */}
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: 20 }}>
{step === 1 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<label htmlFor="topic-prompt" style={{ fontFamily: mono, fontSize: 11, color: "#b5b5bd" }}>
Topic prompt
</label>
<textarea
id="topic-prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="What should we research? E.g., 'How do we harden our sandbox security model against side-channel leaks?'"
rows={5}
style={fieldStyle}
/>
<p style={hintStyle}>
Freeform. Step 2 refines this into a structured framing with
key questions and success criteria.
</p>
</div>
)}
{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 />
{ensureError && (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
Fleet materialization failed: {ensureError}
</p>
)}
{ensureReply && ensuredRepoId === repo?.repo_id && (
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
<div style={{ marginBottom: 4 }}>
{ensureReply.all_ok ? "✓" : "!"} materialized on{" "}
{ensureReply.peers.filter((p) => p.ok).length}/
{ensureReply.peers.length} peers · workspace{" "}
{ensureReply.workspace}
</div>
{ensureReply.peers.map((p) => (
<div key={p.peer}>
{p.ok ? " ✓" : " ✗"} {p.peer}
{p.cached ? " (cached)" : ""}
{p.error ? ` — ${p.error}` : ""}
</div>
))}
</div>
)}
</div>
)}
{step === 3 && (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{!title && !refining ? (
<button
type="button"
onClick={() => runRefine()}
style={{ ...primaryBtn, alignSelf: "flex-start" }}
>
Refine with LLM
</button>
) : null}
{refining && (
<p style={{ fontFamily: mono, fontSize: 12, color: "#8a8a92" }}>
Calling the workspace&apos;s default provider…
</p>
)}
{refineError && (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
{refineError}. Fill in below manually or try again.
</p>
)}
<label htmlFor="topic-title" style={labelStyle}>
Title
</label>
<input
id="topic-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={fieldStyle}
/>
<label htmlFor="topic-desc" style={labelStyle}>
Description (markdown)
</label>
<textarea
id="topic-desc"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={12}
style={{
...fieldStyle,
fontFamily: mono,
fontSize: 12,
}}
/>
{title && !refining && (
<button
type="button"
onClick={() => runRefine(description)}
style={{ ...secondaryBtn, alignSelf: "flex-start" }}
>
Refine again
</button>
)}
</div>
)}
{step === 4 && (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Outcome kind</span>
{OUTCOMES.map((o) => (
<label
key={o.kind}
style={{
display: "flex",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${outcome === o.kind ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
background: outcome === o.kind ? "rgba(255,111,97,.08)" : "transparent",
cursor: "pointer",
}}
>
<input
type="radio"
name="outcome"
checked={outcome === o.kind}
onChange={() => setOutcome(o.kind)}
style={{ marginTop: 2 }}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{o.label}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.4 }}>
{o.hint}
</div>
{o.sections && outcome === o.kind ? (
<div
style={{
marginTop: 8,
padding: "8px 10px",
borderRadius: 6,
background: "rgba(255,255,255,.02)",
border: "1px solid rgba(255,255,255,.06)",
}}
>
<div
style={{
fontFamily: mono,
fontSize: 9,
letterSpacing: ".1em",
color: "#5a5a62",
marginBottom: 4,
}}
>
ARTIFACT WILL CONTAIN
</div>
<ul
style={{
margin: 0,
paddingLeft: 16,
fontFamily: mono,
fontSize: 10.5,
color: "#c3c3c8",
lineHeight: 1.5,
}}
>
{o.sections.map((s) => (
<li key={s}>{s}</li>
))}
</ul>
</div>
) : null}
</div>
</label>
))}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Topology (how the team runs)</span>
<p style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.55, margin: 0 }}>
Shapes the coordinator prompt and the graph edges. For a
research → distill → analyze → implement roster, pick
<span style={{ color: "#f3f3f5" }}> Pipeline</span>. For
domain specialists working under a single hub, keep
<span style={{ color: "#f3f3f5" }}> Hub · spoke</span>.
</p>
{TOPOLOGIES.map((t) => (
<label
key={t.kind}
style={{
display: "flex",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${topology === t.kind ? "rgba(201,138,240,.5)" : "rgba(255,255,255,.1)"}`,
background: topology === t.kind ? "rgba(201,138,240,.08)" : "transparent",
cursor: "pointer",
}}
>
<input
type="radio"
name="topology"
checked={topology === t.kind}
onChange={() => setTopology(t.kind)}
style={{ marginTop: 2 }}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{t.label}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.5 }}>{t.hint}</div>
</div>
</label>
))}
</div>
</div>
)}
{step === 5 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={labelStyle}>Team</span>
{/* Auto-provision panel — always shown; the pick-existing
list beneath it is skipped when the workspace has no
claws yet. */}
<div
style={{
padding: 12,
borderRadius: 10,
border: `1px solid ${autoTeam ? "rgba(95,208,138,.35)" : "rgba(94,200,216,.35)"}`,
background: autoTeam ? "rgba(95,208,138,.06)" : "rgba(94,200,216,.06)",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
{autoTeam
? `Team ready · ${autoTeam.agents.length} agents · ${autoTeam.topology_kind}`
: "Auto-provision a team from this topic"}
</div>
{!autoTeam && (
<p style={hintStyle}>
LLM derives the roster (roles + system prompts) from the topic + outcome kind,
materializes the claws, and stamps a runtime posture. Every agent runs on
<code> claude-sonnet-5</code>. You never leave this wizard.
</p>
)}
{autoTeam ? (
<ul style={{ margin: 0, paddingLeft: 18, color: "#cfcfd5", fontFamily: mono, fontSize: 12 }}>
{autoTeam.agents.map((a) => (
<li key={a.agent_id}>
<strong style={{ color: "#f3f3f5" }}>{a.name}</strong> · <em>{a.role_slot}</em>
</li>
))}
</ul>
) : (
<button
type="button"
onClick={runAutoProvision}
disabled={autoProvisioning || !title.trim() || !description.trim()}
style={{
alignSelf: "flex-start",
padding: "8px 14px",
borderRadius: 8,
background: autoProvisioning
? "rgba(94,200,216,.2)"
: "rgba(94,200,216,.15)",
border: "1px solid rgba(94,200,216,.5)",
color: "#e5f6fb",
cursor:
autoProvisioning || !title.trim() || !description.trim()
? "not-allowed"
: "pointer",
fontFamily: mono,
fontSize: 12,
opacity:
!title.trim() || !description.trim() ? 0.5 : 1,
}}
>
{autoProvisioning ? "Provisioning…" : "Auto-provision team"}
</button>
)}
{autoProvisionError && (
<p style={{ fontFamily: mono, fontSize: 11, color: "#ff8a7a" }}>
{autoProvisionError}
</p>
)}
</div>
{agents.length > 0 && !autoTeam ? (
<>
<div style={{ ...labelStyle, marginTop: 8 }}>Or pick from workspace roster</div>
<p style={hintStyle}>Zero or more. Each optional role slot (&quot;lead&quot;, &quot;critic&quot;) groups avatars on the canvas.</p>
{agents.length === 0 ? (
<p style={hintStyle}>No agents in this workspace yet.</p>
) : (
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) {
setSelected([...selected, { agent_id: a.id, role_slot: "" }]);
} else {
setSelected(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) =>
setSelected(
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>
);
})
)}
</>
) : null}
{submitError && (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
{submitError}
</p>
)}
</div>
)}
{step === 6 && (
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<span style={labelStyle}>How should this research run?</span>
<p style={hintStyle}>
Every research topic materializes as a loop under the hood.
The mode below picks the loop&apos;s triggers.
</p>
{(
[
{
mode: "once" as const,
label: "Just once",
hint: "Runs immediately on create, produces one artifact, then stops.",
},
{
mode: "nightly" as const,
label: "Nightly",
hint: "Runs immediately, then again at 03:00 every day. Each run appends a new artifact version.",
},
{
mode: "manual" as const,
label: "Manual only",
hint: "No auto-fire. You trigger runs via the loop&apos;s Run-now button or webhook.",
},
]
).map((o) => (
<label
key={o.mode}
style={{
display: "flex",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${scheduleMode === o.mode ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
background: scheduleMode === o.mode ? "rgba(255,111,97,.08)" : "transparent",
cursor: "pointer",
}}
>
<input
type="radio"
name="schedule"
checked={scheduleMode === o.mode}
onChange={() => setScheduleMode(o.mode)}
style={{ marginTop: 2 }}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{o.label}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
{o.hint}
</div>
</div>
</label>
))}
<div
style={{
marginTop: 8,
padding: 12,
borderRadius: 10,
border: `1px solid ${pairedCodingLoop ? "rgba(94,200,216,.4)" : "rgba(255,255,255,.08)"}`,
background: pairedCodingLoop ? "rgba(94,200,216,.06)" : "rgba(255,255,255,.02)",
}}
>
<label style={{ display: "flex", gap: 10, cursor: "pointer" }}>
<input
type="checkbox"
checked={pairedCodingLoop}
onChange={(e) => setPairedCodingLoop(e.target.checked)}
style={{ marginTop: 2 }}
/>
<div>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>
Also create a coding loop that consumes each new artifact
</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", lineHeight: 1.5, marginTop: 4 }}>
Adds a second (kind=exec) loop with{" "}
<code>on_artifact_update</code> = true, bound to the same
topic. Wakes up whenever the research loop writes a new
version and consumes one <code>INT-XX</code> per
iteration in order.
</div>
</div>
</label>
{pairedCodingLoop && (
<div style={{ marginTop: 10, paddingLeft: 28, display: "flex", flexDirection: "column", gap: 6 }}>
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: "#5a5a62" }}>
CODING TEAM
</div>
{[
{ k: "fresh" as const, title: "Provision a dedicated coding team", desc: "Fresh writable pipeline (planner → implementer → reviewer) with coding_readwrite risk profile. Recommended — keeps the research team's read-only posture intact." },
{ k: "reuse" as const, title: "Reuse the research team", desc: "Legacy behavior. Coding iterations spawn in the same container as research; both share the same (read-only) risk profile. Only pick this if you know the research team can safely write." },
].map((o) => {
const on = codingTeamMode === o.k;
return (
<label
key={o.k}
style={{
display: "flex",
gap: 8,
padding: 8,
borderRadius: 8,
border: `1px solid ${on ? "rgba(94,200,216,.4)" : "rgba(255,255,255,.08)"}`,
background: on ? "rgba(94,200,216,.05)" : "transparent",
cursor: "pointer",
}}
>
<input
type="radio"
name="coding-team"
checked={on}
onChange={() => setCodingTeamMode(o.k)}
style={{ marginTop: 3 }}
/>
<div>
<div style={{ fontSize: 13, color: "#eaeaee" }}>{o.title}</div>
<div style={{ fontFamily: mono, fontSize: 10.5, color: "#8a8a92", lineHeight: 1.5, marginTop: 3 }}>{o.desc}</div>
</div>
</label>
);
})}
</div>
)}
</div>
{submitError && (
<p style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>
{submitError}
</p>
)}
</div>
)}
</div>
{/* Footer */}
<div
style={{
padding: 14,
borderTop: "1px solid rgba(255,255,255,.06)",
display: "flex",
gap: 8,
justifyContent: "space-between",
}}
>
<button
type="button"
onClick={() => setStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3 | 4 | 5 | 6) : s))}
disabled={step === 1}
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
>
Back
</button>
{step < 6 ? (
<button
type="button"
onClick={handleNext}
disabled={!canNext || refining || ensuring}
style={{ ...primaryBtn, opacity: !canNext || refining || ensuring ? 0.4 : 1 }}
>
{ensuring ? "Materializing repo…" : "Next"}
</button>
) : (
<button
type="button"
onClick={submit}
disabled={submitting || !title.trim() || !description.trim()}
style={{ ...primaryBtn, opacity: submitting ? 0.6 : 1 }}
>
{submitting ? "Creating…" : "Create topic"}
</button>
)}
</div>
</div>
</div>
);
}
const labelStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,
color: "#b5b5bd",
};
const hintStyle: React.CSSProperties = {
fontFamily: mono,
fontSize: 11,
color: "#5a5a62",
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 primaryBtn: React.CSSProperties = {
padding: "9px 16px",
borderRadius: 8,
border: 0,
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
color: "#2a0d0a",
fontSize: 13,
fontWeight: 700,
cursor: "pointer",
};
const secondaryBtn: React.CSSProperties = {
padding: "9px 16px",
borderRadius: 8,
border: "1px solid rgba(255,255,255,.14)",
background: "transparent",
color: "#cfcfd5",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
};
-186
View File
@@ -1,186 +0,0 @@
// Loops API client. Uses relative fetch(); the Next.js proxy forwards to
// the Rust backend with the auth cookie.
export interface Loop {
id: string;
workspace_id: string;
title: string;
description: string;
graph: unknown;
task_template: string;
triggers: LoopTriggers;
repeat_policy: LoopRepeatPolicy;
enabled: boolean;
next_fire_at: string | null;
last_run_id: string | null;
webhook_token: string | null;
webhook_signing_key: string | null;
created_by: string;
created_at: string;
updated_at: string;
agents: LoopAgentSlot[];
teams: string[];
orgs: string[];
}
export interface LoopAgentSlot {
agent_id: string;
role_slot: string | null;
}
export interface LoopTriggers {
cron?: string;
on_completion?: boolean;
webhook_enabled?: boolean;
}
export type LoopRepeatPolicy =
| { kind: "infinite" }
| { kind: "iters"; n: number }
| { kind: "until"; event: string; within_iters?: number };
export interface LoopCreated {
id: string;
/** Set only when triggers.webhook_enabled === true. Returned ONCE. */
webhook_token?: string;
webhook_signing_key?: string;
}
export interface LoopRepoRef {
repo_id: string;
connection_id: string;
provider: string;
owner: string;
name: string;
default_branch: string | null;
}
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
if (!res.ok) throw new Error(`${init?.method ?? "GET"} ${path} → ${res.status}`);
if (res.status === 204 || res.status === 205) return undefined as T;
return res.json();
}
export const listLoops = () => api<Loop[]>("/api/loops");
export interface LoopReorderEvent {
run_id?: string;
iteration?: number;
text?: string;
ts?: string;
}
export interface LoopProgress {
loop_id: string;
source_topic_id: string;
source_topic_title: string;
source_outcome_version: number;
consumed_count: number;
total_int_count: number;
current_int_index: number;
/** Newest-first, capped at 5. Empty when the coordinator hasn't
* emitted any REORDER: markers yet. */
recent_reorders: LoopReorderEvent[];
}
/** Bulk progress read — one entry per loop bound to a research topic.
* Standalone loops (no source) are omitted. Used by the sidebar to
* render an "N/M INTs" pill on source-bound loop cards. */
export const listLoopProgress = () =>
api<LoopProgress[]>("/api/loops/progress");
export const getLoop = (id: string) => api<Loop>(`/api/loops/${id}`);
export const createLoop = (body: {
title: string;
description: string;
graph: unknown;
task_template: string;
triggers: LoopTriggers;
repeat_policy: LoopRepeatPolicy;
repo?: LoopRepoRef | null;
agents?: { agent_id: string; role_slot?: string }[];
teams?: string[];
orgs?: string[];
/** Optional research topic this loop is executing. When set, each
* iteration prepends the topic's latest artifact + a "focus on next
* unconsumed INT" instruction (option b, order-sequential). */
source_research_topic_id?: string;
}) =>
api<LoopCreated>("/api/loops", {
method: "POST",
body: JSON.stringify(body),
});
export const patchLoop = (
id: string,
body: {
title: string;
description: string;
graph: unknown;
task_template: string;
triggers: LoopTriggers;
repeat_policy: LoopRepeatPolicy;
repo?: LoopRepoRef | null;
agents?: { agent_id: string; role_slot?: string }[];
teams?: string[];
orgs?: string[];
},
) =>
api<void>(`/api/loops/${id}`, {
method: "PATCH",
body: JSON.stringify(body),
});
export const deleteLoop = (id: string) =>
api<void>(`/api/loops/${id}`, { method: "DELETE" });
export const runLoopNow = (id: string) =>
api<{ run_id: string; iteration: number }>(`/api/loops/${id}/run`, {
method: "POST",
});
export const enableLoop = (id: string) =>
api<void>(`/api/loops/${id}/enable`, { method: "POST" });
export const disableLoop = (id: string) =>
api<void>(`/api/loops/${id}/disable`, { method: "POST" });
// One iteration/run of a loop as summarized by /api/topology-runs.
// `iteration`/`finished_at` are populated for loop iterations / terminal runs.
export interface RunSummary {
id: string;
task: string;
status: string;
kind: string;
created_at: string;
iteration?: number;
finished_at?: string;
}
export const listLoopRuns = (loopId: string, limit = 20) =>
api<RunSummary[]>(
`/api/topology-runs?loop_id=${encodeURIComponent(loopId)}&limit=${limit}`,
);
/** Full detail for one topology_run. `comparison` carries the run's
* serialized result — for a completed exec/research run this includes
* `final_output` (the produced markdown / code) and `steps` (per-node
* outputs). Used by the "View full output" modal on an iteration. */
export interface RunDetail {
id: string;
task: string;
kind: string;
status: string;
error: string | null;
created_at: string;
updated_at: string;
comparison: unknown;
checkpoint: unknown;
}
export const getRunDetail = (runId: string) =>
api<RunDetail>(`/api/topology-runs/${encodeURIComponent(runId)}`);
-282
View File
@@ -1,282 +0,0 @@
// Research API client. Uses relative fetch(): the Next.js server proxies
// to the Rust backend with the auth cookie.
export type OutcomeKind = "spec" | "prod_plan" | "roadmap" | "paper" | "integrations";
export type TopicStatus =
| "standby"
| "processing"
| "reviewing"
| "publishing"
| "published";
export interface TopicListItem {
id: string;
title: string;
outcome_kind: OutcomeKind;
status: TopicStatus;
updated_at: string;
/** Queued + running topology_runs bound to this topic. */
runs_in_flight: number;
/** Failed runs since the last successful run. Combined with
* status === "processing" && runs_in_flight === 0, drives the
* errored-state icon + rerun affordance in ResearchList. */
runs_failed: number;
}
export interface AgentSlot {
agent_id: string;
role_slot: string | null;
}
export type TopologyKind = "hub_spoke" | "pipeline" | "hierarchical" | "star_moe";
export interface ResearchOutcome {
id: string;
topic_id: string;
version: number;
body_md: string;
produced_by_run_id: string | null;
created_at: string;
}
export interface TopicDetail {
id: string;
workspace_id: string;
title: string;
description: string;
outcome_kind: OutcomeKind;
status: TopicStatus;
created_by: string;
created_at: string;
updated_at: string;
published_at: string | null;
topology_kind: TopologyKind;
agents: AgentSlot[];
/** True while a publish approval is pending. Frontend hides the
* "Request publish" button and shows "Awaiting reviewer approval"
* instead so double-clicks don't 409. */
has_pending_publish_request: boolean;
/** The most recent artifact the pipeline produced for this topic.
* Present once a run has completed; the canvas renders body_md
* in place of description in reviewing/publishing/published. */
latest_outcome: ResearchOutcome | null;
/** queued + running topology_runs bound to this topic. Frontend shows
* a spinner + "Pipeline is running" pill when > 0 and only offers the
* manual "Submit for review" escape hatch when it's 0. */
runs_in_flight: number;
/** When present, the topic is owned by a scheduled research loop
* (D1 fold). Canvas hides the classic Start/Submit buttons and
* shows a "Managed by scheduled loop" strip instead. */
managed_by_loop?: {
loop_id: string;
title: string;
enabled: boolean;
next_fire_at: string | null;
last_run_id: string | null;
schedule_summary: string;
};
}
export interface PublishApproval {
id: string;
workspace_id: string;
topic_id: string;
requested_by: string;
status: "pending" | "approved" | "rejected";
decided_by: string | null;
decided_at: string | null;
created_at: string;
}
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
if (!res.ok) throw new Error(`${init?.method ?? "GET"} ${path} → ${res.status}`);
if (res.status === 204 || res.status === 205) return undefined as T;
return res.json();
}
export const listTopics = () => api<TopicListItem[]>("/api/research");
export const getTopic = (id: string) => api<TopicDetail>(`/api/research/${id}`);
export interface TopicRepoRef {
repo_id: string;
connection_id: string;
provider: string;
owner: string;
name: string;
default_branch: string | null;
}
export const createTopic = (body: {
title: string;
description: string;
outcome_kind: OutcomeKind;
topology_kind?: TopologyKind;
agents: { agent_id: string; role_slot?: string }[];
repo?: TopicRepoRef | null;
/** Schedule mode picked in step 6 of the wizard. When set, the
* handler materializes a kind='research' loop bound to the topic
* with triggers driven by mode:
* once → initial_burst: 1
* nightly → initial_burst: 1 + cron "0 3 * * *"
* manual → webhook_enabled: true
* Omitting this leaves the topic in the legacy one-shot state
* (backwards compatible with older wizard versions). */
schedule?: { mode: "once" | "nightly" | "manual" };
/** When true, also create a kind='exec' loop bound to the same
* topic with `on_artifact_update: true` so it consumes each new
* artifact version one INT-XX at a time. */
create_paired_coding_loop?: boolean;
/** Team disposition for the paired coding loop.
* fresh — provision a new writable coding team (recommended)
* reuse — attach the coding loop to the research team (legacy)
* Ignored when create_paired_coding_loop is false. */
paired_coding_team_mode?: "fresh" | "reuse";
}) =>
api<{ id: string }>("/api/research", {
method: "POST",
body: JSON.stringify(body),
});
export const attachAgent = (
topicId: string,
body: { agent_id: string; role_slot?: string },
) =>
api<void>(`/api/research/${topicId}/agents`, {
method: "POST",
body: JSON.stringify(body),
});
export const detachAgent = (topicId: string, agentId: string) =>
api<void>(`/api/research/${topicId}/agents/${agentId}`, { method: "DELETE" });
export const deleteTopic = (id: string) =>
api<void>(`/api/research/${id}`, { method: "DELETE" });
export const startTopic = (id: string) =>
api<void>(`/api/research/${id}/start`, { method: "POST" });
export const submitReview = (id: string) =>
api<void>(`/api/research/${id}/submit-review`, { method: "POST" });
export const requestPublish = (id: string) =>
api<{ approval_id: string }>(`/api/research/${id}/request-publish`, {
method: "POST",
});
export const listPendingApprovals = () =>
api<PublishApproval[]>("/api/research/publish-approvals");
export const approvePublish = (id: string) =>
api<void>(`/api/research/publish-approvals/${id}/approve`, { method: "POST" });
export interface PipelineStage {
key: string;
label: string;
/** waiting = a run is legitimately in flight; NOT a failure state.
* Rendered as a pulsing cyan dot to distinguish from red "fail". */
status: "ok" | "warn" | "fail" | "skip" | "waiting";
detail?: string;
}
export interface PipelineStateResponse {
topic_id: string;
status: TopicStatus;
stages: PipelineStage[];
}
export const getPipelineState = (id: string) =>
api<PipelineStateResponse>(`/api/research/${id}/pipeline-state`);
export const rejectPublish = (id: string, notes?: string) =>
api<void>(`/api/research/publish-approvals/${id}/reject`, {
method: "POST",
body: notes && notes.trim() ? JSON.stringify({ notes: notes.trim() }) : undefined,
headers: notes && notes.trim() ? { "Content-Type": "application/json" } : undefined,
});
export const wizardRefine = (body: {
prompt: string;
prior_description?: string;
outcome_kind: OutcomeKind;
repo?: TopicRepoRef | null;
}) =>
api<{ description: string; suggested_title: string }>(
"/api/research/wizard/refine",
{ method: "POST", body: JSON.stringify(body) },
);
export interface WizardRepoPeer {
peer: string;
ok: boolean;
path?: string;
head_sha?: string;
cached?: boolean;
removed?: boolean;
error?: string;
}
export interface WizardRepoReply {
url: string;
git_ref: string;
workspace: string;
peers: WizardRepoPeer[];
all_ok: boolean;
}
export const wizardRepoEnsure = (repo_id: string, git_ref?: string) =>
api<WizardRepoReply>("/api/research/wizard/repo/ensure", {
method: "POST",
body: JSON.stringify({ repo_id, ...(git_ref ? { git_ref } : {}) }),
});
export const wizardRepoRelease = (repo_id: string, git_ref?: string) =>
api<WizardRepoReply>("/api/research/wizard/repo/release", {
method: "POST",
body: JSON.stringify({ repo_id, ...(git_ref ? { git_ref } : {}) }),
});
export interface ActiveRun {
id: string;
}
export interface ActiveRunsReply {
topic_id: string;
runs: ActiveRun[];
}
/** List active (queued+running) topology_run ids bound to a research topic.
* Feeds the live-log panel; runs are ordered newest first. */
export const getActiveRuns = (topicId: string) =>
api<ActiveRunsReply>(`/api/research/${encodeURIComponent(topicId)}/active-runs`);
export interface AutoProvisionedAgent {
agent_id: string;
name: string;
role_slot: string;
model: string;
}
export interface AutoProvisionResponse {
team_id: string;
topology_kind: TopologyKind;
agents: AutoProvisionedAgent[];
risk_profile?: string;
mcp_bundles: string[];
}
/** LLM-derive a full team + claws for a topic/loop. The response's
* agents[] shape drops straight into POST /api/research's `agents`
* field so the wizard can skip its handpick step entirely. */
export const autoProvisionTeam = (body: {
title: string;
description: string;
outcome_kind: OutcomeKind;
topology_kind?: TopologyKind;
model?: string;
}) =>
api<AutoProvisionResponse>("/api/teams/auto-provision", {
method: "POST",
body: JSON.stringify(body),
});