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:
@@ -1,12 +1,262 @@
|
||||
"use client";
|
||||
|
||||
// Canvas for the Research tier — stub. The real canvas (selected topic's
|
||||
// agents grid, run timeline, publish gate) arrives with the wizard commit.
|
||||
// Canvas for the Research tier — selected topic detail: title, description,
|
||||
// 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 =
|
||||
"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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -20,71 +270,53 @@ export function ResearchCanvas() {
|
||||
padding: 48,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 460, textAlign: "center" }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, color: "#f3f3f5", marginBottom: 12 }}>
|
||||
Research
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
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 style={{ fontFamily: mono, fontSize: 12, color: "#8a8a92", lineHeight: 1.6 }}>
|
||||
Pick a topic 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 sectionHeader: React.CSSProperties = {
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
letterSpacing: ".12em",
|
||||
color: "#5a5a62",
|
||||
textTransform: "uppercase",
|
||||
};
|
||||
const hintStyle: React.CSSProperties = {
|
||||
fontFamily: mono,
|
||||
fontSize: 12,
|
||||
color: "#8a8a92",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user