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