mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
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:
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");
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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">
|
||||
·
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{recoveryLeafLabel}</span>
|
||||
</>
|
||||
) : null}
|
||||
{mobileMeta ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground sm:hidden" aria-hidden="true">
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user