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
@@ -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",
};