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:
+22
@@ -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"
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,14 +251,18 @@ export function ResearchList({
|
|||||||
color: "#8a8a92",
|
color: "#8a8a92",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
{t.status === "processing" || t.status === "publishing" ? (
|
||||||
style={{
|
<MiniSpinner color={dot} />
|
||||||
width: 7,
|
) : (
|
||||||
height: 7,
|
<span
|
||||||
borderRadius: "50%",
|
style={{
|
||||||
background: dot,
|
width: 7,
|
||||||
}}
|
height: 7,
|
||||||
/>
|
borderRadius: "50%",
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user