From 51f8b24b0141ac953d1f75274a33f669e12f518a Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 26 Apr 2026 16:58:19 -0500 Subject: [PATCH] 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 --- packages/shared/src/types/issue.ts | 5 +- scripts/screenshot-pap2373.mjs | 35 ++++ .../__tests__/issue-blocker-attention.test.ts | 103 +++++++++++ server/src/services/issues.ts | 163 +++++++++++++++--- ui/src/components/IssueChatThread.tsx | 51 +++++- ui/src/components/IssueProperties.test.tsx | 2 + ui/src/components/StatusIcon.test.tsx | 30 ++++ ui/src/components/StatusIcon.tsx | 23 ++- ui/src/pages/IssueDetail.test.tsx | 2 + ui/src/pages/IssueDetail.tsx | 4 + .../stories/status-language.stories.tsx | 84 ++++++--- 11 files changed, 449 insertions(+), 53 deletions(-) create mode 100644 scripts/screenshot-pap2373.mjs diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index deeae205334..64b3b96d65f 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -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 { diff --git a/scripts/screenshot-pap2373.mjs b/scripts/screenshot-pap2373.mjs new file mode 100644 index 00000000000..f97f53e3b1a --- /dev/null +++ b/scripts/screenshot-pap2373.mjs @@ -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(); +} diff --git a/server/src/__tests__/issue-blocker-attention.test.ts b/server/src/__tests__/issue-blocker-attention.test.ts index 097ae60c5de..611921911ad 100644 --- a/server/src/__tests__/issue-blocker-attention.test.ts +++ b/server/src/__tests__/issue-blocker-attention.test.ts @@ -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" }); diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 48893d036c8..67516d490f0 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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 = {}) 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(); + 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, ): 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, })); } diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index f4bdaa8ab1a..d5644bdc36e 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -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 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 ( -
+

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

{blockers.length > 0 ? ( @@ -390,7 +421,14 @@ function IssueBlockedNotice({ {blockers.map(renderBlockerChip)}
) : null} - {terminalBlockers.length > 0 ? ( + {showStalledRow ? ( +
+ + {stalledLeafBlockers.length === 1 ? "Stalled in review" : "Stalled in review"} + + {stalledLeafBlockers.map(renderBlockerChip)} +
+ ) : terminalBlockers.length > 0 ? (
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 ? (
- +
) : null} diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index d98f170b4ac..bb381c677ee 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -403,8 +403,10 @@ describe("IssueProperties", () => { reason: "active_child", unresolvedBlockerCount: 1, coveredBlockerCount: 1, + stalledBlockerCount: 0, attentionBlockerCount: 0, sampleBlockerIdentifier: "PAP-2", + sampleStalledBlockerIdentifier: null, }, }), childIssues: [], diff --git a/ui/src/components/StatusIcon.test.tsx b/ui/src/components/StatusIcon.test.tsx index b2900632ef6..11922d73fa3 100644 --- a/ui/src/components/StatusIcon.test.tsx +++ b/ui/src/components/StatusIcon.test.tsx @@ -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( + , + ); + + 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"); + }); }); diff --git a/ui/src/components/StatusIcon.tsx b/ui/src/components/StatusIcon.tsx index c18655956ac..3c594918aa2 100644 --- a/ui/src/components/StatusIcon.tsx +++ b/ui/src/components/StatusIcon.tsx @@ -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 = ( @@ -75,6 +91,9 @@ export function StatusIcon({ status, blockerAttention, onChange, className, show {isCoveredBlocked && ( )} + {isStalledBlocked && ( + + )} ); diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index cf3d158db0e..4ae3733b05e 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -824,8 +824,10 @@ describe("IssueDetail", () => { reason: "active_child", unresolvedBlockerCount: 1, coveredBlockerCount: 1, + stalledBlockerCount: 0, attentionBlockerCount: 0, sampleBlockerIdentifier: "PAP-2", + sampleStalledBlockerIdentifier: null, }, })); diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index f5ae861634d..7e8ca00e501 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -549,6 +549,7 @@ type IssueDetailChatTabProps = { issueStatus: Issue["status"]; executionRunId: string | null; blockedBy: Issue["blockedBy"]; + blockerAttention: Issue["blockerAttention"] | null; comments: IssueDetailComment[]; locallyQueuedCommentRunIds: ReadonlyMap; 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} diff --git a/ui/storybook/stories/status-language.stories.tsx b/ui/storybook/stories/status-language.stories.tsx index 4b6ac2dfbd2..9d9618272bf 100644 --- a/ui/storybook/stories/status-language.stories.tsx +++ b/ui/storybook/stories/status-language.stories.tsx @@ -43,6 +43,21 @@ type CoveredBlockedCell = { expectedCopy: string; }; +function attention( + partial: Partial & Pick, +): 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() { ))}

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