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:
@@ -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",
|
||||
};
|
||||
Reference in New Issue
Block a user