Files
PaperClipAI/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx
T
DottaandPaperclip 0830737030 feat(ui): add CSV table previews to task file tabs (#15445)
## Thinking Path

> - Paperclip helps people manage AI agents and inspect their work.
> - Task file tabs show the files that agents produce.
> - Markdown has a readable view, but CSV files show only source text.
> - People need to scan CSV reports without a separate app.
> - This pull request adds a table view to attachment and workspace file
tabs.
> - View buttons let people inspect the original source beside download.

## Linked Issues or Issue Description

**Subsystem affected**

ui/ — task file tabs.

**Problem or motivation**

CSV exports are difficult to scan as source text. Related work:
https://github.com/paperclipai/paperclip/pull/14297 added text
attachment tabs.

**Proposed solution**

Render CSV as a table by default. Add row numbers, sticky headers, and
record counts. Keep rendered and raw icons beside download in task tabs.

**Roadmap alignment**

This is a small improvement to existing task file previews. It does not
add a new core subsystem.

## What Changed

- Add a shared CSV parser and table preview.
- Handle quoted commas, escaped quotes, UTF-8 BOM, CRLF, and multiline
cells.
- Show at most 500 data rows and 100 columns. Show a notice for larger
files. Keep the complete download.
- Add CSV attachment and workspace previews. Put workspace tab view
controls in the header.
- Add parser and interaction tests, Storybook examples, and product
documentation.

## Verification

- 28 focused tests pass across the parser, attachment tabs, and file
viewer.
- UI typecheck, UI build, and token gates pass.
- Playwright checked desktop and mobile rendered → raw → rendered flows
using the real attachment preview component in Storybook. Screenshots
are recorded on the driving task.
- Repository-wide typecheck and build were attempted. Both require
cargo, which is absent in this environment.
- The earlier repository-wide local test run stopped after a server test
race failure. The prior commit passed all GitHub CI jobs.
- Review fixes add source-line navigation coverage and CSV boundary
tests. All 22 file-viewer tests pass. UI typecheck and token gates pass.
- All 54 active GitHub checks pass on the final commit. Two Storybook
checks are skipped. Greptile gives 5/5 with no actionable findings.
- The final version preserves HTML previews from master and uses the
shared toolbar control. All 35 focused tests, UI typecheck, UI build,
and token gates pass.

## Risks

- The first CSV record supplies column headers. Files without headers
use that same rule.
- The table preview is bounded. Download and raw view retain the
complete file.
- No API, database, or dependency changes.

## Model Used

- OpenAI Codex, GPT-6. The runtime does not expose a more precise model
ID or context window. Used code editing, shell tools, and browser
checks.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [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>
2026-10-07 09:17:18 -05:00

124 lines
9.2 KiB
TypeScript

// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { queryKeys } from "@/lib/queryKeys";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TaskAttachmentPanel, TextAttachmentPreview, readTextPreview, TEXT_PREVIEW_MAX_BYTES } from "./TaskAttachmentPanel";
import { isTextAttachment } from "@/lib/issue-attachments";
import { readTaskSidePanelState, taskPanelAttachmentTab, writeTaskSidePanelState } from "@/lib/task-side-panel-state";
vi.mock("@/components/MarkdownBody", () => ({ MarkdownBody: ({ children }: { children: string }) => <article data-rendered>{children}</article> }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => { window.localStorage.clear(); vi.unstubAllGlobals(); });
describe("text attachment tabs", () => {
it("does not fetch missing or oversized attachments and offers download for oversized files", async () => {
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
client.setQueryData(queryKeys.issues.attachments("task"), [{ id: "large", originalFilename: "large.txt", contentType: "text/plain", byteSize: TEXT_PREVIEW_MAX_BYTES + 1, contentPath: "/api/attachments/large/content" }]);
const host = document.createElement("div"); const root = createRoot(host);
await act(async () => root.render(<QueryClientProvider client={client}><TaskAttachmentPanel issueId="task" attachmentId="missing" /></QueryClientProvider>));
expect(host.textContent).toContain("File no longer available");
await act(async () => root.render(<QueryClientProvider client={client}><TaskAttachmentPanel issueId="task" attachmentId="large" /></QueryClientProvider>));
expect(host.textContent).toContain("too large");
expect(host.querySelector("a")?.textContent).toBe("Download file");
expect(fetch).not.toHaveBeenCalled();
await act(async () => root.unmount()); client.clear();
});
it("classifies text without treating PDF or images as text", () => {
for (const [contentType, originalFilename] of [["text/plain", "notes.txt"], ["application/octet-stream", "AGENTS.md"], ["application/json", "report.json"]]) expect(isTextAttachment({ contentType, originalFilename })).toBe(true);
expect(isTextAttachment({ contentType: "application/pdf", originalFilename: "report.pdf" })).toBe(false);
expect(isTextAttachment({ contentType: "image/png", originalFilename: "fake.txt" })).toBe(false);
});
it("retains attachment tabs when workspace browsing is disabled", () => {
const tab = taskPanelAttachmentTab("file-1", "AGENTS.md");
writeTaskSidePanelState("user", "company", "task", { state: { tabs: [tab], activeTabId: tab.id }, launcherOpen: false, userInteracted: true, autoPlanHandled: false, updatedAt: 1 });
expect(readTaskSidePanelState("user", "company", "task", false)?.state.tabs).toEqual([tab]);
expect(taskPanelAttachmentTab("file-1", "Renamed.md").id).toBe(tab.id);
});
it("switches rendered/raw Markdown and keeps a download action", async () => {
const host = document.createElement("div"); document.body.append(host);
const root = createRoot(host);
await act(async () => root.render(<TextAttachmentPreview title="AGENTS.md" markdown text="# Charter" downloadUrl="/api/attachments/file-1/content?download=1" />));
expect(host.querySelector("[data-rendered]")).not.toBeNull();
const raw = host.querySelector<HTMLButtonElement>('button[aria-label="Raw"]');
const rendered = host.querySelector<HTMLButtonElement>('button[aria-label="Rendered"]');
expect(raw?.querySelector("svg")).not.toBeNull();
expect(rendered?.querySelector("svg")).not.toBeNull();
expect(raw?.textContent).toBe("");
await act(async () => raw!.click());
expect(host.querySelector("pre")?.textContent).toBe("# Charter");
expect(host.querySelector("a")?.getAttribute("href")).toContain("download=1");
await act(async () => rendered!.click());
expect(host.querySelector("[data-rendered]")).not.toBeNull();
await act(async () => root.unmount()); host.remove();
});
it("renders CSV cells safely and switches to the exact raw source beside download", async () => {
const host = document.createElement("div"); const root = createRoot(host);
const text = 'Name,Notes,Amount\n"Lee, Sam","<script>hello</script>",12\nJo,"line 1\nline 2",';
await act(async () => root.render(<TextAttachmentPreview title="export.csv" markdown={false} csv text={text} downloadUrl="/download" />));
expect(host.querySelector("table")).not.toBeNull();
expect(host.textContent).toContain("2 rows");
expect(host.querySelector("td")?.textContent).toBe("Lee, Sam");
expect(host.querySelector("script")).toBeNull();
expect(host.querySelector("header")?.lastElementChild?.getAttribute("href")).toBe("/download");
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Raw"]')!.click());
expect(host.querySelector("pre")?.textContent).toBe(text);
expect(host.querySelector("table")).toBeNull();
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Rendered"]')!.click());
expect(host.querySelector("table")).not.toBeNull();
await act(async () => root.unmount());
});
it("shows plain text literally without Markdown controls", async () => {
const host = document.createElement("div"); const root = createRoot(host);
await act(async () => root.render(<TextAttachmentPreview title="notes.txt" markdown={false} text="<script>alert(1)</script>" downloadUrl="/download" />));
expect(host.querySelector("script")).toBeNull();
expect(host.querySelector("pre")?.textContent).toContain("<script>");
expect(host.querySelector('[aria-label="Markdown view"]')).toBeNull();
expect(host.querySelector('a[aria-label="Download notes.txt"] svg')).not.toBeNull();
await act(async () => root.unmount());
});
it("renders HTML in an opaque sandbox, switches to literal source, and keeps download beside the controls", async () => {
const html = '<h1>Report</h1><script>parent.document.cookie</script>';
const host = document.createElement("div"); const root = createRoot(host);
await act(async () => root.render(<TextAttachmentPreview title="report.html" markdown={false} html text={html} downloadUrl="/download" />));
const frame = host.querySelector("iframe");
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame?.getAttribute("referrerpolicy")).toBe("no-referrer");
expect(frame?.getAttribute("srcdoc")).toContain("Content-Security-Policy");
expect(host.querySelector("script")).toBeNull();
const toggle = host.querySelector('[aria-label="HTML view"]');
expect(toggle?.nextElementSibling?.getAttribute("href")).toBe("/download");
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Raw"]')!.click());
expect(host.querySelector("iframe")).toBeNull();
expect(host.querySelector("pre")?.textContent).toBe(html);
expect(host.querySelector('[aria-label="Raw"]')?.getAttribute("aria-pressed")).toBe("true");
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Rendered"]')!.click());
expect(host.querySelector("iframe")).not.toBeNull();
await act(async () => root.unmount());
});
it("opens HTML from an authorized attachment and resets the view for the next file", async () => {
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
client.setQueryData(queryKeys.issues.attachments("task"), ["one", "two"].map(id => ({ id, originalFilename: `${id}.html`, contentType: "text/html", byteSize: 20, contentPath: `/api/attachments/${id}/content` })));
for (const id of ["one", "two"]) client.setQueryData(["task-text-attachment", "task", id], `<h1>${id}</h1>`);
const host = document.createElement("div"); const root = createRoot(host);
const panel = (id: string) => <QueryClientProvider client={client}><TaskAttachmentPanel issueId="task" attachmentId={id} /></QueryClientProvider>;
await act(async () => root.render(panel("one")));
expect(host.querySelector("iframe")?.getAttribute("title")).toBe("one.html rendered HTML");
await act(async () => host.querySelector<HTMLButtonElement>('[aria-label="Raw"]')!.click());
await act(async () => root.render(panel("two")));
expect(host.querySelector("iframe")?.getAttribute("title")).toBe("two.html rendered HTML");
expect(fetch).not.toHaveBeenCalled();
await act(async () => root.unmount()); client.clear();
});
it("rejects HTTP errors, oversized responses and binary data", async () => {
await expect(readTextPreview(new Response("denied", { status: 403 }))).rejects.toThrow("403");
await expect(readTextPreview(new Response("x".repeat(TEXT_PREVIEW_MAX_BYTES + 1)))).rejects.toThrow("too large");
await expect(readTextPreview(new Response("binary\0data"))).rejects.toThrow("binary");
expect(await readTextPreview(new Response(""))).toBe("");
expect(await readTextPreview(new Response("héllo"))).toBe("héllo");
});
});