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/server/src/__tests__/issue-summaries-routes.test.ts b/server/src/__tests__/issue-summaries-routes.test.ts index c96f1b6cb18..709beae53ac 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,22 @@ 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" }); + // Unblocked tasks keep the bounded summary shape. + expect(res.body[1]).not.toHaveProperty("blockerAttention"); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index e9b503df416..8025b34e7a5 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -12,7 +12,7 @@ import { getHeartbeatRunReadDecisions, redactHeartbeatRunListRow } from "../serv import { taskInterruptionPresentation, taskInterruptionPresentations } from "../services/task-interruption-presentation.js"; import { parseObject } from "../adapters/utils.js"; import { prepareRestrictedWorkspaceRetry } from "../services/restricted-workspace-retry.js"; -import { issueSummariesQuerySchema, type IssueSummary } from "@paperclipai/shared"; +import { issueSummariesQuerySchema, type IssueBlockerAttention, type IssueSummary } from "@paperclipai/shared"; import { listIssueSummaries } from "../services/issue-summaries.js"; import { assertNoCaseHandoffMutation } from "../services/heartbeat/case-handoff-lifecycle.js"; import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js"; @@ -8418,16 +8418,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 their blocker state so every task view resolves the + // same glyph (covered → "in queue") as the task pane. At most five IDs. + const blockedRows = visible.filter((row) => row.status === "blocked"); + const blockerAttention = blockedRows.length > 0 + ? await svc.listBlockerAttention(companyId, blockedRows) + : new Map(); + 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/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 - ? + ? :