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