From 1d3a1a0a9eab164eb3b0204a216a57c74ab6d72f Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 5 Nov 2026 14:11:16 -0600 Subject: [PATCH] fix: return glyph-only blocker state and carry it to every task view (BUG-07) Address review findings: - Summaries no longer return the raw blocker walk. A shared helper (server/src/services/issue-status-presentation.ts) returns only state, reason and counts; a sampled blocker identifier stays only when the actor can read that blocker. Blocker titles and IDs never leave the route. - Search results and case work tasks return the same glyph-only blocker state for blocked tasks. - The chat "task created" card and coordinator created-task links keep displayStatus, externalConversationState and blockerAttention from their producers, so their chips/glyphs match the task pane. - Tests drive the real producers (taskCreatedItems, summaries-backed links, search service, cases route) and cover a private blocker. Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/types/search.ts | 3 + server/src/__tests__/cases-routes.test.ts | 17 ++++++ .../__tests__/company-search-service.test.ts | 24 ++++++++ .../__tests__/issue-summaries-routes.test.ts | 25 +++++++++ server/src/routes/issues.ts | 15 ++--- server/src/services/case-work-summary.ts | 7 ++- server/src/services/company-search.ts | 10 +++- .../src/services/issue-status-presentation.ts | 56 +++++++++++++++++++ ui/src/api/cases.ts | 4 +- .../CoordinatorTaskCommitment.test.tsx | 15 ++++- .../coordinator/CoordinatorTaskCommitment.tsx | 8 ++- .../components/task-chat/task-chat-model.ts | 2 +- .../task-chat/task-created-items.test.tsx | 31 ++++++++++ .../task-chat/task-created-items.ts | 6 +- 14 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 server/src/services/issue-status-presentation.ts create mode 100644 ui/src/components/task-chat/task-created-items.test.tsx 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..682ffdc3ab4 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,21 @@ 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); + }); 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 709beae53ac..032160b8f99 100644 --- a/server/src/__tests__/issue-summaries-routes.test.ts +++ b/server/src/__tests__/issue-summaries-routes.test.ts @@ -125,7 +125,32 @@ describeEmbeddedPostgres("bounded issue summaries", () => { 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/issues.ts b/server/src/routes/issues.ts index 8025b34e7a5..16ff5201e4b 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -12,8 +12,9 @@ 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 IssueBlockerAttention, type IssueSummary } from "@paperclipai/shared"; +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,12 +8419,12 @@ export function issueRoutes( } const rows = await listIssueSummaries(db, companyId, parsed.data.ids, await issueReadSqlCondition(db, req.actor)); const visible = await filterIssuesForActor(req, rows); - // 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(); + // 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 { 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..c69765cb321 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"; // ----------------------------------------------------------------------------- @@ -139,6 +139,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). */ diff --git a/ui/src/components/coordinator/CoordinatorTaskCommitment.test.tsx b/ui/src/components/coordinator/CoordinatorTaskCommitment.test.tsx index c88dc6d593f..f72b8566aa3 100644 --- a/ui/src/components/coordinator/CoordinatorTaskCommitment.test.tsx +++ b/ui/src/components/coordinator/CoordinatorTaskCommitment.test.tsx @@ -10,7 +10,8 @@ import { TooltipProvider } from "../ui/tooltip"; vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({ useCoordinatorWorkspaceEnabled: () => ({ enabled: true, loaded: true, userId: "viewer" }) })); vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: any) => {children} })); -vi.mock("@/api/issues", () => ({ issuesApi: { summaries: vi.fn(async () => [{ id: "task-created", identifier: "PAPER-108", title: "Write the launch post", status: "todo" }]) } })); +const summaries = vi.hoisted(() => ({ rows: null as null | Array> })); +vi.mock("@/api/issues", () => ({ issuesApi: { summaries: vi.fn(async () => summaries.rows ?? [{ id: "task-created", identifier: "PAPER-108", title: "Write the launch post", status: "todo" }]) } })); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; let root: Root; @@ -21,7 +22,7 @@ beforeEach(() => { client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); window.matchMedia = vi.fn().mockReturnValue({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }); }); -afterEach(async () => { await act(() => root.unmount()); host.remove(); client.clear(); vi.restoreAllMocks(); }); +afterEach(async () => { await act(() => root.unmount()); host.remove(); client.clear(); vi.restoreAllMocks(); summaries.rows = null; }); function render(interaction: typeof pendingSuggestedTasksInteraction, onAcceptInteraction = vi.fn(), onRejectInteraction = vi.fn()) { return act(() => root.render( @@ -134,4 +135,14 @@ describe("Coordinator task commitment", () => { expect(animate).not.toHaveBeenCalled(); expect(host.firstElementChild?.hasAttribute("data-motion")).toBe(false); }); + + it("draws a created task from its full summary state, matching the task pane (BUG-07)", async () => { + summaries.rows = [{ id: "task-created", identifier: "PAPER-108", title: "Write the launch post", status: "blocked", displayStatus: "blocked", + externalConversationState: null, blockerAttention: { state: "covered", reason: "active_child", unresolvedBlockerCount: 1, coveredBlockerCount: 1, + attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: null, sampleStalledBlockerIdentifier: null } }]; + await render({ ...pendingSuggestedTasksInteraction, status: "accepted", result: { version: 1, createdTasks: [{ clientKey: "launch", issueId: "task-created", identifier: "PAPER-108", title: "Write the launch post" }] } }); + await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); + const glyph = host.querySelector(".coordinator-created-task svg[role='img']"); + expect(glyph?.style.color).toBe("var(--status-task-icon-in_queue)"); + }); }); diff --git a/ui/src/components/coordinator/CoordinatorTaskCommitment.tsx b/ui/src/components/coordinator/CoordinatorTaskCommitment.tsx index 5b4485f351b..3971dce3147 100644 --- a/ui/src/components/coordinator/CoordinatorTaskCommitment.tsx +++ b/ui/src/components/coordinator/CoordinatorTaskCommitment.tsx @@ -1,7 +1,7 @@ import { useLayoutEffect, useRef, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { ArrowUpRight, FileCheck2 } from "lucide-react"; -import type { ConversationTaskHandoff } from "@paperclipai/shared"; +import type { ConversationTaskHandoff, IssueSummary } from "@paperclipai/shared"; import type { SuggestTasksInteraction } from "@/lib/issue-thread-interactions"; import { issuesApi } from "@/api/issues"; import { queryKeys } from "@/lib/queryKeys"; @@ -123,7 +123,8 @@ export function CoordinatorTaskMotion({ * handoffs, so both creation paths settle into the same card. */ export function CoordinatorCreatedTaskLink({ task, createdAt }: { createdAt?: Date | string | null; - task: Pick & { status?: ConversationTaskHandoff["status"] }; + task: Pick & { status?: ConversationTaskHandoff["status"] } + & Partial>; }) { return {task.status ? : } @@ -154,6 +155,9 @@ function CreatedTaskLinks({ interaction }: { interaction: SuggestTasksInteractio identifier: summary?.identifier ?? task.identifier ?? null, title: summary?.title ?? task.title ?? "Task created", status: summary?.status, + displayStatus: summary?.displayStatus, + externalConversationState: summary?.externalConversationState, + blockerAttention: summary?.blockerAttention, }} />; })} ; diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index 4680748d479..caa79254fec 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -631,7 +631,7 @@ export interface TaskChatTurnItem { export interface TaskChatTaskCreatedItem { id: string; kind: "task_created"; - task: Pick; + task: Pick; isSubtask: boolean; timestamp: string; } diff --git a/ui/src/components/task-chat/task-created-items.test.tsx b/ui/src/components/task-chat/task-created-items.test.tsx new file mode 100644 index 00000000000..94f720f7258 --- /dev/null +++ b/ui/src/components/task-chat/task-created-items.test.tsx @@ -0,0 +1,31 @@ +// @vitest-environment node +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; +import type { Issue } from "@paperclipai/shared"; +import { taskCreatedItems } from "./task-created-items"; +import { TaskChatTaskCreatedCard } from "./TaskChatTaskCreatedCard"; + +vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: { to: string; children: React.ReactNode }) => {children} })); + +const created = (overrides: Partial): Issue => ({ + id: "child", companyId: "company", identifier: "ONB-1", title: "Paperclip onboarding", parentId: "parent", + createdByAgentId: "agent", createdAt: new Date("2026-11-05T12:00:00Z"), status: "todo", ...overrides, +}) as Issue; + +describe("task created chat card (BUG-07)", () => { + it("keeps the presentation fields so the chip matches the task pane", () => { + const [item] = taskCreatedItems([created({ + status: "blocked", + blockerAttention: { state: "covered", reason: "active_child", unresolvedBlockerCount: 1, coveredBlockerCount: 1, + attentionBlockerCount: 0, stalledBlockerCount: 0, sampleBlockerIdentifier: "ONB-3", sampleStalledBlockerIdentifier: null }, + })], "parent", "company", new Set()); + const html = renderToStaticMarkup(); + expect(html).toContain("var(--status-task-icon-in_queue)"); + expect(html).toContain("Blocked ยท waiting on active sub-task ONB-3"); + }); + + it("shows a waiting review as Idle, like the task pane", () => { + const [item] = taskCreatedItems([created({ status: "in_review", externalConversationState: "waiting" })], "parent", "company", new Set()); + expect(renderToStaticMarkup()).toContain(">Idle<"); + }); +}); diff --git a/ui/src/components/task-chat/task-created-items.ts b/ui/src/components/task-chat/task-created-items.ts index b7ac4b47a76..558d740ec81 100644 --- a/ui/src/components/task-chat/task-created-items.ts +++ b/ui/src/components/task-chat/task-created-items.ts @@ -15,7 +15,11 @@ export function taskCreatedItems( return [{ id: `task-created:${task.id}`, kind: "task_created" as const, - task: { id: task.id, identifier: task.identifier, title: task.title, status: task.status }, + // Keep every status-presentation field so the chip matches the task pane. + task: { + id: task.id, identifier: task.identifier, title: task.title, status: task.status, + displayStatus: task.displayStatus, externalConversationState: task.externalConversationState, blockerAttention: task.blockerAttention, + }, isSubtask: task.parentId === issueId, timestamp: new Date(task.createdAt).toISOString(), }];