mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task feeds show agent messages, documents, and files in time order. > - Each image currently uses a full feed card. > - A turn with seven screenshots makes the conversation hard to scan. > - This pull request groups adjacent images from the same run in a compact grid. > - Users can scan the results and open each image in the existing gallery. ## Linked Issues or Issue Description **What existing behavior does this improve?** Image results in task history and the live task feed. **Current behavior** Each image uses a separate full-width card. Native image upload receipt messages can also separate related screenshots. **Proposed behavior** Adjacent images from one run share a wrapping thumbnail grid. New image results join the grid. Documents, video, and authored messages keep their positions. The grid opens the existing gallery. **Reason and benefit** Users can see a batch of screenshots without scrolling past one large card for each file. **Breaking changes** The feed layout changes. The stored comments, files, APIs, and permissions do not change. Related public work: Refs #17228. Refs #8785. Refs #7667. These changes supplied the artifact cards and media gallery that this pull request reuses. ## What Changed - Add one image grouping function for settled history, turn history, and live results. Group only images with the same known run. Keep the first row key and each image key when results arrive. - Add a token-based grid that reuses the media artifact cards and image gallery. - Add an optional typed attachment-preparation marker to file registration and chat-file reuse. Preserve the marker through comment normalization. Use it with matching image, author, and run links to hide generated receipts. Keep the exact historical sentence as a fallback for old receipts. - Add nine production component stories with seven sample-account screenshots. Cover mobile, Classic, mixed results, single images, missing images, streaming, gallery navigation, and the complete feed projection. - Add grouping and production-feed regressions. Add 18 browser cases across light and dark themes. - Improve contrast for the shared fallback label and gallery counter after the accessibility checks found low contrast. - Match alternate original-image paths when opening the gallery. Keep a separate Open original link beside Download in the gallery. - Record the feed layout in DESIGN.md. ## Verification - Final qualification at `09b12381ce85bee6593050bd8a2fa18cbe2c45a6`: [required CI passed](https://github.com/paperclipai/paperclip/actions/runs/42317080212/job/128237094513), including typecheck, build, all test lanes, E2E, and verify. The snapshot has 58 successful check runs, three conditional skips, one neutral advisory job, and a successful legacy Snyk status. No pending or failed checks remain. Greptile and Paperclip Review both score this exact head 5/5; all review threads are resolved. - Current-head local checks: 544 focused UI tests passed across 11 files, with one existing skip. UI/shared typechecks and token gates pass. All 18 focused Storybook browser cases passed after the CSS merge, with retries disabled. - Two database integration cases verify file registration and chat-file reuse receipt markers. All 52 issue-validator tests and six upstream default-model tests passed. - Regressions failed before the fixes for unknown-run grouping, receipt wording, and authored progress text with an image. The repair uses a typed receipt marker and requires matching author, attachment, image, and run links before hiding a generated receipt. All five review findings are resolved. - Browser checks cover loaded images, wrapping columns, overflow, missing-image fallback, streaming stability, keyboard gallery navigation, original-file links, and downloads. Fixtures use production renderers. These checks do not prove deployed persistence or provider behavior and do not compare visual baselines. - Local limits: the preflight helper could not finish because Cargo was absent. The earlier low-memory server typecheck and dependency-free selector did not replace final-head CI. The now-passing final CI supplies broad typecheck, build, server, Runner, and E2E coverage. - Prior independent CI failures remain recorded: one Slack timer case passed a single diagnosed rerun; its exact timer cause was not proved. A later chat shard lost its self-hosted runner without a test assertion failure. The current revision passes that shard and all applicable gates. Master conflict resolution retained both image-grid and patch CSS rules. ## Risks - Images without a run ID remain separate. Messages and other resource types end each batch. - Receipt suppression requires a matching preparation marker or the exact legacy sentence, plus matching attachment, image work product, run, and author. Tests preserve human messages and native progress text with attachments. The optional marker uses existing JSON metadata and needs no database migration. - Shared gallery and fallback text now have stronger contrast. ## Model Used OpenAI GPT-6, through Codex. The agent used reasoning, repository tools, code execution, and browser tests. The runtime did not expose a more specific 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>
1012 lines
38 KiB
TypeScript
1012 lines
38 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
import { act, forwardRef, useImperativeHandle, 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 { ThemeProvider } from "@/context/ThemeContext";
|
||
import { MemoryRouter } from "@/lib/router";
|
||
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
|
||
import type {
|
||
TaskChatProtocolItem,
|
||
TaskChatProviderActivityFamily,
|
||
TaskChatRuntimeRequestDecision,
|
||
} from "./task-chat-model";
|
||
import type { HeartbeatRunEvent, IssueWorkProduct } from "@paperclipai/shared";
|
||
import { nativeRunEventsToTranscript } from "../transcript/native-run-events";
|
||
import { transcriptToTaskChatItems } from "./transcript-adapter";
|
||
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
|
||
import { RichWorkProductCard } from "./RichWorkProductCard";
|
||
import { stateChipFor } from "./RichWorkProductCard";
|
||
|
||
function workProduct(overrides: Partial<IssueWorkProduct> = {}): IssueWorkProduct {
|
||
return {
|
||
id: "work-product-1",
|
||
companyId: "company-1",
|
||
projectId: null,
|
||
issueId: "issue-1",
|
||
executionWorkspaceId: null,
|
||
runtimeServiceId: null,
|
||
type: "pull_request",
|
||
provider: "github",
|
||
externalId: "42",
|
||
title: "Ship rich work-product cards",
|
||
url: "https://github.com/paperclipai/paperclip/pull/42",
|
||
status: "merged",
|
||
reviewState: "none",
|
||
isPrimary: true,
|
||
healthStatus: "healthy",
|
||
summary: null,
|
||
metadata: null,
|
||
createdByRunId: null,
|
||
createdAt: new Date("2026-09-02T00:00:00.000Z"),
|
||
updatedAt: new Date("2026-09-02T00:00:00.000Z"),
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
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)}
|
||
/>
|
||
);
|
||
}),
|
||
}));
|
||
|
||
function renderCard(
|
||
root: Root,
|
||
item: TaskChatProtocolItem,
|
||
onDecision?: (
|
||
decision: TaskChatRuntimeRequestDecision,
|
||
) => void | Promise<void>,
|
||
presentation: "timeline" | "takeover" = "timeline",
|
||
) {
|
||
flushSync(() =>
|
||
root.render(
|
||
<MemoryRouter>
|
||
<ThemeProvider>
|
||
<TaskChatProtocolCard
|
||
item={item}
|
||
presentation={presentation}
|
||
imageUploadHandler={async () => "blob:question-image"}
|
||
onRuntimeRequestDecision={
|
||
onDecision
|
||
? (_request, decision) => onDecision(decision)
|
||
: undefined
|
||
}
|
||
/>
|
||
</ThemeProvider>
|
||
</MemoryRouter>,
|
||
),
|
||
);
|
||
}
|
||
|
||
describe("TaskChatProtocolCard", () => {
|
||
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 a rich deliverable card without a completed chip", () => {
|
||
const product = workProduct({
|
||
metadata: {
|
||
repo: "paperclipai/paperclip",
|
||
number: 42,
|
||
baseRef: "master",
|
||
headRef: "feat/rich-cards",
|
||
additions: 17,
|
||
deletions: 5,
|
||
changedFiles: 3,
|
||
state: "merged",
|
||
draft: false,
|
||
},
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:deliverable:work-product-1",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: "pull request · merged",
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
expect(container.querySelector('[data-testid="task-chat-rich-work-product-pull_request"]')).not.toBeNull();
|
||
expect(container.textContent).toContain("Open on GitHub");
|
||
expect(container.textContent).toContain("paperclipai/paperclip · #42 · master ← feat/rich-cards");
|
||
expect(container.textContent).toContain("+17 −5 · 3 files");
|
||
expect(container.textContent).toContain("Merged");
|
||
expect(container.textContent).not.toContain("Completed");
|
||
|
||
const action = container.querySelector('a[aria-label="Open on GitHub: Ship rich work-product cards"]');
|
||
expect(action?.querySelector("span")?.className).toContain("hidden @sm:inline");
|
||
const stats = Array.from(container.querySelectorAll("p")).find((node) => node.textContent === "+17 −5 · 3 files");
|
||
expect(stats?.className).toContain("whitespace-nowrap");
|
||
});
|
||
|
||
it("shows pending artifacts with a dashed Pending chip", () => {
|
||
const product = workProduct({
|
||
type: "artifact",
|
||
provider: "paperclip",
|
||
url: "/api/attachments/attachment-1/content",
|
||
status: "pending",
|
||
title: "demo.png",
|
||
metadata: { contentType: "image/png", byteSize: 2048 },
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:deliverable:work-product-1",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: "artifact · pending",
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
const chip = container.querySelector(".status-chip");
|
||
expect(chip?.className).toContain("border-dashed");
|
||
expect(container.textContent).toContain("Image · 2.0 KB");
|
||
expect(container.querySelector('button[aria-label^="Open gallery:"]')).not.toBeNull();
|
||
});
|
||
|
||
it("opens an image when its original path differs from its preview URL", async () => {
|
||
const product = workProduct({
|
||
type: "artifact",
|
||
title: "screen.png",
|
||
url: "/images/preview.png",
|
||
metadata: { contentType: "image/png", originalFilename: "screen.png", openPath: "/images/original.png" },
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:image",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: "Image",
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
await act(async () => {
|
||
container.querySelector<HTMLButtonElement>('button[aria-label="Open gallery: screen.png"]')!.click();
|
||
});
|
||
|
||
expect(document.body.querySelector('[role="dialog"]')).not.toBeNull();
|
||
expect(document.body.querySelector('[role="dialog"] img')?.getAttribute("src")).toBe("/images/preview.png");
|
||
});
|
||
|
||
it.each([
|
||
["image/png", "compact"], ["video/webm", "compact"],
|
||
["image/png", "gallery"], ["video/webm", "gallery"],
|
||
] as const)("opens %s artifacts from the %s presentation in the task gallery", (contentType, variant) => {
|
||
const openGallery = vi.fn(() => true);
|
||
const contentPath = "/api/attachments/media/content";
|
||
flushSync(() => root.render(
|
||
<IssueGalleryContext.Provider value={openGallery}>
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath } })}
|
||
href={contentPath}
|
||
variant={variant}
|
||
/>
|
||
</IssueGalleryContext.Provider>,
|
||
));
|
||
const button = container.querySelector<HTMLButtonElement>('button[aria-label^="Open gallery:"]');
|
||
expect(button).not.toBeNull();
|
||
expect(container.querySelector("a")).toBeNull();
|
||
flushSync(() => button!.click());
|
||
expect(openGallery).toHaveBeenCalledWith(contentPath);
|
||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||
});
|
||
|
||
it("shows a branch summary and lets users inspect a work product without a remote URL", () => {
|
||
flushSync(() => root.render(
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({
|
||
type: "branch",
|
||
provider: "git",
|
||
title: "Company skill update entrypoint",
|
||
summary: "Uncommitted implementation changes are in the execution working tree on branch company-skill-update-entrypoint.",
|
||
url: null,
|
||
metadata: null,
|
||
status: "active",
|
||
})}
|
||
href={null}
|
||
/>,
|
||
));
|
||
expect(container.textContent).not.toContain("Uncommitted implementation changes");
|
||
expect(container.textContent).toContain("Branch · no remote link");
|
||
expect(container.querySelector("strong")?.className).toContain("line-clamp-2");
|
||
expect(container.querySelector("a")).toBeNull();
|
||
const button = container.querySelector<HTMLButtonElement>('button[aria-label^="Show details:"]');
|
||
expect(button?.getAttribute("aria-expanded")).toBe("false");
|
||
flushSync(() => button!.click());
|
||
const savedDescription = container.querySelector<HTMLDetailsElement>("details");
|
||
expect(savedDescription?.open).toBe(false);
|
||
expect(savedDescription?.textContent).toContain("Uncommitted implementation changes");
|
||
flushSync(() => savedDescription!.querySelector("summary")!.click());
|
||
expect(savedDescription?.open).toBe(true);
|
||
flushSync(() => savedDescription!.querySelector("summary")!.click());
|
||
expect(container.textContent).toContain("git · active · Updated");
|
||
expect(container.querySelector('button[aria-label^="Hide details:"]')?.getAttribute("aria-expanded")).toBe("true");
|
||
expect(container.querySelector("strong")?.className).not.toContain("line-clamp-2");
|
||
flushSync(() => container.querySelector<HTMLButtonElement>('button[aria-label^="Hide details:"]')!.click());
|
||
expect(container.querySelector("details")).toBeNull();
|
||
expect(container.textContent).toContain("Branch · no remote link");
|
||
expect(container.querySelector('button[aria-label^="Show details:"]')?.getAttribute("aria-expanded")).toBe("false");
|
||
});
|
||
|
||
it.each(["text/html", "application/zip"])("labels %s artifact links as downloads", (contentType) => {
|
||
const contentPath = "/api/attachments/file/content";
|
||
flushSync(() => root.render(
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath } })}
|
||
href={contentPath}
|
||
/>,
|
||
));
|
||
expect(container.textContent).toContain("Download");
|
||
expect(container.textContent).not.toContain("Open preview");
|
||
expect(container.querySelector("a")?.getAttribute("href")).toBe(`${contentPath}?download=1`);
|
||
});
|
||
|
||
it.each(["application/pdf", "text/plain", "text/markdown", "application/json", "text/csv"])("downloads %s artifacts through the download route", (contentType) => {
|
||
const contentPath = "/api/attachments/file/content";
|
||
const downloadPath = `${contentPath}?download=1`;
|
||
flushSync(() => root.render(
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({ type: "artifact", metadata: { contentType, contentPath, openPath: contentPath, downloadPath } })}
|
||
href={contentPath}
|
||
/>,
|
||
));
|
||
expect(container.querySelector('a[aria-label^="Download:"]')?.getAttribute("href")).toBe(downloadPath);
|
||
});
|
||
|
||
it("downloads a legacy attachment with only a content href", () => {
|
||
const contentPath = "/api/attachments/legacy-file/content";
|
||
flushSync(() => root.render(
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({ type: "artifact", metadata: { contentType: "application/pdf" } })}
|
||
href={contentPath}
|
||
/>,
|
||
));
|
||
expect(container.querySelector('a[aria-label^="Download:"]')?.getAttribute("href")).toBe(`${contentPath}?download=1`);
|
||
});
|
||
|
||
it("opens standalone artifact media in a modal with a download", async () => {
|
||
const contentPath = "/api/attachments/media/content";
|
||
flushSync(() => root.render(
|
||
<RichWorkProductCard
|
||
workProduct={workProduct({ type: "artifact", title: "Screenshot", metadata: { contentType: "image/png", contentPath, originalFilename: "proof.png", downloadPath: `${contentPath}?download=1` } })}
|
||
href={contentPath}
|
||
/>,
|
||
));
|
||
await act(async () => container.querySelector<HTMLButtonElement>('button[aria-label="Open gallery: Screenshot"]')!.click());
|
||
expect(document.querySelector('[role="dialog"] img')?.getAttribute("src")).toBe(contentPath);
|
||
expect(document.querySelector('a[aria-label="Download proof.png"]')?.getAttribute("href")).toBe(`${contentPath}?download=1`);
|
||
await act(async () => document.querySelector<HTMLButtonElement>('button[title="Close"]')!.click());
|
||
expect(document.querySelector('[role="dialog"]')).toBeNull();
|
||
});
|
||
|
||
it("keeps completed and approved states out of the state-chip policy", () => {
|
||
expect(stateChipFor("commit", "completed", "none")).toBeNull();
|
||
expect(stateChipFor("document", "approved", "approved")).toBeNull();
|
||
expect(stateChipFor("pull_request", "open", "none")).toMatchObject({ label: "Open", tone: "progress" });
|
||
expect(stateChipFor("pull_request", "merged", "none")).toMatchObject({ label: "Merged", tone: "success" });
|
||
expect(stateChipFor("pull_request", "closed", "none")).toMatchObject({ label: "Closed", tone: "neutral" });
|
||
expect(stateChipFor("artifact", "pending", "none")).toMatchObject({ label: "Pending", dashed: true });
|
||
});
|
||
|
||
it("shows the live open pull request state while preserving explicit review-state precedence", () => {
|
||
const product = workProduct({
|
||
status: "ready_for_review",
|
||
metadata: { state: "open" },
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:deliverable:work-product-1",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: "pull request · open",
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
const chip = Array.from(container.querySelectorAll("span")).find((node) => node.textContent === "Open");
|
||
expect(chip).toBeDefined();
|
||
expect(container.textContent).not.toContain("Review");
|
||
expect(stateChipFor("pull_request", "open", "needs_board_review")).toMatchObject({
|
||
label: "Review",
|
||
tone: "review",
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
["active", "unhealthy", ["Unhealthy"], ["Running"]],
|
||
["active", "unknown", [], ["Running", "Unhealthy"]],
|
||
["open", "unhealthy", ["Unhealthy"], ["Running"]],
|
||
["closed", "unhealthy", ["Stopped"], ["Unhealthy"]],
|
||
] as const)("renders %s runtime with %s health", (status, healthStatus, visible, absent) => {
|
||
const product = workProduct({
|
||
type: "runtime_service",
|
||
provider: "paperclip",
|
||
status,
|
||
healthStatus,
|
||
title: "Storybook",
|
||
metadata: { service: "storybook", port: 6006 },
|
||
});
|
||
renderCard(root, {
|
||
id: "resource:deliverable:work-product-1",
|
||
kind: "protocol",
|
||
surface: "resource",
|
||
resourceKind: "deliverable",
|
||
title: product.title,
|
||
subtitle: `runtime service · ${status}`,
|
||
href: product.url,
|
||
workProduct: product,
|
||
});
|
||
|
||
for (const label of visible) expect(container.textContent).toContain(label);
|
||
for (const label of absent) expect(container.textContent).not.toContain(label);
|
||
});
|
||
|
||
it("renders provider plan steps and status", () => {
|
||
renderCard(root, {
|
||
id: "provider-plan",
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family: "plan",
|
||
eventType: "plan.updated",
|
||
status: "running",
|
||
title: "Plan",
|
||
summary: "Implement protocol surfaces",
|
||
details: [],
|
||
links: [],
|
||
children: [],
|
||
steps: [
|
||
{ id: "one", label: "Inventory events", status: "completed" },
|
||
{ id: "two", label: "Render widgets", status: "in_progress" },
|
||
],
|
||
});
|
||
expect(
|
||
container.querySelector('[data-testid="task-chat-provider-plan"]'),
|
||
).not.toBeNull();
|
||
expect(container.textContent).toContain("Inventory events");
|
||
expect(container.textContent).toContain("Render widgets");
|
||
});
|
||
|
||
it("renders a structured history card for every provider family", () => {
|
||
const families = [
|
||
"plan",
|
||
"tool_execution",
|
||
"research",
|
||
"delegation",
|
||
"model_identity",
|
||
"context",
|
||
"artifact",
|
||
"review",
|
||
"hook",
|
||
"memory",
|
||
"safety",
|
||
"terminal",
|
||
"wait",
|
||
"provider_notice",
|
||
] satisfies TaskChatProviderActivityFamily[];
|
||
for (const family of families) {
|
||
renderCard(root, {
|
||
id: `provider-${family}`,
|
||
kind: "protocol",
|
||
surface: "provider_activity",
|
||
family,
|
||
eventType: `${family}.fixture`,
|
||
status: "completed",
|
||
title: `Visible ${family}`,
|
||
summary: `Summary ${family}`,
|
||
details: [{ label: "Reference", value: `${family}-1` }],
|
||
links: [],
|
||
children: [],
|
||
steps: [],
|
||
});
|
||
expect(
|
||
container.querySelector(`[data-testid="task-chat-provider-${family}"]`),
|
||
family,
|
||
).not.toBeNull();
|
||
expect(container.textContent, family).toContain(`Visible ${family}`);
|
||
}
|
||
});
|
||
|
||
it("says when a change set counts more files than it lists", () => {
|
||
renderCard(root, {
|
||
id: "workspace-large",
|
||
kind: "protocol",
|
||
surface: "workspace_change",
|
||
changeSetId: "changes-large",
|
||
revision: 1,
|
||
source: "harness_reported",
|
||
complete: true,
|
||
files: [
|
||
{ path: "a.ts", operation: "modify", previousPath: null, additions: 1, deletions: 0, binary: false, diff: null, diffTruncated: true },
|
||
],
|
||
totals: { files: 40, additions: 120, deletions: 3 },
|
||
patchArtifactRef: null,
|
||
});
|
||
expect(container.textContent).toContain("40 files changed");
|
||
expect(container.querySelector('[data-testid="task-chat-workspace-change-omitted"]')?.textContent).toContain("39 more files changed.");
|
||
});
|
||
|
||
it("lists the turn's changed files and opens each diff in place", async () => {
|
||
renderCard(root, {
|
||
id: "workspace",
|
||
kind: "protocol",
|
||
surface: "workspace_change",
|
||
changeSetId: "changes-1",
|
||
revision: 1,
|
||
source: "runner_verified",
|
||
complete: true,
|
||
files: [
|
||
{
|
||
path: "ui/src/App.tsx",
|
||
operation: "modify",
|
||
previousPath: null,
|
||
additions: 1,
|
||
deletions: 1,
|
||
binary: false,
|
||
diff: "@@ -1 +1 @@\n-old\n+new\n",
|
||
},
|
||
],
|
||
totals: { files: 1, additions: 1, deletions: 1 },
|
||
patchArtifactRef: null,
|
||
});
|
||
expect(container.textContent).toContain("Changed in this turn");
|
||
expect(container.textContent).toContain("1 file changed");
|
||
expect(container.querySelector('[data-testid="task-chat-diff-view"]')).toBeNull();
|
||
const row = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-workspace-change-file"] button');
|
||
expect(row?.textContent).toContain("ui/src/App.tsx");
|
||
await act(async () => row?.click());
|
||
const diff = container.querySelector('[data-testid="task-chat-diff-view"]');
|
||
expect(diff?.textContent).toContain("Line 1");
|
||
expect(diff?.textContent).toContain("old");
|
||
expect(diff?.textContent).toContain("new");
|
||
});
|
||
|
||
it("opens a bounded file preview", async () => {
|
||
renderCard(root, {
|
||
id: "file",
|
||
kind: "protocol",
|
||
surface: "workspace_file",
|
||
referenceId: "file-1",
|
||
source: "runner_verified",
|
||
path: "doc/protocol.md",
|
||
displayName: "protocol.md",
|
||
mediaType: "text/markdown",
|
||
presentation: "document",
|
||
line: 12,
|
||
preview: "# Protocol preview",
|
||
previewTruncated: false,
|
||
});
|
||
const button = Array.from(container.querySelectorAll("button")).find(
|
||
(candidate) => candidate.textContent === "Preview",
|
||
);
|
||
await act(async () => button?.click());
|
||
expect(document.querySelector('[role="dialog"]')?.textContent).toContain(
|
||
"Protocol preview",
|
||
);
|
||
});
|
||
|
||
it("submits a runtime choice only when a resolver is supplied", async () => {
|
||
const onDecision = vi.fn();
|
||
renderCard(
|
||
root,
|
||
{
|
||
id: "request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-1",
|
||
requestKind: "command_approval",
|
||
turnId: "turn-1",
|
||
requestType: "permission",
|
||
status: "pending",
|
||
prompt: "Allow command?",
|
||
choices: [{ key: "accept", label: "Allow once" }],
|
||
fields: [],
|
||
},
|
||
onDecision,
|
||
);
|
||
const button = Array.from(container.querySelectorAll("button")).find(
|
||
(candidate) => candidate.textContent === "Allow once",
|
||
);
|
||
expect(button?.disabled).toBe(false);
|
||
await act(async () => button?.click());
|
||
expect(onDecision).toHaveBeenCalledWith({ action: "accept" });
|
||
});
|
||
|
||
it("renders committed ACP permission choices and preserves its exact resolution binding", async () => {
|
||
const event: HeartbeatRunEvent = {
|
||
id: 1, seq: 1, companyId: "company-1", runId: "run-1", agentId: "agent-1",
|
||
eventType: "runtime_request.created", stream: "system", level: "info", color: null, message: null,
|
||
createdAt: new Date("2026-09-28T12:00:00Z"),
|
||
payload: { prpEvent: {
|
||
schema: "paperclip.prp.event.v1", schemaVersion: 1, sourceEventId: "permission-1", sourceSeq: 1,
|
||
sourceKind: "runner", sourceInstanceId: "runner-1", runId: "run-1", normalizedSessionId: "session-1",
|
||
turnId: "turn-1", itemId: "item-1", eventType: "runtime_request.created", priority: 0,
|
||
emittedAt: "2026-09-28T12:00:00Z", payload: { request: {
|
||
schema: "paperclip.runtime_request.v2", requestKind: "permission_approval", type: "permission",
|
||
requestId: "permission-1", turnId: "turn-1", itemId: "item-1", status: "pending",
|
||
prompt: "Allow editing src/example.ts?",
|
||
choices: [{ key: "accept", label: "Allow once" }, { key: "decline", label: "Deny" }],
|
||
details: { toolCallId: "tool-1" },
|
||
origin: { adapter: "acpx-runtime-sidecar", provider: "acpx", method: "session/request_permission" },
|
||
} },
|
||
} },
|
||
};
|
||
const item = transcriptToTaskChatItems(nativeRunEventsToTranscript([event]), {
|
||
runId: "run-1", agentName: "ACP", running: true,
|
||
}).find(candidate => candidate.kind === "protocol" && candidate.surface === "runtime_request");
|
||
if (item?.kind !== "protocol" || item.surface !== "runtime_request") throw new Error("permission card missing");
|
||
expect(item).toMatchObject({ runId: "run-1", requestId: "permission-1", turnId: "turn-1", requestKind: "permission_approval", status: "pending" });
|
||
expect(item.choices.map(choice => choice.key)).toEqual(["accept", "decline"]);
|
||
const resolve = vi.fn().mockResolvedValue(undefined);
|
||
flushSync(() => root.render(<MemoryRouter><ThemeProvider>
|
||
<TaskChatProtocolCard item={item} onRuntimeRequestDecision={resolve} />
|
||
</ThemeProvider></MemoryRouter>));
|
||
expect(container.textContent).toContain("Allow editing src/example.ts?");
|
||
const allow = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Allow once");
|
||
expect(allow?.disabled).toBe(false);
|
||
expect(container.textContent).not.toContain("Allow for session");
|
||
await act(async () => allow?.click());
|
||
expect(resolve).toHaveBeenCalledExactlyOnceWith(item, { action: "accept" });
|
||
const delivered: HeartbeatRunEvent = { ...event, id: 2, seq: 2, eventType: "runtime_request.resolved", payload: { prpEvent: {
|
||
...(event.payload!.prpEvent as Record<string, unknown>), sourceEventId: "permission-delivered", sourceSeq: 2,
|
||
eventType: "runtime_request.resolved", payload: { requestId: "permission-1", requestKind: "permission_approval",
|
||
turnId: "turn-1", itemId: "item-1", status: "delivered", action: "accept" },
|
||
} } };
|
||
const settled = transcriptToTaskChatItems(nativeRunEventsToTranscript([event, delivered]), {
|
||
runId: "run-1", agentName: "ACP", running: true,
|
||
}).find(candidate => candidate.kind === "protocol" && candidate.surface === "runtime_request");
|
||
if (settled?.kind !== "protocol" || settled.surface !== "runtime_request") throw new Error("receipt missing");
|
||
expect(settled).toMatchObject({ requestId: "permission-1", status: "resolved", resolvedAction: "accept" });
|
||
renderCard(root, settled, resolve);
|
||
expect(Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Allow once")).toBeUndefined();
|
||
});
|
||
|
||
it("submits structured runtime input through the production card", async () => {
|
||
const onDecision = vi.fn().mockResolvedValue(undefined);
|
||
renderCard(
|
||
root,
|
||
{
|
||
id: "input-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-input",
|
||
requestKind: "user_input",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "pending",
|
||
prompt: "Which environment should the run target?",
|
||
choices: [],
|
||
fields: [
|
||
{ name: "environment", label: "Environment", placeholder: "staging" },
|
||
],
|
||
},
|
||
onDecision,
|
||
);
|
||
const textarea = container.querySelector("textarea")!;
|
||
await act(async () => {
|
||
const setter = Object.getOwnPropertyDescriptor(
|
||
HTMLTextAreaElement.prototype,
|
||
"value",
|
||
)?.set;
|
||
setter?.call(textarea, "production");
|
||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
const submit = Array.from(container.querySelectorAll("button")).find(
|
||
(candidate) => candidate.textContent === "Submit response",
|
||
);
|
||
await act(async () => submit?.click());
|
||
expect(onDecision).toHaveBeenCalledWith({
|
||
action: "submit",
|
||
values: { environment: "production" },
|
||
});
|
||
expect(container.textContent).toContain("Submitting…");
|
||
});
|
||
|
||
it("keeps complete provider plan Markdown while making image references inert", () => {
|
||
const prefix = '# Release plan\n\n**Preserve every instruction.**\n\n\n\n\n\n';
|
||
const suffix = '\n\n```mermaid\nflowchart LR\n A@{ img: "https://provider.invalid/diagram.png" }\n```\n\nFINAL_PLAN_BOUNDARY';
|
||
const fullInstructions = "x".repeat(100_000 - prefix.length - suffix.length);
|
||
const description = prefix + fullInstructions + suffix;
|
||
expect(description).toHaveLength(100_000);
|
||
renderCard(root, {
|
||
id: "provider-plan", kind: "protocol", surface: "runtime_request", runId: "run-1",
|
||
requestId: "plan-revision-7", requestKind: "runtime", turnId: "turn-1",
|
||
requestType: "input", status: "pending", prompt: "Review the complete plan.", choices: [], fields: [],
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1", description,
|
||
questions: [{ id: "decision", prompt: "Accept this plan?", required: true, answerMode: "single_select",
|
||
options: [{ id: "accept", label: "Accept" }, { id: "reject", label: "Reject" }] }],
|
||
},
|
||
});
|
||
|
||
const context = container.querySelector('[role="region"][aria-label="Question context"]');
|
||
expect(context?.querySelector("h1")?.textContent).toBe("Release plan");
|
||
expect(context?.querySelector("strong")?.textContent).toBe("Preserve every instruction.");
|
||
expect(context?.textContent).toContain(fullInstructions);
|
||
expect(context?.textContent).toContain("FINAL_PLAN_BOUNDARY");
|
||
expect(context?.textContent).toContain("Evidence");
|
||
expect(context?.textContent).toContain("https://provider.invalid/track.png");
|
||
expect(context?.querySelector("img, video, audio, iframe, object, embed, image, link")).toBeNull();
|
||
expect(context?.querySelector('.language-mermaid')?.textContent).toContain('https://provider.invalid/diagram.png');
|
||
expect(context?.querySelector('.paperclip-mermaid')).toBeNull();
|
||
});
|
||
|
||
it("submits the canonical response from a v2 harness question set", async () => {
|
||
const onDecision = vi.fn().mockResolvedValue(undefined);
|
||
renderCard(
|
||
root,
|
||
{
|
||
id: "canonical-input-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-canonical-input",
|
||
requestKind: "runtime",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "pending",
|
||
prompt: "Codex needs your input.",
|
||
choices: [],
|
||
fields: [],
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
title: "Deployment input",
|
||
submitLabel: "Continue",
|
||
questions: [
|
||
{
|
||
id: "environment",
|
||
header: "Environment",
|
||
prompt: "Where should we deploy?",
|
||
required: true,
|
||
answerMode: "single_select",
|
||
options: [
|
||
{ id: "staging", label: "Staging" },
|
||
{ id: "production", label: "Production" },
|
||
],
|
||
},
|
||
{
|
||
id: "regions",
|
||
header: "Regions",
|
||
prompt: "Which regions should receive the release?",
|
||
required: false,
|
||
answerMode: "multi_select",
|
||
options: [
|
||
{ id: "us", label: "US" },
|
||
{ id: "eu", label: "EU" },
|
||
],
|
||
},
|
||
{
|
||
id: "notes",
|
||
header: "Notes",
|
||
prompt: "Anything else we should know?",
|
||
required: false,
|
||
answerMode: "text",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
onDecision,
|
||
);
|
||
expect(container.textContent).toContain("Deployment input");
|
||
expect(container.textContent).toContain("1 of 3");
|
||
const production = Array.from(container.querySelectorAll("button")).find(
|
||
(button) => button.textContent?.includes("Production"),
|
||
);
|
||
await act(async () => production?.click());
|
||
// Selecting answers the question; every page waits for Next.
|
||
const nextButton = () =>
|
||
Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find(
|
||
(button) => button.textContent?.trim() === "Next",
|
||
);
|
||
expect(container.textContent).toContain("1 of 3");
|
||
await act(async () => nextButton()?.click());
|
||
expect(container.textContent).toContain("2 of 3");
|
||
expect(onDecision).not.toHaveBeenCalled();
|
||
expect(container.textContent).toContain(
|
||
"Which regions should receive the release?",
|
||
);
|
||
expect(
|
||
Array.from(container.querySelectorAll("button")).find(
|
||
(button) => button.textContent?.trim() === "Continue",
|
||
),
|
||
).toBeUndefined();
|
||
await act(async () => nextButton()?.click());
|
||
expect(container.textContent).toContain("Anything else we should know?");
|
||
const submit = Array.from(container.querySelectorAll("button")).find(
|
||
(button) => button.textContent?.trim() === "Continue",
|
||
);
|
||
await act(async () => submit?.click());
|
||
expect(onDecision).toHaveBeenCalledWith({
|
||
action: "submit",
|
||
response: {
|
||
schema: "paperclip.question_response.v1",
|
||
answers: { environment: { selectedOptionIds: ["production"] } },
|
||
},
|
||
});
|
||
});
|
||
|
||
it("renders structured runtime questions directly in the takeover without duplicate request copy", () => {
|
||
renderCard(
|
||
root,
|
||
{
|
||
id: "canonical-takeover-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-canonical-takeover",
|
||
requestKind: "runtime",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "pending",
|
||
prompt: "Codex requests user input.",
|
||
choices: [],
|
||
fields: [],
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
title: "Codex needs your input",
|
||
questions: [
|
||
{
|
||
id: "environment",
|
||
header: "Environment",
|
||
prompt: "Where should we deploy?",
|
||
required: false,
|
||
answerMode: "single_select",
|
||
options: [{ id: "staging", label: "Staging" }],
|
||
},
|
||
],
|
||
},
|
||
},
|
||
vi.fn(),
|
||
"takeover",
|
||
);
|
||
|
||
expect(container.textContent).not.toContain("Codex needs your input");
|
||
expect(container.textContent).not.toContain("Codex requests user input.");
|
||
expect(container.textContent).not.toContain("Choose one");
|
||
expect(container.textContent).toContain("Where should we deploy?");
|
||
});
|
||
|
||
it("collapses resolved runtime questions to one expandable timeline row", () => {
|
||
renderCard(root, {
|
||
id: "resolved-input-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-resolved-input",
|
||
requestKind: "runtime",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "resolved",
|
||
prompt: "Codex needs your input.",
|
||
choices: [],
|
||
fields: [],
|
||
resolvedAction: "submit",
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
title: "Server setup",
|
||
questions: [
|
||
{
|
||
id: "style",
|
||
header: "Code style",
|
||
prompt: "Which module style should the server use?",
|
||
required: true,
|
||
answerMode: "single_select",
|
||
options: [{ id: "esm", label: "TypeScript ESM" }],
|
||
},
|
||
],
|
||
},
|
||
response: {
|
||
schema: "paperclip.question_response.v1",
|
||
answers: { style: { selectedOptionIds: ["esm"] } },
|
||
},
|
||
});
|
||
|
||
const receipt = container.querySelector<HTMLDetailsElement>(
|
||
'[data-testid="task-chat-runtime-request"]',
|
||
);
|
||
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-runtime-request-icon"]')
|
||
?.classList.contains("h-3.5"),
|
||
).toBe(true);
|
||
const caret = receipt?.querySelector(
|
||
'[data-testid="task-chat-runtime-request-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.querySelector(
|
||
'[data-testid="task-chat-runtime-request-history"]',
|
||
),
|
||
).not.toBeNull();
|
||
expect(container.textContent).toContain(
|
||
"Which module style should the server use?",
|
||
);
|
||
expect(container.textContent).toContain("TypeScript ESM");
|
||
expect(container.querySelector("textarea")).toBeNull();
|
||
});
|
||
|
||
it("collapses a cancelled runtime question with its original prompt expandable", () => {
|
||
renderCard(root, {
|
||
id: "cancelled-input-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-cancelled-input",
|
||
requestKind: "runtime",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "cancelled",
|
||
prompt: "Codex needs your input.",
|
||
choices: [],
|
||
fields: [],
|
||
resolvedAction: "cancel",
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
questions: [
|
||
{
|
||
id: "goal",
|
||
prompt: "What should the server do?",
|
||
required: false,
|
||
answerMode: "text",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
const receipt = container.querySelector<HTMLDetailsElement>(
|
||
'[data-testid="task-chat-runtime-request"]',
|
||
);
|
||
expect(receipt?.open).toBe(false);
|
||
expect(receipt?.querySelector("summary")?.textContent).toBe(
|
||
"Questions cancelled",
|
||
);
|
||
const iconSlot = receipt?.querySelector(
|
||
'[data-testid="task-chat-runtime-request-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);
|
||
expect(container.textContent).toContain(
|
||
"No answers were submitted; this request was cancelled.",
|
||
);
|
||
});
|
||
|
||
it("requires text when an explicit custom multi-select answer is active", async () => {
|
||
renderCard(
|
||
root,
|
||
{
|
||
id: "custom-input-request",
|
||
kind: "protocol",
|
||
surface: "runtime_request",
|
||
runId: "run-1",
|
||
requestId: "request-custom-input",
|
||
requestKind: "runtime",
|
||
turnId: "turn-1",
|
||
requestType: "input",
|
||
status: "pending",
|
||
prompt: "Choose regions.",
|
||
choices: [],
|
||
fields: [],
|
||
questionSet: {
|
||
schema: "paperclip.question_set.v1",
|
||
questions: [
|
||
{
|
||
id: "regions",
|
||
prompt: "Which regions?",
|
||
required: true,
|
||
answerMode: "multi_select",
|
||
options: [{ id: "us", label: "US" }],
|
||
customAnswer: { enabled: true, label: "Another region" },
|
||
},
|
||
{
|
||
id: "notes",
|
||
prompt: "Notes?",
|
||
required: false,
|
||
answerMode: "text",
|
||
},
|
||
],
|
||
},
|
||
},
|
||
vi.fn(),
|
||
);
|
||
|
||
const us = Array.from(container.querySelectorAll("button")).find(
|
||
(button) => button.textContent?.trim() === "US",
|
||
);
|
||
const custom = Array.from(container.querySelectorAll("button")).find(
|
||
(button) => button.textContent?.trim() === "Another region",
|
||
);
|
||
await act(async () => us?.click());
|
||
await act(async () => custom?.click());
|
||
|
||
const richInput = container.querySelector(
|
||
'[data-testid="question-other-answer-composer"]',
|
||
);
|
||
expect(richInput).not.toBeNull();
|
||
expect(richInput?.classList.contains("border")).toBe(false);
|
||
expect(
|
||
richInput?.querySelector('[data-image-upload="true"]'),
|
||
).not.toBeNull();
|
||
|
||
const next = Array.from(
|
||
container.querySelectorAll<HTMLButtonElement>("button"),
|
||
).find((button) => button.getAttribute("aria-label") === "Next question");
|
||
expect(next?.disabled).toBe(false);
|
||
expect(container.textContent).not.toContain("Enter a custom answer.");
|
||
const submitNext = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.trim() === "Next");
|
||
await act(async () => submitNext?.click());
|
||
expect(container.textContent).toContain("Enter a custom answer.");
|
||
|
||
const textarea = container.querySelector("textarea")!;
|
||
await act(async () => {
|
||
const setter = Object.getOwnPropertyDescriptor(
|
||
HTMLTextAreaElement.prototype,
|
||
"value",
|
||
)?.set;
|
||
setter?.call(textarea, "ap-south");
|
||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||
});
|
||
expect(next?.disabled).toBe(false);
|
||
});
|
||
});
|