Add covered blocked issue attention state

This commit is contained in:
Dotta committed 2026-04-24 13:32:06 -05:00
1 parent 4788e563fd
commit 8f208110b0
14 files changed
+637 -13

No files matched your search

+3
View File
@@ -315,6 +315,9 @@ export type {
IssueWorkProductReviewState,
Issue,
IssueAssigneeAdapterOverrides,
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
+3
View File
@@ -109,6 +109,9 @@ export type {
export type {
Issue,
IssueAssigneeAdapterOverrides,
IssueBlockerAttention,
IssueBlockerAttentionReason,
IssueBlockerAttentionState,
IssueReferenceSource,
IssueRelatedWorkItem,
IssueRelatedWorkSummary,
+18
View File
@@ -118,6 +118,23 @@ export interface IssueRelationIssueSummary {
assigneeUserId: string | null;
}
export type IssueBlockerAttentionState = "none" | "covered" | "needs_attention";
export type IssueBlockerAttentionReason =
| "active_child"
| "active_dependency"
| "attention_required"
| null;
export interface IssueBlockerAttention {
state: IssueBlockerAttentionState;
reason: IssueBlockerAttentionReason;
unresolvedBlockerCount: number;
coveredBlockerCount: number;
attentionBlockerCount: number;
sampleBlockerIdentifier: string | null;
}
export interface IssueRelation {
id: string;
companyId: string;
@@ -242,6 +259,7 @@ export interface Issue {
labels?: IssueLabel[];
blockedBy?: IssueRelationIssueSummary[];
blocks?: IssueRelationIssueSummary[];
blockerAttention?: IssueBlockerAttention;
relatedWork?: IssueRelatedWorkSummary;
referencedIssueIdentifiers?: string[];
planDocument?: IssueDocument | null;
@@ -0,0 +1,223 @@
import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
agentWakeupRequests,
companies,
createDb,
heartbeatRuns,
issueRelations,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres issue blocker attention tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("issue blocker attention", () => {
let db!: ReturnType<typeof createDb>;
let svc!: ReturnType<typeof issueService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-blocker-attention-");
db = createDb(tempDb.connectionString);
svc = issueService(db);
}, 20_000);
afterEach(async () => {
await db.delete(heartbeatRuns);
await db.delete(agentWakeupRequests);
await db.delete(issueRelations);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
async function createCompany(prefix = "PBA") {
const companyId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: `Company ${prefix}`,
issuePrefix: prefix,
requireBoardApprovalForNewAgents: false,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: `${prefix} Agent`,
role: "engineer",
status: "idle",
});
return { companyId, agentId };
}
async function insertIssue(input: {
companyId: string;
id?: string;
identifier: string;
title: string;
status: string;
parentId?: string | null;
assigneeAgentId?: string | null;
}) {
const id = input.id ?? randomUUID();
await db.insert(issues).values({
id,
companyId: input.companyId,
identifier: input.identifier,
title: input.title,
status: input.status,
priority: "medium",
parentId: input.parentId ?? null,
assigneeAgentId: input.assigneeAgentId ?? null,
});
return id;
}
async function block(input: { companyId: string; blockerIssueId: string; blockedIssueId: string }) {
await db.insert(issueRelations).values({
companyId: input.companyId,
issueId: input.blockerIssueId,
relatedIssueId: input.blockedIssueId,
type: "blocks",
});
}
async function activeRun(input: { companyId: string; agentId: string; issueId: string; status?: string }) {
await db.insert(heartbeatRuns).values({
companyId: input.companyId,
agentId: input.agentId,
status: input.status ?? "running",
contextSnapshot: { issueId: input.issueId },
});
}
it("classifies a blocked parent as covered when its child has a running execution path", async () => {
const { companyId, agentId } = await createCompany("PBC");
const parentId = await insertIssue({ companyId, identifier: "PBC-1", title: "Parent", status: "blocked" });
const childId = await insertIssue({
companyId,
identifier: "PBC-2",
title: "Running child",
status: "todo",
parentId,
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: childId, blockedIssueId: parentId });
await activeRun({ companyId, agentId, issueId: childId });
const [parent] = await svc.list(companyId, { status: "blocked" });
expect(parent?.blockerAttention).toMatchObject({
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PBC-2",
});
});
it("keeps mixed blockers attention-required when any path lacks active work", async () => {
const { companyId, agentId } = await createCompany("PBM");
const parentId = await insertIssue({ companyId, identifier: "PBM-1", title: "Parent", status: "blocked" });
const activeChildId = await insertIssue({
companyId,
identifier: "PBM-2",
title: "Running child",
status: "todo",
parentId,
assigneeAgentId: agentId,
});
const idleBlockerId = await insertIssue({
companyId,
identifier: "PBM-3",
title: "Idle blocker",
status: "todo",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: activeChildId, blockedIssueId: parentId });
await block({ companyId, blockerIssueId: idleBlockerId, blockedIssueId: parentId });
await activeRun({ companyId, agentId, issueId: activeChildId });
const [parent] = await svc.list(companyId, { status: "blocked" });
expect(parent?.blockerAttention).toMatchObject({
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 2,
coveredBlockerCount: 1,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PBM-3",
});
});
it("covers recursive blocker chains when the downstream leaf has active work", async () => {
const { companyId, agentId } = await createCompany("PBR");
const parentId = await insertIssue({ companyId, identifier: "PBR-1", title: "Parent", status: "blocked" });
const blockerId = await insertIssue({ companyId, identifier: "PBR-2", title: "Blocked dependency", status: "blocked" });
const leafId = await insertIssue({
companyId,
identifier: "PBR-3",
title: "Running leaf",
status: "todo",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId });
await block({ companyId, blockerIssueId: leafId, blockedIssueId: blockerId });
await activeRun({ companyId, agentId, issueId: leafId });
const [parent] = await svc.list(companyId, { status: "blocked" });
expect(parent?.blockerAttention).toMatchObject({
state: "covered",
reason: "active_dependency",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PBR-3",
});
});
it("does not let another company's active run cover the blocker", async () => {
const { companyId, agentId } = await createCompany("PBS");
const other = await createCompany("PBT");
const parentId = await insertIssue({ companyId, identifier: "PBS-1", title: "Parent", status: "blocked" });
const blockerId = await insertIssue({
companyId,
identifier: "PBS-2",
title: "Same-company blocker",
status: "todo",
assigneeAgentId: agentId,
});
await block({ companyId, blockerIssueId: blockerId, blockedIssueId: parentId });
await activeRun({ companyId: other.companyId, agentId: other.agentId, issueId: blockerId });
const [parent] = await svc.list(companyId, { status: "blocked" });
expect(parent?.blockerAttention).toMatchObject({
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PBS-2",
});
});
});
+6 -1
View File
@@ -1016,6 +1016,7 @@ export function issueRoutes(
commentCursor,
wakeComment,
relations,
blockerAttention,
attachments,
continuationSummary,
currentExecutionWorkspace,
@@ -1026,6 +1027,7 @@ export function issueRoutes(
svc.getCommentCursor(issue.id),
wakeCommentId ? svc.getComment(wakeCommentId) : null,
svc.getRelationSummaries(issue.id),
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
svc.listAttachments(issue.id),
documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
currentExecutionWorkspacePromise,
@@ -1038,6 +1040,7 @@ export function issueRoutes(
title: issue.title,
description: issue.description,
status: issue.status,
...(blockerAttention ? { blockerAttention } : {}),
priority: issue.priority,
projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId,
@@ -1107,12 +1110,13 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, referenceSummary] = await Promise.all([
const [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, blockerAttention, referenceSummary] = await Promise.all([
resolveIssueProjectAndGoal(issue),
svc.getAncestors(issue.id),
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
documentsSvc.getIssueDocumentPayload(issue),
svc.getRelationSummaries(issue.id),
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
issueReferencesSvc.listIssueReferenceSummary(issue.id),
]);
const mentionedProjects = mentionedProjectIds.length > 0
@@ -1126,6 +1130,7 @@ export function issueRoutes(
...issue,
goalId: goal?.id ?? issue.goalId,
ancestors,
...(blockerAttention ? { blockerAttention } : {}),
blockedBy: relations.blockedBy,
blocks: relations.blocks,
relatedWork: referenceSummary,
+307 -1
View File
@@ -3,6 +3,7 @@ import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
activityLog,
agentWakeupRequests,
agents,
assets,
companies,
@@ -23,7 +24,7 @@ import {
projectWorkspaces,
projects,
} from "@paperclipai/db";
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import {
@@ -656,6 +657,39 @@ async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWith
}
const ACTIVE_RUN_STATUSES = ["queued", "running"];
const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running", "scheduled_retry"];
const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"];
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
const BLOCKER_ATTENTION_MAX_NODES = 2000;
const BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
type IssueBlockerAttentionNode = {
id: string;
companyId: string;
parentId: string | null;
identifier: string | null;
title: string;
status: string;
assigneeAgentId: string | null;
assigneeUserId: string | null;
};
type IssueBlockerAttentionEdge = {
issueId: string;
blockerIssueId: string;
};
type IssueBlockerAttentionQueryRow = IssueBlockerAttentionNode & {
issueId: string | null;
blockerIssueId: string;
};
type IssueBlockerAttentionActivePathRow = {
issueId: string | null;
};
type IssueBlockerAttentionAgentRow = {
id: string;
companyId: string;
status: string;
};
async function activeRunMapForIssues(
dbOrTx: any,
@@ -694,6 +728,267 @@ async function activeRunMapForIssues(
return map;
}
function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {}): IssueBlockerAttention {
return {
state: input.state ?? "none",
reason: input.reason ?? null,
unresolvedBlockerCount: input.unresolvedBlockerCount ?? 0,
coveredBlockerCount: input.coveredBlockerCount ?? 0,
attentionBlockerCount: input.attentionBlockerCount ?? 0,
sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null,
};
}
function blockerSampleIdentifier(node: IssueBlockerAttentionNode | null | undefined) {
return node?.identifier ?? node?.id ?? null;
}
function appendBlockerAttentionEdges(
edgesByIssueId: Map<string, IssueBlockerAttentionEdge[]>,
rows: IssueBlockerAttentionEdge[],
) {
for (const row of rows) {
const existing = edgesByIssueId.get(row.issueId) ?? [];
if (!existing.some((edge) => edge.blockerIssueId === row.blockerIssueId)) {
existing.push(row);
edgesByIssueId.set(row.issueId, existing);
}
}
}
async function listIssueBlockerAttentionMap(
dbOrTx: any,
companyId: string,
issueRows: Array<Pick<IssueBlockerAttentionNode, "id" | "companyId" | "parentId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId">>,
): Promise<Map<string, IssueBlockerAttention>> {
const roots = issueRows.filter((row) => row.companyId === companyId && row.status === "blocked");
const attentionMap = new Map<string, IssueBlockerAttention>();
for (const row of issueRows) {
if (row.status !== "blocked") {
attentionMap.set(row.id, createIssueBlockerAttention());
}
}
if (roots.length === 0) return attentionMap;
const nodesById = new Map<string, IssueBlockerAttentionNode>();
const edgesByIssueId = new Map<string, IssueBlockerAttentionEdge[]>();
for (const root of roots) nodesById.set(root.id, { ...root });
let frontier = roots.map((root) => root.id);
let truncated = false;
for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) {
const nextFrontier = new Set<string>();
for (const chunk of chunkList([...new Set(frontier)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const explicitBlockerRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
.select({
issueId: issueRelations.relatedIssueId,
blockerIssueId: issues.id,
id: issues.id,
companyId: issues.companyId,
parentId: issues.parentId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
.from(issueRelations)
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
.where(
and(
eq(issueRelations.companyId, companyId),
eq(issueRelations.type, "blocks"),
inArray(issueRelations.relatedIssueId, chunk),
eq(issues.companyId, companyId),
ne(issues.status, "done"),
),
);
const childRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
.select({
issueId: issues.parentId,
blockerIssueId: issues.id,
id: issues.id,
companyId: issues.companyId,
parentId: issues.parentId,
identifier: issues.identifier,
title: issues.title,
status: issues.status,
assigneeAgentId: issues.assigneeAgentId,
assigneeUserId: issues.assigneeUserId,
})
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
inArray(issues.parentId, chunk),
ne(issues.status, "done"),
),
);
const [explicitBlockerRows, childRows] = await Promise.all([
explicitBlockerRowsPromise,
childRowsPromise,
]);
appendBlockerAttentionEdges(edgesByIssueId, [
...explicitBlockerRows
.filter((row): row is IssueBlockerAttentionQueryRow & { issueId: string } => row.issueId !== null)
.map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })),
...childRows
.filter((row): row is IssueBlockerAttentionQueryRow & { issueId: string } => row.issueId !== null)
.map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })),
]);
for (const row of [...explicitBlockerRows, ...childRows]) {
if (!row.issueId || nodesById.has(row.blockerIssueId)) continue;
nodesById.set(row.blockerIssueId, {
id: row.blockerIssueId,
companyId: row.companyId,
parentId: row.parentId,
identifier: row.identifier,
title: row.title,
status: row.status,
assigneeAgentId: row.assigneeAgentId,
assigneeUserId: row.assigneeUserId,
});
nextFrontier.add(row.blockerIssueId);
}
}
if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) {
truncated = true;
break;
}
frontier = [...nextFrontier];
}
if (frontier.length > 0) truncated = true;
const nodeIds = [...nodesById.keys()];
const activeIssueIds = new Set<string>();
const agentIds = new Set<string>();
for (const node of nodesById.values()) {
if (node.assigneeAgentId) agentIds.add(node.assigneeAgentId);
}
for (const chunk of chunkList(nodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const runRowsPromise: Promise<IssueBlockerAttentionActivePathRow[]> = dbOrTx
.select({
issueId: sql<string | null>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
})
.from(heartbeatRuns)
.where(
and(
eq(heartbeatRuns.companyId, companyId),
inArray(heartbeatRuns.status, BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES),
inArray(sql<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, chunk),
),
);
const wakeRowsPromise: Promise<IssueBlockerAttentionActivePathRow[]> = dbOrTx
.select({
issueId: sql<string | null>`${agentWakeupRequests.payload} ->> 'issueId'`,
})
.from(agentWakeupRequests)
.where(
and(
eq(agentWakeupRequests.companyId, companyId),
inArray(agentWakeupRequests.status, BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES),
sql`${agentWakeupRequests.runId} is null`,
inArray(sql<string>`${agentWakeupRequests.payload} ->> 'issueId'`, chunk),
),
);
const [runRows, wakeRows] = await Promise.all([
runRowsPromise,
wakeRowsPromise,
]);
for (const row of [...runRows, ...wakeRows]) {
if (row.issueId) activeIssueIds.add(row.issueId);
}
}
const agentRows: IssueBlockerAttentionAgentRow[] = agentIds.size > 0
? await dbOrTx
.select({
id: agents.id,
companyId: agents.companyId,
status: agents.status,
})
.from(agents)
.where(and(eq(agents.companyId, companyId), inArray(agents.id, [...agentIds])))
: [];
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
type PathClassification = { covered: boolean; sampleBlockerIdentifier: string | null };
const classifyPath = (
nodeId: string,
seen: Set<string>,
): PathClassification => {
if (truncated || seen.has(nodeId)) return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(nodesById.get(nodeId)) };
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) };
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;
return {
covered: true,
sampleBlockerIdentifier: classified[0]?.sampleBlockerIdentifier ?? blockerSampleIdentifier(node),
};
}
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, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
};
for (const root of roots) {
const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
if (topLevelEdges.length === 0) {
attentionMap.set(root.id, createIssueBlockerAttention({
state: "needs_attention",
reason: "attention_required",
}));
continue;
}
const classified = topLevelEdges.map((edge) => ({
edge,
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 sampleNode = sampleEntry ? nodesById.get(sampleEntry.edge.blockerIssueId) : null;
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",
unresolvedBlockerCount: topLevelEdges.length,
coveredBlockerCount,
attentionBlockerCount,
sampleBlockerIdentifier: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode),
}));
}
return attentionMap;
}
const issueListSelect = {
id: issues.id,
companyId: issues.companyId,
@@ -1514,6 +1809,7 @@ export function issueService(db: Db) {
]);
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
const blockerAttentionByIssueId = await listIssueBlockerAttentionMap(db, companyId, withRuns);
if (!contextUserId) {
return withRuns.map((row) => {
@@ -1526,6 +1822,7 @@ export function issueService(db: Db) {
return {
...row,
lastActivityAt,
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
};
});
}
@@ -1542,6 +1839,7 @@ export function issueService(db: Db) {
return {
...row,
lastActivityAt,
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
...deriveIssueUserContext(row, contextUserId, {
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
myLastReadAt: readByIssueId.get(row.id) ?? null,
@@ -1685,6 +1983,14 @@ export function issueService(db: Db) {
return listIssueDependencyReadinessMap(dbOrTx, companyId, issueIds);
},
listBlockerAttention: async (
companyId: string,
issueRows: Array<Pick<IssueBlockerAttentionNode, "id" | "companyId" | "parentId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId">>,
dbOrTx: any = db,
) => {
return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows);
},
listWakeableBlockedDependents: async (blockerIssueId: string) => {
const blockerIssue = await db
.select({ id: issues.id, companyId: issues.companyId })
+1 -1
View File
@@ -150,7 +150,7 @@ export function InboxIssueMetaLeading({
<>
{showStatus ? (
<span className="hidden shrink-0 sm:inline-flex">
{statusSlot ?? <StatusIcon status={issue.status} />}
{statusSlot ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />}
</span>
) : null}
{showIdentifier ? (
+1 -1
View File
@@ -44,7 +44,7 @@ export function IssueQuicklookCard({
return (
<div className={cn("space-y-2", compact && "space-y-1.5")}>
<div className="flex items-start gap-2">
<StatusIcon status={issue.status} className="mt-0.5 shrink-0" />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className="mt-0.5 shrink-0" />
<RouterDom.Link
to={linkTo}
state={linkState ?? withIssueDetailHeaderSeed(null, issue)}
+2 -2
View File
@@ -69,7 +69,7 @@ export function IssueRow({
)}
>
<span className="shrink-0 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} className={selectedStatusClass} />}
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
<span className="line-clamp-2 text-sm sm:order-2 sm:min-w-0 sm:flex-1 sm:truncate sm:line-clamp-none">
@@ -82,7 +82,7 @@ export function IssueRow({
{desktopMetaLeading ?? (
<>
<span className="hidden shrink-0 sm:inline-flex">
<StatusIcon status={issue.status} className={selectedStatusClass} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} className={selectedStatusClass} />
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{identifier}
+2 -2
View File
@@ -1087,7 +1087,7 @@ export function IssuesList({
</button>
) : (
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)
}
@@ -1111,7 +1111,7 @@ export function IssuesList({
showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")}
statusSlot={(
<span onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}>
<StatusIcon status={issue.status} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} onChange={(s) => onUpdateIssue(issue.id, { status: s })} />
</span>
)}
/>
+48
View File
@@ -0,0 +1,48 @@
// @vitest-environment node
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { StatusIcon } from "./StatusIcon";
describe("StatusIcon", () => {
it("renders covered blocked issues with a non-red attention state label", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
blockerAttention={{
state: "covered",
reason: "active_child",
unresolvedBlockerCount: 1,
coveredBlockerCount: 1,
attentionBlockerCount: 0,
sampleBlockerIdentifier: "PAP-2",
}}
/>,
);
expect(html).toContain('data-blocker-attention-state="covered"');
expect(html).toContain('aria-label="Blocked, waiting on running sub-issue"');
expect(html).toContain("border-cyan-600");
expect(html).not.toContain("border-red-600");
});
it("keeps normal blocked issues on the attention-required visual", () => {
const html = renderToStaticMarkup(
<StatusIcon
status="blocked"
blockerAttention={{
state: "needs_attention",
reason: "attention_required",
unresolvedBlockerCount: 1,
coveredBlockerCount: 0,
attentionBlockerCount: 1,
sampleBlockerIdentifier: "PAP-2",
}}
/>,
);
expect(html).not.toContain('data-blocker-attention-state="covered"');
expect(html).toContain('aria-label="Blocked"');
expect(html).toContain("border-red-600");
});
});
+20 -2
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import type { IssueBlockerAttention } from "@paperclipai/shared";
import { cn } from "../lib/utils";
import { issueStatusIcon, issueStatusIconDefault } from "../lib/status-colors";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -12,15 +13,26 @@ function statusLabel(status: string): string {
interface StatusIconProps {
status: string;
blockerAttention?: IssueBlockerAttention | null;
onChange?: (status: string) => void;
className?: string;
showLabel?: boolean;
}
export function StatusIcon({ status, onChange, className, showLabel }: StatusIconProps) {
function coveredBlockedLabel(blockerAttention: IssueBlockerAttention | null | undefined) {
return blockerAttention?.reason === "active_child"
? "Blocked, waiting on running sub-issue"
: "Blocked, covered by active dependency";
}
export function StatusIcon({ status, blockerAttention, onChange, className, showLabel }: StatusIconProps) {
const [open, setOpen] = useState(false);
const colorClass = issueStatusIcon[status] ?? issueStatusIconDefault;
const isCoveredBlocked = status === "blocked" && blockerAttention?.state === "covered";
const colorClass = isCoveredBlocked
? "text-cyan-600 border-cyan-600 dark:text-cyan-400 dark:border-cyan-400"
: issueStatusIcon[status] ?? issueStatusIconDefault;
const isDone = status === "done";
const ariaLabel = isCoveredBlocked ? coveredBlockedLabel(blockerAttention) : statusLabel(status);
const circle = (
<span
@@ -30,10 +42,16 @@ export function StatusIcon({ status, onChange, className, showLabel }: StatusIco
onChange && !showLabel && "cursor-pointer",
className
)}
data-blocker-attention-state={isCoveredBlocked ? "covered" : undefined}
aria-label={ariaLabel}
title={ariaLabel}
>
{isDone && (
<span className="absolute inset-0 m-auto h-2 w-2 rounded-full bg-current" />
)}
{isCoveredBlocked && (
<span className="absolute -bottom-0.5 -right-0.5 h-2 w-2 rounded-full border border-background bg-current" />
)}
</span>
);
+2 -2
View File
@@ -356,7 +356,7 @@ export function Dashboard() {
<div className="flex items-start gap-2 sm:items-center sm:gap-3">
{/* Status icon - left column on mobile */}
<span className="shrink-0 sm:hidden">
<StatusIcon status={issue.status} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
</span>
{/* Right column on mobile: title + metadata stacked */}
@@ -365,7 +365,7 @@ export function Dashboard() {
{issue.title}
</span>
<span className="flex items-center gap-2 sm:order-1 sm:shrink-0">
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} /></span>
<span className="hidden sm:inline-flex"><StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} /></span>
<span className="text-xs font-mono text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
+1 -1
View File
@@ -56,7 +56,7 @@ export function MyIssues() {
title={issue.title}
to={`/issues/${issue.identifier ?? issue.id}`}
leading={
<StatusIcon status={issue.status} />
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} />
}
trailing={
<span className="text-xs text-muted-foreground">