fix(ui): contain patch previews and highlight diff source (#19478)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Tasks show documents and file attachments in cards and file views.
> - Patch source needs clear added and removed lines in each view.
> - Long patch lines can exceed the width of an inline preview.
> - This pull request keeps the preview inside the card and adds patch
colors.
> - Users can read a compact patch preview and open the full source.

## Linked Issues or Issue Description

**What happened?**
Patch text can exceed the inline document box. Patch files and patch
code blocks show plain text in the document view.

**Expected behavior**
Keep source inside its preview. Show added lines in green and removed
lines in red. Keep headers, source markers, copy controls, line numbers,
and downloads.

**Steps to reproduce**
1. Open a document card with a diff code block that has long unbroken
lines.
2. Open a `.patch` or `.diff` attachment in a task tab.
3. Open the same patch in the workspace file view.

**Paperclip version or commit**
Source checkout based on `e82210386f1e`.

**Deployment mode**
Built from source. The browser checks use Storybook fixtures.

Related work: #19269 adds inline diffs for file edits. This change uses
its line classifier for document and file source views.

## What Changed

- Share the unified diff line classifier with file and document views.
Track hunk lengths so later file headers keep their metadata style.
- Add safe patch source rendering for patch files and `diff` or `patch`
code blocks.
- Clip long lines with an ellipsis in compact cards. Wrap the full
source in file views.
- Add a Preview patch action for small `.patch` and `.diff` attachments,
including generic binary MIME types. Opening a large artifact list does
not fetch patch source. Bound the actual response bytes. Reject remote
preview URLs and redirects. Keep download and retry fallbacks.
- Add patch text colors with sufficient contrast in both themes. Keep
source-line selection visible over patch row colors. Copy the original
patch text without layout line breaks.
- Add five production component stories and browser checks at two widths
in both themes.
- Document the focused Storybook browser command.

## Verification

- Final base qualification at
`a120e0ada3f1eee9746f47a10384b5aa95f79824`: normally integrated the
already-merged #19610 model-fixture repair
(`8621bcf53ee2e4bbefb7d193add42dd544ca06e8`), preserving the reviewed
patch preview changes. Official preflight passes (1.8s); all 129 focused
tests pass again (23.67s), with UI typecheck and token gates passing.
Current revision CI and reviews are pending; prior-head review receipts
below are not current-head approval.

- Conflict qualification at `befc954a6116f7292506200b96623b673f9385b5`:
normally merged current master
`c66b4a55efdc5675b5a5146197078b94de6407da`, resolving the append-only
`ui/src/index.css` conflict by retaining both the patch rules and all
current agent identity rules. No feature behavior was changed during
resolution. Official isolated preflight passes. The seven focused
component/parser suites pass 129 tests; UI typecheck and token gates
pass. Fresh head CI and reviews are pending.
- Qualification setup limits: the first focused command used the
repository root instead of the UI working directory, so setup failed
before any test ran. It was corrected to the UI directory and actual
attachment-test path. The first UI typecheck found the unbuilt local
plugin SDK; building that checkout’s SDK and repeating typecheck passes.
These setup attempts are not test/typecheck passes.

- Focused Vitest run: 139 tests passed in eight files. This includes
source preservation, HTML escaping, large patches, tabs, Markdown
wrapping, line numbers, and transcript diff parsing. A 200-card check
confirms no patch fetch occurs before a preview request. CRLF
regressions cover no-newline markers and added lines that start with
`+++`.
- `pnpm --filter @paperclipai/ui typecheck`: passed after the plugin SDK
build prerequisite.
- `pnpm check:token-gates`: passed.
- Storybook catalog check and 30 map/catalog tests: passed.
- `STORYBOOK_TEST_BASE_URL=http://localhost:6006 pnpm exec playwright
test patch-preview.spec.ts
--config=tests/storybook-visual/playwright.config.ts --project=stories
--workers=2 --retries=0`: 20 passed on Linux Chromium. The checks
include accessibility, theme colors, overflow, opening a document,
wrapping source, exact clipboard contents, and the selected source line.
Screenshots were inspected and saved to the task.
- The first browser runs found contrast failures in diff text and source
gutters. Patch text tokens and patch gutter opacity now fix those
failures. The final run passes without retries.
- `pnpm verify`: exit 2. The new browser spec is a global CI input.
Required CI must supply broad coverage.
- The local full Storybook build was killed with exit 137. This is not a
build pass. External screenshot baselines are not published, so no
baseline comparison was run.
- Reviews found selected-row background precedence, file headers in
unified patches without git separators, eager patch loading across a
collection, and CRLF no-newline markers. Each finding has a focused fix
and regression checks. The Storybook hunk count now matches its source
lines.
- Prior feature commit `b3cb9d6d3c0423315540514b1ad68325a64c1cc2`: [CI
typecheck, build, tests, runner checks, and E2E
passed](https://github.com/paperclipai/paperclip/actions/runs/42247974154).
[Security scan
passed](https://github.com/paperclipai/paperclip/runs/127995160859).
[Greptile is
5/5](https://github.com/paperclipai/paperclip/pull/19478#issuecomment-6688482753),
and [Paperclip Review is
5/5](https://github.com/paperclipai/paperclip/runs/127998788846). All
six inline review threads are resolved.

## Risks

- Compact previews omit the end of long lines. The full document and
downloads keep the source.
- Patch colors apply to unified diff syntax. Other patch formats keep
literal text and do not have full format parsing.
- The shared classifier keeps the existing task chat behavior.
Regression tests cover that consumer.
- No database, API, or access rules change.

## Model Used

- OpenAI Codex, GPT-6, with reasoning, tool use, and code execution. 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
- [ ] 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

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-11-07 00:06:50 +00:00
1 parent 55244f1e9c
commit 752cb8d3da
22 files changed
+559 -73

No files matched your search

+14
View File
@@ -565,6 +565,20 @@ STORYBOOK_TEST_BASE_URL=http://localhost:6006 pnpm exec playwright test failure-
The suite checks Storybook play and accessibility reports and saves screenshots.
It does not compare visual baselines or dispatch real agent work.
**Patch previews** mounts production inline document cards, attachment tabs,
workspace source views, and Markdown diff fences. Use **Preview patch** to load
an inline attachment preview. Opening the artifact list does not load patch bytes.
The fixtures include long
unbroken lines, multiple files, and literal HTML. The browser checks cover both
themes at desktop and mobile widths, source preservation, and overflow:
```sh
STORYBOOK_TEST_BASE_URL=http://localhost:6006 pnpm exec playwright test patch-preview.spec.ts --config=tests/storybook-visual/playwright.config.ts --project=stories
```
These checks save screenshots. They do not compare external visual baselines or
verify live file storage.
**HTML artifacts** covers secure rendered/raw attachment previews, interactive
reports, security probes, workspace previews, and opening a report from a task.
See [HTML artifact previews](html-artifact-previews.md) for the security boundary,
@@ -0,0 +1,46 @@
import { expect, test } from "@playwright/test";
for (const theme of ["light", "dark"]) {
for (const width of [390, 1200]) {
for (const story of ["inline-patch", "markdown-card", "attachment-tab", "workspace-tab", "document"]) {
test(`${story} ${theme} ${width}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto(`/iframe.html?id=documents-patch-preview--${story}&viewMode=story&globals=theme:${theme}`);
const finished = () => page.evaluate(() => (window as unknown as {
__STORYBOOK_ADDONS_CHANNEL__?: { data: Record<string, Array<{ status?: string }>> };
}).__STORYBOOK_ADDONS_CHANNEL__?.data.storyFinished?.[0]?.status);
await expect.poll(finished, { timeout: 30_000 }).not.toBeUndefined();
expect(await finished()).toBe("success");
expect(errors).toEqual([]);
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
const added = page.locator('[data-patch-line="add"]').first();
const removed = page.locator('[data-patch-line="remove"]').first();
await expect(added).toBeVisible();
await expect(removed).toBeVisible();
const colors = await Promise.all([added, removed].map((line) => line.evaluate((node) => ({
color: getComputedStyle(node).color, background: getComputedStyle(node).backgroundColor,
}))));
expect(colors[0].color).not.toBe(colors[1].color);
expect(colors[0].background).not.toBe(colors[1].background);
for (const pre of await page.locator("main pre").all()) {
expect(await pre.evaluate((node) => node.scrollWidth <= node.clientWidth + 1)).toBe(true);
}
if (story === "workspace-tab") {
const selected = page.locator('[data-line-selected]');
await expect(selected).toHaveAttribute("data-line-number", "8");
const ordinaryAdded = page.locator('[data-patch-line="add"]:not([data-line-selected])').first();
expect(await selected.evaluate((node) => getComputedStyle(node).backgroundColor)).not.toBe(await ordinaryAdded.evaluate((node) => getComputedStyle(node).backgroundColor));
}
if (story === "document") {
await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
await page.getByRole("button", { name: "Copy code", exact: true }).click();
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(await page.locator("main pre code").textContent());
}
await expect(page.locator("main script")).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath(`${story}-${theme}-${width}.png`), fullPage: true });
});
}
}
}
@@ -77,6 +77,19 @@ describe("FileContentViewer", () => {
expect(markup).toContain("break-words");
});
it("highlights patch changes while retaining source line numbers and the selected line", () => {
const base = content();
const markup = renderToStaticMarkup(<FileContentViewer content={content({
resource: { ...base.resource, title: "fix.patch", displayPath: "fix.patch", contentType: "text/x-patch" },
content: { encoding: "utf8", data: "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+new" },
})} highlightedLine={5} />);
expect(markup).toContain('data-line-number="4" data-patch-line="remove"');
expect(markup).toContain('data-line-number="5" data-patch-line="add"');
expect(markup).toContain("bg-(--code-highlight-bg-resolved)");
expect(markup).toContain("-old");
expect(markup).toContain("+new");
});
it("reserves code gutter space for at least four digit line numbers", () => {
const markup = renderToStaticMarkup(
<FileContentViewer
+13 -2
View File
@@ -1,4 +1,5 @@
import { CsvPreview } from "./CsvPreview";
import { createUnifiedDiffClassifier, isPatchFile } from "@/lib/patch";
import { isCsvFile } from "@/lib/csv-preview";
import {
useCallback,
@@ -261,6 +262,12 @@ export function FileContentViewer({ content, highlightedLine, onLoaded, previewM
return null;
}, [content.content.data, resource.previewKind]);
const patchLineKinds = useMemo(() => {
if (!lines || !isPatchFile(resource.displayPath || resource.title, resource.contentType)) return null;
const classify = createUnifiedDiffClassifier();
return lines.map((line) => classify(line.replace(/\r$/, "")).kind);
}, [lines, resource.displayPath, resource.title, resource.contentType]);
const codeScrollRef = useRef<HTMLDivElement>(null);
const highlightedLineRef = useRef<HTMLDivElement>(null);
@@ -346,7 +353,7 @@ export function FileContentViewer({ content, highlightedLine, onLoaded, previewM
role="region"
aria-label={`${resource.title} source`}
tabIndex={0}
className="paperclip-file-viewer-code flex-1 overflow-auto bg-(--code-bg-resolved) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
className="paperclip-file-viewer-code min-w-0 flex-1 overflow-auto bg-(--code-bg-resolved) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<pre className="m-0 font-mono text-xs leading-5">
{lines.map((lineText, index) => {
@@ -357,8 +364,11 @@ export function FileContentViewer({ content, highlightedLine, onLoaded, previewM
key={lineNumber}
ref={isHighlighted ? highlightedLineRef : undefined}
data-line-number={lineNumber}
data-patch-line={patchLineKinds?.[index]}
data-line-selected={isHighlighted || undefined}
className={cn(
"grid grid-cols-(--gtc-5)",
patchLineKinds && "paperclip-patch-line",
// Batch 4 resolved the former half-migrated var(--x, fallback)
// pattern here into --code-highlight-bg-resolved (see
// ui/src/index.css MISC token block + TOKEN-AUDIT.md Batch 4 log).
@@ -370,6 +380,7 @@ export function FileContentViewer({ content, highlightedLine, onLoaded, previewM
className={cn(
"sticky left-0 z-10 shrink-0 select-none pl-3 pr-4 text-right text-(--code-gutter-fg-resolved) opacity-70",
"bg-(--code-bg-resolved)",
patchLineKinds && "text-(--patch-meta-fg) opacity-100",
isHighlighted &&
"opacity-100 bg-(--code-highlight-bg-resolved) border-l-2 border-(--code-highlight-border-resolved)",
)}
@@ -377,7 +388,7 @@ export function FileContentViewer({ content, highlightedLine, onLoaded, previewM
>
{lineNumber}
</span>
<code className="min-w-0 whitespace-pre-wrap break-words pr-4">{lineText.length === 0 ? "​" : lineText}</code>
<code className={cn("min-w-0 whitespace-pre-wrap break-words pr-4", patchLineKinds && "wrap-anywhere")}>{lineText.length === 0 ? "​" : lineText}</code>
</div>
);
})}
+8 -3
View File
@@ -1,3 +1,4 @@
import { PatchLines } from "./PatchLines";
import { MarkdownWordStreaming, rehypeStreamingWords } from "./MarkdownWordStreaming";
import { isValidElement, memo, useContext, useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -583,6 +584,10 @@ function CodeBlock({
children: ReactNode;
preProps: React.HTMLAttributes<HTMLPreElement>;
}) {
const code = Array.isArray(children) ? children.find(isValidElement) : children;
const patchCode = isValidElement<{ className?: string; children?: ReactNode }>(code)
&& /\blanguage-(diff|patch)\b/i.test(code.props.className ?? "");
const patchText = patchCode ? flattenText(code.props.children) : null;
const [copied, setCopied] = useState(false);
const [failed, setFailed] = useState(false);
const [wrapLines, setWrapLines] = useState(false);
@@ -592,7 +597,7 @@ function CodeBlock({
useEffect(() => () => clearTimeout(timerRef.current), []);
const handleCopy = useCallback(async () => {
const text = preRef.current?.innerText ?? flattenText(children);
const text = patchText ?? preRef.current?.innerText ?? flattenText(children);
try {
await copyTextToClipboard(text);
setFailed(false);
@@ -606,7 +611,7 @@ function CodeBlock({
setCopied(false);
setFailed(false);
}, 1500);
}, [children]);
}, [children, patchText]);
const copyLabel = failed ? "Copy failed" : copied ? "Copied!" : "Copy";
const wrapLabel = wrapLines ? "Unwrap lines" : "Wrap lines";
@@ -628,7 +633,7 @@ function CodeBlock({
: null),
}}
>
{children}
{patchText !== null ? <code className="language-diff"><PatchLines text={patchText} /></code> : children}
</pre>
<div
className="paperclip-markdown-codeblock-actions"
@@ -48,6 +48,21 @@ describe("MarkdownBody code block wrapping", () => {
container.remove();
});
it.each(["diff", "patch"])("highlights %s fences without changing source or wrap controls", (language) => {
const text = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n--- old\n+++ <script>new</script>\n";
flushSync(() => root.render(<QueryClientProvider client={queryClient}><ThemeProvider>
<MarkdownBody>{"```" + language + "\n" + text + "```"}</MarkdownBody>
</ThemeProvider></QueryClientProvider>));
expect(container.querySelector("pre code")?.textContent).toBe(text);
expect(container.querySelector('[data-patch-line="remove"]')?.textContent).toBe("--- old\n");
expect(container.querySelector('[data-patch-line="add"]')?.textContent).toBe("+++ <script>new</script>\n");
expect(container.querySelector("script")).toBeNull();
const wrap = container.querySelector<HTMLButtonElement>('[aria-label="Wrap lines"]');
flushSync(() => wrap!.click());
expect(container.querySelector("pre")?.style.overflowWrap).toBe("anywhere");
expect(container.querySelector("pre code")?.textContent).toBe(text);
});
it("toggles fenced code blocks between horizontal scroll and wrapped lines", () => {
flushSync(() => {
root.render(
+37
View File
@@ -0,0 +1,37 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { PatchLines } from "./PatchLines";
import { isPatchFile } from "@/lib/patch";
describe("patch source", () => {
it.each(["fix.patch", "FIX.DIFF"])("recognizes %s and patch MIME types", (filename) => {
expect(isPatchFile(filename)).toBe(true);
expect(isPatchFile("download", "Text/X-Patch; charset=utf-8")).toBe(true);
expect(isPatchFile(null, "text/x-diff")).toBe(true);
expect(isPatchFile("notes.txt", "text/plain")).toBe(false);
});
it("preserves CRLF source and header-like changes across multiple files, and escapes HTML", () => {
const source = "diff --git a/a b/a\r\n--- a/a\r\n+++ b/a\r\n@@ -1 +1 @@\r\n--- old\r\n+++ new\r\ndiff --git a/b b/b\r\n--- a/b\r\n+++ b/b\r\n@@ -1 +1 @@\r\n-<script>old</script>\r\n+new";
const markup = renderToStaticMarkup(<code><PatchLines text={source} /></code>);
expect(markup.match(/data-patch-line="add"/g)).toHaveLength(2);
expect(markup.match(/data-patch-line="remove"/g)).toHaveLength(2);
expect(markup.match(/data-patch-line="skip"/g)).toHaveLength(6);
expect(markup).not.toContain("<script>");
expect(markup.replace(/<[^>]*>/g, "").replaceAll("&lt;", "<").replaceAll("&gt;", ">")).toBe(source);
});
it("distinguishes later file headers in a unified patch without git separators", () => {
const source = "--- a/one\n+++ b/one\n@@ -1 +1 @@\n--- old\n+++ new\n--- a/two\n+++ b/two\n@@ -0,0 +1 @@\n+new file\n";
const markup = renderToStaticMarkup(<PatchLines text={source} />);
expect(markup.match(/data-patch-line="add"/g)).toHaveLength(2);
expect(markup.match(/data-patch-line="remove"/g)).toHaveLength(1);
expect(markup.match(/data-patch-line="skip"/g)).toHaveLength(4);
});
it("renders all lines in a large patch", () => {
const source = "@@ -1 +1,2000 @@\n-old\n" + "+new\n".repeat(2000);
const markup = renderToStaticMarkup(<PatchLines text={source} />);
expect(markup.match(/data-patch-line="add"/g)).toHaveLength(2000);
});
});
+15
View File
@@ -0,0 +1,15 @@
import { memo, useMemo } from "react";
import { createUnifiedDiffClassifier } from "@/lib/patch";
export const PatchLines = memo(function PatchLines({ text }: { text: string }) {
const lines = useMemo(() => {
const classify = createUnifiedDiffClassifier();
return text.split(/(?<=\n)/).map((line) => ({
text: line,
kind: classify(line.replace(/\r?\n$/, "")).kind,
}));
}, [text]);
return <>{lines.map((line, index) => (
<span key={index} className="paperclip-patch-line" data-patch-line={line.kind}>{line.text}</span>
))}</>;
});
@@ -1,3 +1,5 @@
import { isPatchFile } from "@/lib/patch";
import { loadAttachmentText, TEXT_PREVIEW_MAX_BYTES } from "@/lib/text-preview";
import { useContext, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
@@ -30,6 +32,7 @@ import { QueryErrorState, useQueryView } from "@/components/QueryView";
import {
CommitCard,
DataCard,
DocumentCard,
FileCard,
ImageCard,
LinkPreviewCard,
@@ -70,6 +73,7 @@ export function IssueArtifactFile(props: IssueArtifactFileProps) {
) : null;
const [galleryOpen, setGalleryOpen] = useState(false);
const [csvRequested, setCsvRequested] = useState(false);
const [patchRequested, setPatchRequested] = useState(false);
const image = isImageLikeOutput(props.contentType, props.filename);
const video = isVideoLikeOutput(props.contentType, props.filename);
const csv =
@@ -86,6 +90,16 @@ export function IssueArtifactFile(props: IssueArtifactFileProps) {
enabled: localCsv && !tooLarge && csvRequested,
staleTime: Infinity,
});
const patch = isPatchFile(props.filename, props.contentType) && isTextAttachment({ contentType: props.contentType, originalFilename: props.filename });
const patchEligible = patch && (props.byteSize === null || props.byteSize <= TEXT_PREVIEW_MAX_BYTES)
&& /^\/api\/attachments\/[a-zA-Z0-9-]+\/content$/.test(props.contentPath);
const patchData = useQuery({
queryKey: ["artifact-patch", props.id, props.contentPath, props.updatedAt],
queryFn: ({ signal }) => loadAttachmentText(props.contentPath, signal),
enabled: patchEligible && patchRequested,
staleTime: Infinity,
});
const patchView = useQueryView(patchData);
// Read the CSV preview through the shared view: a loaded preview stays
// visible during a transient refetch failure,and a no-data failure shows
// readable copy with Retry.
@@ -136,6 +150,12 @@ export function IssueArtifactFile(props: IssueArtifactFileProps) {
</>
);
}
if (patchEligible && patchData.data !== undefined) {
return <DocumentCard {...props} patch body={patchData.data} actions={<>
{openTextAction}
{downloadPath ? <Button asChild size="sm" variant="outline"><a href={downloadPath} download={props.filename}>Download file</a></Button> : null}
</>} />;
}
if (localCsv && !tooLarge && data.data) {
return (
<DataCard
@@ -168,6 +188,11 @@ export function IssueArtifactFile(props: IssueArtifactFileProps) {
actions={
<>
{openTextAction}
{patchEligible && patchView.kind !== "error" ? (
<Button variant="outline" size="sm" disabled={patchData.isFetching} onClick={() => setPatchRequested(true)}>
{patchData.isFetching ? "Loading preview…" : "Preview patch"}
</Button>
) : null}
{localCsv && !tooLarge && dataView.kind !== "error" ? (
<Button
variant="outline"
@@ -181,6 +206,9 @@ export function IssueArtifactFile(props: IssueArtifactFileProps) {
</>
}
/>
{patchEligible && patchView.kind === "error" ? (
<QueryErrorState size="inline" error={patchView.error} action="load the patch preview" onRetry={patchView.retry} retrying={patchView.isFetching} />
) : patchEligible && patchData.isFetching ? <p role="status" className="text-xs text-muted-foreground">Loading patch preview…</p> : null}
{csv && (tooLarge || dataView.kind === "error" || !localCsv) && (
<div className="px-2 text-xs text-muted-foreground" role="status">
{tooLarge
@@ -12,6 +12,8 @@ vi.mock("@/lib/artifact-card-data", async (importOriginal) => ({
loadArtifactCsv: vi.fn(),
}));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("CSV preview consent", () => {
it("does not fetch any CSV until its preview is requested", async () => {
const load = vi
@@ -86,3 +88,93 @@ describe("CSV preview consent", () => {
}
});
});
describe("inline patch attachment preview", () => {
const text = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+<script>new</script>\n";
async function mount(overrides: Partial<React.ComponentProps<typeof IssueArtifactFile>> = {}) {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => root.render(<QueryClientProvider client={client}><IssueArtifactFile
id="patch" attachmentId="patch" title="Fix preview" summary="" author="" updatedAt="Today"
filename="fix.patch" contentType="application/octet-stream" byteSize={100}
contentPath="/api/attachments/patch/content" downloadPath="/api/attachments/patch/content?download=1"
{...overrides}
/></QueryClientProvider>));
return { container, cleanup: async () => { await act(async () => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); } };
}
it("does not fetch patch attachments in a large collection until one preview is requested", async () => {
const fetch = vi.fn(async () => new Response(text)); vi.stubGlobal("fetch", fetch);
const container = document.createElement("div"); document.body.append(container);
const root = createRoot(container);
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
try {
await act(async () => root.render(<QueryClientProvider client={client}>
{Array.from({ length: 200 }, (_, index) => <IssueArtifactFile key={index}
id={`patch-${index}`} attachmentId={`patch-${index}`} title={`Patch ${index}`} summary="" author="" updatedAt="Today"
filename={`fix-${index}.patch`} contentType="text/x-patch" byteSize={512 * 1024}
contentPath={`/api/attachments/patch-${index}/content`} downloadPath={`/api/attachments/patch-${index}/content?download=1`}
/>)}
</QueryClientProvider>));
expect(fetch).not.toHaveBeenCalled();
const preview = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Preview patch");
expect(preview).toBeDefined();
await act(async () => preview!.click());
await vi.waitFor(() => expect(container.querySelector('[data-patch-line="add"]')).not.toBeNull());
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith("/api/attachments/patch-0/content", expect.anything());
} finally { await act(async () => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); }
});
it.each([
{ filename: "fix.patch", contentType: "application/octet-stream" },
{ filename: "download", contentType: "text/x-diff" },
])("loads and highlights $filename in the real card", async (overrides) => {
const fetch = vi.fn().mockResolvedValue(new Response(text)); vi.stubGlobal("fetch", fetch);
const { container, cleanup } = await mount(overrides);
try {
expect(fetch).not.toHaveBeenCalled();
const preview = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Preview patch");
await act(async () => preview!.click());
await vi.waitFor(() => expect(container.querySelector('[data-patch-line="add"]')).not.toBeNull());
expect(container.querySelector("pre")?.textContent).toBe(text);
expect(container.querySelector("script")).toBeNull();
expect(container.querySelector('a[download]')?.getAttribute("href")).toBe("/api/attachments/patch/content?download=1");
expect(fetch).toHaveBeenCalledWith("/api/attachments/patch/content", expect.objectContaining({ credentials: "same-origin", redirect: "error", signal: expect.any(AbortSignal) }));
} finally { await cleanup(); }
});
it.each([
{ contentPath: "https://example.com/fix.patch" },
{ byteSize: 512 * 1024 + 1 },
{ filename: "notes.txt", contentType: "text/plain" },
])("keeps the download fallback without fetching ineligible source %j", async (overrides) => {
const fetch = vi.fn(); vi.stubGlobal("fetch", fetch);
const { container, cleanup } = await mount(overrides);
try {
expect(fetch).not.toHaveBeenCalled();
expect(container.querySelector('a[download]')).not.toBeNull();
expect(container.querySelector('[data-patch-line]')).toBeNull();
} finally { await cleanup(); }
});
it("keeps download after a load failure and retries the patch preview", async () => {
const fetch = vi.fn().mockRejectedValueOnce(new Error("Patch preview failed")).mockResolvedValueOnce(new Response(text));
vi.stubGlobal("fetch", fetch);
const { container, cleanup } = await mount();
try {
const preview = Array.from(container.querySelectorAll("button")).find(button => button.textContent === "Preview patch");
await act(async () => preview!.click());
await vi.waitFor(() => expect(container.textContent).toContain("Patch preview failed"));
expect(container.querySelector('a[download]')).not.toBeNull();
const retry = Array.from(container.querySelectorAll("button")).find(button => button.textContent?.includes("Retry"));
expect(retry).toBeDefined();
await act(async () => retry!.click());
await vi.waitFor(() => expect(container.querySelector('[data-patch-line="add"]')).not.toBeNull());
expect(fetch).toHaveBeenCalledTimes(2);
} finally { await cleanup(); }
});
});
@@ -1,3 +1,5 @@
import { isPatchFile } from "@/lib/patch";
import { PatchLines } from "@/components/PatchLines";
import { useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import {
@@ -79,7 +81,7 @@ function Footer({
function Card({ children }: { children: ReactNode }) {
return (
<article className="w-full overflow-hidden rounded-lg border border-border bg-card text-card-foreground">
<article className="w-full min-w-0 overflow-hidden rounded-lg border border-border bg-card text-card-foreground">
{children}
</article>
);
@@ -302,11 +304,16 @@ export function CommitCard(props: CommitCardProps) {
);
}
function Markdown({ body }: { body: string }) {
function Markdown({ body, preview = false }: { body: string; preview?: boolean }) {
return (
<div className="flex flex-col gap-3 text-sm leading-relaxed">
<ReactMarkdown
components={{
pre: ({ children }) => <pre className={preview
? "paperclip-patch-preview min-w-0 max-w-full overflow-hidden whitespace-pre font-mono text-xs"
: "min-w-0 max-w-full overflow-x-auto whitespace-pre-wrap wrap-anywhere font-mono text-xs"}>{children}</pre>,
code: ({ className, children }) => <code className={className}>{/\blanguage-(diff|patch)\b/i.test(className ?? "")
? <PatchLines text={String(children)} /> : children}</code>,
h1: ({ children }) => (
<h3 className="text-lg font-semibold">{children}</h3>
),
@@ -368,6 +375,7 @@ function Markdown({ body }: { body: string }) {
}
export interface DocumentCardProps extends ArtifactIdentity {
patch?: boolean;
filename: string;
revision?: number | null;
body: string;
@@ -376,6 +384,12 @@ export interface DocumentCardProps extends ArtifactIdentity {
actions?: ReactNode;
}
export function DocumentCard(props: DocumentCardProps) {
const patch = props.patch ?? isPatchFile(props.filename);
const content = (body: string, preview = false) => patch
? <pre className={preview
? "paperclip-patch-preview m-0 min-w-0 overflow-hidden whitespace-pre font-mono text-xs leading-5"
: "m-0 min-w-0 whitespace-pre-wrap wrap-anywhere font-mono text-xs leading-5"}><code><PatchLines text={body} /></code></pre>
: <Markdown body={body} preview={preview} />;
return (
<Card>
{props.body && (
@@ -383,12 +397,12 @@ export function DocumentCard(props: DocumentCardProps) {
className="max-h-52 overflow-hidden border-b border-border bg-muted/20 p-5"
inert
>
<Markdown body={props.body.slice(0, 4000)} />
{content(props.body.slice(0, 4000), true)}
</div>
)}
<div className="flex flex-col gap-3 p-5">
<span className="flex items-center gap-2 text-xs text-muted-foreground">
<FileText className="size-4" /> Markdown
<FileText className="size-4" /> {patch ? "Patch" : "Markdown"}
{props.revision != null && ` · revision ${props.revision}`}
</span>
<Identity {...props} />
@@ -416,7 +430,7 @@ export function DocumentCard(props: DocumentCardProps) {
description={props.filename}
action="Read document"
>
<Markdown body={props.body} />
{content(props.body)}
</Viewer>
)}
</div>
@@ -14,4 +14,14 @@ describe("parseUnifiedDiffLines", () => {
{ kind: "context", text: "keep" },
]);
});
it("preserves CRLF changed lines after a no-newline marker", () => {
const parsed = parseUnifiedDiffLines("@@ -1 +1 @@\r\n-old\r\n\\ No newline at end of file\r\n+++ new\r\n");
expect(parsed).toMatchObject({ added: 1, removed: 1 });
expect(parsed.lines).toEqual([
{ kind: "hunk", text: "@@ -1 +1 @@" },
{ kind: "remove", text: "old" },
{ kind: "add", text: "++ new" },
]);
});
});
+3 -29
View File
@@ -1,37 +1,11 @@
import type { TaskChatDiffLine } from "./task-chat-model";
import { createUnifiedDiffClassifier } from "@/lib/patch";
/** Display cap for one diff in the task chat. */
export const TASK_CHAT_DIFF_LINE_LIMIT = 400;
export type UnifiedDiffLineKind = TaskChatDiffLine["kind"] | "skip";
/**
* Classify unified diff lines. File headers (`diff --git`, `index`, `---`,
* `+++`, mode and rename lines) are skipped only before a file's first hunk;
* inside a hunk, `--- x` is a removed `-- x` line and `+++ x` an added `++ x`.
*/
export function createUnifiedDiffClassifier(): (raw: string) => { kind: UnifiedDiffLineKind; text: string } {
let inHunk = false;
return (raw) => {
if (raw.startsWith("diff --git ")) {
inHunk = false;
return { kind: "skip", text: raw };
}
if (raw.startsWith("@@")) {
inHunk = true;
return { kind: "hunk", text: raw };
}
if (raw === "\\ No newline at end of file") return { kind: "skip", text: raw };
if (!inHunk && (
raw.startsWith("+++ ") || raw.startsWith("--- ") || raw.startsWith("index ") ||
raw.startsWith("new file mode") || raw.startsWith("deleted file mode") ||
raw.startsWith("similarity index") || raw.startsWith("rename from") || raw.startsWith("rename to")
)) return { kind: "skip", text: raw };
if (raw.startsWith("+")) return { kind: "add", text: raw.slice(1) };
if (raw.startsWith("-")) return { kind: "remove", text: raw.slice(1) };
return { kind: "context", text: raw.startsWith(" ") ? raw.slice(1) : raw };
};
}
export { createUnifiedDiffClassifier } from "@/lib/patch";
export type { PatchLineKind as UnifiedDiffLineKind } from "@/lib/patch";
/**
* Parse a unified diff body into display lines. File headers (`---`, `+++`,
@@ -27,7 +27,7 @@ describe("text attachment tabs", () => {
});
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);
for (const [contentType, originalFilename] of [["application/octet-stream", "fix.patch"], ["application/octet-stream", "fix.diff"], ["text/x-patch", "download"], ["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);
});
@@ -70,6 +70,17 @@ describe("text attachment tabs", () => {
expect(host.querySelector("table")).not.toBeNull();
await act(async () => root.unmount());
});
it.each(["fix.patch", "fix.diff"])("highlights %s while preserving literal source and download", async (title) => {
const host = document.createElement("div"); const root = createRoot(host);
const text = "--- a/file\n+++ b/file\n@@ -1 +1 @@\n-old\n+<script>new</script>\n";
await act(async () => root.render(<TextAttachmentPreview title={title} markdown={false} text={text} downloadUrl="/download" />));
expect(host.querySelector("pre")?.textContent).toBe(text);
expect(host.querySelector('[data-patch-line="add"]')?.textContent).toBe("+<script>new</script>\n");
expect(host.querySelector('[data-patch-line="remove"]')?.textContent).toBe("-old\n");
expect(host.querySelector("script")).toBeNull();
expect(host.querySelector("a")?.getAttribute("href")).toBe("/download");
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" />));
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button";
import { QueryErrorState, useQueryView } from "@/components/QueryView";
import { CsvPreview } from "@/components/CsvPreview";
import { isCsvFile } from "@/lib/csv-preview";
import { PatchLines } from "@/components/PatchLines";
import { isPatchFile } from "@/lib/patch";
import { MarkdownBody } from "@/components/MarkdownBody";
import { HtmlArtifactPreview } from "@/components/HtmlArtifactPreview";
import { FilePreviewModeToggle, type FilePreviewMode } from "@/components/FilePreviewModeToggle";
@@ -13,40 +15,16 @@ import { isHtmlPreview } from "@/lib/html-preview";
import { attachmentDownloadPath, isMarkdownAttachment, isTextAttachment } from "@/lib/issue-attachments";
import { queryKeys } from "@/lib/queryKeys";
export const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
import { readTextPreview, TEXT_PREVIEW_MAX_BYTES } from "@/lib/text-preview";
export { readTextPreview, TEXT_PREVIEW_MAX_BYTES } from "@/lib/text-preview";
/** Bound the actual response, not just producer-supplied attachment metadata. */
export async function readTextPreview(response: Response) {
// Carry the status so the shared classification can tell an outage (503) from a real failure.
if (!response.ok) throw Object.assign(new Error(`Could not load file (${response.status}).`), { status: response.status });
if (!response.body) throw new Error("The file response is empty.");
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8", { fatal: true });
let bytes = 0;
let text = "";
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > TEXT_PREVIEW_MAX_BYTES) throw new Error("This file is too large to preview. Download it instead.");
text += decoder.decode(chunk.value, { stream: true });
}
text += decoder.decode();
if (text.includes("\0")) throw new Error("This file contains binary data. Download it instead.");
return text;
} finally {
await reader.cancel();
reader.releaseLock();
}
}
export function TextAttachmentPreview({ title, text, markdown, html = false, csv = false, downloadUrl }: {
export function TextAttachmentPreview({ title, text, markdown, html = false, csv = false, patch = isPatchFile(title), downloadUrl }: {
title: string;
text: string;
markdown: boolean;
html?: boolean;
csv?: boolean;
patch?: boolean;
downloadUrl: string;
}) {
const [mode, setMode] = useState<FilePreviewMode>("rendered");
@@ -54,20 +32,21 @@ export function TextAttachmentPreview({ title, text, markdown, html = false, csv
const renderCsv = csv && mode === "rendered" && text.length > 0;
const renderHtml = html && mode === "rendered" && text.length > 0;
return (
<div className="flex h-full min-h-0 flex-col">
<header className="flex items-center gap-2 border-b border-border px-3 py-2">
<div className="flex h-full min-h-0 min-w-0 max-w-full flex-col">
<header className="flex min-w-0 items-center gap-2 border-b border-border px-3 py-2">
<h2 className="min-w-0 flex-1 truncate text-sm font-medium" title={title}>{title}</h2>
{markdown || html || csv ? <FilePreviewModeToggle mode={mode} onChange={setMode} label={html ? "HTML view" : csv ? "CSV view" : "Markdown view"} /> : null}
<Button asChild variant="ghost" size="icon-sm">
<a href={downloadUrl} download aria-label={`Download ${title}`} title={`Download ${title}`}><Download aria-hidden /></a>
</Button>
</header>
<div className={renderHtml || renderCsv ? "flex min-h-0 flex-1 flex-col overflow-hidden" : "min-h-0 flex-1 overflow-auto p-4"}>
<div className={renderHtml || renderCsv ? "flex min-h-0 flex-1 flex-col overflow-hidden" : "min-h-0 min-w-0 flex-1 overflow-auto p-4"}>
{text.length === 0 ? <p className="text-sm text-muted-foreground">File is empty.</p>
: renderCsv ? <CsvPreview text={text} title={title} />
: renderHtml ? <HtmlArtifactPreview html={text} title={title} />
: markdown && mode === "rendered" ? <MarkdownBody mediaMode="reference">{text}</MarkdownBody>
: <pre className="whitespace-pre-wrap break-words font-mono text-sm" aria-label={`${title} raw text`}>{text}</pre>}
: patch ? <pre className="paperclip-patch-source m-0 min-w-0 whitespace-pre-wrap wrap-anywhere font-mono text-sm" aria-label={`${title} patch`}><code><PatchLines text={text} /></code></pre>
: <pre className="whitespace-pre-wrap wrap-anywhere font-mono text-sm" aria-label={`${title} raw text`}>{text}</pre>}
</div>
</div>
);
@@ -115,5 +94,5 @@ export function TaskAttachmentPanel({ issueId, attachmentId }: { issueId: string
);
}
if (content.data === undefined) return <p className="p-4 text-sm" role="status">Loading file…</p>;
return <TextAttachmentPreview key={attachment.id} title={attachment.originalFilename ?? attachment.id} text={content.data} csv={isCsvFile(attachment.originalFilename ?? "", attachment.contentType)} markdown={isMarkdownAttachment(attachment)} html={isHtmlPreview(attachment.contentType, attachment.originalFilename)} downloadUrl={downloadUrl} />;
return <TextAttachmentPreview key={attachment.id} title={attachment.originalFilename ?? attachment.id} text={content.data} patch={isPatchFile(attachment.originalFilename, attachment.contentType)} csv={isCsvFile(attachment.originalFilename ?? "", attachment.contentType)} markdown={isMarkdownAttachment(attachment)} html={isHtmlPreview(attachment.contentType, attachment.originalFilename)} downloadUrl={downloadUrl} />;
}
@@ -1476,6 +1476,17 @@ describe("edit diffs", () => {
expect(tool).toMatchObject({ kind: "tool", target: "src/price.js", diff: { path: "src/price.js", added: 1, removed: 1 } });
});
it("preserves CRLF changes after a no-newline marker in an edit transcript", () => {
const entries = nativeRunEventsToTranscript([
event(1, "tool.execution.completed", execution("completed", [change("@@ -1 +1 @@\r\n-old\r\n\\ No newline at end of file\r\n+++ new\r\n", 1, 1)])),
]);
expect(entries.filter((entry) => entry.kind === "diff" && entry.changeType !== "file_header")).toEqual([
expect.objectContaining({ changeType: "hunk", text: "@@ -1 +1 @@" }),
expect.objectContaining({ changeType: "remove", text: "old" }),
expect.objectContaining({ changeType: "add", text: "++ new" }),
]);
});
it("attaches an edit's patch once when later events refine the call", () => {
const entries = nativeRunEventsToTranscript([
event(1, "tool.execution.started", execution("running", [change("@@ -1 +1 @@\n-a\n+b\n", 1, 1)])),
+15
View File
@@ -380,6 +380,9 @@
--diff-remove-fg: var(--status-task-icon-blocked);
--diff-add-bg: color-mix(in oklab, var(--status-task-icon-done) 12%, transparent);
--diff-remove-bg: color-mix(in oklab, var(--status-task-icon-blocked) 12%, transparent);
--patch-add-fg: #116329;
--patch-remove-fg: #9a1c1c;
--patch-meta-fg: color-mix(in oklab, var(--foreground) 85%, var(--background));
--sz-edit-diff-max-h: calc(var(--spacing) * 100);
--folder-color-indigo: #6366f1;
@@ -712,6 +715,8 @@
--status-task-icon-todo: #fbbf24;
--status-task-icon-in_progress: var(--color-blue-400); /* matches nav dots and live labels */
--status-task-icon-in_review: #9474f0; /* deeper than violet-400, reads purple */
--patch-add-fg: #7ee787;
--patch-remove-fg: #ffa198;
--status-task-icon-done: #34d06f;
--status-task-icon-cancelled: #9a958a;
}
@@ -5324,6 +5329,16 @@ html:not(.dark) .coordinator-sidebar {
.agent-selection-bar { transition: none; transform: translate(-50%, 0); }
}
.paperclip-patch-line { min-width: 0; }
span.paperclip-patch-line { display: block; }
.paperclip-patch-line[data-patch-line="add"] { color: var(--patch-add-fg); background: var(--diff-add-bg); }
.paperclip-patch-line[data-patch-line="remove"] { color: var(--patch-remove-fg); background: var(--diff-remove-bg); }
.paperclip-patch-line[data-patch-line="hunk"] { color: var(--patch-meta-fg); background: var(--muted); }
.paperclip-patch-line[data-patch-line="skip"] { color: var(--patch-meta-fg); }
.paperclip-patch-preview .paperclip-patch-line { overflow: hidden; text-overflow: ellipsis; white-space: pre; }
.paperclip-patch-line[data-line-selected] { background: var(--code-highlight-bg-resolved); }
/* Agent Identity: equal panel gutters and a viewport-bound dirty footer. */
:root {
--agent-identity-panel-gap: calc(var(--spacing) * 6);
+2
View File
@@ -1,6 +1,7 @@
import type { IssueAttachment } from "@paperclipai/shared";
import { isMarkdownAttachmentContent } from "@paperclipai/shared";
import { isImageLikeOutput, isVideoLikeOutput } from "./issue-output";
import { isPatchFile } from "./patch";
import { isHtmlPreview } from "./html-preview";
type AttachmentPathLike = {
@@ -50,6 +51,7 @@ export function isTextAttachment(attachment: {
return isMarkdownAttachmentContent(attachment)
|| isHtmlPreview(type, attachment.originalFilename)
|| type.startsWith("text/")
|| (["", "application/octet-stream"].includes(type) && isPatchFile(attachment.originalFilename))
|| /^(application\/(json|xml|javascript|x-yaml|yaml)|application\/[\w.-]+\+json)$/.test(type)
|| (["", "application/octet-stream"].includes(type)
&& /\.(txt|log|csv|json|yaml|yml|toml|ini|sh|ts|tsx|js|jsx|py|css|html)$/i.test(attachment.originalFilename ?? ""));
+38
View File
@@ -0,0 +1,38 @@
export function isPatchFile(filename: string | null | undefined, contentType?: string | null) {
const type = contentType?.toLowerCase().split(";")[0]?.trim();
return /\.(patch|diff)$/i.test(filename ?? "")
|| type === "text/x-patch" || type === "text/x-diff";
}
export type PatchLineKind = "add" | "remove" | "hunk" | "context" | "file" | "skip";
export function createUnifiedDiffClassifier(): (raw: string) => { kind: PatchLineKind; text: string } {
let inHunk = false;
let remaining: { old: number; next: number } | null = null;
return (source) => {
const raw = source.replace(/\r$/, "");
if (raw.startsWith("diff --git ")) {
inHunk = false;
return { kind: "skip", text: raw };
}
if (raw.startsWith("@@")) {
const header = raw.match(/^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@/);
remaining = header ? { old: Number(header[1] ?? 1), next: Number(header[2] ?? 1) } : null;
inHunk = !remaining || remaining.old > 0 || remaining.next > 0;
return { kind: "hunk", text: raw };
}
if (raw === "\\ No newline at end of file") return { kind: "skip", text: raw };
if (!inHunk && (
raw.startsWith("+++ ") || raw.startsWith("--- ") || raw.startsWith("index ") ||
raw.startsWith("new file mode") || raw.startsWith("deleted file mode") ||
raw.startsWith("similarity index") || raw.startsWith("rename from") || raw.startsWith("rename to")
)) return { kind: "skip", text: raw };
const kind = raw.startsWith("+") ? "add" : raw.startsWith("-") ? "remove" : "context";
if (inHunk && remaining) {
if (kind !== "add") remaining.old -= 1;
if (kind !== "remove") remaining.next -= 1;
if (remaining.old <= 0 && remaining.next <= 0) inHunk = false;
}
return { kind, text: kind === "add" || kind === "remove" || raw.startsWith(" ") ? raw.slice(1) : raw };
};
}
+32
View File
@@ -0,0 +1,32 @@
export const TEXT_PREVIEW_MAX_BYTES = 512 * 1024;
export async function readTextPreview(response: Response) {
if (!response.ok) throw Object.assign(new Error(`Could not load file (${response.status}).`), { status: response.status });
if (!response.body) throw new Error("The file response is empty.");
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8", { fatal: true });
let bytes = 0;
let text = "";
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > TEXT_PREVIEW_MAX_BYTES) throw new Error("This file is too large to preview. Download it instead.");
text += decoder.decode(chunk.value, { stream: true });
}
text += decoder.decode();
if (text.includes("\0")) throw new Error("This file contains binary data. Download it instead.");
return text;
} finally {
await reader.cancel();
reader.releaseLock();
}
}
export async function loadAttachmentText(contentPath: string, signal?: AbortSignal) {
if (!/^\/api\/attachments\/[a-zA-Z0-9-]+\/content$/.test(contentPath)) {
throw new Error("File preview is unavailable. Download the file to view it.");
}
return readTextPreview(await fetch(contentPath, { credentials: "same-origin", redirect: "error", signal }));
}
@@ -0,0 +1,89 @@
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { IssueArtifactFile } from "@/components/artifacts/IssueArtifactCard";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, within, userEvent, waitFor } from "storybook/test";
import { DocumentCard } from "@/components/artifacts/RichArtifactCards";
import { TextAttachmentPreview } from "@/components/task-side-panel/TaskAttachmentPanel";
import { FileContentViewer } from "@/components/FileViewerSheet";
import { MarkdownBody } from "@/components/MarkdownBody";
import { patchContent, patchMarkdown, patchText } from "../../support/patch-preview";
const meta = {
id: "documents-patch-preview",
title: "Tasks and conversations/Documents, attachments, and work products/Patch previews",
parameters: {
layout: "fullscreen",
paperclip: {
capabilities: ["documents-artifacts/attachments", "documents-artifacts/documents"],
status: "current",
implementation: "production",
boundary: "fixtures",
notes: "Production click-to-preview attachment card, document cards, attachment and workspace source views, and Markdown. Simulated attachment fetch and static patch data includes long unbroken lines, multiple files, and literal HTML. No live storage or save.",
},
},
beforeEach: () => {
const originalFetch = window.fetch;
const fixtureFetch: typeof window.fetch = async (input, init) => {
const path = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin).pathname;
if (path === "/api/attachments/patch-story/content") return new Response(patchText, { headers: { "content-type": "application/octet-stream" } });
return originalFetch(input, init);
};
window.fetch = fixtureFetch;
return () => { if (window.fetch === fixtureFetch) window.fetch = originalFetch; };
},
decorators: [(Story) => <main className="min-h-screen bg-background p-4 text-foreground"><Story /></main>],
} satisfies Meta;
export default meta;
type Story = StoryObj<typeof meta>;
const card = {
title: "Preview layout fix", summary: "Contain long patch lines in the document preview.",
author: "Engineer", updatedAt: "Just now", filename: "preview-layout.patch", body: patchText,
};
function InlineAttachment() {
const [client] = useState(() => new QueryClient({ defaultOptions: { queries: { retry: false } } }));
return <QueryClientProvider client={client}><div className="w-full max-w-md"><IssueArtifactFile
{...card} id="patch-story" attachmentId="patch-story" contentType="application/octet-stream"
contentPath="/api/attachments/patch-story/content" downloadPath="/api/attachments/patch-story/content?download=1" byteSize={patchText.length}
/></div></QueryClientProvider>;
}
export const InlinePatch: Story = {
render: () => <InlineAttachment />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvasElement.querySelector('[data-patch-line="add"]')).toBeNull();
await userEvent.click(canvas.getByRole("button", { name: "Preview patch" }));
await waitFor(() => expect(canvasElement.querySelector('[data-patch-line="add"]')).not.toBeNull());
await userEvent.click(canvas.getByRole("button", { name: "Read document" }));
const dialog = within(canvasElement.ownerDocument.body).getByRole("dialog");
await expect(dialog.querySelector("pre")?.textContent).toBe(patchText);
await userEvent.click(within(dialog).getByRole("button", { name: "Close" }));
},
};
export const MarkdownCard: Story = {
render: () => <div className="w-full max-w-md"><DocumentCard {...card} filename="review.md" body={patchMarkdown} /></div>,
};
export const AttachmentTab: Story = {
render: () => <div className="h-screen min-w-0 max-w-3xl"><TextAttachmentPreview title="preview-layout.patch" text={patchText} markdown={false} downloadUrl="data:text/x-patch,fixture" /></div>,
};
export const WorkspaceTab: Story = {
render: () => <div className="flex h-screen min-w-0 max-w-3xl flex-col"><FileContentViewer content={patchContent} highlightedLine={8} /></div>,
};
export const Document: Story = {
render: () => <div className="min-w-0 max-w-3xl"><MarkdownBody>{patchMarkdown}</MarkdownBody></div>,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const source = canvasElement.querySelector("pre code");
await expect(source?.textContent).toBe(patchText);
await userEvent.click(canvas.getByRole("button", { name: "Wrap lines" }));
await expect(canvas.getByRole("button", { name: "Unwrap lines" })).toHaveAttribute("aria-pressed", "true");
await expect(source?.textContent).toBe(patchText);
},
};
+35
View File
@@ -0,0 +1,35 @@
import type { WorkspaceFileContent } from "@paperclipai/shared";
export const patchText = `diff --git a/ui/src/preview.ts b/ui/src/preview.ts
index 1a2b3c4..5d6e7f8 100644
--- a/ui/src/preview.ts
+++ b/ui/src/preview.ts
@@ -1,3 +1,4 @@
export function preview() {
- return "${"old_preview_".repeat(24)}";
+ const label = "${"new_preview_".repeat(24)}";
+ return label;
}
diff --git a/notes.txt b/notes.txt
--- a/notes.txt
+++ b/notes.txt
@@ -1,2 +1,2 @@
--- old comment
+++ new comment
-<script>alert("old")</script>
+<script>alert("new")</script>
\\ No newline at end of file
`;
export const patchMarkdown = `# Preview layout fix\n\nKeep long lines inside the document.\n\n\`\`\`diff\n${patchText}\`\`\`\n`;
export const patchContent: WorkspaceFileContent = {
resource: {
kind: "file", provider: "git_worktree", title: "preview-layout.patch",
displayPath: "patches/preview-layout.patch", workspaceLabel: "Preview workspace",
workspaceKind: "execution_workspace", workspaceId: "patch-story-workspace",
contentType: "text/x-patch", byteSize: patchText.length, previewKind: "text",
capabilities: { preview: true, download: true, listChildren: false },
},
content: { encoding: "utf8", data: patchText },
};