diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index 451f745db97..1b5563bd74e 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -855,6 +855,8 @@ export type IssueSummary = Pick< "id" | "companyId" | "title" | "identifier" | "status" | "displayStatus" | "updatedAt" > & { externalConversationState: "active" | "waiting" | null; + /** Present for blocked tasks so summary views resolve the same status glyph as detail views. */ + blockerAttention?: IssueBlockerAttention; }; export interface Issue { diff --git a/packages/shared/src/types/search.ts b/packages/shared/src/types/search.ts index bc9ea562d53..4e208a9b194 100644 --- a/packages/shared/src/types/search.ts +++ b/packages/shared/src/types/search.ts @@ -1,4 +1,5 @@ import type { IssueDisplayStatus, IssuePriority, IssueStatus } from "../constants.js"; +import type { IssueBlockerAttention } from "./issue.js"; export const COMPANY_SEARCH_SCOPES = ["all", "issues", "comments", "documents", "artifacts", "agents", "projects"] as const; export type CompanySearchScope = (typeof COMPANY_SEARCH_SCOPES)[number]; @@ -40,6 +41,8 @@ export interface CompanySearchIssueSummary { status: IssueStatus; displayStatus?: IssueDisplayStatus; externalConversationState?: "active" | "waiting" | null; + /** Glyph-only blocker state for blocked results (no blocker titles or IDs). */ + blockerAttention?: IssueBlockerAttention; priority: IssuePriority; assigneeAgentId: string | null; assigneeUserId: string | null; diff --git a/server/src/__tests__/cases-routes.test.ts b/server/src/__tests__/cases-routes.test.ts index 721299a606b..055f56ff9b4 100644 --- a/server/src/__tests__/cases-routes.test.ts +++ b/server/src/__tests__/cases-routes.test.ts @@ -24,6 +24,7 @@ import { documentRevisions, heartbeatRuns, instanceSettings, + issueRelations, issues, labels, projects, @@ -116,6 +117,7 @@ describeEmbeddedPostgres("cases routes", () => { await db.delete(documents); await db.delete(assets); await db.delete(labels); + await db.delete(issueRelations); await db.delete(issues); await db.delete(heartbeatRuns); await db.delete(projects); @@ -969,6 +971,33 @@ describeEmbeddedPostgres("cases routes", () => { expect(forIssue.body[0]).toMatchObject({ role: "work" }); expect(forIssue.body[0].case).toMatchObject({ id: child.body.id, identifier: child.body.identifier, status: child.body.status }); }); + it("carries glyph-only blocker state on a blocked case task without blocker details (BUG-07)", async () => { + await enableCases(); + const company = await seedCompany("WORK-BLOCKED"); + const [work, blocker] = await db.insert(issues).values([ + { companyId: company.id, title: "Blocked case work", identifier: `${company.issuePrefix}-1`, status: "blocked" }, + { companyId: company.id, title: "Hidden case blocker", identifier: `${company.issuePrefix}-2`, status: "todo" }, + ]).returning(); + await db.insert(issueRelations).values({ companyId: company.id, issueId: blocker!.id, relatedIssueId: work!.id, type: "blocks" }); + const [record] = await db.insert(cases).values({ companyId: company.id, caseNumber: 1, identifier: `${company.issuePrefix}-C1`, + caseType: "factory", title: "Blocked case", workIssueId: work!.id }).returning(); + const detail = await request(app(boardActor)).get(`/api/cases/${record!.id}`).expect(200); + expect(detail.body.workIssue).toMatchObject({ id: work!.id, status: "blocked", blockerAttention: { state: "needs_attention", sampleBlockerIdentifier: null } }); + expect(JSON.stringify(detail.body.workIssue)).not.toContain("Hidden case blocker"); + expect(JSON.stringify(detail.body.workIssue)).not.toContain(blocker!.id); + + // Related-task links and the activity feed row draw from the same state. + await db.insert(caseIssueLinks).values({ companyId: company.id, caseId: record!.id, issueId: work!.id, role: "reference" }); + const withLink = await request(app(boardActor)).get(`/api/cases/${record!.id}`).expect(200); + expect(withLink.body.issueLinks[0].issue).toMatchObject({ id: work!.id, blockerAttention: { state: "needs_attention", sampleBlockerIdentifier: null } }); + + await db.insert(caseEvents).values({ companyId: company.id, caseId: record!.id, kind: "issue_linked", + actorType: "system", payload: { issueId: work!.id } }); + const events = await request(app(boardActor)).get(`/api/cases/${record!.id}/events`).expect(200); + const linked = events.body.find((event: { kind: string }) => event.kind === "issue_linked"); + expect(linked.issue).toMatchObject({ id: work!.id, status: "blocked", blockerAttention: { state: "needs_attention", sampleBlockerIdentifier: null } }); + expect(JSON.stringify(linked.issue)).not.toContain("Hidden case blocker"); + }); it("enriches case pages and detail with current work in bounded batch queries", async () => { await enableCases(); const company = await seedCompany("WORK-LIST"); diff --git a/server/src/__tests__/company-search-service.test.ts b/server/src/__tests__/company-search-service.test.ts index 45a0fecc639..a5d62e17e33 100644 --- a/server/src/__tests__/company-search-service.test.ts +++ b/server/src/__tests__/company-search-service.test.ts @@ -10,6 +10,7 @@ import { issueComments, issueDocuments, issueAccessGrants, + issueRelations, issueLabels, issueWorkProducts, issues, @@ -175,6 +176,29 @@ describeEmbeddedPostgres("companySearchService", () => { return id; } + it("returns glyph-only blocker state for blocked results without blocker details (BUG-07)", async () => { + const companyId = await createCompany(); + const parent = await createIssue(companyId, { title: "Launch parent", status: "blocked" }); + const blocker = await createIssue(companyId, { title: "Hidden blocker detail", status: "todo", identifier: "SRCH-2" }); + const open = await createIssue(companyId, { title: "Launch open", status: "todo" }); + await db.insert(issueRelations).values({ companyId, issueId: blocker, relatedIssueId: parent, type: "blocks" }); + try { + const result = await svc.search(companyId, companySearchQuerySchema.parse({ q: "launch", scope: "issues" })); + const blocked = result.results.find((row) => row.id === parent)?.issue; + expect(blocked?.blockerAttention).toMatchObject({ state: "needs_attention", unresolvedBlockerCount: 1 }); + expect(Object.keys(blocked!.blockerAttention!).sort()).toEqual([ + "attentionBlockerCount", "coveredBlockerCount", "reason", "sampleBlockerIdentifier", + "sampleStalledBlockerIdentifier", "stalledBlockerCount", "state", "unresolvedBlockerCount", + ]); + expect(blocked?.blockerAttention?.sampleBlockerIdentifier).toBeNull(); + expect(JSON.stringify(result)).not.toContain("Hidden blocker detail"); + expect(JSON.stringify(result)).not.toContain(blocker); + expect(result.results.find((row) => row.id === open)?.issue).not.toHaveProperty("blockerAttention"); + } finally { + await db.delete(issueRelations); + } + }); + it("paginates issue-result-only searches before unrelated entity competition", async () => { const companyId = await createCompany(); const now = Date.now(); diff --git a/server/src/__tests__/issue-summaries-routes.test.ts b/server/src/__tests__/issue-summaries-routes.test.ts index c96f1b6cb18..032160b8f99 100644 --- a/server/src/__tests__/issue-summaries-routes.test.ts +++ b/server/src/__tests__/issue-summaries-routes.test.ts @@ -4,7 +4,7 @@ import request from "supertest"; import { eq } from "drizzle-orm"; import { afterAll, beforeAll, expect, it, vi } from "vitest"; import { - agents, chatConversations, chatEndpoints, issues, toolApplications, toolConnections, + agents, chatConversations, chatEndpoints, heartbeatRuns, issueRelations, issues, toolApplications, toolConnections, } from "@paperclipai/db"; import { issueRoutes } from "../routes/issues.js"; import * as issueServices from "../services/issues.js"; @@ -110,4 +110,47 @@ describeEmbeddedPostgres("bounded issue summaries", () => { expect(active.externalConversationState).toBe("active"); expect(active.updatedAt).toBe(waiting.updatedAt); }); + + it("projects the covered-blocker state so every task view picks the same queued glyph (BUG-07)", async () => { + const f = await fixture(); + const [agent] = await ctx.db.insert(agents).values({ companyId: f.companyId, name: "Builder", role: "engineer", adapterType: "process" }).returning(); + const [parent] = await ctx.db.insert(issues).values({ companyId: f.companyId, title: "Parent", status: "blocked" }).returning(); + const [child] = await ctx.db.insert(issues).values({ + companyId: f.companyId, title: "Running child", identifier: "SUM-9", status: "todo", parentId: parent!.id, assigneeAgentId: agent!.id, + }).returning(); + await ctx.db.insert(issueRelations).values({ companyId: f.companyId, issueId: child!.id, relatedIssueId: parent!.id, type: "blocks" }); + const runId = randomUUID(); + await ctx.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: agent!.id, status: "running", contextSnapshot: { issueId: child!.id } }); + await ctx.db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, child!.id)); + + const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: `${parent!.id},${f.rows[0]!.id}` }).expect(200); + expect(res.body[0].blockerAttention).toMatchObject({ state: "covered", reason: "active_child", sampleBlockerIdentifier: "SUM-9" }); + // Only what the glyph needs: no blocker titles or IDs. + expect(Object.keys(res.body[0].blockerAttention).sort()).toEqual([ + "attentionBlockerCount", "coveredBlockerCount", "reason", "sampleBlockerIdentifier", + "sampleStalledBlockerIdentifier", "stalledBlockerCount", "state", "unresolvedBlockerCount", + ]); + expect(JSON.stringify(res.body)).not.toContain("Running child"); + expect(JSON.stringify(res.body)).not.toContain(child!.id); + // Unblocked tasks keep the bounded summary shape. + expect(res.body[1]).not.toHaveProperty("blockerAttention"); + }); + + it("does not disclose a private blocker through a readable blocked task's summary", async () => { + const f = await fixture(); + const [parent] = await ctx.db.insert(issues).values({ companyId: f.companyId, title: "Public parent", status: "blocked" }).returning(); + const privateId = randomUUID(); + await ctx.db.insert(issues).values({ + id: privateId, companyId: f.companyId, title: "Secret blocker title", status: "todo", visibility: "private", + privacyRootIssueId: privateId, responsibleUserId: "someone-else", createdByUserId: "someone-else", + }); + await ctx.db.insert(issueRelations).values({ companyId: f.companyId, issueId: privateId, relatedIssueId: parent!.id, type: "blocks" }); + + const res = await request(app(f.actor)).get(url(f.companyId)).query({ ids: parent!.id }).expect(200); + expect(res.body).toHaveLength(1); + expect(res.body[0].blockerAttention?.state).toBeDefined(); + expect(JSON.stringify(res.body)).not.toContain("Secret blocker title"); + expect(JSON.stringify(res.body)).not.toContain(privateId); + expect(res.body[0].blockerAttention).not.toHaveProperty("terminalBlocker"); + }); }); diff --git a/server/src/routes/cases.ts b/server/src/routes/cases.ts index 8839ca693ea..db853f38e38 100644 --- a/server/src/routes/cases.ts +++ b/server/src/routes/cases.ts @@ -56,6 +56,8 @@ import type { StorageService } from "../storage/types.js"; import { authorizationService, issueReadSqlCondition, type AuthorizationActor } from "../services/authorization.js"; import { assertActorCanReadCase, assertActorCanWriteCase, caseReadSqlCondition, filterCasesForActor, retainCaseAccessSource } from "../services/case-access.js"; import { enrichCaseWork } from "../services/case-work-summary.js"; +import { listIssueStatusBlockerAttention } from "../services/issue-status-presentation.js"; +import type { IssueBlockerAttention } from "@paperclipai/shared"; import { listCaseTaskDocuments } from "../services/case-task-documents.js"; import { authorizeCaseCreation, authorizeCaseMutation } from "../services/case-mutation-authorization.js"; import { logger } from "../middleware/logger.js"; @@ -270,6 +272,19 @@ async function resolveAgentNames(db: CaseRouteDb, agentIds: (string | null)[]) { return new Map(rows.map((row) => [row.id, row.name])); } +type CaseAttributedIssue = { id: string; identifier: string; title: string; status: string; blockerAttention?: IssueBlockerAttention }; + +/** Glyph-only blocker state so a case activity row draws the same status glyph as the task pane. */ +async function withStatusBlockerAttention(db: CaseRouteDb, companyId: string, map: Map) { + const attention = await listIssueStatusBlockerAttention(db as Db, companyId, [...map.values()]); + if (attention.size === 0) return map; + for (const [key, issue] of map) { + const item = attention.get(issue.id); + if (item) map.set(key, { ...issue, blockerAttention: item }); + } + return map; +} + /** * Batch resolve run → issue attribution. Mirrors resolveIssueForRun's precedence * (latest-updated issue whose execution/checkout/origin run matches), but for a @@ -277,7 +292,7 @@ async function resolveAgentNames(db: CaseRouteDb, agentIds: (string | null)[]) { */ async function resolveIssuesForRuns(db: CaseRouteDb, companyId: string, runIds: (string | null)[], actor: AuthorizationActor) { const valid = [...new Set(runIds.filter((id): id is string => !!id && isUuidLike(id)))]; - const map = new Map(); + const map = new Map(); if (valid.length === 0) return map; const rows = await db .select({ @@ -314,7 +329,7 @@ async function resolveIssuesForRuns(db: CaseRouteDb, companyId: string, runIds: map.set(runId, { id: match.id, identifier: match.identifier ?? match.id, title: match.title, status: match.status }); } } - return map; + return withStatusBlockerAttention(db, companyId, map); } function payloadIssueIdForEvent(kind: string, payload: Record | null | undefined) { @@ -325,7 +340,7 @@ function payloadIssueIdForEvent(kind: string, payload: Record | async function resolveIssuesByIds(db: CaseRouteDb, companyId: string, issueIds: (string | null)[], actor: AuthorizationActor) { const valid = [...new Set(issueIds.filter((id): id is string => !!id && isUuidLike(id)))]; - const map = new Map(); + const map = new Map(); if (valid.length === 0) return map; const rows = await db .select({ @@ -342,7 +357,7 @@ async function resolveIssuesByIds(db: CaseRouteDb, companyId: string, issueIds: for (const [index, row] of rows.entries()) { if (readDecisions[index]?.allowed) map.set(row.id, { id: row.id, identifier: row.identifier ?? row.id, title: row.title, status: row.status }); } - return map; + return withStatusBlockerAttention(db, companyId, map); } async function autoLinkRunIssue(db: CaseRouteDb, input: { @@ -468,18 +483,21 @@ async function loadCaseDetail(db: CaseRouteDb, row: typeof cases.$inferSelect, a type: "issue" as const, companyId: row.companyId, issueId: item.issue.id, }))); const [workSummary] = await enrichCaseWork(db, row.companyId, [row], actor); + const visibleLinks = linkRows.filter((_, index) => linkDecisions[index]?.allowed); + const linkBlockerAttention = await listIssueStatusBlockerAttention(db as Db, row.companyId, visibleLinks.map((item) => item.issue)); return { ...workSummary!, ...taskDocumentRollup, parent: visibleParent, labels: labelRows.map((item) => item.label), - issueLinks: linkRows.filter((_, index) => linkDecisions[index]?.allowed).map((item) => ({ + issueLinks: visibleLinks.map((item) => ({ ...item.link, issue: { id: item.issue.id, identifier: item.issue.identifier, title: item.issue.title, status: item.issue.status, + ...(linkBlockerAttention.has(item.issue.id) ? { blockerAttention: linkBlockerAttention.get(item.issue.id)! } : {}), }, })), documents: documentRows.map((item) => ({ diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e9b503df416..16ff5201e4b 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -14,6 +14,7 @@ import { parseObject } from "../adapters/utils.js"; import { prepareRestrictedWorkspaceRetry } from "../services/restricted-workspace-retry.js"; import { issueSummariesQuerySchema, type IssueSummary } from "@paperclipai/shared"; import { listIssueSummaries } from "../services/issue-summaries.js"; +import { listIssueStatusBlockerAttention } from "../services/issue-status-presentation.js"; import { assertNoCaseHandoffMutation } from "../services/heartbeat/case-handoff-lifecycle.js"; import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js"; import { runRetainsIssueIdentity } from "../services/task-repository-acquisition.js"; @@ -8418,16 +8419,26 @@ export function issueRoutes( } const rows = await listIssueSummaries(db, companyId, parsed.data.ids, await issueReadSqlCondition(db, req.actor)); const visible = await filterIssuesForActor(req, rows); - const summaries: IssueSummary[] = visible.map((row) => ({ - id: row.id, - companyId: row.companyId, - title: row.title, - identifier: row.identifier, - status: row.status as IssueSummary["status"], - displayStatus: row.displayStatus, - updatedAt: row.updatedAt, - externalConversationState: row.externalConversationState, - })); + // Blocked rows carry glyph-only blocker state so every task view resolves + // the same glyph (covered → "in queue") as the task pane. At most five IDs. + const blockerAttention = await listIssueStatusBlockerAttention(db, companyId, visible, async (ids) => new Set( + (await filterIssuesForActor(req, await listIssueSummaries(db, companyId, ids, await issueReadSqlCondition(db, req.actor)))) + .map((row) => row.id), + )); + const summaries: IssueSummary[] = visible.map((row) => { + const attention = blockerAttention.get(row.id); + return { + id: row.id, + companyId: row.companyId, + title: row.title, + identifier: row.identifier, + status: row.status as IssueSummary["status"], + displayStatus: row.displayStatus, + updatedAt: row.updatedAt, + externalConversationState: row.externalConversationState, + ...(attention ? { blockerAttention: attention } : {}), + }; + }); res.setHeader("Cache-Control", "private, no-store"); res.json(summaries); }); diff --git a/server/src/services/case-work-summary.ts b/server/src/services/case-work-summary.ts index c97dde2f453..c94ce7c7060 100644 --- a/server/src/services/case-work-summary.ts +++ b/server/src/services/case-work-summary.ts @@ -1,6 +1,7 @@ import { and, eq, getTableColumns, inArray } from "drizzle-orm"; import { agents, caseHandoffs, issues, workflows, type Db } from "@paperclipai/db"; import { authorizationService, issueReadSqlCondition, type AuthorizationActor } from "./authorization.js"; +import { listIssueStatusBlockerAttention } from "./issue-status-presentation.js"; type CaseSummaryDb = Db | Parameters[0]>[0]; type CaseWorkIdentity = { id: string; companyId: string; workIssueId: string | null; workflowId?: string | null }; @@ -35,6 +36,9 @@ export async function enrichCaseWork( }))); const visibleWork = workRows.filter((_, index) => workDecisions[index]?.allowed); const workById = new Map(visibleWork.map(row => [row.id, row])); + // Glyph-only blocker state (no blocker titles, IDs or identifiers), so a + // covered-blocked case task shows the same "in queue" glyph as its task pane. + const blockerAttention = await listIssueStatusBlockerAttention(db as Db, companyId, visibleWork); // A malformed/legacy link must not expose pending task instructions merely // because its case is readable. The primary task has its own access boundary. const primaryByCase = new Map(rows.map(row => [row.id, row.workIssueId])); @@ -56,7 +60,8 @@ export async function enrichCaseWork( return { ...row, workflow: row.workflowId ? workflowById.get(row.workflowId) ?? null : null, - workIssue: work ? { ...work, assigneeAgentName: work.assigneeAgentId ? names.get(work.assigneeAgentId) ?? null : null } : null, + workIssue: work ? { ...work, assigneeAgentName: work.assigneeAgentId ? names.get(work.assigneeAgentId) ?? null : null, + ...(blockerAttention.has(work.id) ? { blockerAttention: blockerAttention.get(work.id)! } : {}) } : null, pendingHandoff: pending ? { ...pending, fromAgentName: pending.fromAgentId ? names.get(pending.fromAgentId) ?? null : null, toAgentName: pending.toAgentId ? names.get(pending.toAgentId) ?? null : null } : null, }; diff --git a/server/src/services/company-search.ts b/server/src/services/company-search.ts index 17055aa3146..5e7dd2116ac 100644 --- a/server/src/services/company-search.ts +++ b/server/src/services/company-search.ts @@ -42,6 +42,7 @@ import { companyArtifactsService } from "./company-artifacts.js"; import { companySearchExtractService } from "./company-search-extract.js"; import { visibleIssueCondition } from "./issue-visibility.js"; import { parseTaskSearch, taskSearchCtes, taskSearchScore, taskSearchFieldMatch, taskSearchTermMatch } from "./task-search.js"; +import { listIssueStatusBlockerAttention } from "./issue-status-presentation.js"; const SNIPPET_MAX_CHARS = 240; export const COMPANY_SEARCH_BRANCH_FETCH_LIMIT = COMPANY_SEARCH_MAX_OFFSET + COMPANY_SEARCH_MAX_LIMIT + 1; @@ -1155,8 +1156,15 @@ export function companySearchService(db: Db) { const states = await db.select({ id: issues.id, state: externalConversationStateSql() }).from(issues) .where(and(eq(issues.companyId, companyId), inArray(issues.id, issueIds))); const byId = new Map(states.map((row) => [row.id, row.state])); + // Glyph-only blocker state (no blocker titles, IDs or identifiers), so a + // covered-blocked result shows the same "in queue" glyph as its task. + const blockerAttention = await listIssueStatusBlockerAttention(db, companyId, + paged.flatMap((result) => result.issue ? [result.issue] : [])); for (const result of paged) { - if (result.issue) result.issue.externalConversationState = byId.get(result.issue.id) ?? null; + if (!result.issue) continue; + result.issue.externalConversationState = byId.get(result.issue.id) ?? null; + const attention = blockerAttention.get(result.issue.id); + if (attention) result.issue.blockerAttention = attention; } } return { diff --git a/server/src/services/issue-status-presentation.ts b/server/src/services/issue-status-presentation.ts new file mode 100644 index 00000000000..cc413aa28a3 --- /dev/null +++ b/server/src/services/issue-status-presentation.ts @@ -0,0 +1,56 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { issues, type Db } from "@paperclipai/db"; +import type { IssueBlockerAttention } from "@paperclipai/shared"; +import { issueService } from "./issues.js"; + +/** + * Blocker state for task status glyphs on bounded summary surfaces (recent + * tasks, search results, case work). Every view resolves the same glyph from + * it (covered → "in queue"), so a blocked task never looks different in a + * sidebar than in its detail pane. + * + * The blocker walk is company-scoped, so the result carries only what the + * glyph needs: state, reason and counts. Blocker titles and IDs never leave + * this helper. A sampled blocker identifier is kept only when the caller + * confirms that the actor can read that blocker. + */ +export async function listIssueStatusBlockerAttention( + db: Db, + companyId: string, + rows: ReadonlyArray<{ id: string; status: string }>, + readableIssueIds?: (ids: string[]) => Promise>, +): Promise> { + const blockedIds = [...new Set(rows.filter((row) => row.status === "blocked").map((row) => row.id))]; + if (blockedIds.length === 0) return new Map(); + const nodes = await db.select({ + id: issues.id, + companyId: issues.companyId, + parentId: issues.parentId, + identifier: issues.identifier, + title: issues.title, + status: issues.status, + executionRunId: issues.executionRunId, + assigneeAgentId: issues.assigneeAgentId, + assigneeUserId: issues.assigneeUserId, + }).from(issues).where(and(eq(issues.companyId, companyId), inArray(issues.id, blockedIds))); + const attention = await issueService(db).listBlockerAttention(companyId, nodes); + const sampledIds = [...new Set([...attention.values()].flatMap((item) => item.terminalBlocker ? [item.terminalBlocker.id] : []))]; + const readable = readableIssueIds && sampledIds.length > 0 ? await readableIssueIds(sampledIds) : new Set(); + + const result = new Map(); + for (const [issueId, item] of attention) { + const sample = item.terminalBlocker; + const readableSample = sample && readable.has(sample.id) ? (sample.identifier ?? sample.id) : null; + result.set(issueId, { + state: item.state, + reason: item.reason, + unresolvedBlockerCount: item.unresolvedBlockerCount, + coveredBlockerCount: item.coveredBlockerCount, + stalledBlockerCount: item.stalledBlockerCount, + attentionBlockerCount: item.attentionBlockerCount, + sampleBlockerIdentifier: readableSample && item.sampleBlockerIdentifier === readableSample ? readableSample : null, + sampleStalledBlockerIdentifier: readableSample && item.sampleStalledBlockerIdentifier === readableSample ? readableSample : null, + }); + } + return result; +} diff --git a/ui/src/api/cases.ts b/ui/src/api/cases.ts index 4aa007e20e7..ac6d27b72f9 100644 --- a/ui/src/api/cases.ts +++ b/ui/src/api/cases.ts @@ -1,4 +1,4 @@ -import type { CaseHandoff, DocumentRevision, HandoffCaseInput, HandoffCaseResult, IssueDocument, IssueLabel } from "@paperclipai/shared"; +import type { CaseHandoff, DocumentRevision, HandoffCaseInput, HandoffCaseResult, IssueBlockerAttention, IssueDocument, IssueLabel } from "@paperclipai/shared"; import { api } from "./client"; // ----------------------------------------------------------------------------- @@ -102,6 +102,8 @@ export interface CaseIssueLink { identifier: string; title: string; status: string; + /** Glyph-only blocker state; present for blocked tasks. */ + blockerAttention?: IssueBlockerAttention; }; } @@ -139,6 +141,8 @@ export function isImageAttachment(attachment: CaseAttachmentRef): boolean { export interface CaseWorkIssue { id: string; identifier: string | null; title: string; status: string; statusVersion: number; assigneeAgentId: string | null; assigneeAgentName: string | null; + /** Glyph-only blocker state; present for blocked tasks. */ + blockerAttention?: IssueBlockerAttention; } /** The full detail payload (loadCaseDetail on the server). */ @@ -183,6 +187,8 @@ export interface CaseAttributionIssue { identifier: string; title: string; status: string; + /** Glyph-only blocker state; present for blocked tasks. */ + blockerAttention?: IssueBlockerAttention; } export interface CaseEvent { diff --git a/ui/src/components/BlockedInboxView.tsx b/ui/src/components/BlockedInboxView.tsx index 7b904c0a797..8ade04d6268 100644 --- a/ui/src/components/BlockedInboxView.tsx +++ b/ui/src/components/BlockedInboxView.tsx @@ -27,6 +27,7 @@ import { Identity } from "./Identity"; import { StatusIcon } from "./StatusIcon"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { taskStatusIconProps } from "@/lib/task-status-presentation"; interface BlockedInboxViewProps { companyId: string; @@ -377,7 +378,7 @@ function BlockedInboxRow({ showDivider={presentation === "legacy"} statusSlot={presentation === "task" ? showStatusColumn - ? + ? :