slice 9: big-bang cutover — delete legacy research + loops UI
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:
co-authored by
Claude Opus 4.7
parent
9b5e63cbb7
commit
4663348a0e
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 "Run now" 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'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'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 ("lead", "critic") 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'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'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",
|
|
||||||
};
|
|
||||||
@@ -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)}`);
|
|
||||||
@@ -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),
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user