Files
PaperClipAI/ui/src/components/IssueAttachmentsSection.test.tsx
T
Dotta b4fccaa8c4 [codex] Unify issue media attachment gallery (#8785)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Issue detail pages and run output cards both render user-facing
attachment previews
> - Media attachments were handled through separate gallery entry
points, so attachments from issue comments and issue output did not
share one consistent viewing path
> - A shared gallery path makes image browsing more predictable across
the issue surface
> - This pull request unifies the issue media attachment gallery
behavior across attachments, output cards, and issue detail rendering
> - The benefit is a more consistent image preview experience with
focused regression coverage around the affected UI paths

## Linked Issues or Issue Description

Refs #8788

This PR fixes inconsistent issue media preview behavior across
attachment and output surfaces.

## What Changed

- Unified issue media attachment gallery behavior across issue
attachments, issue output, output primary cards, and issue detail
rendering.
- Added image gallery modal coverage and regression tests for
attachments, output image handling, keyboard navigation, and issue
detail interactions.
- Preserved the primary video output Open action while still allowing
gallery browsing when a gallery handler is available.
- Updated issue attachment and issue output helper logic to preserve
shared image gallery metadata.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/ImageGalleryModal.test.tsx
src/components/issue-output/IssueOutputSection.test.tsx
src/components/IssueAttachmentsSection.test.tsx
src/pages/IssueDetail.test.tsx`
- Result: 4 files passed, 47 tests passed.

## Risks

Low to medium risk. The change is UI-scoped but touches shared issue
attachment/output rendering paths, so regressions would most likely
appear as image/video preview ordering, missing gallery entries, or
incorrect non-image attachment behavior.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-5 coding agent session with shell and git/GitHub CLI
tool use. Exact serving revision and context window were not exposed by
the runtime.

## Checklist

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

331 lines
10 KiB
TypeScript

// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { IssueAttachment } from "@paperclipai/shared";
import type { ComponentProps, ReactNode } 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 { IssueAttachmentsSection } from "./IssueAttachmentsSection";
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children, className }: { children: string; className?: string }) => (
<div className={className} data-testid="markdown-body">{children}</div>
),
}));
vi.mock("./FoldCurtain", () => ({
FoldCurtain: ({ children }: { children?: ReactNode }) => <div data-testid="fold-curtain">{children}</div>,
}));
vi.mock("@/components/ui/button", () => ({
Button: ({
asChild,
children,
onClick,
type = "button",
...props
}: ComponentProps<"button"> & { asChild?: boolean }) => {
if (asChild) return <>{children}</>;
return <button type={type} onClick={onClick} {...props}>{children}</button>;
},
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function makeAttachment(overrides: Partial<IssueAttachment> = {}): IssueAttachment {
return {
id: "attachment-1",
companyId: "company-1",
issueId: "issue-1",
issueCommentId: null,
assetId: "asset-1",
provider: "local_disk",
objectKey: "att-1",
contentType: "text/plain",
byteSize: 1024,
sha256: "sha",
originalFilename: "notes.txt",
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date("2026-06-01T00:00:00.000Z"),
updatedAt: new Date("2026-06-01T00:00:00.000Z"),
contentPath: "/api/attachments/attachment-1/content",
...overrides,
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function waitForAssertion(assertion: () => void, attempts = 20) {
let lastError: unknown;
for (let index = 0; index < attempts; index += 1) {
try {
assertion();
return;
} catch (error) {
lastError = error;
await flushReact();
}
}
throw lastError;
}
describe("IssueAttachmentsSection", () => {
let container: HTMLDivElement;
let root: Root;
let queryClient: QueryClient;
let fetchSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
fetchSpy = vi.fn().mockResolvedValue({
ok: true,
text: () => Promise.resolve("# Imported plan\n\n- Use the document renderer"),
});
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(async () => {
await act(async () => {
root.unmount();
});
queryClient.clear();
container.remove();
vi.unstubAllGlobals();
});
it("renders markdown attachments with the document markdown presentation", async () => {
const attachment = makeAttachment({
id: "markdown-attachment",
originalFilename: "plan.md",
contentType: "text/plain",
contentPath: "/api/attachments/markdown-attachment/content",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={[attachment]}
onDelete={vi.fn()}
onImageClick={vi.fn()}
/>
</QueryClientProvider>,
);
});
await flushReact();
expect(fetchSpy).toHaveBeenCalledWith(
"/api/attachments/markdown-attachment/content",
expect.objectContaining({
headers: expect.objectContaining({ Accept: expect.stringContaining("text/markdown") }),
}),
);
await waitForAssertion(() => {
expect(container.querySelector('[data-testid="fold-curtain"]')).toBeTruthy();
const markdownBody = container.querySelector('[data-testid="markdown-body"]');
expect(markdownBody?.textContent).toContain("Imported plan");
expect(markdownBody?.className).toContain("paperclip-edit-in-place-content");
});
});
it("does not promote specific non-markdown content types by filename alone", async () => {
const attachment = makeAttachment({
id: "zip-markdown",
originalFilename: "report.md",
contentType: "application/zip",
contentPath: "/api/attachments/zip-markdown/content",
openPath: "/api/attachments/zip-markdown/content",
downloadPath: "/api/attachments/zip-markdown/content?download=1",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={[attachment]}
onDelete={vi.fn()}
onImageClick={vi.fn()}
/>
</QueryClientProvider>,
);
});
await flushReact();
expect(container.querySelector('[data-testid="markdown-body"]')).toBeNull();
expect(container.textContent).toContain("report.md");
expect(container.textContent).toContain("application/zip");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("renders video attachments through the same player used for artifact outputs", async () => {
const attachment = makeAttachment({
id: "video-attachment",
originalFilename: "demo.webm",
contentType: "video/webm",
contentPath: "/api/attachments/video-attachment/content",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={[attachment]}
onDelete={vi.fn()}
onImageClick={vi.fn()}
/>
</QueryClientProvider>,
);
});
await flushReact();
const video = container.querySelector("video");
expect(video?.getAttribute("src")).toBe("/api/attachments/video-attachment/content");
expect(video?.getAttribute("controls")).not.toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("lets video attachments open the shared media gallery", async () => {
const attachment = makeAttachment({
id: "video-attachment",
originalFilename: "demo.webm",
contentType: "video/webm",
contentPath: "/api/attachments/video-attachment/content",
});
const onImageClick = vi.fn();
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={[attachment]}
onDelete={vi.fn()}
onImageClick={onImageClick}
/>
</QueryClientProvider>,
);
});
await flushReact();
const browse = container.querySelector<HTMLButtonElement>(
'button[aria-label="Browse demo.webm in gallery"]',
);
expect(browse).toBeTruthy();
await act(async () => {
browse?.click();
});
expect(onImageClick).toHaveBeenCalledWith(attachment);
});
it("treats mp4 filenames as playable videos even with a generic binary content type", async () => {
const attachment = makeAttachment({
id: "misclassified-mp4",
originalFilename: "demo.mp4",
contentType: "application/octet-stream",
contentPath: "/api/attachments/misclassified-mp4/content",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={[attachment]}
onDelete={vi.fn()}
onImageClick={vi.fn()}
/>
</QueryClientProvider>,
);
});
await flushReact();
const video = container.querySelector("video");
expect(video?.getAttribute("src")).toBe("/api/attachments/misclassified-mp4/content");
expect(container.textContent).toContain("application/octet-stream");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("does not promote specific non-video content types by filename alone", async () => {
const attachment = makeAttachment({
id: "zip-mp4",
originalFilename: "bundle.mp4",
contentType: "application/zip",
contentPath: "/api/attachments/zip-mp4/content",
openPath: "/api/attachments/zip-mp4/content",
downloadPath: "/api/attachments/zip-mp4/content?download=1",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={[attachment]}
onDelete={vi.fn()}
onImageClick={vi.fn()}
/>
</QueryClientProvider>,
);
});
await flushReact();
expect(container.querySelector("video")).toBeNull();
expect(container.textContent).toContain("bundle.mp4");
expect(container.textContent).toContain("application/zip");
expect(fetchSpy).not.toHaveBeenCalled();
});
it("keeps generic attachments as compact file rows with open and download actions", async () => {
const attachment = makeAttachment({
id: "pdf-attachment",
originalFilename: "report.pdf",
contentType: "application/pdf",
contentPath: "/api/attachments/pdf-attachment/content",
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueAttachmentsSection
attachments={[attachment]}
onDelete={vi.fn()}
onImageClick={vi.fn()}
/>
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("report.pdf");
expect(container.textContent).toContain("application/pdf");
expect(container.querySelector('a[aria-label="Open report.pdf"]')?.getAttribute("href")).toBe(
"/api/attachments/pdf-attachment/content",
);
expect(container.querySelector('a[aria-label="Download report.pdf"]')?.getAttribute("href")).toBe(
"/api/attachments/pdf-attachment/content?download=1",
);
});
});