research UI: real list + canvas + 4-step wizard, wired to the backend
ci / gates (push) Successful in 6s
ci / frontend (push) Failing after 19s
ci / rust (push) Successful in 2m49s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

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:
Omar Sobh
2026-07-06 07:01:15 -07:00
parent 2ff00934b2
commit ec1ddc634d
5 changed files with 1037 additions and 88 deletions
@@ -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 (&quot;lead&quot;, &quot;critic&quot;) groups avatars on the canvas.</p>
{agents.length === 0 ? (
<p style={hintStyle}>No agents in this workspace yet.</p>
) : (
agents.map((a) => {
const chosen = selected.find((s) => s.agent_id === a.id);
return (
<div
key={a.id}
style={{
display: "flex",
alignItems: "center",
gap: 10,
padding: "10px 12px",
borderRadius: 10,
border: `1px solid ${chosen ? "rgba(255,111,97,.5)" : "rgba(255,255,255,.1)"}`,
background: chosen ? "rgba(255,111,97,.06)" : "transparent",
}}
>
<input
type="checkbox"
checked={!!chosen}
onChange={(e) => {
if (e.target.checked) {
setSelected([...selected, { agent_id: a.id, role_slot: "" }]);
} else {
setSelected(selected.filter((s) => s.agent_id !== a.id));
}
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, color: "#f3f3f5" }}>{a.name}</div>
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92" }}>
{a.job_title || "—"}
</div>
</div>
{chosen && (
<input
value={chosen.role_slot}
onChange={(e) =>
setSelected(
selected.map((s) =>
s.agent_id === a.id ? { ...s, role_slot: e.target.value } : s,
),
)
}
placeholder="role slot (optional)"
style={{ ...fieldStyle, width: 180, padding: "6px 10px" }}
/>
)}
</div>
);
})
)}
{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",
};
+114
View File
@@ -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) },
);