diff --git a/ui/src/components/CommentThread.test.tsx b/ui/src/components/CommentThread.test.tsx index 41c23bed..0fa73210 100644 --- a/ui/src/components/CommentThread.test.tsx +++ b/ui/src/components/CommentThread.test.tsx @@ -61,12 +61,26 @@ vi.mock("@/plugins/slots", () => ({ describe("CommentThread", () => { let container: HTMLDivElement; + let writeTextMock: ReturnType; + let execCommandMock: ReturnType; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z")); + writeTextMock = vi.fn(async () => {}); + execCommandMock = vi.fn(() => true); + Object.assign(navigator, { + clipboard: { + writeText: writeTextMock, + }, + }); + Object.defineProperty(window, "isSecureContext", { + value: true, + configurable: true, + }); + document.execCommand = execCommandMock; }); afterEach(() => { @@ -234,4 +248,59 @@ describe("CommentThread", () => { root.unmount(); }); }); + + it("uses a larger copy control with feedback and a clipboard fallback", async () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + /> + , + ); + }); + + const copyButton = Array.from(container.querySelectorAll("button")).find( + (element) => element.getAttribute("aria-label") === "Copy comment as markdown", + ) as HTMLButtonElement | undefined; + + expect(copyButton).toBeDefined(); + expect(copyButton?.className).toContain("min-h-8"); + expect(copyButton?.textContent).toContain("Copy"); + + Object.defineProperty(window, "isSecureContext", { + value: false, + configurable: true, + }); + + await act(async () => { + copyButton?.click(); + }); + + expect(writeTextMock).not.toHaveBeenCalled(); + expect(execCommandMock).toHaveBeenCalledWith("copy"); + expect(copyButton?.textContent).toContain("Copied"); + + act(() => { + vi.advanceTimersByTime(1500); + }); + + expect(copyButton?.textContent).toContain("Copy"); + + act(() => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/CommentThread.tsx b/ui/src/components/CommentThread.tsx index b2125b25..062cbcf7 100644 --- a/ui/src/components/CommentThread.tsx +++ b/ui/src/components/CommentThread.tsx @@ -210,21 +210,71 @@ function runStatusClass(status: string) { } } +async function copyTextWithFallback(text: string) { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return; + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + + try { + textarea.select(); + const success = document.execCommand("copy"); + if (!success) throw new Error("execCommand copy failed"); + } finally { + document.body.removeChild(textarea); + } +} + function CopyMarkdownButton({ text }: { text: string }) { - const [copied, setCopied] = useState(false); + const [status, setStatus] = useState<"idle" | "copied" | "failed">("idle"); + const timeoutRef = useRef | null>(null); + + useEffect(() => () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }, []); + + const label = status === "copied" ? "Copied" : status === "failed" ? "Copy failed" : "Copy"; + return ( ); }