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
@@ -70,6 +70,29 @@ export function ResearchList({
};
}, [refreshKey, localBump]);
// Light polling while any topic is actively running so the spinner + status
// in the sidebar transition automatically without waiting on a parent bump.
// Depending only on the boolean prevents the effect from thrashing every
// time the polled response substitutes a fresh array.
const anyActive = topics.some(
(t) => t.status === "processing" || t.status === "publishing",
);
useEffect(() => {
if (!anyActive) return;
let alive = true;
const tick = async () => {
try {
const rows = await listTopics();
if (alive) setTopics(rows);
} catch { /* transient — keep the last snapshot */ }
};
const handle = setInterval(tick, 6000);
return () => {
alive = false;
clearInterval(handle);
};
}, [anyActive]);
async function doDelete(id: string) {
if (busyId) return;
setBusyId(id);
@@ -228,14 +251,18 @@ export function ResearchList({
color: "#8a8a92",
}}
>
<span
style={{
width: 7,
height: 7,
borderRadius: "50%",
background: dot,
}}
/>
{t.status === "processing" || t.status === "publishing" ? (
<MiniSpinner color={dot} />
) : (
<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>
@@ -344,6 +371,26 @@ export function ResearchList({
);
}
function MiniSpinner({ color }: { color: string }) {
return (
<svg width={10} height={10} viewBox="0 0 20 20" aria-hidden>
<g fill="none" stroke={color} strokeWidth="2.6" 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>
);
}
const dangerBtn: React.CSSProperties = {
padding: "4px 10px",
borderRadius: 6,