From 5ab9e314cb43fbe7a3d1c3e96e9d147352c03636 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 22 May 2026 02:31:38 +0000 Subject: [PATCH] Add markdown code wrap toggle Co-Authored-By: Paperclip --- ui/src/components/MarkdownBody.test.tsx | 3 + ui/src/components/MarkdownBody.tsx | 66 ++++++++++---- ui/src/components/MarkdownBody.wrap.test.tsx | 93 ++++++++++++++++++++ ui/src/index.css | 35 ++++++-- 4 files changed, 171 insertions(+), 26 deletions(-) create mode 100644 ui/src/components/MarkdownBody.wrap.test.tsx diff --git a/ui/src/components/MarkdownBody.test.tsx b/ui/src/components/MarkdownBody.test.tsx index 72c4a2d96ee..768f01ad52b 100644 --- a/ui/src/components/MarkdownBody.test.tsx +++ b/ui/src/components/MarkdownBody.test.tsx @@ -449,6 +449,9 @@ describe("MarkdownBody", () => { const html = renderMarkdown("```ts\nconst a = 1;\n```"); expect(html).toContain("paperclip-markdown-codeblock"); + expect(html).toContain("paperclip-markdown-codeblock-actions"); + expect(html).toContain("paperclip-markdown-codeblock-wrap"); + expect(html).toContain('aria-label="Wrap lines"'); expect(html).toContain("paperclip-markdown-codeblock-copy"); expect(html).toContain('aria-label="Copy code"'); expect(html).toContain("lucide-copy"); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 75675a0f695..96776dc9b04 100644 --- a/ui/src/components/MarkdownBody.tsx +++ b/ui/src/components/MarkdownBody.tsx @@ -1,6 +1,6 @@ import { isValidElement, useCallback, useEffect, useId, useRef, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Check, Copy, ExternalLink, Github } from "lucide-react"; +import { Check, Copy, ExternalLink, Github, WrapText } from "lucide-react"; import Markdown, { defaultUrlTransform, type Components, type Options } from "react-markdown"; import remarkGfm from "remark-gfm"; import { cn } from "../lib/utils"; @@ -110,6 +110,21 @@ function mergeScrollableBlockStyle(style?: React.CSSProperties): React.CSSProper }; } +function mergeCodeBlockStyle( + style: React.CSSProperties | undefined, + wrapped: boolean, +): React.CSSProperties { + if (!wrapped) return mergeScrollableBlockStyle(style); + return { + ...style, + maxWidth: "100%", + overflowX: "hidden", + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", + wordBreak: "break-word", + }; +} + function flattenText(value: ReactNode): string { if (value == null) return ""; if (typeof value === "string" || typeof value === "number") return String(value); @@ -364,6 +379,7 @@ function CodeBlock({ }) { const [copied, setCopied] = useState(false); const [failed, setFailed] = useState(false); + const [wrapped, setWrapped] = useState(false); const preRef = useRef(null); const timerRef = useRef>(undefined); @@ -402,32 +418,46 @@ function CodeBlock({ }, [children]); const label = failed ? "Copy failed" : copied ? "Copied!" : "Copy"; + const wrapLabel = wrapped ? "Disable line wrap" : "Wrap lines"; return (
         {children}
       
- +
+ + +
); } diff --git a/ui/src/components/MarkdownBody.wrap.test.tsx b/ui/src/components/MarkdownBody.wrap.test.tsx new file mode 100644 index 00000000000..b7be735a063 --- /dev/null +++ b/ui/src/components/MarkdownBody.wrap.test.tsx @@ -0,0 +1,93 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ThemeProvider } from "../context/ThemeContext"; +import { MarkdownBody } from "./MarkdownBody"; + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + ...props + }: { children: ReactNode; to: string } & React.ComponentProps<"a">) => ( + {children} + ), +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { + get: vi.fn(), + }, +})); + +describe("MarkdownBody code block wrapping", () => { + let container: HTMLDivElement; + let root: Root; + let queryClient: QueryClient; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + }); + + afterEach(() => { + flushSync(() => root.unmount()); + queryClient.clear(); + container.remove(); + }); + + it("toggles fenced code blocks between horizontal scroll and wrapped lines", () => { + flushSync(() => { + root.render( + + + {"```text\nlong line that can wrap when requested\n```"} + + , + ); + }); + + const pre = container.querySelector("pre"); + const wrapButton = container.querySelector( + ".paperclip-markdown-codeblock-wrap", + ); + + expect(pre).not.toBeNull(); + expect(wrapButton).not.toBeNull(); + expect(wrapButton?.getAttribute("aria-pressed")).toBe("false"); + expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines"); + expect(pre?.style.overflowX).toBe("auto"); + expect(pre?.style.whiteSpace).toBe(""); + + flushSync(() => { + wrapButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(wrapButton?.getAttribute("aria-pressed")).toBe("true"); + expect(wrapButton?.getAttribute("aria-label")).toBe("Disable line wrap"); + expect(pre?.style.overflowX).toBe("hidden"); + expect(pre?.style.whiteSpace).toBe("pre-wrap"); + expect(pre?.style.overflowWrap).toBe("anywhere"); + + flushSync(() => { + wrapButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(wrapButton?.getAttribute("aria-pressed")).toBe("false"); + expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines"); + expect(pre?.style.overflowX).toBe("auto"); + expect(pre?.style.whiteSpace).toBe(""); + }); +}); diff --git a/ui/src/index.css b/ui/src/index.css index a29d4b67bc8..02e21e29412 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -717,18 +717,28 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { background: none; } -/* Copy-to-clipboard button on fenced code blocks */ +/* Hover actions on fenced code blocks */ .paperclip-markdown-codeblock { position: relative; } -.paperclip-markdown-codeblock-copy { +.paperclip-markdown-codeblock-actions { position: absolute; top: 0.4rem; right: 0.4rem; display: inline-flex; align-items: center; gap: 0.25rem; + opacity: 0; + transition: opacity 0.12s ease; +} + +.paperclip-markdown-codeblock-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + min-height: 1.55rem; padding: 0.2rem 0.4rem; border-radius: calc(var(--radius) - 4px); border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent); @@ -737,21 +747,30 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { font-size: 0.7rem; line-height: 1; cursor: pointer; - opacity: 0; - transition: opacity 0.12s ease, background-color 0.12s ease, color 0.12s ease; + transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease; } -.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-copy, -.paperclip-markdown-codeblock-copy:focus-visible, -.paperclip-markdown-codeblock-copy[data-copied] { +.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions, +.paperclip-markdown-codeblock-actions:focus-within, +.paperclip-markdown-codeblock-actions[data-active] { opacity: 1; } -.paperclip-markdown-codeblock-copy:hover { +.paperclip-markdown-codeblock-button:hover { background-color: var(--accent); color: var(--accent-foreground); } +.paperclip-markdown-codeblock-wrap { + width: 1.55rem; + padding-inline: 0; +} + +.paperclip-markdown-codeblock-wrap[data-wrapped] { + border-color: color-mix(in oklab, var(--primary) 38%, transparent); + color: var(--primary); +} + .paperclip-markdown-codeblock-copy[data-copied] { color: var(--primary); }