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.