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
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) AS n\n FROM topology_runs\n WHERE research_topic_id = $1\n AND status IN ('queued', 'running')",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "n",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "a014c185ae37744045471dfbb4d82f783f23177c4e53bc1348f3384be446aecd"
}
+8
View File
@@ -245,6 +245,11 @@ pub struct TopicDetail {
/// beyond so reviewers see what actually needs approval. /// beyond so reviewers see what actually needs approval.
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub latest_outcome: Option<cm_db::repo::research_outcomes::Outcome>, pub latest_outcome: Option<cm_db::repo::research_outcomes::Outcome>,
/// queued + running topology_runs bound to this topic. > 0 means the
/// pipeline is still working — the canvas shows a running badge with a
/// spinner and hides the manual "Submit for review" button, which is
/// only offered when this is 0 (as an escape hatch for stalled runs).
pub runs_in_flight: i64,
} }
pub async fn get_topic( pub async fn get_topic(
@@ -261,11 +266,14 @@ pub async fn get_topic(
.await? .await?
.is_some(); .is_some();
let latest_outcome = cm_db::repo::research_outcomes::latest(&state.pool, id).await?; let latest_outcome = cm_db::repo::research_outcomes::latest(&state.pool, id).await?;
let runs_in_flight =
cm_db::repo::topology_runs::active_runs_for_research_topic(&state.pool, id).await?;
Ok(Json(TopicDetail { Ok(Json(TopicDetail {
topic, topic,
agents, agents,
has_pending_publish_request, has_pending_publish_request,
latest_outcome, latest_outcome,
runs_in_flight,
})) }))
} }
+20
View File
@@ -147,6 +147,26 @@ pub async fn enqueue_run_for_team(
Ok(()) Ok(())
} }
/// Count `queued` + `running` runs whose `research_topic_id` matches. The
/// research canvas polls this so it can show a spinner "the pipeline is
/// running" and suppress the manual "Submit for review" escape hatch
/// while any run is still in flight.
pub async fn active_runs_for_research_topic(
pool: &PgPool,
research_topic_id: Uuid,
) -> Result<i64, DbError> {
let row = sqlx::query!(
"SELECT count(*) AS n
FROM topology_runs
WHERE research_topic_id = $1
AND status IN ('queued', 'running')",
research_topic_id,
)
.fetch_one(pool)
.await?;
Ok(row.n.unwrap_or(0))
}
/// The research topic this run belongs to, if any. Used by the topology /// The research topic this run belongs to, if any. Used by the topology
/// worker's `freeze_research_outcome` post-hook to snapshot the run's /// worker's `freeze_research_outcome` post-hook to snapshot the run's
/// final synthesis into `research_outcomes`. /// final synthesis into `research_outcomes`.
@@ -36,8 +36,8 @@ const STAGE_COPY: Record<TopicStatus, { stage: string; nextHint: string }> = {
nextHint: "Kick off the research — assigned agents start working the prompt.", nextHint: "Kick off the research — assigned agents start working the prompt.",
}, },
processing: { processing: {
stage: "Research in progress. Waiting for the assigned agents to finish drafting.", stage: "Pipeline is running. The assigned agents are drafting the outcome; each agent's activity streams into their card in real time.",
nextHint: "Move the topic to review once the draft looks complete.", nextHint: "This transitions automatically when the last agent finishes. The manual button is only offered as an escape hatch if the runner stalls.",
}, },
reviewing: { reviewing: {
stage: "Under review — read the draft above and decide whether to publish.", 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: "" }, published: { stage: "Published. This topic is done.", nextHint: "" },
}; };
function nextAction(status: TopicStatus, hasPendingPublish: boolean): { function nextAction(
status: TopicStatus,
hasPendingPublish: boolean,
runsInFlight: number,
): {
label: string; label: string;
run: (id: string) => Promise<unknown>; run: (id: string) => Promise<unknown>;
} | null { } | null {
@@ -55,7 +59,11 @@ function nextAction(status: TopicStatus, hasPendingPublish: boolean): {
case "standby": case "standby":
return { label: "Start research", run: startTopic }; return { label: "Start research", run: startTopic };
case "processing": 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": case "reviewing":
// Backend already has a pending approval — don't offer the button // Backend already has a pending approval — don't offer the button
// (a second click 409s). Frontend surfaces an "Awaiting" note below. // (a second click 409s). Frontend surfaces an "Awaiting" note below.
@@ -84,12 +92,12 @@ export function ResearchCanvas({
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const load = async () => { const load = async (opts?: { silent?: boolean }) => {
if (!selectedId) { if (!selectedId) {
if (alive) setTopic(null); if (alive) setTopic(null);
return; return;
} }
setLoading(true); if (!opts?.silent) setLoading(true);
setError(null); setError(null);
try { try {
const d = await getTopic(selectedId); const d = await getTopic(selectedId);
@@ -97,7 +105,7 @@ export function ResearchCanvas({
} catch (e) { } catch (e) {
if (alive) setError(e instanceof Error ? e.message : "load failed"); if (alive) setError(e instanceof Error ? e.message : "load failed");
} finally { } finally {
if (alive) setLoading(false); if (alive && !opts?.silent) setLoading(false);
} }
}; };
load(); load();
@@ -106,6 +114,32 @@ export function ResearchCanvas({
}; };
}, [selectedId, refreshKey]); }, [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) { if (!selectedId) {
return <Placeholder />; return <Placeholder />;
} }
@@ -122,8 +156,15 @@ export function ResearchCanvas({
} }
const dot = STATUS_COLOR[topic.status]; 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 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])); const agentById = new Map(agents.map((a) => [a.id, a]));
async function runAction() { async function runAction() {
@@ -174,6 +215,27 @@ export function ResearchCanvas({
> >
<span style={{ width: 7, height: 7, borderRadius: "50%", background: dot }} /> <span style={{ width: 7, height: 7, borderRadius: "50%", background: dot }} />
<span style={{ color: dot, fontWeight: 700 }}>{topic.status.toUpperCase()}</span> <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 style={{ opacity: 0.5 }}>·</span>
<span>{topic.outcome_kind.replace("_", " ")}</span> <span>{topic.outcome_kind.replace("_", " ")}</span>
{topic.published_at && ( {topic.published_at && (
@@ -318,26 +380,59 @@ export function ResearchCanvas({
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div <div
style={{ style={{
padding: "10px 14px", padding: "12px 14px",
borderRadius: 10, borderRadius: 10,
border: "1px solid rgba(255,255,255,.06)", border: pipelineRunning
background: "#101014", ? "1px solid rgba(94,200,216,.35)"
: "1px solid rgba(255,255,255,.06)",
background: pipelineRunning ? "rgba(94,200,216,.06)" : "#101014",
display: "flex", display: "flex",
flexDirection: "column", 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()} STAGE · {topic.status.toUpperCase()}
{pipelineRunning ? (
<span style={{ letterSpacing: 0, color: "#5ec8d8", opacity: 0.8 }}>
· pipeline is running
</span>
) : null}
</div> </div>
<div style={{ fontSize: 12.5, color: "#cfcfd5", lineHeight: 1.55 }}> <div style={{ fontSize: 12.5, color: "#cfcfd5", lineHeight: 1.55 }}>
{stageCopy.stage} {stageCopy.stage}
</div> </div>
{action && stageCopy.nextHint ? ( {stageCopy.nextHint ? (
<div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", marginTop: 2 }}> <div style={{ fontFamily: mono, fontSize: 11, color: "#8a8a92", marginTop: 2 }}>
Next → {stageCopy.nextHint} Next → {stageCopy.nextHint}
</div> </div>
) : null} ) : 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> </div>
{action ? ( {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() { function Placeholder() {
return ( return (
<div <div
@@ -70,6 +70,29 @@ export function ResearchList({
}; };
}, [refreshKey, localBump]); }, [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) { async function doDelete(id: string) {
if (busyId) return; if (busyId) return;
setBusyId(id); setBusyId(id);
@@ -228,6 +251,9 @@ export function ResearchList({
color: "#8a8a92", color: "#8a8a92",
}} }}
> >
{t.status === "processing" || t.status === "publishing" ? (
<MiniSpinner color={dot} />
) : (
<span <span
style={{ style={{
width: 7, width: 7,
@@ -236,6 +262,7 @@ export function ResearchList({
background: dot, background: dot,
}} }}
/> />
)}
<span style={{ color: dot }}>{t.status}</span> <span style={{ color: dot }}>{t.status}</span>
<span style={{ opacity: 0.5 }}>·</span> <span style={{ opacity: 0.5 }}>·</span>
<span>{t.outcome_kind.replace("_", " ")}</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 = { const dangerBtn: React.CSSProperties = {
padding: "4px 10px", padding: "4px 10px",
borderRadius: 6, borderRadius: 6,
+4
View File
@@ -54,6 +54,10 @@ export interface TopicDetail {
* Present once a run has completed; the canvas renders body_md * Present once a run has completed; the canvas renders body_md
* in place of description in reviewing/publishing/published. */ * in place of description in reviewing/publishing/published. */
latest_outcome: ResearchOutcome | null; latest_outcome: ResearchOutcome | null;
/** queued + running topology_runs bound to this topic. Frontend shows
* a spinner + "Pipeline is running" pill when > 0 and only offers the
* manual "Submit for review" escape hatch when it's 0. */
runs_in_flight: number;
} }
export interface PublishApproval { export interface PublishApproval {