From 752cb8d3da43fdc2dd026229a350a646feda5bed Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 18:06:50 -0600 Subject: [PATCH] fix(ui): contain patch previews and highlight diff source (#19478) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- doc/DEVELOPING.md | 14 +++ tests/storybook-visual/patch-preview.spec.ts | 46 ++++++++++ ui/src/components/FileViewerSheet.test.tsx | 13 +++ ui/src/components/FileViewerSheet.tsx | 15 ++- ui/src/components/MarkdownBody.tsx | 11 ++- ui/src/components/MarkdownBody.wrap.test.tsx | 15 +++ ui/src/components/PatchLines.test.tsx | 37 ++++++++ ui/src/components/PatchLines.tsx | 15 +++ .../artifacts/IssueArtifactCard.tsx | 28 ++++++ .../artifacts/IssueArtifactFile.test.tsx | 92 +++++++++++++++++++ .../artifacts/RichArtifactCards.tsx | 24 ++++- .../components/task-chat/diff-lines.test.ts | 10 ++ ui/src/components/task-chat/diff-lines.ts | 32 +------ .../TaskAttachmentPanel.test.tsx | 13 ++- .../task-side-panel/TaskAttachmentPanel.tsx | 45 +++------ .../transcript/native-run-events.test.ts | 11 +++ ui/src/index.css | 15 +++ ui/src/lib/issue-attachments.ts | 2 + ui/src/lib/patch.ts | 38 ++++++++ ui/src/lib/text-preview.ts | 32 +++++++ .../patch-preview.stories.tsx | 89 ++++++++++++++++++ ui/storybook/support/patch-preview.ts | 35 +++++++ 22 files changed, 559 insertions(+), 73 deletions(-) create mode 100644 tests/storybook-visual/patch-preview.spec.ts create mode 100644 ui/src/components/PatchLines.test.tsx create mode 100644 ui/src/components/PatchLines.tsx create mode 100644 ui/src/lib/patch.ts create mode 100644 ui/src/lib/text-preview.ts create mode 100644 ui/storybook/stories/documents-artifacts/patch-preview.stories.tsx create mode 100644 ui/storybook/support/patch-preview.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index bb7692a25c5..c7bb4aaea03 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -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, diff --git a/tests/storybook-visual/patch-preview.spec.ts b/tests/storybook-visual/patch-preview.spec.ts new file mode 100644 index 00000000000..1a4f5505604 --- /dev/null +++ b/tests/storybook-visual/patch-preview.spec.ts @@ -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> }; + }).__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 }); + }); + } + } +} diff --git a/ui/src/components/FileViewerSheet.test.tsx b/ui/src/components/FileViewerSheet.test.tsx index 0a1ff0692d8..1f3945185fd 100644 --- a/ui/src/components/FileViewerSheet.test.tsx +++ b/ui/src/components/FileViewerSheet.test.tsx @@ -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(); + 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( { + 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(null); const highlightedLineRef = useRef(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" >
         {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}
               
-              {lineText.length === 0 ? "​" : lineText}
+              {lineText.length === 0 ? "​" : lineText}
             
           );
         })}
diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx
index 2630566e3db..602d985031b 100644
--- a/ui/src/components/MarkdownBody.tsx
+++ b/ui/src/components/MarkdownBody.tsx
@@ -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;
 }) {
+  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 ?  : children}
       
{ 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+++ \n"; + flushSync(() => root.render( + {"```" + language + "\n" + text + "```"} + )); + 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("+++ \n"); + expect(container.querySelector("script")).toBeNull(); + const wrap = container.querySelector('[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( diff --git a/ui/src/components/PatchLines.test.tsx b/ui/src/components/PatchLines.test.tsx new file mode 100644 index 00000000000..0fdc43501dc --- /dev/null +++ b/ui/src/components/PatchLines.test.tsx @@ -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-\r\n+new"; + const markup = renderToStaticMarkup(); + 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("\n"; + async function mount(overrides: Partial> = {}) { + 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()); + 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( + {Array.from({ length: 200 }, (_, index) => )} + )); + 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(); } + }); +}); diff --git a/ui/src/components/artifacts/RichArtifactCards.tsx b/ui/src/components/artifacts/RichArtifactCards.tsx index f26a761c607..04d36e0e0ff 100644 --- a/ui/src/components/artifacts/RichArtifactCards.tsx +++ b/ui/src/components/artifacts/RichArtifactCards.tsx @@ -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 ( -
+
{children}
); @@ -302,11 +304,16 @@ export function CommitCard(props: CommitCardProps) { ); } -function Markdown({ body }: { body: string }) { +function Markdown({ body, preview = false }: { body: string; preview?: boolean }) { return (
{children}
, + code: ({ className, children }) => {/\blanguage-(diff|patch)\b/i.test(className ?? "") + ? : children}, h1: ({ children }) => (

{children}

), @@ -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 + ?
+ : ; return ( {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 > - + {content(props.body.slice(0, 4000), true)}
)}
- Markdown + {patch ? "Patch" : "Markdown"} {props.revision != null && ` · revision ${props.revision}`} @@ -416,7 +430,7 @@ export function DocumentCard(props: DocumentCardProps) { description={props.filename} action="Read document" > - + {content(props.body)} )}
diff --git a/ui/src/components/task-chat/diff-lines.test.ts b/ui/src/components/task-chat/diff-lines.test.ts index 2cc5e0e74d6..b11ab6f3217 100644 --- a/ui/src/components/task-chat/diff-lines.test.ts +++ b/ui/src/components/task-chat/diff-lines.test.ts @@ -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" }, + ]); + }); + }); diff --git a/ui/src/components/task-chat/diff-lines.ts b/ui/src/components/task-chat/diff-lines.ts index a0b5c389dda..4cbe86d2d6b 100644 --- a/ui/src/components/task-chat/diff-lines.ts +++ b/ui/src/components/task-chat/diff-lines.ts @@ -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 (`---`, `+++`, diff --git a/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx b/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx index 72ad95bac0e..de4c2ba45e3 100644 --- a/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx +++ b/ui/src/components/task-side-panel/TaskAttachmentPanel.test.tsx @@ -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+\n"; + await act(async () => root.render()); + expect(host.querySelector("pre")?.textContent).toBe(text); + expect(host.querySelector('[data-patch-line="add"]')?.textContent).toBe("+\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()); diff --git a/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx b/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx index fc1dc0b9ba2..738de068a7d 100644 --- a/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx +++ b/ui/src/components/task-side-panel/TaskAttachmentPanel.tsx @@ -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("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 ( -
-
+
+

{title}

{markdown || html || csv ? : null}
-
+
{text.length === 0 ?

File is empty.

: renderCsv ? : renderHtml ? : markdown && mode === "rendered" ? {text} - :
{text}
} + : patch ?
+ :
{text}
}
); @@ -115,5 +94,5 @@ export function TaskAttachmentPanel({ issueId, attachmentId }: { issueId: string ); } if (content.data === undefined) return

Loading file…

; - return ; + return ; } diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 9920b0daa17..379045b632d 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -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)])), diff --git a/ui/src/index.css b/ui/src/index.css index 2d2cc05176a..f3594d73bad 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -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); diff --git a/ui/src/lib/issue-attachments.ts b/ui/src/lib/issue-attachments.ts index 6d0c8d020d5..79e269df181 100644 --- a/ui/src/lib/issue-attachments.ts +++ b/ui/src/lib/issue-attachments.ts @@ -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 ?? "")); diff --git a/ui/src/lib/patch.ts b/ui/src/lib/patch.ts new file mode 100644 index 00000000000..f25abe4072f --- /dev/null +++ b/ui/src/lib/patch.ts @@ -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 }; + }; +} diff --git a/ui/src/lib/text-preview.ts b/ui/src/lib/text-preview.ts new file mode 100644 index 00000000000..296565058b9 --- /dev/null +++ b/ui/src/lib/text-preview.ts @@ -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 })); +} diff --git a/ui/storybook/stories/documents-artifacts/patch-preview.stories.tsx b/ui/storybook/stories/documents-artifacts/patch-preview.stories.tsx new file mode 100644 index 00000000000..21286f81e81 --- /dev/null +++ b/ui/storybook/stories/documents-artifacts/patch-preview.stories.tsx @@ -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) =>
], +} satisfies Meta; +export default meta; +type Story = StoryObj; + +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
; +} + +export const InlinePatch: Story = { + render: () => , + 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: () =>
, +}; + +export const AttachmentTab: Story = { + render: () =>
, +}; + +export const WorkspaceTab: Story = { + render: () =>
, +}; + +export const Document: Story = { + render: () =>
{patchMarkdown}
, + 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); + }, +}; diff --git a/ui/storybook/support/patch-preview.ts b/ui/storybook/support/patch-preview.ts new file mode 100644 index 00000000000..2f7dfcb12ba --- /dev/null +++ b/ui/storybook/support/patch-preview.ts @@ -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 +- ++ +\\ 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 }, +};