Files
PaperClipAI/ui/src/components/FileViewerSheet.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

219 lines
8.5 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { WorkspaceFileContent } from "@paperclipai/shared";
import type { FileViewerUrlState } from "@/context/FileViewerContext";
import { ThemeProvider } from "@/context/ThemeContext";
import { describeDenial, FileContentViewer, FileViewerMetadataRow } from "./FileViewerSheet";
describe("describeDenial", () => {
it("returns the curated body for too_large regardless of fallback", () => {
expect(describeDenial("too_large", "").body).toBe(
"This file exceeds the supported preview size.",
);
expect(describeDenial("too_large", "ignored fallback").body).toBe(
"This file exceeds the supported preview size.",
);
});
it("does not leak the raw denial code as body when fallback is empty", () => {
for (const code of [
"denied_by_policy_sensitive",
"outside_workspace_root",
"workspace_archived",
"binary_unsupported",
"remote_preview_unsupported",
]) {
const { body } = describeDenial(code, "");
expect(body).not.toBe(code);
expect(body).not.toMatch(/^[a-z_]+$/);
}
});
it("describes unsupported previews in terms of text, image, and video", () => {
expect(describeDenial("unsupported_content", "").body).toBe(
"This file does not have a text, image, or video preview available.",
);
});
it("falls back to the generic message for unknown codes with empty fallback", () => {
const { body, title } = describeDenial("", "");
expect(title).toBe("Can't preview this file");
expect(body).toBe("The viewer was unable to load this file.");
});
it("prefers a human-readable server message for unknown codes", () => {
const { body } = describeDenial("unknown_code", "Server refused the request.");
expect(body).toBe("Server refused the request.");
});
});
describe("FileContentViewer", () => {
function content(overrides: Partial<WorkspaceFileContent> = {}): WorkspaceFileContent {
return {
resource: {
kind: "file",
provider: "local_fs",
title: "notes.txt",
displayPath: "notes.txt",
workspaceLabel: "Workspace",
workspaceKind: "project_workspace",
workspaceId: "11111111-1111-4111-8111-111111111111",
contentType: "text/plain; charset=utf-8",
byteSize: 18,
previewKind: "text",
capabilities: { preview: true, download: true, listChildren: false },
},
content: {
encoding: "utf8",
data: "one very long line",
},
...overrides,
};
}
it("wraps text content instead of forcing horizontal preformatted scrolling", () => {
const markup = renderToStaticMarkup(<FileContentViewer content={content()} highlightedLine={null} />);
expect(markup).toContain("whitespace-pre-wrap");
expect(markup).toContain("break-words");
});
it("reserves code gutter space for at least four digit line numbers", () => {
const markup = renderToStaticMarkup(
<FileContentViewer
content={content({
content: {
encoding: "utf8",
data: "one\ntwo\nthree",
},
})}
highlightedLine={null}
/>,
);
expect(markup).toContain("calc(4ch + 2rem)");
});
it("renders video previews with native controls", () => {
const markup = renderToStaticMarkup(
<FileContentViewer
content={content({
resource: {
...content().resource,
title: "demo.mp4",
displayPath: "demo.mp4",
contentType: "video/mp4",
previewKind: "video",
},
content: {
encoding: "base64",
data: "AAAA",
},
})}
highlightedLine={null}
/>,
);
expect(markup).toContain("<video");
expect(markup).toContain("controls");
expect(markup).toContain("data:video/mp4;base64,AAAA");
});
it("shows an icon toggle for Markdown files and defaults to rendered Markdown", () => {
const markup = renderToStaticMarkup(
<ThemeProvider>
<FileContentViewer
content={content({
resource: {
...content().resource,
title: "README.md",
displayPath: "docs/README.md",
contentType: "text/markdown; charset=utf-8",
},
content: {
encoding: "utf8",
data: "# Heading\n\nBody",
},
})}
highlightedLine={null}
/>
</ThemeProvider>,
);
expect(markup).toContain("Markdown preview mode");
expect(markup).toContain("Show rendered Markdown");
expect(markup).toContain("Show raw Markdown");
expect(markup).toContain("README.md rendered Markdown");
expect(markup).not.toContain("README.md source");
});
it("does not show the Markdown toggle for non-Markdown text files", () => {
const markup = renderToStaticMarkup(<FileContentViewer content={content()} highlightedLine={null} />);
expect(markup).not.toContain("Markdown preview mode");
expect(markup).toContain("notes.txt source");
});
});
describe("FileViewerMetadataRow", () => {
const state: FileViewerUrlState = {
path: "videos/90-days-paperclip/tweet.md",
workspace: "auto",
line: null,
column: null,
projectId: null,
workspaceId: null,
};
it("reserves metadata row height while file details load", () => {
const markup = renderToStaticMarkup(<FileViewerMetadataRow state={state} />);
expect(markup).toContain("min-h-(--sz-18px)");
expect(markup).toContain("Loading file details");
});
});
describe("workspace CSV content", () => {
const csv: WorkspaceFileContent = {
resource: { kind: "file", provider: "git_worktree", title: "export.csv", displayPath: "export.csv", workspaceLabel: "Workspace", workspaceKind: "execution_workspace", workspaceId: "ws", contentType: "text/csv", byteSize: 20, previewKind: "text", capabilities: { preview: true, download: true, listChildren: false } },
content: { encoding: "utf8", data: 'Name,Amount\n"Lee, Sam",12' },
};
it("defaults to a table and supports toolbar-controlled raw mode", () => {
const rendered = renderToStaticMarkup(<FileContentViewer content={csv} highlightedLine={null} previewMode="rendered" />);
expect(rendered).toContain("<table");
expect(rendered).toContain("Lee, Sam");
expect(rendered).not.toContain("CSV preview mode");
const raw = renderToStaticMarkup(<FileContentViewer content={csv} highlightedLine={null} previewMode="raw" />);
expect(raw).toContain("export.csv source");
expect(raw).not.toContain("<table");
});
});
describe("CSV preview limits", () => {
it.each([[500, 2], [501, 2], [1, 100], [1, 101]])(
"bounds a file with %i rows and %i columns and preserves raw source",
(rowCount, columnCount) => {
const header = Array.from({ length: columnCount }, (_, i) => `Header${i}`).join(",");
const row = Array.from({ length: columnCount }, (_, i) => `Value${i}`).join(",");
const source = [header, ...Array.from({ length: rowCount }, () => row)].join("\n");
const content: WorkspaceFileContent = {
resource: { kind: "file", provider: "git_worktree", title: "limits.csv", displayPath: "limits.csv", workspaceLabel: "Workspace", workspaceKind: "execution_workspace", workspaceId: "ws", contentType: "text/csv", byteSize: source.length, previewKind: "text", capabilities: { preview: true, download: true, listChildren: false } },
content: { encoding: "utf8", data: source },
};
const rendered = renderToStaticMarkup(<FileContentViewer content={content} highlightedLine={null} previewMode="rendered" />);
expect((rendered.match(/<td /g) ?? []).length).toBe(Math.min(rowCount, 500) * Math.min(columnCount, 100));
expect((rendered.match(/scope="col"/g) ?? []).length).toBe(Math.min(columnCount, 100) + 1);
expect(rendered).toContain(`${rowCount} ${rowCount === 1 ? "row" : "rows"}`);
expect(rendered).toContain(`${columnCount} columns`);
if (rowCount > 500 || columnCount > 100) {
expect(rendered).toContain(`Showing the first ${Math.min(rowCount, 500)} rows and ${Math.min(columnCount, 100)} columns.`);
} else {
expect(rendered).not.toContain("Showing the first");
}
const raw = renderToStaticMarkup(<FileContentViewer content={content} highlightedLine={null} previewMode="raw" />);
expect(raw).not.toContain("<table");
expect((raw.match(/Value0/g) ?? []).length).toBe(rowCount);
expect(raw).toContain(row);
expect(raw).toContain(header);
},
);
});