huskies: progress 983 — differentiated icons for stuck-story states

Distinct icons in StagePanel/GatewayPanel/render.rs status output for
blocked-with-running-recovery (robot), blocked-with-queued-recovery (hourglass),
and blocked-cold (red circle). All 2822 tests pass.
This commit is contained in:
Timmy
2026-05-13 15:46:36 +01:00
parent 14a39b6205
commit c811672e18
3 changed files with 202 additions and 35 deletions
+62
View File
@@ -68,6 +68,67 @@ export function StoryRow({ item }: { item: PipelineItem }) {
const color = STAGE_COLORS[item.stage] ?? "#8b949e"; const color = STAGE_COLORS[item.stage] ?? "#8b949e";
const label = STAGE_LABELS[item.stage] ?? item.stage; const label = STAGE_LABELS[item.stage] ?? item.stage;
const idNum = item.story_id.match(/^(\d+)/)?.[1]; 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"
? (
<span
data-testid={`recovery-badge-${item.story_id}`}
title="Recovery in progress — no human action needed"
style={{
fontSize: "0.75em",
fontWeight: 700,
color: "#e3b341",
background: "#2a1e00",
border: "1px solid #6e4f00",
borderRadius: "4px",
padding: "1px 5px",
flexShrink: 0,
}}
>
RECOVERING
</span>
)
: agentStatus === "pending"
? (
<span
data-testid={`recovery-badge-${item.story_id}`}
title="Recovery scheduled — waiting for a slot"
style={{
fontSize: "0.75em",
fontWeight: 700,
color: "#e3b341",
background: "#2a1e00",
border: "1px solid #6e4f00",
borderRadius: "4px",
padding: "1px 5px",
flexShrink: 0,
}}
>
QUEUED
</span>
)
: (
<span
data-testid={`recovery-badge-${item.story_id}`}
title={item.merge_failure ? "Merge failed — needs human" : "Blocked — awaiting human unblock"}
style={{
fontSize: "0.75em",
fontWeight: 700,
color: "#f85149",
background: "#2a1010",
border: "1px solid #6e1b1b",
borderRadius: "4px",
padding: "1px 5px",
flexShrink: 0,
}}
>
STUCK
</span>
)
: null;
return ( return (
<div <div
@@ -92,6 +153,7 @@ export function StoryRow({ item }: { item: PipelineItem }) {
> >
{label} {label}
</span> </span>
{recoveryBadge}
<span style={{ color: "#e6edf3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <span style={{ color: "#e6edf3", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{idNum && <span style={{ color: "#8b949e", fontFamily: "monospace" }}>#{idNum}{" "}</span>} {idNum && <span style={{ color: "#8b949e", fontFamily: "monospace" }}>#{idNum}{" "}</span>}
{item.name} {item.name}
+111 -32
View File
@@ -345,19 +345,53 @@ export function StagePanel({
<> <>
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, fontSize: "0.9em" }}> <div style={{ fontWeight: 600, fontSize: "0.9em" }}>
{hasMergeFailure && ( {hasMergeFailure && (() => {
<span const agentStatus = item.agent?.status;
data-testid={`merge-failure-icon-${item.story_id}`} if (agentStatus === "running") {
title="Merge failed" return (
style={{ <span
color: "#f85149", data-testid={`merge-failure-icon-${item.story_id}`}
marginRight: "6px", title="Merge recovery in progress — no human action needed"
fontStyle: "normal", style={{
}} display: "inline-block",
> color: "#e3b341",
marginRight: "6px",
</span> animation: "spin 1s linear infinite",
)} }}
>
</span>
);
}
if (agentStatus === "pending") {
return (
<span
data-testid={`merge-failure-icon-${item.story_id}`}
title="Merge recovery scheduled — waiting for a slot"
style={{
color: "#e3b341",
marginRight: "6px",
fontStyle: "normal",
}}
>
</span>
);
}
return (
<span
data-testid={`merge-failure-icon-${item.story_id}`}
title="Merge failed — needs human"
style={{
color: "#f85149",
marginRight: "6px",
fontStyle: "normal",
}}
>
</span>
);
})()}
{mergesInFlight?.has(item.story_id) && ( {mergesInFlight?.has(item.story_id) && (
<span <span
data-testid={`merge-in-flight-icon-${item.story_id}`} data-testid={`merge-in-flight-icon-${item.story_id}`}
@@ -396,25 +430,70 @@ export function StagePanel({
{typeLabel} {typeLabel}
</span> </span>
)} )}
{item.blocked && !item.merge_failure && ( {item.blocked && !item.merge_failure && (() => {
<span const agentStatus = item.agent?.status;
data-testid={`blocked-badge-${item.story_id}`} if (agentStatus === "running") {
title="Blocked — awaiting human unblock" return (
style={{ <span
fontSize: "0.65em", data-testid={`blocked-badge-${item.story_id}`}
fontWeight: 700, title="Recovery in progress — no human action needed"
color: "#f85149", style={{
background: "#2a1010", fontSize: "0.65em",
border: "1px solid #6e1b1b", fontWeight: 700,
borderRadius: "4px", color: "#e3b341",
padding: "1px 4px", background: "#2a1e00",
marginRight: "8px", border: "1px solid #6e4f00",
letterSpacing: "0.05em", borderRadius: "4px",
}} padding: "1px 4px",
> marginRight: "8px",
BLOCKED letterSpacing: "0.05em",
</span> }}
)} >
RECOVERING
</span>
);
}
if (agentStatus === "pending") {
return (
<span
data-testid={`blocked-badge-${item.story_id}`}
title="Recovery scheduled — waiting for a slot"
style={{
fontSize: "0.65em",
fontWeight: 700,
color: "#e3b341",
background: "#2a1e00",
border: "1px solid #6e4f00",
borderRadius: "4px",
padding: "1px 4px",
marginRight: "8px",
letterSpacing: "0.05em",
}}
>
QUEUED
</span>
);
}
return (
<span
data-testid={`blocked-badge-${item.story_id}`}
title="Blocked — awaiting human unblock"
style={{
fontSize: "0.65em",
fontWeight: 700,
color: "#f85149",
background: "#2a1010",
border: "1px solid #6e1b1b",
borderRadius: "4px",
padding: "1px 4px",
marginRight: "8px",
letterSpacing: "0.05em",
}}
>
BLOCKED
</span>
);
})()}
{item.frozen && ( {item.frozen && (
<span <span
data-testid={`frozen-badge-${item.story_id}`} data-testid={`frozen-badge-${item.story_id}`}
+29 -3
View File
@@ -233,13 +233,27 @@ fn render_item_line(
item.stage, item.stage,
Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } Stage::Merge { .. } | Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. }
) { ) {
// MergeFailure and MergeFailureFinal carry their reason directly on
// the stage variant — always show ⛔ with the failure snippet.
match &item.stage { match &item.stage {
Stage::MergeFailure { reason, .. } | Stage::MergeFailureFinal { reason } => { // MergeFailureFinal: mergemaster already tried and gave up — always ⛔.
Stage::MergeFailureFinal { reason } => {
let snippet = first_non_empty_snippet(reason, 120); let snippet = first_non_empty_snippet(reason, 120);
return format!(" \u{26D4} {display}{cost_suffix}{dep_suffix}{snippet}\n"); 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(); 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 throttled = agent.map(|a| a.throttled).unwrap_or(false);
let dot = super::traffic_light_dot(blocked, throttled, agent.is_some()); let dot = super::traffic_light_dot(blocked, throttled, agent.is_some());
if let Some(agent) = agent { if let Some(agent) = agent {