Files
PaperClipAI/ui/src/components/task-chat/TaskChatInteractionCard.test.tsx
T
DottaandClaude Opus 5.5 07a548073a fix(ui): keep inline document links in the text line; plain-text request previews (#19639)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agents talk to people in task chat. Their messages render as
markdown.
> - In an onboarding walk, two chat papercuts made agent text hard to
read.
> - First: a document link inside a sentence ("Your [plan is saved],
approved, ...") rendered as a padded card. The card is taller than the
text line, so it overlaps the lines around it.
> - Second: the one-line row above a pending review card showed the
agent's raw markdown summary (backticks, "- ", "**").
> - This pull request renders document links in running text as an
inline chip, and flattens the collapsed preview to plain text.
> - The benefit is that agent messages and pending-request rows read
cleanly.

## Linked Issues or Issue Description

**What happened?**
1. In task chat, an agent wrote: "Yes—we completed the onboarding
interview here in chat. Your [plan is saved](#document-plan), approved,
and handed off to execution task PIX-3." The "plan is saved" link
rendered as a 42px-tall bordered card in a 20px text line. It overlapped
the text above and below and left the text baseline.
2. The collapsed row above a "Review the creative brief (v1)" card read:
"The `creative-brief` document is saved on this task. It covers: -
**Brand voice and audience:** ..." with literal markdown syntax.

**Steps to reproduce**
1. Open a coordinator task chat where an agent message has a
`#document-...` link inside a sentence. Or open the Storybook story
`coordinator-conversation--inline-document-link`.
2. Open a task with a pending request whose summary contains markdown.
Or open
`composer-interaction-above-composer--pending-request-markdown-summary`.

**Expected behavior**
1. The document link sits in the text line like other inline links.
2. The collapsed preview shows the words as plain text.

## What Changed

- `SidebarDocumentLink` gets a `variant` prop. `card` (default) is
unchanged and is still used by the standalone plan preview card.
`inline` is a text-flow chip: `display: inline`, horizontal padding
only, a small muted background, and a 14px icon aligned to the text
bottom. Token classes only.
- `MarkdownBody` uses the `inline` variant for both document-link paths
(coordinator issue document links and `#document-...` hash links). The
label no longer uses `truncate` (nowrap), so long labels wrap with the
sentence.
- New `ui/src/lib/markdown-plain-text.ts` (`markdownToPlainText`): drops
code ticks, emphasis/strikethrough markers, list/heading/quote/task-box
prefixes, fences, rules, link/image targets and inline HTML tags, keeps
snake_case intact, and collapses whitespace. Unit tests included.
- `TaskChatInteractionCard` flattens the collapsed pending-request
summary and the unanswered-question prompt with `markdownToPlainText`.
- Stories: `Coordinator conversation / Inline document link` (+ dark)
with a play check that the link is no taller than the line and starts
inside the paragraph; `Interaction above composer / Pending request
markdown summary` with a play check that the row has no markdown syntax.

## Verification

Red on master (`e482f0f632`), then green with the fix:

```
# Red (master + new tests)
 × renders a document link inside a sentence as inline text, not a padded card
   AssertionError: expected 'inline-flex max-w-full items-center gap-2 rounded-lg border border-border bg-muted/25 px-3 py-2.5 ...' not to match /(^|\s)(inline-flex|flex|block|py-\S+|p-\S+)(\s|$)/
 × shows the collapsed pending-request preview as plain text, not raw markdown
   AssertionError: expected 'Review the creative brief (v1)The `cr…' to contain 'The creative-brief document is saved …'
 Tests  2 failed

# Green (fix)
 Tests  7 passed  (the 2 above + 5 markdownToPlainText unit tests)
```

Browser geometry check (Playwright against Storybook, story
`coordinator-conversation--inline-document-link`, light and dark). It
measures the link box against the paragraph line-height and the text
node before the link:

```
# Before
light: FAIL lineHeight=20 chipHeight=42.0 chipTop=61.7 chipBottom=103.7 textTop=76.7 textBottom=93.7
dark:  FAIL lineHeight=20 chipHeight=42.0 chipTop=61.7 chipBottom=103.7 textTop=76.7 textBottom=93.7
# After
light: PASS lineHeight=20 chipHeight=17.0 chipTop=62.7 chipBottom=79.7 textTop=62.7 textBottom=79.7
dark:  PASS lineHeight=20 chipHeight=17.0 chipTop=62.7 chipBottom=79.7 textTop=62.7 textBottom=79.7
```

The story's own play function also failed on master in the browser:
`AssertionError: expected 42 to be less than or equal to 20`.

Inline document link, before / after (dark, then light):

![chip before
dark](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/chip-before-dark.png)
![chip after
dark](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/chip-after-dark.png)
![chip before
light](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/chip-before-light.png)
![chip after
light](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/chip-after-light.png)

Collapsed pending-request preview, before / after:

![preview before
dark](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/preview-before-dark.png)
![preview after
dark](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/preview-after-dark.png)
![preview before
light](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/preview-before-light.png)
![preview after
light](https://raw.githubusercontent.com/paperclipai/paperclip/bbbeaadb3fb5ff2521edfe9434a31389bf8dfd50/inline-chip-preview/preview-after-light.png)

Neighbouring suites: `npx vitest run src/components/MarkdownBody
src/components/task-chat src/lib/markdown-plain-text.test.ts
src/components/MarkdownDocumentCardStyles.test.ts
src/components/MarkdownExternalLink.test.tsx` → 67 files, 1001 tests
passed.

Also run: `pnpm check:token-gates` (clean), UI `tsc -b` (no errors in
changed files; the only errors were an unbuilt local
`@paperclipai/plugin-sdk`), `node scripts/storybook-catalog.mjs`. `pnpm
verify` exited 2 (story file is a broad input), so broad coverage comes
from required CI.

## Risks

- Low risk. UI-only styling and text change.
- Document links in markdown now look like inline links with a small
icon instead of a card. A link that sits alone in its own paragraph also
renders inline. The standalone plan preview card keeps the card style.
- `markdownToPlainText` is a regex flattener for one-line previews, not
a full markdown parser. An unusual construct can keep a stray marker. It
does not change stored data.

## Model Used

- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, file edits, Playwright browser checks).

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 17:41:07 -06:00

1284 lines
50 KiB
TypeScript

// @vitest-environment jsdom
import { act, forwardRef, useImperativeHandle, useState, type ForwardedRef } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
vi.mock("@/hooks/useCoordinatorWorkspaceEnabled", () => ({
useCoordinatorWorkspaceEnabled: () => ({ enabled: false, loaded: true }),
}));
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IssueDocument } from "@paperclipai/shared";
import type {
IssueThreadInteraction,
RequestConfirmationInteraction,
} from "@/lib/issue-thread-interactions";
import { ThemeProvider } from "@/context/ThemeContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import {
expiredSecretProposalInteraction,
pendingToolActionLongProposalInteraction,
pendingAskUserQuestionsInteraction,
pendingRequestCheckboxConfirmationInteraction,
pendingRequestItemVerdictsInteraction,
} from "@/fixtures/issueThreadInteractionFixtures";
import { TaskChatInteractionCard } from "./TaskChatInteractionCard";
vi.mock("@/lib/router", async importOriginal => ({
...await importOriginal<typeof import("@/lib/router")>(),
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
}));
import { TaskChatThreadView } from "./TaskChatThreadView";
import type { TaskChatInteractionItem } from "./task-chat-model";
vi.mock("@/components/MarkdownEditor", () => ({
MarkdownEditor: forwardRef(function MockMarkdownEditor(
{
value,
onChange,
placeholder,
imageUploadHandler,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
imageUploadHandler?: (file: File) => Promise<string>;
},
ref: ForwardedRef<unknown>,
) {
useImperativeHandle(ref, () => ({
insertMarkdown: (markdown: string) => onChange(`${value}${markdown}`),
focus: () => {},
}));
return (
<textarea
data-testid="mock-markdown-editor"
data-image-upload={imageUploadHandler ? "true" : "false"}
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
);
}),
}));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function createRequestConfirmation(
overrides: Partial<RequestConfirmationInteraction> = {},
): RequestConfirmationInteraction {
return {
id: "confirmation-1",
companyId: "company-1",
issueId: "issue-1",
kind: "request_confirmation",
title: "Approve the plan",
summary: "Review and approve the latest plan.",
status: "pending",
continuationPolicy: "wake_assignee",
resolverPolicy: "anyone",
requestedResolverPolicy: "anyone",
effectiveResolverPolicy: "anyone",
resolverPolicyProvenance: "inherited",
effectiveResolverPolicySource: "requested",
legacyResolverPolicyAliases: {
requested: "board_or_agents",
effective: "board_or_agents",
},
createdByAgentId: "agent-1",
createdByUserId: null,
resolvedByAgentId: null,
resolvedByUserId: null,
createdAt: new Date("2026-04-06T12:01:00.000Z"),
updatedAt: new Date("2026-04-06T12:01:00.000Z"),
resolvedAt: null,
payload: {
version: 1,
prompt: "Approve the plan?",
},
result: null,
...overrides,
};
}
function interactionItem(
interaction: IssueThreadInteraction,
): TaskChatInteractionItem {
return {
id: `interaction:${interaction.id}`,
kind: "interaction",
interaction,
};
}
describe("TaskChatInteractionCard", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
});
it("opens the real tool review and reveals the proposal tail before approval", async () => {
const accept = vi.fn();
const reject = vi.fn();
const interaction = structuredClone(pendingToolActionLongProposalInteraction);
function ReviewSurface() {
const [reviewing, setReviewing] = useState(false);
return <TaskChatInteractionCard item={interactionItem(interaction)}
presentation={reviewing ? "takeover" : "timeline"}
onReviewRequest={() => setReviewing(true)}
onAcceptInteraction={accept} onRejectInteraction={reject} />;
}
flushSync(() => root.render(<TooltipProvider><ThemeProvider><ReviewSurface /></ThemeProvider></TooltipProvider>));
expect(container.textContent).not.toContain("November 8, 2030");
await act(async () => {
Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Review request")!.click();
});
const details = container.querySelector("details")!;
expect(details).not.toBeNull();
expect(details.open).toBe(false);
expect(container.querySelector("pre")?.textContent).toBe(interaction.payload.toolAction!.argumentsSummaryJson);
expect(details.textContent).toContain("November 8, 2030");
expect(details.textContent).toContain("[redacted]");
expect(details.textContent).not.toContain(interaction.payload.toolAction!.argumentsHash);
expect(details.textContent).toContain("Copy audit hash");
await act(async () => details.querySelector("summary")!.click());
expect(details.open).toBe(true);
expect(accept).not.toHaveBeenCalled();
expect(reject).not.toHaveBeenCalled();
await act(async () => {
Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Approve & run")!.click();
});
expect(accept).toHaveBeenCalledExactlyOnceWith(interaction, undefined, undefined, false);
expect(reject).not.toHaveBeenCalled();
});
it("keeps raw tool ids and the audit hash out of the tool approval card text", async () => {
// Shape of the GitHub create_repository approval from the Nov 5 staging run.
const rawToolName = "mcp.app-gallery-github-42e51e3f-9377-447b-ad45-333beda8a50a-b873f178:create-repository";
const argumentsHash = "8ca5312877235a4c0f6e9b1d2a7c3e5f9081b2c4d6e8fa1b3c5d7e9f0a2b4c6d";
const base = structuredClone(pendingToolActionLongProposalInteraction);
const interaction: RequestConfirmationInteraction = {
...base,
title: "Approve tool action",
summary: "This action needs your approval before it runs.",
payload: {
...base.payload,
prompt: "Approve create_repository?",
detailsMarkdown: `Tool: \`${rawToolName}\`\nRisk: \`write\``,
target: { type: "custom", key: "tool-action:2f92393c-c044-4367-8b6e-b81e2e732165", revisionId: argumentsHash, label: rawToolName },
toolAction: {
...base.payload.toolAction!,
toolName: rawToolName,
toolDisplayName: "create_repository",
appDisplayName: "GitHub",
previewMarkdown: "create_repository · Name: brainrot-storefront-prototype · Private: true",
argumentsSummaryJson: JSON.stringify({ name: "brainrot-storefront-prototype", private: true }),
argumentsHash,
},
},
};
const writeText = vi.fn().mockResolvedValue(undefined);
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
onTestFinished(() => {
if (originalClipboard) Object.defineProperty(navigator, "clipboard", originalClipboard);
else delete (navigator as { clipboard?: unknown }).clipboard;
});
function ReviewSurface() {
const [reviewing, setReviewing] = useState(false);
return <TaskChatInteractionCard item={interactionItem(interaction)}
presentation={reviewing ? "takeover" : "timeline"}
onReviewRequest={() => setReviewing(true)}
onAcceptInteraction={vi.fn()} onRejectInteraction={vi.fn()} />;
}
flushSync(() => root.render(<TooltipProvider><ThemeProvider><ReviewSurface /></ThemeProvider></TooltipProvider>));
const hashLike = /[0-9a-f]{12,}/i;
expect(container.textContent).not.toMatch(hashLike);
await act(async () => {
Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Review request")!.click();
});
const details = container.querySelector("details")!;
await act(async () => details.querySelector("summary")!.click());
expect(details.open).toBe(true);
expect(container.textContent).toContain("brainrot-storefront-prototype");
expect(container.textContent).not.toMatch(hashLike);
expect(container.textContent).not.toContain("app-gallery");
// The audit hash stays reachable for anyone who needs it, through a copy action.
const copy = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Copy audit hash");
expect(copy).toBeDefined();
await act(async () => copy!.click());
expect(writeText).toHaveBeenCalledWith(argumentsHash);
});
it("does not render a pending confirmation in the timeline", () => {
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(createRequestConfirmation())}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
const card = container.querySelector(
'[data-testid="task-chat-interaction"]',
);
expect(card).toBeNull();
expect(container.textContent).not.toContain("Requested confirmation");
expect(container.textContent).not.toContain("waiting for response");
expect(container.textContent).not.toContain("Approve the plan?");
});
it("shows the collapsed pending-request preview as plain text, not raw markdown", () => {
const summary = "The `creative-brief` document is saved on this task. It covers:\n\n- **Brand voice and audience:** Gen Z core buyers and holiday gift buyers.\n- __Style rules:__ see [the brief](#document-creative-brief) and ~~old~~ *new* notes.";
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(createRequestConfirmation({ title: "Review the creative brief (v1)", summary }))}
showPendingRequest
onReviewRequest={() => {}}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
const row = container.querySelector('[data-testid="task-chat-pending-request"]');
expect(row).not.toBeNull();
const preview = row!.textContent ?? "";
expect(preview).toContain("The creative-brief document is saved on this task. It covers: Brand voice and audience: Gen Z core buyers and holiday gift buyers. Style rules: see the brief and old new notes.");
expect(preview).not.toMatch(/[`*_~[\]]|(^|\s)- /);
});
it("renders a plan confirmation as a linked document preview", () => {
const interaction = createRequestConfirmation({
sourceRunId: "run-plan",
title: "Review plan revision 3",
payload: {
version: 1,
prompt: "Approve plan revision 3?",
acceptLabel: "Approve plan",
target: {
type: "issue_document",
issueId: "issue-1",
documentId: "document-plan",
key: "plan",
revisionId: "revision-3",
revisionNumber: 3,
label: "Plan v3",
},
},
});
const planDocument = {
id: "document-plan",
issueId: "issue-1",
key: "plan",
title: "Plan",
body: "# Health-check endpoint\n1. Define the health response contract.\n2. Test readiness before and after startup.\n3. Document the verification request.",
latestRevisionId: "revision-3",
latestRevisionNumber: 3,
} as IssueDocument;
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(interaction)}
planDocument={planDocument}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
const preview = container.querySelector(
'[data-testid="plan-review-preview"]',
);
expect(preview?.getAttribute("href")).toBe("#document-plan");
expect(preview?.getAttribute("aria-label")).toBe("Open Plan revision 3");
expect(container.textContent).toContain("Health-check endpoint");
expect(container.textContent).toContain(
"Test readiness before and after startup.",
);
expect(container.textContent).not.toContain("Approve plan revision 3?");
expect(container.textContent).not.toContain("Approve plan");
expect(container.textContent).not.toContain("Requested plan review");
});
it("renders a runner-owned expired plan review as a receipt without a second preview", () => {
const interaction = createRequestConfirmation({
sourceRunId: "run-plan",
status: "expired",
title: "Review plan revision 3",
payload: {
version: 1,
prompt: "Approve plan revision 3?",
target: {
type: "issue_document",
issueId: "issue-1",
documentId: "document-plan",
key: "plan",
revisionId: "revision-3",
label: "Plan v3",
},
},
result: {
version: 1,
outcome: "superseded_by_comment",
commentId: "comment-1",
},
});
const planDocument = {
id: "document-plan",
issueId: "issue-1",
key: "plan",
title: "Plan",
body: "# Health-check endpoint",
latestRevisionId: "revision-3",
latestRevisionNumber: 3,
} as IssueDocument;
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(interaction)}
planDocument={planDocument}
showPlanPreview={false}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
expect(
container.querySelector('[data-testid="plan-review-preview"]'),
).toBeNull();
expect(
container.querySelector('[data-testid="task-chat-interaction-receipt"]'),
).not.toBeNull();
expect(container.textContent).toContain(
"Confirmation expired after comment",
);
});
it("does not put the latest plan body into an older revision receipt", () => {
const interaction = createRequestConfirmation({
status: "expired",
payload: {
version: 1,
prompt: "Approve plan revision 2?",
target: {
type: "issue_document",
issueId: "issue-1",
documentId: "document-plan",
key: "plan",
revisionId: "revision-2",
label: "Plan revision 2",
},
},
result: {
version: 1,
outcome: "superseded_by_comment",
commentId: "comment-1",
},
});
const currentPlan = {
id: "document-plan",
issueId: "issue-1",
key: "plan",
title: "Plan",
body: "# Revision 3 only\nThis must not appear in the revision 2 receipt.",
latestRevisionId: "revision-3",
latestRevisionNumber: 3,
} as IssueDocument;
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(interaction)}
planDocument={currentPlan}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
expect(
container.querySelector('[data-testid="plan-review-preview"]'),
).not.toBeNull();
expect(container.textContent).not.toContain("Revision 3 only");
expect(container.textContent).toContain(
"Confirmation expired after comment",
);
});
it.each([createRequestConfirmation(), pendingRequestCheckboxConfirmationInteraction])(
"enables $kind acceptance when preparation clears on the same mounted card",
async (fixture) => {
const onAcceptInteraction = vi.fn();
const onRejectInteraction = vi.fn();
const render = (preparing: boolean) => flushSync(() => root.render(
<TooltipProvider><ThemeProvider>
<TaskChatInteractionCard
item={interactionItem({ ...fixture, acceptanceBlocker: preparing ? "workspace_sync_pending" : undefined })}
presentation="takeover"
onAcceptInteraction={onAcceptInteraction}
onRejectInteraction={onRejectInteraction}
/>
</ThemeProvider></TooltipProvider>,
));
const approve = () => [...container.querySelectorAll<HTMLButtonElement>("button")].find((button) =>
button.textContent === (fixture.payload.acceptLabel ?? "Approve"),
)!;
render(true);
expect(container.querySelector('[role="status"]')?.textContent).toContain("Preparing approval…");
expect(approve().disabled).toBe(true);
await act(async () => approve().click());
expect(onAcceptInteraction).not.toHaveBeenCalled();
expect([...container.querySelectorAll<HTMLButtonElement>("button")].find((button) =>
button.textContent === (fixture.payload.rejectLabel ?? "Reject"),
)?.disabled).toBe(false);
render(false);
expect(container.textContent).not.toContain("Preparing approval…");
expect(approve().disabled).toBe(false);
await act(async () => approve().click());
expect(onAcceptInteraction).toHaveBeenCalledTimes(1);
},
);
it("puts the primary CTA at the right edge of the compact action row", () => {
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(createRequestConfirmation())}
presentation="takeover"
/>
</ThemeProvider>
</TooltipProvider>,
);
});
const buttons = Array.from(container.querySelectorAll("button"));
const approve = buttons.find((button) => button.textContent === "Approve");
const reject = buttons.find((button) => button.textContent === "Reject");
expect(approve).not.toBeUndefined();
expect(reject).not.toBeUndefined();
const row = approve?.parentElement;
expect(row).toBe(reject?.parentElement);
expect(Array.from(row?.querySelectorAll("button") ?? []).at(-1)).toBe(
approve,
);
expect(container.textContent).not.toContain("proposed by");
expect(container.textContent).not.toContain(
"Review and approve the latest plan.",
);
expect(container.textContent).not.toContain("Needs response");
expect(container.textContent).not.toContain("Anyone can respond");
expect(
container.querySelector('[data-testid="interaction-status-badge"]'),
).toBeNull();
});
it.each([
["confirmation", "pending"],
["confirmation", "accepted"],
["checkbox", "pending"],
["checkbox", "accepted"],
] as const)("renders Markdown in %s prompts when %s", (kind, status) => {
const interaction = structuredClone(kind === "confirmation"
? createRequestConfirmation()
: pendingRequestCheckboxConfirmationInteraction);
interaction.status = status;
interaction.payload.prompt = "Review the [two screenshots](/PAP/issues/PAP-42#document-preview).\n\nSelect **Accept** to keep this design.\n\n- Check `preview.png`\n- Keep the layout\n\n![Review image](https://provider.invalid/review.png)\n\n```mermaid\nflowchart LR\n A --> B\n```";
if (status === "accepted") interaction.result = { version: 1, outcome: "accepted" };
const accept = vi.fn();
const reject = vi.fn();
flushSync(() => root.render(
<QueryClientProvider client={new QueryClient()}><TooltipProvider><ThemeProvider>
<TaskChatInteractionCard item={interactionItem(interaction)}
presentation={status === "pending" ? "takeover" : "timeline"}
onAcceptInteraction={accept} onRejectInteraction={reject} />
</ThemeProvider></TooltipProvider></QueryClientProvider>,
));
const link = container.querySelector('a[href="/issues/PAP-42#document-preview"]');
expect(link?.textContent).toBe("two screenshots");
const prompt = link?.closest(".paperclip-markdown");
expect(prompt?.querySelector("strong")?.textContent).toBe("Accept");
expect(prompt?.querySelector("code")?.textContent).toBe("preview.png");
expect(prompt?.querySelector("img")).toBeNull();
expect(prompt?.querySelectorAll("p")).toHaveLength(3);
expect(prompt?.querySelector("[data-markdown-image-reference]")?.textContent)
.toContain("Image: Review image (https://provider.invalid/review.png)");
expect(prompt?.querySelector("pre code.language-mermaid")?.textContent)
.toContain("flowchart LR");
expect(prompt?.querySelector(".paperclip-mermaid")).toBeNull();
expect(prompt?.querySelectorAll("li")).toHaveLength(2);
expect(prompt?.textContent).not.toContain("[two screenshots]");
expect(accept).not.toHaveBeenCalled();
expect(reject).not.toHaveBeenCalled();
if (kind === "checkbox" && status === "pending") {
const group = container.querySelector('[role="group"]');
const label = document.getElementById(group?.getAttribute("aria-labelledby") ?? "");
expect(label?.contains(prompt ?? null)).toBe(true);
expect(label?.textContent).toContain("two screenshots");
expect(label?.textContent).not.toContain("[two screenshots]");
expect(label?.textContent).not.toContain("**Accept**");
expect(group?.getAttribute("aria-label")).toBeNull();
}
});
it("stacks the confirmation prompt full-width and hides unlabeled custom target keys", () => {
const render = (target: RequestConfirmationInteraction["payload"]["target"]) =>
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(
createRequestConfirmation({
payload: {
version: 1,
prompt: "The change is open as PR #16.\n\nMay the PR merge?",
target,
},
}),
)}
presentation="takeover"
/>
</ThemeProvider>
</TooltipProvider>,
);
});
render({ type: "custom", key: "github:paperclipai/paperclip-desktop:pull:16" });
expect(container.textContent).not.toContain("github:paperclipai");
const prompt = Array.from(container.querySelectorAll("p")).find((node) =>
node.textContent?.startsWith("The change is open"),
);
expect(prompt?.className).not.toContain("flex-1");
expect(prompt?.closest(".paperclip-markdown")?.querySelectorAll("p")).toHaveLength(2);
expect(prompt?.parentElement?.className).not.toContain("justify-between");
render({
type: "custom",
key: "github:paperclipai/paperclip-desktop:pull:16",
label: "PR #16",
href: "https://github.com/paperclipai/paperclip-desktop/pull/16",
});
const link = container.querySelector(
'a[href="https://github.com/paperclipai/paperclip-desktop/pull/16"]',
);
expect(link?.textContent).toBe("PR #16");
expect(container.textContent).not.toContain("github:paperclipai");
});
it("stacks the checkbox confirmation prompt full-width and hides unlabeled custom target keys", () => {
const render = (target: RequestConfirmationInteraction["payload"]["target"]) =>
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem({
...pendingRequestCheckboxConfirmationInteraction,
payload: {
...pendingRequestCheckboxConfirmationInteraction.payload,
prompt: "Pick the drafts to delete.\n\nUnchecked drafts stay.",
target,
},
})}
presentation="takeover"
/>
</ThemeProvider>
</TooltipProvider>,
);
});
render({ type: "custom", key: "github:paperclipai/paperclip-desktop:pull:16" });
expect(container.textContent).not.toContain("github:paperclipai");
const prompt = Array.from(container.querySelectorAll("p")).find((node) =>
node.textContent?.startsWith("Pick the drafts"),
);
expect(prompt?.className).not.toContain("flex-1");
expect(prompt?.closest(".paperclip-markdown")?.querySelectorAll("p")).toHaveLength(2);
expect(prompt?.parentElement?.className).not.toContain("justify-between");
render({
type: "custom",
key: "github:paperclipai/paperclip-desktop:pull:16",
label: "PR #16",
href: "https://github.com/paperclipai/paperclip-desktop/pull/16",
});
const link = container.querySelector(
'a[href="https://github.com/paperclipai/paperclip-desktop/pull/16"]',
);
expect(link?.textContent).toBe("PR #16");
expect(container.textContent).not.toContain("github:paperclipai");
});
it("keeps the plan revision in the header and hides the prompt while requesting changes", async () => {
const interaction = createRequestConfirmation({
sourceRunId: "run-plan",
title: "Review the proposed plan",
payload: {
version: 1,
prompt: "Do you accept this plan?",
acceptLabel: "Approve plan",
rejectLabel: "Request changes",
rejectRequiresReason: true,
rejectReasonLabel: "What should change?",
target: {
type: "issue_document",
issueId: "issue-1",
documentId: "document-plan",
key: "plan",
revisionId: "revision-4",
revisionNumber: 4,
},
},
});
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(interaction)}
presentation="takeover"
onAcceptInteraction={vi.fn()}
onRejectInteraction={vi.fn()}
onUploadImage={vi.fn().mockResolvedValue("/uploads/boat.png")}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
const title = Array.from(container.querySelectorAll("strong")).find(
(candidate) => candidate.textContent === "Review the proposed plan",
);
const revision = Array.from(container.querySelectorAll("span")).find(
(candidate) => candidate.textContent === "plan · v4",
);
expect(title?.parentElement).toBe(revision?.parentElement);
expect(container.textContent).toContain("Do you accept this plan?");
expect(container.querySelectorAll(".border-t")).toHaveLength(0);
const requestChanges = Array.from(
container.querySelectorAll<HTMLButtonElement>("button"),
).find((button) => button.textContent?.trim() === "Request changes");
flushSync(() => requestChanges?.click());
expect(container.textContent).not.toContain("Do you accept this plan?");
expect(container.textContent).toContain("What should change?");
expect(container.textContent).toContain("plan · v4");
expect(
container.querySelector('[data-testid="plan-revision-composer"]'),
).not.toBeNull();
expect(
container
.querySelector('[data-testid="mock-markdown-editor"]')
?.getAttribute("data-image-upload"),
).toBe("true");
expect(container.textContent).not.toContain("Attach image");
expect(container.textContent).not.toContain("drop/paste an image");
const reasonGroup = container.querySelector(
'[data-testid="plan-revision-composer"]',
)?.parentElement;
expect(reasonGroup?.className).toContain("space-y-2");
expect(reasonGroup?.className).not.toContain("mt-3");
});
it("shows one question at a time and preserves answers across pages", async () => {
const submit = vi.fn();
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(pendingAskUserQuestionsInteraction)}
presentation="takeover"
onSubmitInteractionAnswers={submit}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
expect(container.textContent).toContain("1 of 2");
expect(container.textContent).not.toContain("Choose one");
expect(container.textContent).toContain("How aggressive should");
expect(container.textContent).not.toContain(
"What should the answered-state card emphasize",
);
const firstAnswer = Array.from(container.querySelectorAll("button")).find(
(button) =>
button.textContent?.includes("Only collapse hidden descendants"),
);
await act(async () => firstAnswer?.click());
expect(submit).not.toHaveBeenCalled();
// Answering stays on the question; Next is what moves on.
expect(container.textContent).toContain("1 of 2");
await act(async () =>
Array.from(container.querySelectorAll<HTMLButtonElement>("button"))
.find((button) => button.textContent?.trim() === "Next")
?.click(),
);
expect(container.textContent).toContain("2 of 2");
expect(container.textContent).toContain(
"What should the answered-state card emphasize",
);
expect(container.textContent).not.toContain("How aggressive should");
const secondAnswer = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Inline answer pills"),
);
await act(async () => secondAnswer?.click());
const send = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Send answers",
);
await act(async () => send?.click());
expect(submit).toHaveBeenCalledOnce();
expect(submit.mock.calls[0]?.[1]).toEqual([
{ questionId: "collapse-depth", optionIds: ["visible-root"] },
{ questionId: "post-submit-summary", optionIds: ["answers-inline"] },
]);
});
it("collapses answered questions to one expandable timeline row", () => {
const answered = structuredClone(pendingAskUserQuestionsInteraction);
answered.status = "answered";
answered.resolvedAt = new Date("2026-08-24T13:30:00.000Z");
answered.result = {
version: 1,
answers: [{ questionId: "collapse-depth", optionIds: ["visible-root"] }],
};
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard item={interactionItem(answered)} />
</ThemeProvider>
</TooltipProvider>,
);
});
const receipt = container.querySelector<HTMLDetailsElement>(
'[data-testid="task-chat-answered-questions-receipt"]',
);
expect(receipt?.open).toBe(false);
expect(receipt?.querySelector("summary")?.textContent).toBe(
"Questions answered",
);
expect(receipt?.querySelectorAll("summary svg")).toHaveLength(2);
expect(receipt?.className).not.toContain("border");
expect(receipt?.className).not.toContain("bg-card");
expect(receipt?.querySelector("summary")?.className).toContain("px-1");
expect(receipt?.querySelector("summary")?.className).toContain(
"text-muted-foreground",
);
expect(receipt?.querySelector("summary")?.className).toContain("leading-5");
expect(receipt?.querySelector("summary span")?.className).not.toContain(
"font-medium",
);
expect(
receipt
?.querySelector('[data-testid="task-chat-interaction-receipt-icon"]')
?.classList.contains("h-3.5"),
).toBe(true);
const iconSlot = receipt?.querySelector(
'[data-testid="task-chat-interaction-receipt-icon-slot"]',
);
expect(iconSlot?.classList.contains("w-6")).toBe(true);
expect(iconSlot?.classList.contains("items-center")).toBe(true);
expect(iconSlot?.classList.contains("justify-center")).toBe(true);
const caret = receipt?.querySelector(
'[data-testid="task-chat-interaction-receipt-caret"]',
);
expect(caret?.classList.contains("h-3")).toBe(true);
expect(caret?.classList.contains("opacity-0")).toBe(true);
expect(caret?.getAttribute("class")).toContain("group-hover:opacity-70");
expect(container.textContent).not.toContain("Answered 2 questions");
expect(container.textContent).not.toContain(
"View original request and resolution",
);
expect(container.textContent).not.toContain("Options:");
expect(container.textContent).not.toContain("Asked by:");
expect(container.textContent).not.toContain("Resolved by:");
});
it("collapses resolved confirmations and selections to one borderless row", () => {
const acceptedConfirmation = createRequestConfirmation({
status: "accepted",
resolvedAt: new Date("2026-08-24T13:30:00.000Z"),
result: { version: 1, outcome: "accepted" },
});
const continuedConfirmation = createRequestConfirmation({
id: "confirmation-continued",
status: "rejected",
resolvedAt: new Date("2026-08-24T13:30:30.000Z"),
payload: {
version: 1,
prompt: "Is this task ready to complete?",
rejectLabel: "Continue work",
},
result: { version: 1, outcome: "rejected" },
});
const acceptedSelection = structuredClone(
pendingRequestCheckboxConfirmationInteraction,
);
acceptedSelection.status = "accepted";
acceptedSelection.resolvedAt = new Date("2026-08-24T13:31:00.000Z");
acceptedSelection.result = {
version: 1,
outcome: "accepted",
selectedOptionIds: [],
};
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<>
<TaskChatInteractionCard
item={interactionItem(acceptedConfirmation)}
/>
<TaskChatInteractionCard
item={interactionItem(continuedConfirmation)}
/>
<TaskChatInteractionCard
item={interactionItem(acceptedSelection)}
/>
</>
</ThemeProvider>
</TooltipProvider>,
);
});
const receipts = container.querySelectorAll<HTMLDetailsElement>(
'[data-testid="task-chat-interaction-receipt"]',
);
expect(receipts).toHaveLength(3);
expect(receipts[0]?.querySelector("summary")?.textContent).toBe(
"Confirmed request",
);
expect(receipts[1]?.querySelector("summary")?.textContent).toBe(
"Selected “Continue work”",
);
expect(receipts[2]?.querySelector("summary")?.textContent).toBe(
"Confirmed with no options selected",
);
for (const receipt of receipts) {
expect(receipt.open).toBe(false);
expect(receipt.className).not.toContain("border");
expect(receipt.className).not.toContain("bg-card");
}
expect(container.textContent).not.toContain("Asked by:");
expect(container.textContent).not.toContain("Outcome:");
});
it("reuses the question form for a recovered harness text request", async () => {
const submit = vi.fn();
const recovered = structuredClone(pendingAskUserQuestionsInteraction);
recovered.id = "recovered-runtime-question";
recovered.title = "Configure deployment";
recovered.payload = {
version: 1,
supersedeOnUserComment: false,
submitLabel: "Continue",
questions: [
{
id: "replicas",
prompt: "How many replicas?",
selectionMode: "single",
required: true,
options: [
{
id: "__paperclip_text__",
label: "Enter an integer",
freeText: true,
},
],
},
],
questionSet: {
schema: "paperclip.question_set.v1",
title: "Configure deployment",
description: "The provider disconnected while waiting for this answer.",
submitLabel: "Continue",
questions: [
{
id: "replicas",
header: "Replicas",
prompt: "How many replicas?",
required: true,
answerMode: "text",
textValidation: { inputType: "integer", minimum: 1, maximum: 10 },
},
],
},
};
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(recovered)}
presentation="takeover"
onSubmitInteractionAnswers={submit}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
expect(container.textContent).toContain(
"The provider disconnected while waiting for this answer.",
);
expect(container.textContent).toContain("Write an answer");
expect(container.textContent).not.toContain("Required");
const textarea = container.querySelector("textarea")!;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(textarea, "3");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
const send = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Continue",
);
await act(async () => send?.click());
expect(submit).toHaveBeenCalledWith(recovered, [
{
questionId: "replicas",
optionIds: [],
otherText: "3",
},
]);
});
it.each(["canonical_text", "canonical_custom", "legacy_custom"])("preserves the %s answer mapping", async (mode) => {
const submit = vi.fn();
const interaction = structuredClone(pendingAskUserQuestionsInteraction);
interaction.payload.questions = [{
id: "draft", prompt: "Edit", selectionMode: "single", required: true,
options: [{ id: "preset", label: "Preset" }, { id: "other", label: "Custom answer", freeText: true }],
}];
interaction.payload.questionSet = mode === "legacy_custom" ? undefined : {
schema: "paperclip.question_set.v1",
questions: [mode === "canonical_text"
? { id: "draft", prompt: "Edit", required: true, answerMode: "text", initialText: "Provider draft" }
: { id: "draft", prompt: "Edit", required: true, answerMode: "single_select", options: [{ id: "preset", label: "Preset" }], customAnswer: { enabled: true, label: "Custom answer" } }],
};
await act(async () => root.render(
<TooltipProvider><ThemeProvider>
<TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionAnswers={submit} />
</ThemeProvider></TooltipProvider>,
));
if (mode !== "canonical_text") {
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Custom answer"))!.click());
}
const edited = "\n Operator 漢字 edit\nLiteral \\n stays literal. \n";
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!.call(container.querySelector("textarea")!, edited);
container.querySelector("textarea")!.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => Array.from(container.querySelectorAll("button")).find(button => button.textContent?.trim() === "Send answers" || button.textContent?.trim() === "Submit answers")!.click());
expect(submit).toHaveBeenCalledExactlyOnceWith(interaction, [{
questionId: "draft", optionIds: [], otherText: mode === "canonical_text" ? edited : edited.trim(),
}]);
});
it("opens a compact attachment item through the authenticated file endpoint", () => {
const interaction = { ...pendingRequestItemVerdictsInteraction, payload: { ...pendingRequestItemVerdictsInteraction.payload,
items: [{ id: "file", label: "Reviewed file", target: { type: "issue_attachment" as const, attachmentId: "immutable-file", assetId: "asset", sha256: "hash" } }],
} };
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionVerdicts={() => undefined} /></ThemeProvider></TooltipProvider>));
expect(container.querySelector('a[href="/api/attachments/immutable-file/content"]')?.getAttribute("target")).toBe("_blank");
});
it.each([undefined, "/stale-display-link"])("opens a compact confirmation's bound attachment when href is %s", href => {
const interaction = createRequestConfirmation({ payload: { version: 1, prompt: "Review the attached copy", target: {
type: "issue_attachment", attachmentId: "immutable-file", assetId: "asset", sha256: "hash", href,
} } });
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onAcceptInteraction={() => undefined} /></ThemeProvider></TooltipProvider>));
expect(container.querySelector('a[href="/api/attachments/immutable-file/content"]')?.getAttribute("target")).toBe("_blank");
});
it("opens the bound output revision from a compact review item", () => {
const interaction = { ...pendingRequestItemVerdictsInteraction, payload: { ...pendingRequestItemVerdictsInteraction.payload,
items: [{ id: "copy", label: "Reviewed copy", target: { type: "issue_document" as const, issueId: "owner", key: "copy", revisionId: "reviewed-v1" } }],
} };
flushSync(() => root.render(<TooltipProvider><ThemeProvider><TaskChatInteractionCard item={interactionItem(interaction)} presentation="takeover" onSubmitInteractionVerdicts={() => undefined} /></ThemeProvider></TooltipProvider>));
expect(container.querySelector('a[href*="document-copy&revision=reviewed-v1"]')?.getAttribute("href"))
.toBe(`/issues/owner#document-copy&revision=reviewed-v1&interaction=${interaction.id}`);
});
it("paginates item verdicts instead of expanding the whole review set", async () => {
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(pendingRequestItemVerdictsInteraction)}
presentation="takeover"
onSubmitInteractionVerdicts={() => undefined}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
expect(container.textContent).toContain("1 of 5");
expect(container.textContent).toContain("Spring launch recap");
expect(container.textContent).not.toContain("Monthly changelog digest");
expect(
container.querySelector('[aria-label="Item pagination"]'),
).not.toBeNull();
const approve = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "approve",
);
const rejectOnFirstItem = Array.from(
container.querySelectorAll("button"),
).find((button) => button.textContent?.trim() === "reject");
expect(approve?.className).toContain("text-emerald-700");
expect(rejectOnFirstItem?.className).toContain("text-destructive");
expect(approve?.querySelector("svg")).not.toBeNull();
expect(rejectOnFirstItem?.querySelector("svg")).not.toBeNull();
await act(async () => approve?.click());
expect(container.textContent).toContain("2 of 5");
expect(container.textContent).toContain("Monthly changelog digest");
expect(container.textContent).not.toContain("Spring launch recap");
const reject = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "reject",
);
await act(async () => reject?.click());
expect(container.textContent).toContain("Why reject?");
expect(container.textContent).toContain("Submit rejection reason");
expect(container.textContent).toContain("2 of 5");
expect(
container.querySelector<HTMLButtonElement>('[aria-label="Next item"]')
?.disabled,
).toBe(true);
const textarea = container.querySelector("textarea")!;
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(
HTMLTextAreaElement.prototype,
"value",
)?.set;
setter?.call(textarea, "Needs a warmer introduction.");
textarea.dispatchEvent(new Event("input", { bubbles: true }));
});
expect(
container.querySelector<HTMLButtonElement>('[aria-label="Next item"]')
?.disabled,
).toBe(true);
const submitReason = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Submit rejection reason",
);
await act(async () => submitReason?.click());
expect(container.textContent).toContain("3 of 5");
expect(container.textContent).toContain("Founder's note on reliability");
});
it("collapses an expired confirmation receipt at the request position", () => {
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(
createRequestConfirmation({ status: "expired" }),
)}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
const receipt = container.querySelector<HTMLDetailsElement>(
'[data-testid="task-chat-interaction-receipt"]',
);
expect(receipt).not.toBeNull();
expect(receipt?.open).toBe(false);
expect(container.textContent).toContain("Approve the plan");
expect(container.textContent).toContain("expired");
expect(container.textContent).toContain("Approve the plan?");
expect(receipt?.querySelector("summary")?.textContent).toBe(
"Confirmation expired",
);
expect(container.textContent).not.toContain(
"View original request and resolution",
);
});
it("keeps security details inside an expandable expired receipt", () => {
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(expiredSecretProposalInteraction)}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
expect(
container.querySelector('[data-testid="task-chat-interaction-receipt"]'),
).not.toBeNull();
expect(container.textContent).toContain("Confirmation expired");
expect(container.textContent).toContain("OpenAI API key");
expect(container.textContent).toContain("access.evals_openai_api_key");
expect(container.textContent).toContain("EvalsEngineer");
expect(container.textContent).not.toContain("Asked by:");
expect(container.textContent).not.toContain("Resolved:");
expect(container.textContent).not.toContain("Outcome:");
});
it("keeps skipped confirmation context, actor metadata, and terminal reason expandable", () => {
const skipped = createRequestConfirmation({
status: "cancelled",
resolvedByUserId: "user-1",
resolvedAt: new Date("2026-04-06T12:05:00.000Z"),
result: {
version: 1,
outcome: "skipped",
reason: "I want to write a normal message.",
},
});
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatInteractionCard
item={interactionItem(skipped)}
agentMap={
new Map([
["agent-1", { id: "agent-1", name: "Planner" }],
]) as never
}
userLabelMap={new Map([["user-1", "Riley"]])}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
const receipt = container.querySelector<HTMLDetailsElement>(
'[data-testid="task-chat-interaction-receipt"]',
);
expect(receipt?.open).toBe(false);
expect(receipt?.querySelector("summary")?.textContent).toBe(
"Skipped interaction",
);
expect(container.textContent).toContain("Approve the plan?");
expect(container.textContent).not.toContain("Asked by:");
expect(container.textContent).not.toContain("Resolved by:");
expect(container.textContent).not.toContain("Outcome:");
expect(container.textContent).toContain(
"I want to write a normal message.",
);
});
});
describe("TaskChatThreadView interaction items", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
});
it("renders interaction items through renderInteraction", () => {
const item = interactionItem(
createRequestConfirmation({
status: "accepted",
result: { version: 1, outcome: "accepted" },
}),
);
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatThreadView
items={[
{
id: "turn-boundary",
kind: "marker",
variant: "turn_boundary",
label: "Earlier activity",
},
item,
]}
scroll={false}
renderInteraction={(it) => <TaskChatInteractionCard item={it} />}
/>
</ThemeProvider>
</TooltipProvider>,
);
});
expect(
container.querySelector('[data-testid="task-chat-interaction"]'),
).not.toBeNull();
expect(
container
.querySelector('[data-testid="task-chat-interaction"]')
?.parentElement?.classList.contains("mt-3"),
).toBe(true);
});
it("renders nothing for interaction items without renderInteraction", () => {
const item = interactionItem(createRequestConfirmation());
flushSync(() => {
root.render(
<TooltipProvider>
<ThemeProvider>
<TaskChatThreadView items={[item]} scroll={false} />
</ThemeProvider>
</TooltipProvider>,
);
});
expect(
container.querySelector('[data-testid="task-chat-interaction"]'),
).toBeNull();
});
});