mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
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:
1 parent
eccba68be4
commit
51f8b24b01
11 files changed
+449
-53
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -824,8 +824,10 @@ describe("IssueDetail", () => {
|
||||
reason: "active_child",
|
||||
unresolvedBlockerCount: 1,
|
||||
coveredBlockerCount: 1,
|
||||
stalledBlockerCount: 0,
|
||||
attentionBlockerCount: 0,
|
||||
sampleBlockerIdentifier: "PAP-2",
|
||||
sampleStalledBlockerIdentifier: null,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user