research: pipeline-running signal + spinners so users aren't guessing
The prior flow was ambiguous: after hitting Start research, status flipped to "processing" and a "Submit for review" button appeared immediately with no indication that anything was actually running. Users had to guess whether the pipeline was working or stalled. Backend surfaces the truth as a signal: - new topology_runs::active_runs_for_research_topic counts queued+running runs whose research_topic_id matches - TopicDetail includes runs_in_flight: i64 alongside the existing status field, so the canvas can distinguish "pipeline still working" from "runner stalled". ResearchCanvas is now honest about state: - while runs_in_flight > 0, the header status pill grows a cyan "N runs in flight" badge with an inline SVG spinner - the stage-explainer card turns cyan-bordered and shows a "pipeline is running" hint, plus copy pointing the user at the Agents tier where each teammate's activity streams live - the "Submit for review (manual)" button is HIDDEN while any run is in flight — it's an escape hatch for stalled runs only, not the happy-path action. It reappears if runs_in_flight drops to zero but the topic is still marked processing, so a stalled runner can still be nudged along. - the canvas polls getTopic every 4s while status is processing/ publishing or runs_in_flight > 0, so the spinner + outcome swap in automatically when the pipeline completes. ResearchList sidebar: - each row's status dot becomes a spinner when the topic's status is processing or publishing, matching the canvas at a glance - the list also polls every 6s while ANY topic is active, so transitions land in the sidebar without waiting on a parent bump. The poll is gated on a derived boolean to avoid effect thrash. Follow-up: same pattern belongs on LoopsList / LoopsCanvas for loop iterations in flight — same signal (queued+running runs per loop) but not wired here.
This commit is contained in:
@@ -36,8 +36,8 @@ const STAGE_COPY: Record<TopicStatus, { stage: string; nextHint: string }> = {
|
||||
nextHint: "Kick off the research — assigned agents start working the prompt.",
|
||||
},
|
||||
processing: {
|
||||
stage: "Research in progress. Waiting for the assigned agents to finish drafting.",
|
||||
nextHint: "Move the topic to review once the draft looks complete.",
|
||||
stage: "Pipeline is running. The assigned agents are drafting the outcome; each agent's activity streams into their card in real time.",
|
||||
nextHint: "This transitions automatically when the last agent finishes. The manual button is only offered as an escape hatch if the runner stalls.",
|
||||
},
|
||||
reviewing: {
|
||||
stage: "Under review — read the draft above and decide whether to publish.",
|
||||
@@ -47,7 +47,11 @@ const STAGE_COPY: Record<TopicStatus, { stage: string; nextHint: string }> = {
|
||||
published: { stage: "Published. This topic is done.", nextHint: "" },
|
||||
};
|
||||
|
||||
function nextAction(status: TopicStatus, hasPendingPublish: boolean): {
|
||||
function nextAction(
|
||||
status: TopicStatus,
|
||||
hasPendingPublish: boolean,
|
||||
runsInFlight: number,
|
||||
): {
|
||||
label: string;
|
||||
run: (id: string) => Promise<unknown>;
|
||||
} | null {
|
||||
@@ -55,7 +59,11 @@ function nextAction(status: TopicStatus, hasPendingPublish: boolean): {
|
||||
case "standby":
|
||||
return { label: "Start research", run: startTopic };
|
||||
case "processing":
|
||||
return { label: "Submit for review", run: submitReview };
|
||||
// While any run is queued or executing, DON'T offer submit-for-review
|
||||
// — the runner auto-transitions on completion. Only show the manual
|
||||
// escape hatch when nothing's in flight (indicates a stall).
|
||||
if (runsInFlight > 0) return null;
|
||||
return { label: "Submit for review (manual)", run: submitReview };
|
||||
case "reviewing":
|
||||
// Backend already has a pending approval — don't offer the button
|
||||
// (a second click 409s). Frontend surfaces an "Awaiting" note below.
|
||||
@@ -84,12 +92,12 @@ export function ResearchCanvas({
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
const load = async (opts?: { silent?: boolean }) => {
|
||||
if (!selectedId) {
|
||||
if (alive) setTopic(null);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
if (!opts?.silent) setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const d = await getTopic(selectedId);
|
||||
@@ -97,7 +105,7 @@ export function ResearchCanvas({
|
||||
} catch (e) {
|
||||
if (alive) setError(e instanceof Error ? e.message : "load failed");
|
||||
} finally {
|
||||
if (alive) setLoading(false);
|
||||
if (alive && !opts?.silent) setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
@@ -106,6 +114,32 @@ export function ResearchCanvas({
|
||||
};
|
||||
}, [selectedId, refreshKey]);
|
||||
|
||||
// Poll while the pipeline is actively running so the status pill, spinner,
|
||||
// and outcome swap in automatically without the user hitting refresh.
|
||||
// Stops polling as soon as the topic settles into a terminal state or
|
||||
// enters `reviewing` (nothing more will auto-change until user acts).
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
if (!topic) return;
|
||||
const shouldPoll =
|
||||
topic.status === "processing" || topic.status === "publishing" ||
|
||||
topic.runs_in_flight > 0;
|
||||
if (!shouldPoll) return;
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
if (!alive) return;
|
||||
try {
|
||||
const d = await getTopic(selectedId);
|
||||
if (alive) setTopic(d);
|
||||
} catch { /* keep silent — a transient error shouldn't wipe the panel */ }
|
||||
};
|
||||
const handle = setInterval(tick, 4000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(handle);
|
||||
};
|
||||
}, [selectedId, topic?.status, topic?.runs_in_flight]);
|
||||
|
||||
if (!selectedId) {
|
||||
return <Placeholder />;
|
||||
}
|
||||
@@ -122,8 +156,15 @@ export function ResearchCanvas({
|
||||
}
|
||||
|
||||
const dot = STATUS_COLOR[topic.status];
|
||||
const action = nextAction(topic.status, topic.has_pending_publish_request);
|
||||
const action = nextAction(
|
||||
topic.status,
|
||||
topic.has_pending_publish_request,
|
||||
topic.runs_in_flight,
|
||||
);
|
||||
const stageCopy = STAGE_COPY[topic.status];
|
||||
const pipelineRunning =
|
||||
(topic.status === "processing" || topic.status === "publishing") &&
|
||||
topic.runs_in_flight > 0;
|
||||
const agentById = new Map(agents.map((a) => [a.id, a]));
|
||||
|
||||
async function runAction() {
|
||||
@@ -174,6 +215,27 @@ export function ResearchCanvas({
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: "50%", background: dot }} />
|
||||
<span style={{ color: dot, fontWeight: 700 }}>{topic.status.toUpperCase()}</span>
|
||||
{pipelineRunning ? (
|
||||
<>
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
padding: "2px 9px",
|
||||
borderRadius: 999,
|
||||
background: "rgba(94,200,216,.12)",
|
||||
color: "#5ec8d8",
|
||||
border: "1px solid rgba(94,200,216,.35)",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Spinner />
|
||||
{topic.runs_in_flight} run{topic.runs_in_flight === 1 ? "" : "s"} in flight
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
<span style={{ opacity: 0.5 }}>·</span>
|
||||
<span>{topic.outcome_kind.replace("_", " ")}</span>
|
||||
{topic.published_at && (
|
||||
@@ -318,26 +380,59 @@ export function ResearchCanvas({
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 14px",
|
||||
padding: "12px 14px",
|
||||
borderRadius: 10,
|
||||
border: "1px solid rgba(255,255,255,.06)",
|
||||
background: "#101014",
|
||||
border: pipelineRunning
|
||||
? "1px solid rgba(94,200,216,.35)"
|
||||
: "1px solid rgba(255,255,255,.06)",
|
||||
background: pipelineRunning ? "rgba(94,200,216,.06)" : "#101014",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontFamily: mono, fontSize: 10, letterSpacing: ".12em", color: dot }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontFamily: mono,
|
||||
fontSize: 10,
|
||||
letterSpacing: ".12em",
|
||||
color: pipelineRunning ? "#5ec8d8" : dot,
|
||||
}}
|
||||
>
|
||||
{pipelineRunning ? <Spinner /> : null}
|
||||
STAGE · {topic.status.toUpperCase()}
|
||||
{pipelineRunning ? (
|
||||
<span style={{ letterSpacing: 0, color: "#5ec8d8", opacity: 0.8 }}>
|
||||
· pipeline is running
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={{ fontSize: 12.5, color: "#cfcfd5", lineHeight: 1.55 }}>
|
||||
{stageCopy.stage}
|
||||
</div>
|
||||
{action && stageCopy.nextHint ? (
|
||||
{stageCopy.nextHint ? (
|
||||
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", marginTop: 2 }}>
|
||||
Next → {stageCopy.nextHint}
|
||||
</div>
|
||||
) : null}
|
||||
{pipelineRunning ? (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: mono,
|
||||
fontSize: 11,
|
||||
color: "#8a8a92",
|
||||
marginTop: 4,
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
Open the Agents tier to watch each teammate's activity live —
|
||||
the reasoning stream and tool calls surface on their agent
|
||||
cards as the run progresses.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{action ? (
|
||||
@@ -388,6 +483,35 @@ export function ResearchCanvas({
|
||||
);
|
||||
}
|
||||
|
||||
// Small SVG spinner used inline in the status pill and the running-pipeline
|
||||
// card. Uses SMIL <animateTransform> so it works without any global CSS
|
||||
// keyframes and doesn't depend on a lucide icon.
|
||||
function Spinner({ size = 12, color = "#5ec8d8" }: { size?: number; color?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 20 20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<g fill="none" stroke={color} strokeWidth="2.4" strokeLinecap="round">
|
||||
<circle cx="10" cy="10" r="7.5" opacity="0.22" />
|
||||
<path d="M17.5 10 A7.5 7.5 0 0 0 10 2.5">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 10 10"
|
||||
to="360 10 10"
|
||||
dur="0.9s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Placeholder() {
|
||||
return (
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user