From 2c0cc8ee6478e3e9c8d65a33fbdb777dd11f9c0b Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Nov 2026 13:40:26 -0600 Subject: [PATCH] fix(ui): resolve task status glyphs through one shared mapping (BUG-07) The same task state rendered different status icons across views. A blocked task covered by an active sub-task ("in queue") showed the blue queued glyph in the task header and status trigger, but the status menu's current row, the chat chip and the coordinator sidebar recents/project rows showed the red blocked glyph, because those surfaces dropped blockerAttention (and some dropped externalConversationState) before calling StatusIcon. - Add ui/src/lib/task-status-presentation.ts: one resolver for glyph, label and accessible copy, plus taskStatusIconProps() so call sites pass every presentation field from a task record. - StatusIcon and IssueStatusBadge use the resolver; the picker draws the current row with the trigger's presentation. - Task call sites (sidebar, lists, inbox, properties, pills, search, chat chips, status cards) spread taskStatusIconProps. - /issues/summaries returns blockerAttention for blocked rows; recent-task snapshots store and refresh it; sidebar project rows use the rows projection. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/types/issue.ts | 2 + .../__tests__/issue-summaries-routes.test.ts | 20 +++- server/src/routes/issues.ts | 32 +++-- ui/src/components/BlockedInboxView.tsx | 7 +- ui/src/components/CaseActivityFeed.tsx | 3 +- ui/src/components/CoordinatorSidebar.test.tsx | 43 ++++++- ui/src/components/CoordinatorSidebar.tsx | 7 +- ui/src/components/IssueColumns.tsx | 3 +- ui/src/components/IssueReferencePill.tsx | 3 +- ui/src/components/IssueRow.tsx | 7 +- ui/src/components/IssueSiblingNavigation.tsx | 3 +- ui/src/components/IssuesList.tsx | 7 +- ui/src/components/LegacyIssuesList.tsx | 5 +- ui/src/components/SidebarRecentTasks.tsx | 3 +- ui/src/components/StatusBadge.tsx | 14 ++- ui/src/components/StatusIcon.picker.test.tsx | 76 ++++++++++++ ui/src/components/StatusIcon.tsx | 72 ++--------- ui/src/components/cases/CaseWorkSummary.tsx | 3 +- ui/src/components/chat/AgentWorkPanels.tsx | 4 +- .../coordinator/CoordinatorTaskCommitment.tsx | 3 +- .../issue-properties/IssueProperties.tsx | 10 +- .../issue-properties/relation-controls.tsx | 3 +- ui/src/components/search/SearchResultRow.tsx | 3 +- .../task-chat/TaskChatTaskCreatedCard.tsx | 3 +- .../task-detail/TaskDetailRelationsPanel.tsx | 3 +- ui/src/lib/recent-tasks.test.ts | 15 +++ ui/src/lib/recent-tasks.ts | 14 ++- ui/src/lib/status-colors.ts | 2 + ui/src/lib/task-status-presentation.ts | 113 ++++++++++++++++++ ui/src/pages/Dashboard.tsx | 3 +- ui/src/pages/Inbox.tsx | 5 +- ui/src/pages/IssueDetail.tsx | 6 +- ui/src/pages/LegacyInbox.tsx | 5 +- ui/src/pages/MyIssues.tsx | 3 +- .../StatusCards/StatusCardDetailDrawer.tsx | 3 +- ui/src/pages/UserProfile.tsx | 3 +- .../foundations/status-language.stories.tsx | 37 +++++- 37 files changed, 418 insertions(+), 130 deletions(-) create mode 100644 ui/src/components/StatusIcon.picker.test.tsx create mode 100644 ui/src/lib/task-status-presentation.ts 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 - ? + ? :