diff --git a/ui/src/components/MarkdownBody.interaction.test.tsx b/ui/src/components/MarkdownBody.interaction.test.tsx new file mode 100644 index 00000000000..e912a890e47 --- /dev/null +++ b/ui/src/components/MarkdownBody.interaction.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ThemeProvider } from "../context/ThemeContext"; +import { MarkdownBody } from "./MarkdownBody"; + +vi.mock("@/lib/router", () => ({ + Link: ({ + children, + to, + ...props + }: { children: React.ReactNode; to: string } & React.ComponentProps<"a">) => ( + {children} + ), +})); + +vi.mock("../api/issues", () => ({ + issuesApi: { + get: vi.fn(), + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +let root: ReturnType | null = null; +let container: HTMLDivElement | null = null; + +afterEach(() => { + if (root) { + flushSync(() => root?.unmount()); + } + root = null; + container?.remove(); + container = null; +}); + +function renderMarkdown(children: string) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + flushSync(() => { + root?.render( + + + {children} + + , + ); + }); + + return container; +} + +function click(element: Element | null) { + if (!element) throw new Error("Expected element to exist"); + flushSync(() => { + element.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); +} + +describe("MarkdownBody code block interactions", () => { + it("toggles line wrapping for indented preformatted markdown blocks", () => { + const node = renderMarkdown("Plan:\n\n source fetch/sync -> signal inbox"); + const pre = node.querySelector("pre"); + const wrapButton = node.querySelector(".paperclip-markdown-codeblock-wrap"); + + expect(pre?.style.whiteSpace).toBe(""); + expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines"); + + click(wrapButton); + + expect(pre?.style.whiteSpace).toBe("pre-wrap"); + expect(pre?.style.overflowWrap).toBe("anywhere"); + expect(wrapButton?.getAttribute("aria-pressed")).toBe("true"); + expect(wrapButton?.getAttribute("aria-label")).toBe("Unwrap lines"); + + click(wrapButton); + + expect(pre?.style.whiteSpace).toBe(""); + expect(wrapButton?.getAttribute("aria-pressed")).toBe("false"); + expect(wrapButton?.getAttribute("aria-label")).toBe("Wrap lines"); + }); +}); diff --git a/ui/src/components/MarkdownBody.tsx b/ui/src/components/MarkdownBody.tsx index 75675a0f695..41ec495a52c 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"; @@ -364,6 +364,7 @@ function CodeBlock({ }) { const [copied, setCopied] = useState(false); const [failed, setFailed] = useState(false); + const [wrapLines, setWrapLines] = useState(false); const preRef = useRef(null); const timerRef = useRef>(undefined); @@ -401,33 +402,57 @@ function CodeBlock({ }, 1500); }, [children]); - const label = failed ? "Copy failed" : copied ? "Copied!" : "Copy"; + const copyLabel = failed ? "Copy failed" : copied ? "Copied!" : "Copy"; + const wrapLabel = wrapLines ? "Unwrap lines" : "Wrap lines"; return ( -
+
         {children}
       
- +
+ + +
); } diff --git a/ui/src/index.css b/ui/src/index.css index a29d4b67bc8..82128ed9a02 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -717,15 +717,23 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before { background: none; } -/* Copy-to-clipboard button on fenced code blocks */ +/* Actions for fenced and indented preformatted markdown 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-action { display: inline-flex; align-items: center; gap: 0.25rem; @@ -737,30 +745,31 @@ 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; } -.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-action[data-copied], +.paperclip-markdown-codeblock-action[data-active] { opacity: 1; } -.paperclip-markdown-codeblock-copy:hover { +.paperclip-markdown-codeblock-action:hover { background-color: var(--accent); color: var(--accent-foreground); } -.paperclip-markdown-codeblock-copy[data-copied] { +.paperclip-markdown-codeblock-action[data-active], +.paperclip-markdown-codeblock-action[data-copied] { color: var(--primary); } -.paperclip-markdown-codeblock-copy[data-failed] { +.paperclip-markdown-codeblock-action[data-failed] { color: var(--destructive); } -.paperclip-markdown-codeblock-copy-label { +.paperclip-markdown-codeblock-action-label { font-weight: 500; }