diff --git a/ui/src/components/IssueBlockedNotice.test.tsx b/ui/src/components/IssueBlockedNotice.test.tsx new file mode 100644 index 00000000000..24cee80ac6b --- /dev/null +++ b/ui/src/components/IssueBlockedNotice.test.tsx @@ -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( + + {node} + , + ); +} + +describe("IssueBlockedNotice", () => { + it("renders the rose recovery variant with parent-perspective copy and a wake-to-continue verb", () => { + const html = renderNotice( + <> + + , + ); + + 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( + <> + + , + ); + + 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( + <> + + , + ); + + expect(html).toContain("Stalled in review"); + expect(html).not.toContain("Liveness break"); + expect(html).toContain("border-amber-300/70"); + }); +}); diff --git a/ui/src/components/IssueBlockedNotice.tsx b/ui/src/components/IssueBlockedNotice.tsx index e57c7c3bd6a..761dcc9742e 100644 --- a/ui/src/components/IssueBlockedNotice.tsx +++ b/ui/src/components/IssueBlockedNotice.tsx @@ -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 = new Set([ + "productive_run_stopped", + "continuation_exhausted", + "continuation_suppressed", +]); + +const NEXT_ACTION_VERB: Record, 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 ( {blocker.identifier ?? blocker.id.slice(0, 8)} - - {blocker.title} - + {blocker.title} ); }; + const recoveryParentCopy = (reason: RecoveryReason, leafIdent: string) => { + const leafChip = [{leafIdent}]; + if (reason === "productive_run_stopped") { + return ( + <>Work on this issue is paused at a liveness break in {leafChip}. 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 stuck at {leafChip}. Automatic continuation has been used up, so a human or recovery issue is needed before it moves again. + ); + } + return ( + <>Work on this issue is stuck at {leafChip} 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 productive run that exited without queueing a continuation. It is non-terminal with no live action path. + ); + } + if (reason === "continuation_exhausted") { + return ( + <>This issue's automatic continuation has been exhausted. It is non-terminal with no live action path. + ); + } + return ( + <>This issue's automatic continuation was suppressed 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 = ( + + ); + } else if (owner?.type === "user" && owner.userId) { + ownerNode = ( + + ); + } + return ( +
+ {verb ? ( + <> + Next action: + {verb} + · + + ) : null} + Owner: + {ownerNode} +
+ ); + })(); + return (
- +

- {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.}

- {blockers.length > 0 ? ( + {!isRecoveryNeeded && blockers.length > 0 ? (
- {blockers.map(renderBlockerChip)} + {blockers.map((blocker) => renderBlockerChip(blocker, "amber"))}
) : null} - {showStalledRow ? ( + {isRecoveryNeeded && !isLeafSurface && recoveryLeafBlockers.length > 0 ? (
- - Stalled in review - - {stalledLeafBlockers.map(renderBlockerChip)} -
- ) : terminalBlockers.length > 0 ? ( -
- - Ultimately waiting on - - {terminalBlockers.map(renderBlockerChip)} + Liveness break at + {recoveryLeafBlockers.map((blocker) => renderBlockerChip(blocker, "rose"))}
) : null} + {!isRecoveryNeeded && showStalledRow ? ( +
+ Stalled in review + {stalledLeafBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))} +
+ ) : !isRecoveryNeeded && terminalBlockers.length > 0 ? ( +
+ Ultimately waiting on + {terminalBlockers.map((blocker) => renderBlockerChip(blocker, "amber"))} +
+ ) : null} + {ownerPill}
diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index bb381c677ee..86e8702da19 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -407,6 +407,8 @@ describe("IssueProperties", () => { attentionBlockerCount: 0, sampleBlockerIdentifier: "PAP-2", sampleStalledBlockerIdentifier: null, + nextActionOwner: null, + nextActionHint: null, }, }), childIssues: [], diff --git a/ui/src/components/IssueRow.tsx b/ui/src/components/IssueRow.tsx index 664990b1e17..81aa5b95991 100644 --- a/ui/src/components/IssueRow.tsx +++ b/ui/src/components/IssueRow.tsx @@ -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 ? ( )} + {recoveryLeafLabel ? ( + <> + + {recoveryLeafLabel} + + ) : null} {mobileMeta ? ( <>