research UI: real list + canvas + 4-step wizard, wired to the backend
Sixth commit of the Research + Loops arc. Replaces the placeholder
Research surface from commit 5 with the full working UI.
New:
- lib/api/research.ts — thin TypeScript client for every /api/research
endpoint (list, get, create, attach/detach agent, start, submit-review,
request-publish, list/approve/reject publish approvals, wizard refine).
- dashboard/ResearchWizard.tsx — 4-step modal (topic prompt → LLM refine
→ outcome kind → agents). The refine step calls
POST /api/research/wizard/refine which streams the workspace's default
LLM and returns {title, description}. Users can accept, edit, or refine
again. Manual fallback if the LLM call errors.
- dashboard/ResearchList.tsx — replaces the stub. Real topic cards with
status pill (standby / processing / reviewing / publishing / published),
outcome-kind chip, and the + button that opens the wizard. Re-fetches
when the parent bumps refreshKey.
- dashboard/ResearchCanvas.tsx — replaces the stub. Selected topic
detail: title, status header, outcome + published_at meta, assigned
agents grid (matched to workspace claws by id), the full description
in a monospace preformatted block, and a state-appropriate primary
action button (Start research → Submit for review → Request publish).
Wired into Dashboard.tsx via a `researchSel` / `researchRefresh` state
pair: selecting a card sets the id, mutations bump the counter so both
list + canvas re-fetch.
All API calls go through the existing /api/[...path] catch-all Next
proxy — no new server routes needed.
This commit is contained in:
@@ -203,6 +203,10 @@ 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
|
||||||
|
// list + canvas re-fetch after start/submit/publish etc.
|
||||||
|
const [researchSel, setResearchSel] = useState<string | null>(null);
|
||||||
|
const [researchRefresh, setResearchRefresh] = useState(0);
|
||||||
// Infrastructure tier: which nav view (local / cloud) + the connect-host wizard.
|
// Infrastructure tier: which nav view (local / cloud) + the connect-host wizard.
|
||||||
const [infraSel, setInfraSel] = useState<string | null>("local");
|
const [infraSel, setInfraSel] = useState<string | null>("local");
|
||||||
const [infraConnectOpen, setInfraConnectOpen] = useState(false);
|
const [infraConnectOpen, setInfraConnectOpen] = useState(false);
|
||||||
@@ -599,7 +603,16 @@ export function Dashboard({ user, orgs, claws }: { user?: { display_name?: strin
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isResearch ? (
|
{isResearch ? (
|
||||||
<ResearchList />
|
<ResearchList
|
||||||
|
agents={claws}
|
||||||
|
selectedId={researchSel}
|
||||||
|
onSelect={setResearchSel}
|
||||||
|
refreshKey={researchRefresh}
|
||||||
|
onCreated={(id) => {
|
||||||
|
setResearchSel(id);
|
||||||
|
setResearchRefresh((n) => n + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
) : isLoops ? (
|
) : isLoops ? (
|
||||||
<LoopsList />
|
<LoopsList />
|
||||||
) : isInfra ? (
|
) : isInfra ? (
|
||||||
@@ -630,7 +643,12 @@ 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 ? (
|
{isResearch ? (
|
||||||
<ResearchCanvas />
|
<ResearchCanvas
|
||||||
|
selectedId={researchSel}
|
||||||
|
agents={claws}
|
||||||
|
refreshKey={researchRefresh}
|
||||||
|
onChanged={() => setResearchRefresh((n) => n + 1)}
|
||||||
|
/>
|
||||||
) : isLoops ? (
|
) : isLoops ? (
|
||||||
<LoopsCanvas />
|
<LoopsCanvas />
|
||||||
) : isWorld ? (
|
) : isWorld ? (
|
||||||
|
|||||||
@@ -1,12 +1,262 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Canvas for the Research tier — stub. The real canvas (selected topic's
|
// Canvas for the Research tier — selected topic detail: title, description,
|
||||||
// agents grid, run timeline, publish gate) arrives with the wizard commit.
|
// outcome chip, assigned agents grid, status pill, and the state-appropriate
|
||||||
|
// primary action button (start → submit-review → request-publish).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import {
|
||||||
|
getTopic,
|
||||||
|
requestPublish,
|
||||||
|
startTopic,
|
||||||
|
submitReview,
|
||||||
|
type TopicDetail,
|
||||||
|
type TopicStatus,
|
||||||
|
} from "@/lib/api/research";
|
||||||
|
|
||||||
const mono =
|
const mono =
|
||||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
export function ResearchCanvas() {
|
const STATUS_COLOR: Record<TopicStatus, string> = {
|
||||||
|
standby: "#8a8a92",
|
||||||
|
processing: "#5ec8d8",
|
||||||
|
reviewing: "#ffb44a",
|
||||||
|
publishing: "#c98af0",
|
||||||
|
published: "#5fd08a",
|
||||||
|
};
|
||||||
|
|
||||||
|
function nextAction(status: TopicStatus): {
|
||||||
|
label: string;
|
||||||
|
run: (id: string) => Promise<unknown>;
|
||||||
|
} | null {
|
||||||
|
switch (status) {
|
||||||
|
case "standby":
|
||||||
|
return { label: "Start research", run: startTopic };
|
||||||
|
case "processing":
|
||||||
|
return { label: "Submit for review", run: submitReview };
|
||||||
|
case "reviewing":
|
||||||
|
return { label: "Request publish", run: requestPublish };
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResearchCanvas({
|
||||||
|
selectedId,
|
||||||
|
agents,
|
||||||
|
refreshKey,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
selectedId: string | null;
|
||||||
|
agents: Agent[];
|
||||||
|
refreshKey: number;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [topic, setTopic] = useState<TopicDetail | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [acting, setActing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) {
|
||||||
|
setTopic(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
getTopic(selectedId)
|
||||||
|
.then((d) => {
|
||||||
|
if (alive) setTopic(d);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (alive) setError(e instanceof Error ? e.message : "load failed");
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [selectedId, refreshKey]);
|
||||||
|
|
||||||
|
if (!selectedId) {
|
||||||
|
return <Placeholder />;
|
||||||
|
}
|
||||||
|
if (loading && !topic) {
|
||||||
|
return (
|
||||||
|
<PlaceholderText>Loading topic…</PlaceholderText>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return <PlaceholderText color="#ff8a7a">{error}</PlaceholderText>;
|
||||||
|
}
|
||||||
|
if (!topic) {
|
||||||
|
return <Placeholder />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dot = STATUS_COLOR[topic.status];
|
||||||
|
const action = nextAction(topic.status);
|
||||||
|
const agentById = new Map(agents.map((a) => [a.id, a]));
|
||||||
|
|
||||||
|
async function runAction() {
|
||||||
|
if (!action) return;
|
||||||
|
setActing(true);
|
||||||
|
try {
|
||||||
|
await action.run(topic!.id);
|
||||||
|
onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "action failed");
|
||||||
|
} finally {
|
||||||
|
setActing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 780, 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: dot }} />
|
||||||
|
<span style={{ color: dot, fontWeight: 700 }}>{topic.status.toUpperCase()}</span>
|
||||||
|
<span style={{ opacity: 0.5 }}>·</span>
|
||||||
|
<span>{topic.outcome_kind.replace("_", " ")}</span>
|
||||||
|
{topic.published_at && (
|
||||||
|
<>
|
||||||
|
<span style={{ opacity: 0.5 }}>·</span>
|
||||||
|
<span>published {new Date(topic.published_at).toLocaleDateString()}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: 30,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#f3f3f5",
|
||||||
|
letterSpacing: "-.02em",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topic.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Agents */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<div style={sectionHeader}>Agents ({topic.agents.length})</div>
|
||||||
|
{topic.agents.length === 0 ? (
|
||||||
|
<p style={hintStyle}>None assigned yet.</p>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))",
|
||||||
|
gap: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topic.agents.map((slot) => {
|
||||||
|
const a = agentById.get(slot.agent_id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={slot.agent_id}
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 10,
|
||||||
|
background: "#101014",
|
||||||
|
border: "1px solid rgba(255,255,255,.06)",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: "#f3f3f5" }}>
|
||||||
|
{a?.name ?? "(missing agent)"}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
|
||||||
|
{slot.role_slot ?? a?.job_title ?? "—"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<div style={sectionHeader}>Description</div>
|
||||||
|
<pre
|
||||||
|
style={{
|
||||||
|
padding: 16,
|
||||||
|
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",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{topic.description}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action */}
|
||||||
|
{action && (
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runAction}
|
||||||
|
disabled={acting}
|
||||||
|
style={{
|
||||||
|
padding: "12px 22px",
|
||||||
|
borderRadius: 10,
|
||||||
|
border: 0,
|
||||||
|
background: "linear-gradient(135deg,#ff8a7a,#ff5f57)",
|
||||||
|
color: "#2a0d0a",
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 700,
|
||||||
|
cursor: acting ? "default" : "pointer",
|
||||||
|
opacity: acting ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{acting ? "Working…" : action.label}
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<span style={{ fontFamily: mono, fontSize: 12, color: "#ff8a7a" }}>{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Placeholder() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -20,71 +270,53 @@ export function ResearchCanvas() {
|
|||||||
padding: 48,
|
padding: 48,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ maxWidth: 460, textAlign: "center" }}>
|
||||||
style={{
|
<div style={{ fontSize: 22, fontWeight: 700, color: "#f3f3f5", marginBottom: 12 }}>
|
||||||
maxWidth: 460,
|
|
||||||
textAlign: "center",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: 20,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Book + magnifying-lens: the research motif from the rail */}
|
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" aria-hidden>
|
|
||||||
<rect
|
|
||||||
x="4"
|
|
||||||
y="3.5"
|
|
||||||
width="12"
|
|
||||||
height="14"
|
|
||||||
rx="1.5"
|
|
||||||
stroke="#5a5a62"
|
|
||||||
strokeWidth="1.4"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M4 15.5 H16"
|
|
||||||
stroke="#5a5a62"
|
|
||||||
strokeWidth="1.4"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="17.5"
|
|
||||||
cy="17.5"
|
|
||||||
r="3.2"
|
|
||||||
stroke="#ff6f61"
|
|
||||||
strokeWidth="1.6"
|
|
||||||
fill="none"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M19.9 19.9 L22 22"
|
|
||||||
stroke="#ff6f61"
|
|
||||||
strokeWidth="1.6"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#f3f3f5",
|
|
||||||
letterSpacing: "-.01em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Research
|
Research
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div style={{ fontFamily: mono, fontSize: 12, color: "#8a8a92", lineHeight: 1.6 }}>
|
||||||
style={{
|
Pick a topic on the left, or hit the + button in the sidebar to launch
|
||||||
fontFamily: mono,
|
the wizard.
|
||||||
fontSize: 12,
|
|
||||||
color: "#8a8a92",
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Pick a topic on the left to see the agents assembling around it, the
|
|
||||||
run timeline, and the publish gate. Full canvas ships next commit.
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 sectionHeader: React.CSSProperties = {
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: ".12em",
|
||||||
|
color: "#5a5a62",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
};
|
||||||
|
const hintStyle: React.CSSProperties = {
|
||||||
|
fontFamily: mono,
|
||||||
|
fontSize: 12,
|
||||||
|
color: "#8a8a92",
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,13 +1,66 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// Sidebar for the Research tier — stub for the shell-expansion commit. The
|
// Sidebar for the Research tier — real topic list, +New opens the wizard.
|
||||||
// real list (topics + status pills + `+` opens the wizard) lands in the
|
|
||||||
// next commit that also brings the API client and canvas panels.
|
import { useEffect, useState } from "react";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
|
||||||
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
|
import {
|
||||||
|
listTopics,
|
||||||
|
type TopicListItem,
|
||||||
|
type TopicStatus,
|
||||||
|
} from "@/lib/api/research";
|
||||||
|
|
||||||
|
import { ResearchWizard } from "./ResearchWizard";
|
||||||
|
|
||||||
const mono =
|
const mono =
|
||||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
export function ResearchList() {
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
setLoading(true);
|
||||||
|
listTopics()
|
||||||
|
.then((rows) => {
|
||||||
|
if (alive) setTopics(rows);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (alive) setTopics([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (alive) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [refreshKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -29,7 +82,7 @@ export function ResearchList() {
|
|||||||
marginBottom: 6,
|
marginBottom: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
0 TOPICS
|
{topics.length} TOPIC{topics.length === 1 ? "" : "S"}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -42,25 +95,126 @@ export function ResearchList() {
|
|||||||
Research
|
Research
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
<div
|
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "8px" }}>
|
||||||
style={{
|
{loading && topics.length === 0 ? (
|
||||||
flex: 1,
|
<p
|
||||||
minHeight: 0,
|
style={{
|
||||||
display: "flex",
|
fontFamily: mono,
|
||||||
alignItems: "center",
|
fontSize: 11,
|
||||||
justifyContent: "center",
|
color: "#5a5a62",
|
||||||
padding: "40px 16px",
|
textAlign: "center",
|
||||||
fontFamily: mono,
|
padding: 24,
|
||||||
fontSize: 11,
|
}}
|
||||||
color: "#5a5a62",
|
>
|
||||||
textAlign: "center",
|
Loading…
|
||||||
lineHeight: 1.6,
|
</p>
|
||||||
}}
|
) : topics.length === 0 ? (
|
||||||
>
|
<p
|
||||||
Research topic cards land in the next commit. Wizard: capture, refine
|
style={{
|
||||||
with LLM, assemble outputs.
|
fontFamily: mono,
|
||||||
|
fontSize: 11,
|
||||||
|
color: "#5a5a62",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: 24,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No topics yet. Hit the + button to launch the wizard.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
topics.map((t) => {
|
||||||
|
const on = t.id === selectedId;
|
||||||
|
const dot = STATUS_COLOR[t.status];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(t.id)}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
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",
|
||||||
|
color: "#eaeaee",
|
||||||
|
cursor: "pointer",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: 6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: dot,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ color: dot }}>{t.status}</span>
|
||||||
|
<span style={{ opacity: 0.5 }}>·</span>
|
||||||
|
<span>{t.outcome_kind.replace("_", " ")}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{wizardOpen && (
|
||||||
|
<ResearchWizard
|
||||||
|
agents={agents}
|
||||||
|
onClose={() => setWizardOpen(false)}
|
||||||
|
onCreated={(id) => {
|
||||||
|
setWizardOpen(false);
|
||||||
|
onCreated(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
"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 {
|
||||||
|
createTopic,
|
||||||
|
wizardRefine,
|
||||||
|
type OutcomeKind,
|
||||||
|
} from "@/lib/api/research";
|
||||||
|
|
||||||
|
const mono =
|
||||||
|
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, monospace";
|
||||||
|
|
||||||
|
const OUTCOMES: { kind: OutcomeKind; label: string; hint: string }[] = [
|
||||||
|
{ kind: "spec", label: "Spec", hint: "A structured technical specification." },
|
||||||
|
{
|
||||||
|
kind: "prod_plan",
|
||||||
|
label: "Prod plan",
|
||||||
|
hint: "A prioritized product/execution plan.",
|
||||||
|
},
|
||||||
|
{ kind: "roadmap", label: "Roadmap", hint: "A phased horizon roadmap." },
|
||||||
|
{
|
||||||
|
kind: "paper",
|
||||||
|
label: "Paper",
|
||||||
|
hint: "A short scientific-style paper with methods + findings.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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>(1);
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [outcome, setOutcome] = useState<OutcomeKind>("spec");
|
||||||
|
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);
|
||||||
|
|
||||||
|
async function runRefine(prior?: string) {
|
||||||
|
setRefineError(null);
|
||||||
|
setRefining(true);
|
||||||
|
try {
|
||||||
|
const out = await wizardRefine({
|
||||||
|
prompt,
|
||||||
|
outcome_kind: outcome,
|
||||||
|
prior_description: prior,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
agents: selected.map((s) => ({
|
||||||
|
agent_id: s.agent_id,
|
||||||
|
role_slot: s.role_slot.trim() || undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
onCreated(id);
|
||||||
|
} catch (e) {
|
||||||
|
setSubmitError(e instanceof Error ? e.message : "create failed");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canNext =
|
||||||
|
(step === 1 && prompt.trim().length > 0) ||
|
||||||
|
(step === 2 && title.trim().length > 0 && description.trim().length > 0) ||
|
||||||
|
step === 3 ||
|
||||||
|
step === 4;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,.55)",
|
||||||
|
zIndex: 200,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 24,
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<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} / 4
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, fontSize: 16, fontWeight: 700, color: "#f3f3f5" }}>
|
||||||
|
New research topic
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
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: 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 === 3 && (
|
||||||
|
<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>
|
||||||
|
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{o.label}</div>
|
||||||
|
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>{o.hint}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 4 && (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<span style={labelStyle}>Assign agents</span>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
{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) : s))}
|
||||||
|
disabled={step === 1}
|
||||||
|
style={{ ...secondaryBtn, opacity: step === 1 ? 0.4 : 1 }}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
{step < 4 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStep((s) => ((s + 1) as 1 | 2 | 3 | 4))}
|
||||||
|
disabled={!canNext || refining}
|
||||||
|
style={{ ...primaryBtn, opacity: !canNext || refining ? 0.4 : 1 }}
|
||||||
|
>
|
||||||
|
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",
|
||||||
|
};
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// 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";
|
||||||
|
export type TopicStatus =
|
||||||
|
| "standby"
|
||||||
|
| "processing"
|
||||||
|
| "reviewing"
|
||||||
|
| "publishing"
|
||||||
|
| "published";
|
||||||
|
|
||||||
|
export interface TopicListItem {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
outcome_kind: OutcomeKind;
|
||||||
|
status: TopicStatus;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AgentSlot {
|
||||||
|
agent_id: string;
|
||||||
|
role_slot: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
agents: AgentSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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 const createTopic = (body: {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
outcome_kind: OutcomeKind;
|
||||||
|
agents: { agent_id: string; role_slot?: string }[];
|
||||||
|
}) =>
|
||||||
|
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 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 const rejectPublish = (id: string) =>
|
||||||
|
api<void>(`/api/research/publish-approvals/${id}/reject`, { method: "POST" });
|
||||||
|
|
||||||
|
export const wizardRefine = (body: {
|
||||||
|
prompt: string;
|
||||||
|
prior_description?: string;
|
||||||
|
outcome_kind: OutcomeKind;
|
||||||
|
}) =>
|
||||||
|
api<{ description: string; suggested_title: string }>(
|
||||||
|
"/api/research/wizard/refine",
|
||||||
|
{ method: "POST", body: JSON.stringify(body) },
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user