mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## Thinking Path > - Paperclip helps people manage work by AI agents. > - People use confirmation cards to review work before an agent continues. > - A prompt can include a Markdown link to the work that needs review. > - Confirmation cards showed that link as text, which made review harder. > - The details section already uses the shared Markdown renderer. > - This pull request uses that renderer for confirmation prompts too. > - People can read formatted prompts and open their review links. ## Linked Issues or Issue Description **What happened?** Confirmation prompts show Markdown links as text in the task composer and classic cards. Checkbox confirmations and expanded receipts have the same problem. **Expected behavior** Render links, emphasis, code, lists, and paragraph breaks with the shared Markdown renderer. **Steps to reproduce** 1. Create a confirmation with this prompt: `Review the [two screenshots](/issues/EXAMPLE-42#document-preview).` 2. Open the request above the task composer. 3. Observe the raw link text. Expect a link with the label “two screenshots”. **Paperclip version or commit** Source checkout at `1f55073f9`. **Deployment mode** Built from source. The defect is in the shared board UI. Related work: #17014 covers question prompts. This change covers confirmation prompts. ## What Changed - Render confirmation and checkbox prompts with `MarkdownBody` in compact and classic cards. - Keep prompt images as references and Mermaid diagrams as source text. - Use the rendered checkbox prompt as the options group’s accessible name. - Use the same renderer in expanded confirmation receipts. - Pass external link metadata to the prompt renderer. - Add eight regression cases for document links, emphasis, code, lists, paragraphs, and inert media. Check the checkbox group’s accessible label. - Replace CSS-specific paragraph checks with checks of the rendered paragraphs. - Document the prompt rendering rule in `DESIGN.md`. ## Verification - Before the initial fix, all eight new regression cases failed because the document link did not render. Before the review fix, the same cases also proved that prompt images were live. Both defects are fixed. - After the fix, 180 tests pass across the two card suites and two Markdown suites. - `pnpm check:token-gates` passes. - `pnpm --filter @paperclipai/ui... build` passed for the initial change. `pnpm --filter @paperclipai/ui build` passes after the review fixes. - Full `pnpm -r typecheck` and `pnpm build` could not complete. The workspace does not have Rust `cargo`. - `PAPERCLIP_TYPECHECK_LOW_MEMORY=1 pnpm --filter @paperclipai/ui typecheck` passes. The first UI typecheck hit the workspace memory limit before this successful retry. - Full `pnpm test:run` started but did not complete locally. It was stopped during the first chat integration shard when the task moved to a scheduled CI check. No full-suite pass is claimed. GitHub CI runs the full checks. - Browser suites were not run locally. In the first CI run, build and full typecheck passed. Server shard 8/12 and browser shards 1/8 and 7/8 failed because their self-hosted runners lost communication. Other jobs were cancelled. See [the first CI run](https://github.com/paperclipai/paperclip/actions/runs/41713671579). The current head has fresh 5/5 reviews from Greptile and Paperclip. On the first attempt of [the new-head CI run](https://github.com/paperclipai/paperclip/actions/runs/41717410154), the Runner package test job received a shutdown signal. One unchanged CloudAccessGate test also failed: the reconnect banner was absent at its assertion. Its root cause is not confirmed. The latest attempt of that run passed on commit `591078ca568056369ee9067c9535845bd47d8dc1`. Build, full typecheck, all test shards, all eight browser shards, and `ci / verify` passed. The earlier assertion failure remains part of the record; its root cause is not confirmed. All 58 current-head check runs are complete: 54 passed, one review is neutral, and three checks were skipped. The Snyk status passed. No review threads remain open. ## Risks - Prompts use the shared Markdown and URL handling. Prompt images remain inert, and Mermaid diagrams display as source text. - Rich prompts can take more vertical space than plain text. - Existing plan previews, selection rules, and approval callbacks remain covered by the card tests. - This change does not require an API or database migration. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool calls, and code execution. The runtime does not expose the exact model ID or context window size. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1199 lines
45 KiB
TypeScript
1199 lines
45 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, vi } from "vitest";
|
|
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).toContain(interaction.payload.toolAction!.argumentsHash);
|
|
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("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("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\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();
|
|
});
|
|
});
|