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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user