Surface stalled blocker chains as a distinct attention state

Phase 4 of the recoverable-work liveness contract: extend the blocker
attention algorithm to recognize an in_review leaf without an action
path (no active run, queued wake, pending interaction, pending
approval, open recovery issue, or user owner) as "stalled" rather than
silently coloring the chain "covered". The new state surfaces in the
status icon (amber ring with centered dot, distinct from cyan covered
and red needs_attention), in the issue blocked notice (calls out the
stalled review leaf with concise UX copy), and in the Storybook
status-language matrix for desktop/mobile UX review.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-26 16:58:19 -05:00
1 parent eccba68be4
commit 51f8b24b01
11 files changed
+449 -53

No files matched your search

+4 -1
View File
@@ -119,11 +119,12 @@ export interface IssueRelationIssueSummary {
terminalBlockers?: IssueRelationIssueSummary[];
}
export type IssueBlockerAttentionState = "none" | "covered" | "needs_attention";
export type IssueBlockerAttentionState = "none" | "covered" | "stalled" | "needs_attention";
export type IssueBlockerAttentionReason =
| "active_child"
| "active_dependency"
| "stalled_review"
| "attention_required"
| null;
@@ -132,8 +133,10 @@ export interface IssueBlockerAttention {
reason: IssueBlockerAttentionReason;
unresolvedBlockerCount: number;
coveredBlockerCount: number;
stalledBlockerCount: number;
attentionBlockerCount: number;
sampleBlockerIdentifier: string | null;
sampleStalledBlockerIdentifier: string | null;
}
export interface IssueRelation {
+35
View File
@@ -0,0 +1,35 @@
import { chromium } from "@playwright/test";
import { mkdir } from "node:fs/promises";
import { argv } from "node:process";
const outDir = argv[2] ?? "/tmp/paperclip/pap-2373-stalled-blocker-screens";
const baseUrl = argv[3] ?? "http://localhost:6610";
await mkdir(outDir, { recursive: true });
const id = "foundations-status-language--full-matrix";
const runs = [
{ name: "desktop-1440x900-light", w: 1440, h: 900, theme: "light" },
{ name: "desktop-1440x900-dark", w: 1440, h: 900, theme: "dark" },
{ name: "mobile-390x844-light", w: 390, h: 844, theme: "light" },
{ name: "mobile-390x844-dark", w: 390, h: 844, theme: "dark" },
];
const browser = await chromium.launch();
try {
for (const run of runs) {
const url = `${baseUrl}/iframe.html?id=${id}&viewMode=story&globals=theme:${run.theme}`;
const context = await browser.newContext({
viewport: { width: run.w, height: run.h },
deviceScaleFactor: 2,
});
const page = await context.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
await page.waitForTimeout(1500);
const file = `${outDir}/${run.name}.png`;
await page.screenshot({ path: file, fullPage: true });
console.log("wrote", file);
await context.close();
}
} finally {
await browser.close();
}
@@ -253,6 +253,109 @@ describeEmbeddedPostgres("issue blocker attention", () => {
});
});
it("flags a chain whose leaf is in_review without an action path as stalled", async () => {
const { companyId, agentId } = await createCompany("PBV");
const parentId = await insertIssue({ companyId, identifier: "PBV-1", title: "Parent", status: "blocked" });
const reviewLeafId = await insertIssue({
companyId,
identifier: "PBV-2",
title: "Stalled review leaf",
status: "in_review",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: reviewLeafId, blockedIssueId: parentId });
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "stalled",
reason: "stalled_review",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PBV-2",
sampleStalledBlockerIdentifier: "PBV-2",
});
});
it("does not flag an in_review leaf as stalled when an active run is still progressing it", async () => {
const { companyId, agentId } = await createCompany("PBW");
const parentId = await insertIssue({ companyId, identifier: "PBW-1", title: "Parent", status: "blocked" });
const reviewLeafId = await insertIssue({
companyId,
identifier: "PBW-2",
title: "Active review leaf",
status: "in_review",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: reviewLeafId, blockedIssueId: parentId });
await activeRun({ companyId, agentId, issueId: reviewLeafId });
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "covered",
stalledBlockerCount: 0,
});
});
it("flags a deep chain whose leaf is stalled in_review through multiple layers", async () => {
const { companyId, agentId } = await createCompany("PBZ");
const rootId = await insertIssue({ companyId, identifier: "PBZ-1", title: "Root", status: "blocked" });
const midId = await insertIssue({ companyId, identifier: "PBZ-2", title: "Mid blocker", status: "blocked" });
const leafId = await insertIssue({
companyId,
identifier: "PBZ-3",
title: "Stalled leaf",
status: "in_review",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: midId, blockedIssueId: rootId });
await block({ companyId, blockerIssueId: leafId, blockedIssueId: midId });
const root = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === rootId);
expect(root?.blockerAttention).toMatchObject({
state: "stalled",
reason: "stalled_review",
stalledBlockerCount: 1,
sampleStalledBlockerIdentifier: "PBZ-3",
});
});
it("prefers needs_attention over stalled when the chain also has a hard attention case", async () => {
const { companyId, agentId } = await createCompany("PBQ");
const parentId = await insertIssue({ companyId, identifier: "PBQ-1", title: "Parent", status: "blocked" });
const reviewLeafId = await insertIssue({
companyId,
identifier: "PBQ-2",
title: "Stalled review leaf",
status: "in_review",
assigneeAgentId: agentId,
});
const cancelledLeafId = await insertIssue({
companyId,
identifier: "PBQ-3",
title: "Cancelled blocker",
status: "cancelled",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: reviewLeafId, blockedIssueId: parentId });
await block({ companyId, blockerIssueId: cancelledLeafId, blockedIssueId: parentId });
const parent = (await svc.list(companyId, { status: "blocked" })).find((issue) => issue.id === parentId);
expect(parent?.blockerAttention).toMatchObject({
state: "needs_attention",
reason: "attention_required",
coveredBlockerCount: 0,
stalledBlockerCount: 1,
attentionBlockerCount: 1,
sampleStalledBlockerIdentifier: "PBQ-2",
});
});
it("does not treat a scheduled retry as actively covered work", async () => {
const { companyId, agentId } = await createCompany("PBY");
const parentId = await insertIssue({ companyId, identifier: "PBY-1", title: "Parent", status: "blocked" });
+142 -21
View File
@@ -1,10 +1,11 @@
import { Buffer } from "node:buffer";
import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, isNull, ne, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agentWakeupRequests,
agents,
approvals,
assets,
companies,
companyMemberships,
@@ -12,6 +13,7 @@ import {
goals,
heartbeatRuns,
executionWorkspaces,
issueApprovals,
issueAttachments,
issueInboxArchives,
issueLabels,
@@ -19,6 +21,7 @@ import {
issueComments,
issueDocuments,
issueReadStates,
issueThreadInteractions,
issues,
labels,
projectWorkspaces,
@@ -660,6 +663,10 @@ async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWith
const ACTIVE_RUN_STATUSES = ["queued", "running"];
const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running"];
const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"];
const BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES = ["pending"];
const BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES = ["pending", "revision_requested"];
const BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND = "harness_liveness_escalation";
const BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES = ["done", "cancelled"];
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
const BLOCKER_ATTENTION_MAX_NODES = 2000;
const BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
@@ -742,8 +749,10 @@ function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {})
reason: input.reason ?? null,
unresolvedBlockerCount: input.unresolvedBlockerCount ?? 0,
coveredBlockerCount: input.coveredBlockerCount ?? 0,
stalledBlockerCount: input.stalledBlockerCount ?? 0,
attentionBlockerCount: input.attentionBlockerCount ?? 0,
sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null,
sampleStalledBlockerIdentifier: input.sampleStalledBlockerIdentifier ?? null,
};
}
@@ -1026,6 +1035,55 @@ async function listIssueBlockerAttentionMap(
}
}
const reviewNodeIds = [...nodesById.values()]
.filter((node) => node.status === "in_review")
.map((node) => node.id);
const explicitWaitingIssueIds = new Set<string>();
if (reviewNodeIds.length > 0) {
for (const chunk of chunkList(reviewNodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const interactionRows: Array<{ issueId: string }> = await dbOrTx
.select({ issueId: issueThreadInteractions.issueId })
.from(issueThreadInteractions)
.where(
and(
eq(issueThreadInteractions.companyId, companyId),
inArray(issueThreadInteractions.status, BLOCKER_ATTENTION_PENDING_INTERACTION_STATUSES),
inArray(issueThreadInteractions.issueId, chunk),
),
);
for (const row of interactionRows) explicitWaitingIssueIds.add(row.issueId);
const approvalRows: Array<{ issueId: string }> = await dbOrTx
.select({ issueId: issueApprovals.issueId })
.from(issueApprovals)
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(
and(
eq(issueApprovals.companyId, companyId),
inArray(approvals.status, BLOCKER_ATTENTION_PENDING_APPROVAL_STATUSES),
inArray(issueApprovals.issueId, chunk),
),
);
for (const row of approvalRows) explicitWaitingIssueIds.add(row.issueId);
const recoveryRows: Array<{ originId: string | null }> = await dbOrTx
.select({ originId: issues.originId })
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, BLOCKER_ATTENTION_OPEN_RECOVERY_ORIGIN_KIND),
isNull(issues.hiddenAt),
inArray(issues.originId, chunk),
notInArray(issues.status, BLOCKER_ATTENTION_OPEN_RECOVERY_TERMINAL_STATUSES),
),
);
for (const row of recoveryRows) {
if (row.originId) explicitWaitingIssueIds.add(row.originId);
}
}
}
const agentRows: IssueBlockerAttentionAgentRow[] = agentIds.size > 0
? await dbOrTx
.select({
@@ -1038,39 +1096,83 @@ async function listIssueBlockerAttentionMap(
: [];
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
type PathClassification = { covered: boolean; sampleBlockerIdentifier: string | null };
type PathClassification = {
covered: boolean;
stalled: boolean;
sampleBlockerIdentifier: string | null;
sampleStalledBlockerIdentifier: string | null;
};
const classifyPath = (
nodeId: string,
seen: Set<string>,
): PathClassification => {
if (truncated || seen.has(nodeId)) return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(nodesById.get(nodeId)) };
const sample = blockerSampleIdentifier(nodesById.get(nodeId));
if (truncated || seen.has(nodeId)) {
return { covered: false, stalled: false, sampleBlockerIdentifier: sample, sampleStalledBlockerIdentifier: null };
}
const node = nodesById.get(nodeId);
if (!node || node.companyId !== companyId) return { covered: false, sampleBlockerIdentifier: nodeId };
if (node.status === "done") return { covered: true, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
if (activeIssueIds.has(node.id)) return { covered: true, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
if (node.status === "cancelled") return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
if (!node || node.companyId !== companyId) {
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeId, sampleStalledBlockerIdentifier: null };
}
const nodeSample = blockerSampleIdentifier(node);
if (node.status === "done") {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
if (node.status === "in_review") {
const hasWaitingPath = activeIssueIds.has(node.id) || Boolean(node.assigneeUserId) || explicitWaitingIssueIds.has(node.id);
if (hasWaitingPath) {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
return { covered: false, stalled: true, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: nodeSample };
}
if (activeIssueIds.has(node.id)) {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
if (node.status === "cancelled") {
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
if (downstream.length > 0) {
const nextSeen = new Set(seen);
nextSeen.add(nodeId);
const classified = downstream.map((edge) => classifyPath(edge.blockerIssueId, nextSeen));
const attention = classified.find((result) => !result.covered);
if (attention) return attention;
const stalledChild = classified.find((result) => result.stalled || result.sampleStalledBlockerIdentifier);
const sampleStalled = stalledChild?.sampleStalledBlockerIdentifier ?? null;
const hardAttention = classified.find((result) => !result.covered && !result.stalled);
if (hardAttention) {
return {
covered: false,
stalled: false,
sampleBlockerIdentifier: hardAttention.sampleBlockerIdentifier,
sampleStalledBlockerIdentifier: sampleStalled,
};
}
const stalledEntry = classified.find((result) => result.stalled);
if (stalledEntry) {
return {
covered: false,
stalled: true,
sampleBlockerIdentifier: stalledEntry.sampleBlockerIdentifier,
sampleStalledBlockerIdentifier: sampleStalled,
};
}
return {
covered: true,
sampleBlockerIdentifier: classified[0]?.sampleBlockerIdentifier ?? blockerSampleIdentifier(node),
stalled: false,
sampleBlockerIdentifier: classified[0]?.sampleBlockerIdentifier ?? nodeSample,
sampleStalledBlockerIdentifier: null,
};
}
if (node.assigneeAgentId) {
const assignee = agentsById.get(node.assigneeAgentId);
if (!assignee || assignee.companyId !== companyId || !BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status)) {
return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
}
return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
};
for (const root of roots) {
@@ -1088,22 +1190,41 @@ async function listIssueBlockerAttentionMap(
result: classifyPath(edge.blockerIssueId, new Set([root.id])),
}));
const coveredBlockerCount = classified.filter((entry) => entry.result.covered).length;
const attentionBlockerCount = classified.length - coveredBlockerCount;
const attentionEntry = classified.find((entry) => !entry.result.covered);
const sampleEntry = attentionEntry ?? classified[0] ?? null;
const stalledBlockerCount = classified.filter((entry) => entry.result.stalled).length;
const attentionBlockerCount = classified.length - coveredBlockerCount - stalledBlockerCount;
const hardAttentionEntry = classified.find((entry) => !entry.result.covered && !entry.result.stalled);
const stalledEntry = classified.find((entry) => entry.result.stalled);
const sampleEntry = hardAttentionEntry ?? stalledEntry ?? classified[0] ?? null;
const sampleNode = sampleEntry ? nodesById.get(sampleEntry.edge.blockerIssueId) : null;
const sampleStalledFromChain = classified
.map((entry) => entry.result.sampleStalledBlockerIdentifier)
.find((value) => value);
let state: IssueBlockerAttention["state"];
let reason: IssueBlockerAttention["reason"];
if (attentionBlockerCount > 0) {
state = "needs_attention";
reason = "attention_required";
} else if (stalledBlockerCount > 0) {
state = "stalled";
reason = "stalled_review";
} else {
state = "covered";
reason = topLevelEdges.every((edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id)
? "active_child"
: "active_dependency";
}
attentionMap.set(root.id, createIssueBlockerAttention({
state: attentionBlockerCount === 0 ? "covered" : "needs_attention",
reason: attentionBlockerCount === 0
? topLevelEdges.every((edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id)
? "active_child"
: "active_dependency"
: "attention_required",
state,
reason,
unresolvedBlockerCount: topLevelEdges.length,
coveredBlockerCount,
stalledBlockerCount,
attentionBlockerCount,
sampleBlockerIdentifier: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode),
sampleStalledBlockerIdentifier:
stalledEntry?.result.sampleStalledBlockerIdentifier ?? sampleStalledFromChain ?? null,
}));
}
+47 -4
View File
@@ -32,6 +32,7 @@ import type {
FeedbackVote,
FeedbackVoteValue,
IssueAttachment,
IssueBlockerAttention,
IssueRelationIssueSummary,
} from "@paperclipai/shared";
import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats";
@@ -245,6 +246,7 @@ interface IssueChatThreadProps {
liveRuns?: LiveRunForIssue[];
activeRun?: ActiveRunForIssue | null;
blockedBy?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
companyId?: string | null;
projectId?: string | null;
issueStatus?: string;
@@ -347,9 +349,11 @@ class IssueChatErrorBoundary extends Component<IssueChatErrorBoundaryProps, Issu
function IssueBlockedNotice({
issueStatus,
blockers,
blockerAttention,
}: {
issueStatus?: string;
blockers: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention | null;
}) {
if (blockers.length === 0 && issueStatus !== "blocked") return null;
@@ -358,6 +362,28 @@ function IssueBlockedNotice({
.flatMap((blocker) => blocker.terminalBlockers ?? [])
.filter((blocker, index, all) => all.findIndex((candidate) => candidate.id === blocker.id) === index);
const isStalled = blockerAttention?.state === "stalled";
const stalledLeafIdentifier =
blockerAttention?.sampleStalledBlockerIdentifier ?? blockerAttention?.sampleBlockerIdentifier ?? null;
const stalledLeafBlockers = (() => {
const candidates: IssueRelationIssueSummary[] = [];
for (const blocker of [...blockers, ...terminalBlockers]) {
if (blocker.status !== "in_review") continue;
if (candidates.some((existing) => existing.id === blocker.id)) continue;
candidates.push(blocker);
}
if (stalledLeafIdentifier) {
const preferred = candidates.find(
(blocker) => (blocker.identifier ?? blocker.id) === stalledLeafIdentifier,
);
if (preferred) {
return [preferred, ...candidates.filter((blocker) => blocker.id !== preferred.id)];
}
}
return candidates;
})();
const showStalledRow = isStalled && stalledLeafBlockers.length > 0;
const renderBlockerChip = (blocker: IssueRelationIssueSummary) => {
const issuePathId = blocker.identifier ?? blocker.id;
return (
@@ -376,13 +402,18 @@ function IssueBlockedNotice({
};
return (
<div 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">
<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"
>
<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" />
<div className="min-w-0 space-y-1.5">
<p className="leading-5">
{blockers.length > 0
? <>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.</>
? isStalled
? <>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 ? (
@@ -390,7 +421,14 @@ function IssueBlockedNotice({
{blockers.map(renderBlockerChip)}
</div>
) : null}
{terminalBlockers.length > 0 ? (
{showStalledRow ? (
<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">
{stalledLeafBlockers.length === 1 ? "Stalled in review" : "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
@@ -2511,6 +2549,7 @@ export function IssueChatThread({
liveRuns = [],
activeRun = null,
blockedBy = [],
blockerAttention = null,
companyId,
projectId,
issueStatus,
@@ -2867,7 +2906,11 @@ export function IssueChatThread({
)}
{showComposer ? (
<div data-testid="issue-chat-thread-notices" className="space-y-2">
<IssueBlockedNotice issueStatus={issueStatus} blockers={unresolvedBlockers} />
<IssueBlockedNotice
issueStatus={issueStatus}
blockers={unresolvedBlockers}
blockerAttention={blockerAttention}
/>
<IssueAssigneePausedNotice agent={assignedAgent} />
</div>
) : null}
@@ -403,8 +403,10 @@ describe("IssueProperties", () => {
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
},
}),
childIssues: [],
+30
View File
@@ -14,8 +14,10 @@ describe("StatusIcon", () => {
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
}}
/>,
);
@@ -38,8 +40,10 @@ describe("StatusIcon", () => {
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
sampleStalledBlockerIdentifier: null,
}}
/>,
);
@@ -58,8 +62,10 @@ describe("StatusIcon", () => {
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
}}
/>,
);
@@ -69,4 +75,28 @@ describe("StatusIcon", () => {
expect(html).toContain("border-red-600");
expect(html).not.toContain("border-dashed");
});
it("renders stalled review chains with amber visual and stalled-leaf copy", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
blockerAttention={{
state: "stalled",
reason: "stalled_review",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
stalledBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2279",
sampleStalledBlockerIdentifier: "PAP-2279",
}}
/>,
);
expect(html).toContain('data-blocker-attention-state="stalled"');
expect(html).toContain('aria-label="Blocked · review stalled on PAP-2279"');
expect(html).toContain("border-amber-600");
expect(html).not.toContain("border-cyan-600");
expect(html).not.toContain("border-red-600");
});
});
+21 -2
View File
@@ -40,6 +40,14 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
return `Blocked · covered by ${count} active dependencies`;
}
if (blockerAttention.reason === "stalled_review") {
const count = blockerAttention.stalledBlockerCount;
const leaf = blockerAttention.sampleStalledBlockerIdentifier ?? blockerAttention.sampleBlockerIdentifier;
if (count === 1 && leaf) return `Blocked · review stalled on ${leaf}`;
if (count === 1) return "Blocked · review stalled with no clear next step";
return `Blocked · ${count} reviews stalled with no clear next step`;
}
if (blockerAttention.reason === "attention_required") {
const count = blockerAttention.unresolvedBlockerCount;
return `Blocked · ${count} unresolved ${count === 1 ? "blocker needs" : "blockers need"} attention`;
@@ -51,11 +59,19 @@ function blockedAttentionLabel(blockerAttention: IssueBlockerAttention | null |
export function StatusIcon({ status, blockerAttention, onChange, className, showLabel }: StatusIconProps) {
const [open, setOpen] = useState(false);
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
const isStalledBlocked = status === "blocked" && blockerAttention?.state === "stalled";
const colorClass = isCoveredBlocked
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
: issueStatusIcon[status] ?? issueStatusIconDefault;
: isStalledBlocked
? "text-amber-600 border-amber-600 dark:text-amber-400 dark:border-amber-400"
: issueStatusIcon[status] ?? issueStatusIconDefault;
const isDone = status === "done";
const ariaLabel = status === "blocked" ? blockedAttentionLabel(blockerAttention) : statusLabel(status);
const blockerAttentionState = isCoveredBlocked
? "covered"
: isStalledBlocked
? "stalled"
: undefined;
const circle = (
<span
@@ -65,7 +81,7 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
onChange && !showLabel && "cursor-pointer",
className
)}
data-blocker-attention-state={isCoveredBlocked ? "covered" : undefined}
data-blocker-attention-state={blockerAttentionState}
aria-label={ariaLabel}
title={ariaLabel}
>
@@ -75,6 +91,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show
{isCoveredBlocked && (
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
)}
{isStalledBlocked && (
<span className="absolute inset-0 m-auto h-1.5 w-1.5 rounded-full bg-current" />
)}
</span>
);
+2
View File
@@ -824,8 +824,10 @@ describe("IssueDetail", () => {
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
stalledBlockerCount: 0,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
sampleStalledBlockerIdentifier: null,
},
}));
+4
View File
@@ -549,6 +549,7 @@ type IssueDetailChatTabProps = {
issueStatus: Issue["status"];
executionRunId: string | null;
blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null;
comments: IssueDetailComment[];
locallyQueuedCommentRunIds: ReadonlyMap<string, string>;
interactions: IssueThreadInteraction[];
@@ -601,6 +602,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
issueStatus,
executionRunId,
blockedBy,
blockerAttention,
comments,
locallyQueuedCommentRunIds,
interactions,
@@ -795,6 +797,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
liveRuns={resolvedLiveRuns}
activeRun={resolvedActiveRun}
blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention}
companyId={companyId}
projectId={projectId}
issueStatus={issueStatus}
@@ -3374,6 +3377,7 @@ export function IssueDetail() {
issueStatus={issue.status}
executionRunId={issue.executionRunId ?? null}
blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null}
comments={threadComments}
locallyQueuedCommentRunIds={locallyQueuedCommentRunIds}
interactions={interactions}
@@ -43,6 +43,21 @@ type CoveredBlockedCell = {
expectedCopy: string;
};
function attention(
partial: Partial<IssueBlockerAttention> & Pick<IssueBlockerAttention, "state" | "reason">,
): IssueBlockerAttention {
return {
state: partial.state,
reason: partial.reason,
unresolvedBlockerCount: partial.unresolvedBlockerCount ?? 0,
coveredBlockerCount: partial.coveredBlockerCount ?? 0,
stalledBlockerCount: partial.stalledBlockerCount ?? 0,
attentionBlockerCount: partial.attentionBlockerCount ?? 0,
sampleBlockerIdentifier: partial.sampleBlockerIdentifier ?? null,
sampleStalledBlockerIdentifier: partial.sampleStalledBlockerIdentifier ?? null,
};
}
const coveredBlockedMatrix: CoveredBlockedCell[] = [
{
label: "Normal blocked",
@@ -54,98 +69,116 @@ const coveredBlockedMatrix: CoveredBlockedCell[] = [
{
label: "Covered by 1 active child",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2175",
},
}),
expectedVisual: "cyan ring",
expectedCopy: "Blocked · waiting on active sub-issue PAP-2175",
},
{
label: "Covered by N active children",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 3,
coveredBlockerCount: 3,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
},
}),
expectedVisual: "cyan ring",
expectedCopy: "Blocked · waiting on 3 active sub-issues",
},
{
label: "Covered by active dependency",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-1918",
},
}),
expectedVisual: "cyan ring",
expectedCopy: "Blocked · covered by active dependency PAP-1918",
},
{
label: "Covered by N active dependencies",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 2,
coveredBlockerCount: 2,
attentionBlockerCount: 0,
sampleBlockerIdentifier: null,
},
}),
expectedVisual: "cyan ring",
expectedCopy: "Blocked · covered by 2 active dependencies",
},
{
label: "Stalled review (single leaf)",
status: "blocked",
blockerAttention: attention({
state: "stalled",
reason: "stalled_review",
unresolvedBlockerCount: 1,
stalledBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2279",
sampleStalledBlockerIdentifier: "PAP-2279",
}),
expectedVisual: "amber ring with dot",
expectedCopy: "Blocked · review stalled on PAP-2279",
},
{
label: "Stalled review (multiple leaves)",
status: "blocked",
blockerAttention: attention({
state: "stalled",
reason: "stalled_review",
unresolvedBlockerCount: 2,
stalledBlockerCount: 2,
sampleStalledBlockerIdentifier: "PAP-2279",
}),
expectedVisual: "amber ring with dot",
expectedCopy: "Blocked · 2 reviews stalled with no clear next step",
},
{
label: "Mixed: 1 covered, 1 needs attention",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 2,
coveredBlockerCount: 1,
attentionBlockerCount: 1,
sampleBlockerIdentifier: null,
},
}),
expectedVisual: "solid red ring",
expectedCopy: "Blocked · 2 unresolved blockers need attention",
},
{
label: "Needs attention (single blocker)",
status: "blocked",
blockerAttention: {
blockerAttention: attention({
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PAP-1042",
},
}),
expectedVisual: "solid red ring",
expectedCopy: "Blocked · 1 unresolved blocker needs attention",
},
{
label: "Non-blocked with prop ignored",
status: "in_progress",
blockerAttention: {
blockerAttention: attention({
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2175",
},
}),
expectedVisual: "yellow ring",
expectedCopy: "In Progress",
},
@@ -248,8 +281,9 @@ function StatusLanguage() {
))}
</div>
<p className="mt-3 text-xs text-muted-foreground">
Tooltip and aria-label copy begin with "Blocked · " for cells 2-7; cells 6 and 7 retain the solid red ring
and mention blockers that need attention.
Tooltip and aria-label copy begin with "Blocked · " for every cell after the first. Covered cells show a cyan
ring with a small dot, stalled-review cells show an amber ring with a centered dot, and the needs-attention
cells retain the solid red ring.
</p>
</Section>