mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
2c0cc8ee64
commit
1d3a1a0a9e
14 files changed
+207
-16
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string, IssueBlockerAttention>();
|
||||
// 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 {
|
||||
|
||||
@@ -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<Parameters<Db["transaction"]>[0]>[0];
|
||||
type CaseWorkIdentity = { id: string; companyId: string; workIssueId: string | null; workflowId?: string | null };
|
||||
@@ -35,6 +36,9 @@ export async function enrichCaseWork<T extends CaseWorkIdentity>(
|
||||
})));
|
||||
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<T extends CaseWorkIdentity>(
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ReadonlySet<string>>,
|
||||
): Promise<Map<string, IssueBlockerAttention>> {
|
||||
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<string>();
|
||||
|
||||
const result = new Map<string, IssueBlockerAttention>();
|
||||
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;
|
||||
}
|
||||
+3
-1
@@ -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). */
|
||||
|
||||
@@ -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) => <a href={to} {...props}>{children}</a> }));
|
||||
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<Record<string, unknown>> }));
|
||||
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(<QueryClientProvider client={client}><TooltipProvider>
|
||||
@@ -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<SVGElement>(".coordinator-created-task svg[role='img']");
|
||||
expect(glyph?.style.color).toBe("var(--status-task-icon-in_queue)");
|
||||
});
|
||||
});
|
||||
@@ -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<ConversationTaskHandoff, "issueId" | "identifier" | "title"> & { status?: ConversationTaskHandoff["status"] };
|
||||
task: Pick<ConversationTaskHandoff, "issueId" | "identifier" | "title"> & { status?: ConversationTaskHandoff["status"] }
|
||||
& Partial<Pick<IssueSummary, "displayStatus" | "externalConversationState" | "blockerAttention">>;
|
||||
}) {
|
||||
return <Link to={`/issues/${task.identifier ?? task.issueId}`} className="coordinator-created-task">
|
||||
{task.status ? <StatusIcon {...taskStatusIconProps({ ...task, status: task.status })} size="lg" /> : <FileCheck2 className="size-5 shrink-0 text-muted-foreground" aria-label="Task created" />}
|
||||
@@ -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,
|
||||
}} />;
|
||||
})}
|
||||
</div>;
|
||||
|
||||
@@ -631,7 +631,7 @@ export interface TaskChatTurnItem {
|
||||
export interface TaskChatTaskCreatedItem {
|
||||
id: string;
|
||||
kind: "task_created";
|
||||
task: Pick<Issue, "id" | "identifier" | "title" | "status">;
|
||||
task: Pick<Issue, "id" | "identifier" | "title" | "status" | "displayStatus" | "externalConversationState" | "blockerAttention">;
|
||||
isSubtask: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
@@ -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 }) => <a href={to} {...props}>{children}</a> }));
|
||||
|
||||
const created = (overrides: Partial<Issue>): 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(<TaskChatTaskCreatedCard item={item!} />);
|
||||
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(<TaskChatTaskCreatedCard item={item!} />)).toContain(">Idle<");
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
}];
|
||||
|
||||
Reference in new issue
Block a user