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,13 +1,66 @@
|
||||
"use client";
|
||||
|
||||
// Sidebar for the Research tier — stub for the shell-expansion commit. The
|
||||
// real list (topics + status pills + `+` opens the wizard) lands in the
|
||||
// next commit that also brings the API client and canvas panels.
|
||||
// Sidebar for the Research tier — real topic list, +New opens the wizard.
|
||||
|
||||
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 =
|
||||
"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 (
|
||||
<>
|
||||
<div
|
||||
@@ -29,7 +82,7 @@ export function ResearchList() {
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
0 TOPICS
|
||||
{topics.length} TOPIC{topics.length === 1 ? "" : "S"}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -42,25 +95,126 @@ export function ResearchList() {
|
||||
Research
|
||||
</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
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "40px 16px",
|
||||
fontFamily: mono,
|
||||
fontSize: 11,
|
||||
color: "#5a5a62",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
Research topic cards land in the next commit. Wizard: capture, refine
|
||||
with LLM, assemble outputs.
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "auto", padding: "8px" }}>
|
||||
{loading && topics.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 11,
|
||||
color: "#5a5a62",
|
||||
textAlign: "center",
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
Loading…
|
||||
</p>
|
||||
) : topics.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
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>
|
||||
{wizardOpen && (
|
||||
<ResearchWizard
|
||||
agents={agents}
|
||||
onClose={() => setWizardOpen(false)}
|
||||
onCreated={(id) => {
|
||||
setWizardOpen(false);
|
||||
onCreated(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user