From f0c22b5ddbd4478e8febbc4eb55fef886abe80a0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 24 Apr 2026 09:06:00 -0500 Subject: [PATCH] Add covered blocked issue attention state --- packages/shared/src/index.ts | 3 + packages/shared/src/types/index.ts | 3 + packages/shared/src/types/issue.ts | 18 + .../__tests__/issue-blocker-attention.test.ts | 223 +++++++++++++ server/src/routes/issues.ts | 7 +- server/src/services/issues.ts | 308 +++++++++++++++++- ui/src/components/IssueColumns.tsx | 2 +- ui/src/components/IssueLinkQuicklook.tsx | 2 +- ui/src/components/IssueRow.tsx | 4 +- ui/src/components/IssuesList.tsx | 4 +- ui/src/components/StatusIcon.test.tsx | 48 +++ ui/src/components/StatusIcon.tsx | 22 +- ui/src/pages/Dashboard.tsx | 4 +- ui/src/pages/MyIssues.tsx | 2 +- 14 files changed, 637 insertions(+), 13 deletions(-) create mode 100644 server/src/__tests__/issue-blocker-attention.test.ts create mode 100644 ui/src/components/StatusIcon.test.tsx diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b68d2d14afa..a356b911e24 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -315,6 +315,9 @@ export type { IssueWorkProductReviewState, Issue, IssueAssigneeAdapterOverrides, + IssueBlockerAttention, + IssueBlockerAttentionReason, + IssueBlockerAttentionState, IssueReferenceSource, IssueRelatedWorkItem, IssueRelatedWorkSummary, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 400721ceff3..d4d500141ce 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -109,6 +109,9 @@ export type { export type { Issue, IssueAssigneeAdapterOverrides, + IssueBlockerAttention, + IssueBlockerAttentionReason, + IssueBlockerAttentionState, IssueReferenceSource, IssueRelatedWorkItem, IssueRelatedWorkSummary, diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index a0dacc1eccf..113c3f36957 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -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; diff --git a/server/src/__tests__/issue-blocker-attention.test.ts b/server/src/__tests__/issue-blocker-attention.test.ts new file mode 100644 index 00000000000..c35e414073a --- /dev/null +++ b/server/src/__tests__/issue-blocker-attention.test.ts @@ -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; + let svc!: ReturnType; + let tempDb: Awaited> | 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", + }); + }); +}); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 795da2975ab..af56e686ffc 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -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, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 1f1254775d2..0fbe1a22382 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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 = {}): 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, + 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>, +): Promise> { + const roots = issueRows.filter((row) => row.companyId === companyId && row.status === "blocked"); + const attentionMap = new Map(); + for (const row of issueRows) { + if (row.status !== "blocked") { + attentionMap.set(row.id, createIssueBlockerAttention()); + } + } + if (roots.length === 0) return attentionMap; + + const nodesById = new Map(); + const edgesByIssueId = new Map(); + 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(); + + for (const chunk of chunkList([...new Set(frontier)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) { + const explicitBlockerRowsPromise: Promise = 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 = 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(); + const agentIds = new Set(); + 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 = dbOrTx + .select({ + issueId: sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + }) + .from(heartbeatRuns) + .where( + and( + eq(heartbeatRuns.companyId, companyId), + inArray(heartbeatRuns.status, BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES), + inArray(sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, chunk), + ), + ); + const wakeRowsPromise: Promise = dbOrTx + .select({ + issueId: sql`${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`${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, + ): 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>, + dbOrTx: any = db, + ) => { + return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows); + }, + listWakeableBlockedDependents: async (blockerIssueId: string) => { const blockerIssue = await db .select({ id: issues.id, companyId: issues.companyId }) diff --git a/ui/src/components/IssueColumns.tsx b/ui/src/components/IssueColumns.tsx index 492ba2fba74..54813689395 100644 --- a/ui/src/components/IssueColumns.tsx +++ b/ui/src/components/IssueColumns.tsx @@ -150,7 +150,7 @@ export function InboxIssueMetaLeading({ <> {showStatus ? ( - {statusSlot ?? } + {statusSlot ?? } ) : null} {showIdentifier ? ( diff --git a/ui/src/components/IssueLinkQuicklook.tsx b/ui/src/components/IssueLinkQuicklook.tsx index 1d66b2d0f79..16b16822178 100644 --- a/ui/src/components/IssueLinkQuicklook.tsx +++ b/ui/src/components/IssueLinkQuicklook.tsx @@ -44,7 +44,7 @@ export function IssueQuicklookCard({ return (
- + - {mobileLeading ?? } + {mobileLeading ?? } @@ -82,7 +82,7 @@ export function IssueRow({ {desktopMetaLeading ?? ( <> - + {identifier} diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index 195c7ead9b1..7edbee6df94 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1087,7 +1087,7 @@ export function IssuesList({ ) : ( { e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} /> + onUpdateIssue(issue.id, { status: s })} /> ) } @@ -1111,7 +1111,7 @@ export function IssuesList({ showIdentifier={visibleIssueColumnSet.has("id") && availableIssueColumnSet.has("id")} statusSlot={( { e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} /> + onUpdateIssue(issue.id, { status: s })} /> )} /> diff --git a/ui/src/components/StatusIcon.test.tsx b/ui/src/components/StatusIcon.test.tsx new file mode 100644 index 00000000000..4330afc0fd1 --- /dev/null +++ b/ui/src/components/StatusIcon.test.tsx @@ -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( + , + ); + + 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( + , + ); + + expect(html).not.toContain('data-blocker-attention-state="covered"'); + expect(html).toContain('aria-label="Blocked"'); + expect(html).toContain("border-red-600"); + }); +}); diff --git a/ui/src/components/StatusIcon.tsx b/ui/src/components/StatusIcon.tsx index bafbb35b970..02ddd07bf69 100644 --- a/ui/src/components/StatusIcon.tsx +++ b/ui/src/components/StatusIcon.tsx @@ -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 = ( {isDone && ( )} + {isCoveredBlocked && ( + + )} ); diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 5776359b7e5..eda0e2f8385 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -356,7 +356,7 @@ export function Dashboard() {
{/* Status icon - left column on mobile */} - + {/* Right column on mobile: title + metadata stacked */} @@ -365,7 +365,7 @@ export function Dashboard() { {issue.title} - + {issue.identifier ?? issue.id.slice(0, 8)} diff --git a/ui/src/pages/MyIssues.tsx b/ui/src/pages/MyIssues.tsx index 301c526f3e6..60f1be66745 100644 --- a/ui/src/pages/MyIssues.tsx +++ b/ui/src/pages/MyIssues.tsx @@ -56,7 +56,7 @@ export function MyIssues() { title={issue.title} to={`/issues/${issue.identifier ?? issue.id}`} leading={ - + } trailing={