research: pipeline-running signal + spinners so users aren't guessing
ci / gates (push) Successful in 7s
ci / frontend (push) Failing after 19s
ci / rust (push) Successful in 4m0s
ci / e2e (push) Has been skipped
ci / publish (push) Has been skipped

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:
Omar Sobh
2026-07-08 18:36:34 -07:00
parent 316cdbf929
commit 7c1af2e070
6 changed files with 247 additions and 22 deletions
@@ -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