mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-15 12:28:10 +02:00
## 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>
219 lines
8.5 KiB
TypeScript
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);
|
|
},
|
|
);
|
|
});
|