Render rose recovery_needed surface across StatusIcon, BlockedNotice, and rows

StatusIcon adds an AlertTriangle overlay on the red-ring blocked variant
when the read model reports recovery_needed; tooltip and aria-label
spell out the liveness break and which leaf is invalid.

IssueBlockedNotice gets a rose container variant with two copy
templates: parent-perspective ("paused at a liveness break in [leaf]")
and leaf-perspective ("productive run that exited without queueing a
continuation"). The sub-row label flips to "Liveness break at" and an
owner pill renders with the next-action verb derived from the
nextActionHint.

IssueRow surfaces "liveness break at {leaf}" in the meta line for
recovery_needed parents so inbox/MyIssues rows show the actionable leaf
without opening the parent.

The status-language Storybook matrix gains the four new state/reason
combinations and parent/leaf-perspective notice variants. Targeted
vitest cases cover both the icon overlay and notice rendering.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 15:59:14 -05:00
1 parent 3ea2ff4187
commit 38b57c5d56
8 files changed
+529 -34

No files matched your search

@@ -0,0 +1,136 @@
// @vitest-environment node
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { IssueBlockedNotice } from "./IssueBlockedNotice";
function renderNotice(node: React.ReactNode) {
const queryClient = new QueryClient();
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{node}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("IssueBlockedNotice", () => {
it("renders the rose recovery variant with parent-perspective copy and a wake-to-continue verb", () => {
const html = renderNotice(
<>
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "issue-recovery-parent",
identifier: "PAP-2089",
title: "Liveness root",
status: "blocked",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
terminalBlockers: [
{
id: "issue-recovery-leaf",
identifier: "PAP-2642",
title: "Implementation phase",
status: "in_progress",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
],
},
]}
blockerAttention={{
state: "recovery_needed",
reason: "productive_run_stopped",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2642",
sampleStalledBlockerIdentifier: null,
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
nextActionHint: "wake_to_continue",
}}
ownerAgentName="CodexCoder"
/>
</>,
);
expect(html).toContain('data-blocker-attention-state="recovery_needed"');
expect(html).toContain('data-blocker-attention-reason="productive_run_stopped"');
expect(html).toContain("paused at a liveness break");
expect(html).toContain("Liveness break at");
expect(html).toContain("Wake to continue");
expect(html).toContain("PAP-2642");
expect(html).toContain("border-rose-300/70");
});
it("renders the leaf-perspective copy without blocker chips when the issue itself is the invalid leaf", () => {
const html = renderNotice(
<>
<IssueBlockedNotice
issueStatus="in_progress"
blockers={[]}
blockerAttention={{
state: "recovery_needed",
reason: "productive_run_stopped",
unresolvedBlockerCount: 0,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2642",
sampleStalledBlockerIdentifier: null,
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
nextActionHint: "wake_to_continue",
}}
ownerAgentName="CodexCoder"
/>
</>,
);
expect(html).toContain("productive run that exited without queueing a continuation");
expect(html).not.toContain("Liveness break at");
expect(html).toContain("Wake to continue");
});
it("falls back to amber stalled treatment when state is stalled rather than recovery_needed", () => {
const html = renderNotice(
<>
<IssueBlockedNotice
issueStatus="blocked"
blockers={[
{
id: "issue-stalled",
identifier: "PAP-2279",
title: "Stage gate review",
status: "in_review",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
]}
blockerAttention={{
state: "stalled",
reason: "stalled_review",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2279",
sampleStalledBlockerIdentifier: "PAP-2279",
nextActionOwner: null,
nextActionHint: null,
}}
/>
</>,
);
expect(html).toContain("Stalled in review");
expect(html).not.toContain("Liveness break");
expect(html).toContain("border-amber-300/70");
});
});
+166 -29
View File
@@ -2,17 +2,43 @@ import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@papercli
import { AlertTriangle } from "lucide-react";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { IssueLinkQuicklook } from "./IssueLinkQuicklook";
import { Identity } from "./Identity";
type RecoveryReason =
| "productive_run_stopped"
| "continuation_exhausted"
| "continuation_suppressed";
const RECOVERY_REASONS: ReadonlySet<string> = new Set<RecoveryReason>([
"productive_run_stopped",
"continuation_exhausted",
"continuation_suppressed",
]);
const NEXT_ACTION_VERB: Record<NonNullable<IssueBlockerAttention["nextActionHint"]>, string> = {
wake_to_continue: "Wake to continue",
needs_human_review: "Needs human review",
create_recovery_issue: "Create recovery issue",
reassign: "Reassign",
};
export function IssueBlockedNotice({
issueStatus,
blockers,
blockerAttention,
ownerAgentName,
ownerUserName,
}: {
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
ownerAgentName?: string | null;
ownerUserName?: string | null;
}) {
if (blockers.length === 0 && issueStatus !== "blocked") return null;
const isRecoveryNeeded = blockerAttention?.state === "recovery_needed"
&& blockerAttention.reason !== null
&& RECOVERY_REASONS.has(blockerAttention.reason);
if (blockers.length === 0 && issueStatus !== "blocked" && !isRecoveryNeeded) return null;
const blockerLabel = blockers.length === 1 ? "the linked issue" : "the linked issues";
const terminalBlockers = blockers
@@ -41,60 +67,171 @@ export function IssueBlockedNotice({
})();
const showStalledRow = isStalled && stalledLeafBlockers.length > 0;
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
const recoveryLeafIdentifier = isRecoveryNeeded ? blockerAttention?.sampleBlockerIdentifier ?? null : null;
const recoveryLeafBlockers = (() => {
if (!isRecoveryNeeded) return [] as IssueRelationIssueSummary[];
const candidates: IssueRelationIssueSummary[] = [];
for (const blocker of [...blockers, ...terminalBlockers]) {
if (blocker.status === "done" || blocker.status === "cancelled") continue;
if (candidates.some((existing) => existing.id === blocker.id)) continue;
candidates.push(blocker);
}
if (recoveryLeafIdentifier) {
const preferred = candidates.find(
(blocker) => (blocker.identifier ?? blocker.id) === recoveryLeafIdentifier,
);
if (preferred) {
return [preferred, ...candidates.filter((blocker) => blocker.id !== preferred.id)];
}
}
return candidates;
})();
// Leaf-level surface: the issue itself is the invalid leaf. We render the
// rose notice without any blocker chips, using the leaf-perspective copy.
const isLeafSurface = isRecoveryNeeded && issueStatus !== "blocked";
const containerClass = isRecoveryNeeded
? "mb-3 rounded-md border border-rose-300/70 bg-rose-50/90 px-3 py-2.5 text-sm text-rose-950 shadow-sm dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100"
: "mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100";
const iconClass = isRecoveryNeeded
? "mt-0.5 h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300"
: "mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300";
const subRowLabelClass = isRecoveryNeeded
? "text-xs font-medium text-rose-800 dark:text-rose-200"
: "text-xs font-medium text-amber-800 dark:text-amber-200";
const renderBlockerChip = (blocker: IssueRelationIssueSummary, variant: "amber" | "rose" = "amber") => {
const issuePathId = blocker.identifier ?? blocker.id;
const chipClass = variant === "rose"
? "inline-flex max-w-full items-center gap-1 rounded-md border border-rose-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-rose-950 transition-colors hover:border-rose-500 hover:bg-rose-100 hover:underline dark:border-rose-500/40 dark:bg-background/40 dark:text-rose-100 dark:hover:bg-rose-500/15"
: "inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15";
const labelClass = variant === "rose"
? "max-w-[18rem] truncate font-sans text-[11px] text-rose-800 dark:text-rose-200"
: "max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200";
return (
<IssueLinkQuicklook
key={blocker.id}
issuePathId={issuePathId}
to={createIssueDetailPath(issuePathId)}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-amber-300/70 bg-background/80 px-2 py-1 font-mono text-xs text-amber-950 transition-colors hover:border-amber-500 hover:bg-amber-100 hover:underline dark:border-amber-500/40 dark:bg-background/40 dark:text-amber-100 dark:hover:bg-amber-500/15"
className={chipClass}
>
<span>{blocker.identifier ?? blocker.id.slice(0, 8)}</span>
<span className="max-w-[18rem] truncate font-sans text-[11px] text-amber-800 dark:text-amber-200">
{blocker.title}
</span>
<span className={labelClass}>{blocker.title}</span>
</IssueLinkQuicklook>
);
};
const recoveryParentCopy = (reason: RecoveryReason, leafIdent: string) => {
const leafChip = <span className="font-mono text-xs">[{leafIdent}]</span>;
if (reason === "productive_run_stopped") {
return (
<>Work on this issue is <strong>paused at a liveness break in {leafChip}</strong>. The last run was productive but exited without queueing the next step, so nothing will resume on its own.</>
);
}
if (reason === "continuation_exhausted") {
return (
<>Work on this issue is <strong>stuck at {leafChip}</strong>. Automatic continuation has been used up, so a human or recovery issue is needed before it moves again.</>
);
}
return (
<>Work on this issue is <strong>stuck at {leafChip}</strong> because automatic continuation was held back. It needs review before resuming.</>
);
};
const recoveryLeafCopy = (reason: RecoveryReason) => {
if (reason === "productive_run_stopped") {
return (
<>This issue had a <strong>productive run that exited without queueing a continuation</strong>. It is non-terminal with no live action path.</>
);
}
if (reason === "continuation_exhausted") {
return (
<>This issue's <strong>automatic continuation has been exhausted</strong>. It is non-terminal with no live action path.</>
);
}
return (
<>This issue's <strong>automatic continuation was suppressed</strong> and it is non-terminal with no live action path.</>
);
};
const ownerPill = (() => {
if (!isRecoveryNeeded) return null;
const owner = blockerAttention?.nextActionOwner ?? null;
const hint = blockerAttention?.nextActionHint ?? null;
const verb = hint ? NEXT_ACTION_VERB[hint] : null;
let ownerNode: React.ReactNode = "No one assigned";
if (owner?.type === "agent" && owner.agentId) {
ownerNode = (
<Identity name={ownerAgentName ?? "Agent"} size="xs" />
);
} else if (owner?.type === "user" && owner.userId) {
ownerNode = (
<Identity name={ownerUserName ?? "User"} size="xs" />
);
}
return (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5 text-xs text-rose-900 dark:text-rose-100">
{verb ? (
<>
<span>Next action:</span>
<span className="font-medium">{verb}</span>
<span aria-hidden>·</span>
</>
) : null}
<span>Owner:</span>
<span className="font-medium">{ownerNode}</span>
</div>
);
})();
return (
<div
data-blocker-attention-state={blockerAttention?.state}
className="mb-3 rounded-md border border-amber-300/70 bg-amber-50/90 px-3 py-2.5 text-sm text-amber-950 shadow-sm dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100"
data-blocker-attention-reason={blockerAttention?.reason ?? undefined}
className={containerClass}
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-300" />
<AlertTriangle className={iconClass} />
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
{isRecoveryNeeded
? isLeafSurface
? recoveryLeafCopy(blockerAttention!.reason as RecoveryReason)
: recoveryParentCopy(
blockerAttention!.reason as RecoveryReason,
recoveryLeafIdentifier ?? "linked issue",
)
: blockers.length > 0
? isStalled
? stalledLeafBlockers.length > 1
? <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled reviews below or remove them as blockers.</>
: <>Work on this issue is blocked by {blockerLabel}, but the chain is stalled in review without a clear next step. Resolve the stalled review below or remove it as a blocker.</>
: <>Work on this issue is blocked by {blockerLabel} until {blockers.length === 1 ? "it is" : "they are"} complete. Comments still wake the assignee for questions or triage.</>
: <>Work on this issue is blocked until it is moved back to todo. Comments still wake the assignee for questions or triage.</>}
</p>
{blockers.length > 0 ? (
{!isRecoveryNeeded && blockers.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{blockers.map(renderBlockerChip)}
{blockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
</div>
) : null}
{showStalledRow ? (
{isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Stalled in review
</span>
{stalledLeafBlockers.map(renderBlockerChip)}
</div>
) : terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className="text-xs font-medium text-amber-800 dark:text-amber-200">
Ultimately waiting on
</span>
{terminalBlockers.map(renderBlockerChip)}
<span className={subRowLabelClass}>Liveness break at</span>
{recoveryLeafBlockers.map((blocker) => renderBlockerChip(blocker, "rose"))}
</div>
) : null}
{!isRecoveryNeeded && showStalledRow ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className={subRowLabelClass}>Stalled in review</span>
{stalledLeafBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
</div>
) : !isRecoveryNeeded && terminalBlockers.length > 0 ? (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<span className={subRowLabelClass}>Ultimately waiting on</span>
{terminalBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
</div>
) : null}
{ownerPill}
</div>
</div>
</div>
@@ -407,6 +407,8 @@ describe("IssueProperties", () => {
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
nextActionOwner: null,
nextActionHint: null,
},
}),
childIssues: [],
+15
View File
@@ -65,6 +65,13 @@ export function IssueRow({
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
const productivityReview = issue.productivityReview ?? null;
const recoveryLeafIdentifier =
issue.blockerAttention?.state === "recovery_needed"
? issue.blockerAttention.sampleBlockerIdentifier
: null;
const recoveryLeafLabel = recoveryLeafIdentifier && recoveryLeafIdentifier !== identifier
? `liveness break at ${recoveryLeafIdentifier}`
: null;
const productivityReviewIndicator = productivityReview ? (
<span
className={cn(
@@ -130,6 +137,14 @@ export function IssueRow({
</span>
</>
)}
{recoveryLeafLabel ? (
<>
<span className="text-xs text-muted-foreground" aria-hidden="true">
&middot;
</span>
<span className="text-xs text-muted-foreground">{recoveryLeafLabel}</span>
</>
) : null}
{mobileMeta ? (
<>
<span className="text-xs text-muted-foreground sm:hidden" aria-hidden="true">
+55
View File
@@ -18,6 +18,8 @@ describe("StatusIcon", () => {
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
nextActionOwner: null,
nextActionHint: null,
}}
/>,
);
@@ -44,6 +46,8 @@ describe("StatusIcon", () => {
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
sampleStalledBlockerIdentifier: null,
nextActionOwner: null,
nextActionHint: null,
}}
/>,
);
@@ -66,6 +70,8 @@ describe("StatusIcon", () => {
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
nextActionOwner: null,
nextActionHint: null,
}}
/>,
);
@@ -76,6 +82,53 @@ describe("StatusIcon", () => {
expect(html).not.toContain("border-dashed");
});
it("renders recovery_needed leaves with the alert overlay and liveness-break tooltip", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
blockerAttention={{
state: "recovery_needed",
reason: "productive_run_stopped",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2642",
sampleStalledBlockerIdentifier: null,
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
nextActionHint: "wake_to_continue",
}}
/>,
);
expect(html).toContain('data-blocker-attention-state="recovery_needed"');
expect(html).toContain('aria-label="Blocked · liveness break at PAP-2642 · productive run stopped without continuation"');
expect(html).toContain("border-red-600");
expect(html).toContain("text-rose-500");
});
it("uses continuation_exhausted copy when the chain ran out of automatic continuations", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
blockerAttention={{
state: "recovery_needed",
reason: "continuation_exhausted",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2642",
sampleStalledBlockerIdentifier: null,
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
nextActionHint: "create_recovery_issue",
}}
/>,
);
expect(html).toContain('aria-label="Blocked · liveness break at PAP-2642 · automatic continuation exhausted"');
});
it("renders stalled review chains with amber visual and stalled-leaf copy", () => {
const html = renderToStaticMarkup(
<StatusIcon
@@ -89,6 +142,8 @@ describe("StatusIcon", () => {
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2279",
sampleStalledBlockerIdentifier: "PAP-2279",
nextActionOwner: null,
nextActionHint: null,
}}
/>,
);
+39 -3
View File
@@ -1,5 +1,6 @@
import { useState } from "react";
import type { IssueBlockerAttention } from "@paperclipai/shared";
import { AlertTriangle } from "lucide-react";
import { cn } from "../lib/utils";
import { issueStatusIcon, issueStatusIconDefault } from "../lib/status-colors";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -48,6 +49,26 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
return `Blocked · ${count} reviews stalled with no clear next step`;
}
if (blockerAttention.state === "recovery_needed") {
const leaf = blockerAttention.sampleBlockerIdentifier;
if (blockerAttention.reason === "productive_run_stopped") {
return leaf
? `Blocked · liveness break at ${leaf} · productive run stopped without continuation`
: "Blocked · liveness break · productive run stopped without continuation";
}
if (blockerAttention.reason === "continuation_exhausted") {
return leaf
? `Blocked · liveness break at ${leaf} · automatic continuation exhausted`
: "Blocked · liveness break · automatic continuation exhausted";
}
if (blockerAttention.reason === "continuation_suppressed") {
return leaf
? `Blocked · liveness break at ${leaf} · automatic continuation suppressed`
: "Blocked · liveness break · automatic continuation suppressed";
}
return "Blocked · liveness break needs recovery";
}
if (blockerAttention.reason === "attention_required") {
const count = blockerAttention.unresolvedBlockerCount;
return `Blocked · ${count} unresolved ${count === 1 ? "blocker needs" : "blockers need"} attention`;
@@ -60,18 +81,25 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
const [open, setOpen] = useState(false);
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
const isRecoveryNeeded = blockerAttention?.state === "recovery_needed";
const colorClass = isCoveredBlocked
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
: isStalledBlocked
? "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400"
: issueStatusIcon[status] ?? issueStatusIconDefault;
: isRecoveryNeeded
? "text-red-600 border-red-600 dark:text-red-400 dark:border-red-400"
: issueStatusIcon[status] ?? issueStatusIconDefault;
const isDone = status === "done";
const ariaLabel = status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status);
const recoveryAriaLabel = isRecoveryNeeded ? blockedAttentionLabel(blockerAttention) : null;
const ariaLabel = recoveryAriaLabel
?? (status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status));
const blockerAttentionState = isCoveredBlocked
? "covered"
: isStalledBlocked
? "stalled"
: undefined;
: isRecoveryNeeded
? "recovery_needed"
: undefined;
const circle = (
<span
@@ -94,6 +122,14 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
{isStalledBlocked && (
<span className="absolute inset-0 m-auto h-1.5 w-1.5 rounded-full bg-current" />
)}
{isRecoveryNeeded && (
<AlertTriangle
aria-hidden
className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full bg-background text-rose-500 dark:text-rose-300"
strokeWidth={3}
fill="currentColor"
/>
)}
</span>
);
+2
View File
@@ -831,6 +831,8 @@ describe("IssueDetail", () => {
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
nextActionOwner: null,
nextActionHint: null,
},
}));
@@ -61,6 +61,8 @@ function attention(
attentionBlockerCount: partial.attentionBlockerCount ?? 0,
sampleBlockerIdentifier: partial.sampleBlockerIdentifier ?? null,
sampleStalledBlockerIdentifier: partial.sampleStalledBlockerIdentifier ?? null,
nextActionOwner: partial.nextActionOwner ?? null,
nextActionHint: partial.nextActionHint ?? null,
};
}
@@ -188,6 +190,64 @@ const coveredBlockedMatrix: CoveredBlockedCell[] = [
expectedVisual: "yellow ring",
expectedCopy: "In Progress",
},
{
label: "Recovery needed (productive run stopped)",
status: "blocked",
blockerAttention: attention({
state: "recovery_needed",
reason: "productive_run_stopped",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2642",
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
nextActionHint: "wake_to_continue",
}),
expectedVisual: "red ring + alert overlay",
expectedCopy: "Blocked · liveness break at PAP-2642 · productive run stopped without continuation",
},
{
label: "Recovery needed (continuation exhausted)",
status: "blocked",
blockerAttention: attention({
state: "recovery_needed",
reason: "continuation_exhausted",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2642",
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
nextActionHint: "create_recovery_issue",
}),
expectedVisual: "red ring + alert overlay",
expectedCopy: "Blocked · liveness break at PAP-2642 · automatic continuation exhausted",
},
{
label: "Recovery needed (continuation suppressed)",
status: "blocked",
blockerAttention: attention({
state: "recovery_needed",
reason: "continuation_suppressed",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2642",
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
nextActionHint: "needs_human_review",
}),
expectedVisual: "red ring + alert overlay",
expectedCopy: "Blocked · liveness break at PAP-2642 · automatic continuation suppressed",
},
{
label: "Recovery needed (leaf surface, productive run stopped)",
status: "in_progress",
blockerAttention: attention({
state: "recovery_needed",
reason: "productive_run_stopped",
sampleBlockerIdentifier: "PAP-2642",
nextActionOwner: { type: "agent", agentId: "agent-1", userId: null },
nextActionHint: "wake_to_continue",
}),
expectedVisual: "yellow ring + alert overlay",
expectedCopy: "Blocked · liveness break at PAP-2642 · productive run stopped without continuation",
},
];
const coveredBlockedIssue = createIssue({
@@ -220,13 +280,17 @@ function summaryBlocker(
type BlockedNoticeStateLabel =
| "Default covered"
| "Stalled (single leaf)"
| "Stalled (multiple leaves)";
| "Stalled (multiple leaves)"
| "Recovery needed (parent perspective)"
| "Recovery needed (leaf perspective)";
type BlockedNoticeFixture = {
label: BlockedNoticeStateLabel;
caption: string;
issueStatus: string;
blockers: IssueRelationIssueSummary[];
blockerAttention: IssueBlockerAttention;
ownerAgentName?: string;
};
const stalledLeafSingle = summaryBlocker({
@@ -250,10 +314,18 @@ const stalledLeafMultiSecondary = summaryBlocker({
status: "in_review",
});
const recoveryLeafSummary = summaryBlocker({
id: "issue-recovery-leaf",
identifier: "PAP-2642",
title: "Implementation phase that exited without continuation",
status: "in_progress",
});
const blockedNoticeFixtures: BlockedNoticeFixture[] = [
{
label: "Default covered",
caption: "Active sub-issue covers the chain — informational only.",
issueStatus: "blocked",
blockers: [
summaryBlocker({
id: "issue-active-child",
@@ -273,6 +345,7 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
{
label: "Stalled (single leaf)",
caption: "Chain stalled on one leaf review — copy names the leaf and shows the chip strip.",
issueStatus: "blocked",
blockers: [
summaryBlocker({
id: "issue-stalled-parent-single",
@@ -294,6 +367,7 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
{
label: "Stalled (multiple leaves)",
caption: "Multiple stalled reviews — body uses plural agreement (\"reviews\"/\"them\") to match the chip strip.",
issueStatus: "blocked",
blockers: [
summaryBlocker({
id: "issue-stalled-parent-multi-a",
@@ -318,6 +392,43 @@ const blockedNoticeFixtures: BlockedNoticeFixture[] = [
sampleStalledBlockerIdentifier: "PAP-2284",
}),
},
{
label: "Recovery needed (parent perspective)",
caption: "Blocked parent whose dependency leaf had a productive run that exited without queueing a continuation.",
issueStatus: "blocked",
blockers: [
summaryBlocker({
id: "issue-recovery-parent",
identifier: "PAP-2089",
title: "Liveness forensics root",
status: "blocked",
terminalBlockers: [recoveryLeafSummary],
}),
],
blockerAttention: attention({
state: "recovery_needed",
reason: "productive_run_stopped",
unresolvedBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2642",
nextActionOwner: { type: "agent", agentId: "agent-codex", userId: null },
nextActionHint: "wake_to_continue",
}),
ownerAgentName: "CodexSparkCoder",
},
{
label: "Recovery needed (leaf perspective)",
caption: "Same component on the invalid leaf itself — leaf-perspective copy, no chip strip, owner pill is the leaf assignee.",
issueStatus: "in_progress",
blockers: [],
blockerAttention: attention({
state: "recovery_needed",
reason: "productive_run_stopped",
sampleBlockerIdentifier: "PAP-2642",
nextActionOwner: { type: "agent", agentId: "agent-codex", userId: null },
nextActionHint: "wake_to_continue",
}),
ownerAgentName: "CodexSparkCoder",
},
];
function BlockedNoticeSurface({
@@ -342,9 +453,10 @@ function BlockedNoticeSurface({
</div>
<div className={isMobile ? "max-w-[358px] px-3 py-3" : "min-w-[620px] px-4 py-3"}>
<IssueBlockedNotice
issueStatus="blocked"
issueStatus={fixture.issueStatus}
blockers={fixture.blockers}
blockerAttention={fixture.blockerAttention}
ownerAgentName={fixture.ownerAgentName}
/>
<p className="text-[11px] text-muted-foreground">{fixture.caption}</p>
</div>