diff --git a/frontend/src/components/GatewayPanel.tsx b/frontend/src/components/GatewayPanel.tsx index 9e34664b..9277721b 100644 --- a/frontend/src/components/GatewayPanel.tsx +++ b/frontend/src/components/GatewayPanel.tsx @@ -68,6 +68,67 @@ export function StoryRow({ item }: { item: PipelineItem }) { const color = STAGE_COLORS[item.stage] ?? "#8b949e"; const label = STAGE_LABELS[item.stage] ?? item.stage; const idNum = item.story_id.match(/^(\d+)/)?.[1]; + const agentStatus = item.agent?.status; + const isStuck = item.blocked || (item.merge_failure != null && item.merge_failure !== ""); + + const recoveryBadge = isStuck + ? agentStatus === "running" + ? ( + + ⟳ RECOVERING + + ) + : agentStatus === "pending" + ? ( + + ⏳ QUEUED + + ) + : ( + + ⊘ STUCK + + ) + : null; return (
{label} + {recoveryBadge} {idNum && #{idNum}{" "}} {item.name} diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index 422ae881..bd8e84f4 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -345,19 +345,53 @@ export function StagePanel({ <>
- {hasMergeFailure && ( - - ✕ - - )} + {hasMergeFailure && (() => { + const agentStatus = item.agent?.status; + if (agentStatus === "running") { + return ( + + ⟳ + + ); + } + if (agentStatus === "pending") { + return ( + + ⏳ + + ); + } + return ( + + ✕ + + ); + })()} {mergesInFlight?.has(item.story_id) && ( )} - {item.blocked && !item.merge_failure && ( - - ⊘ BLOCKED - - )} + {item.blocked && !item.merge_failure && (() => { + const agentStatus = item.agent?.status; + if (agentStatus === "running") { + return ( + + ⟳ RECOVERING + + ); + } + if (agentStatus === "pending") { + return ( + + ⏳ QUEUED + + ); + } + return ( + + ⊘ BLOCKED + + ); + })()} {item.frozen && ( { + // MergeFailureFinal: mergemaster already tried and gave up — always ⛔. + Stage::MergeFailureFinal { reason } => { let snippet = first_non_empty_snippet(reason, 120); return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n"); } + // MergeFailure: a recovery agent may be running or queued. + Stage::MergeFailure { reason, .. } => { + return match agent.map(|a| &a.status) { + Some(AgentStatus::Running) => format!( + " \u{1F916} {display}{cost_suffix}{dep_suffix} — mergemaster running\n" + ), + Some(AgentStatus::Pending) => format!( + " \u{23F3} {display}{cost_suffix}{dep_suffix} — mergemaster queued\n" + ), + _ => { + let snippet = first_non_empty_snippet(reason, 120); + format!(" \u{26D4} {display}{cost_suffix}{dep_suffix} — {snippet}\n") + } + }; + } _ => {} } @@ -264,6 +278,18 @@ fn render_item_line( } let blocked = item.stage.is_blocked(); + // Blocked items with a recovery agent get differentiated indicators. + if blocked { + return match agent.map(|a| &a.status) { + Some(AgentStatus::Running) => { + format!(" \u{1F916} {display}{cost_suffix}{dep_suffix} — recovery coder running\n") + } + Some(AgentStatus::Pending) => { + format!(" \u{23F3} {display}{cost_suffix}{dep_suffix} — recovery coder queued\n") + } + _ => format!(" \u{1F534} {display}{cost_suffix}{dep_suffix}\n"), + }; + } let throttled = agent.map(|a| a.throttled).unwrap_or(false); let dot = super::traffic_light_dot(blocked, throttled, agent.is_some()); if let Some(agent) = agent {