From 6756ae8289a74b6ed41c1f930f71e22f557b199c Mon Sep 17 00:00:00 2001 From: joegalbert-ai Date: Fri, 19 Jun 2026 02:43:57 -0400 Subject: [PATCH] fix(ui): restore issue copy buttons on HTTP (#8212) ## Thinking Path > - Paperclip is the control plane operators use to run AI-agent companies, so board actions like copying task state need to work reliably on the live board UI. > - The affected surface is the issue detail and issue chat UI, where operators copy task descriptions and comment text during triage and handoff. > - On self-hosted HTTP deployments, the async Clipboard API can exist but fail, which breaks these copy buttons silently. > - The internal FMAI reproduction pointed specifically at task description copy and task comment copy in the issue view. > - There is already an overlapping upstream PR, [#6353](https://github.com/paperclipai/paperclip/pull/6353), but it is currently `CONFLICTING` and does not provide a mergeable path. > - This pull request adds a shared clipboard helper with a fallback path, wires the task-detail and task-thread copy actions through it, and locks the behavior in with targeted tests. > - The benefit is that copy buttons keep working on HTTP self-hosted boards without changing behavior for secure deployments. ## Linked Issues or Issue Description - Fixes #3529 - Refs #6353 ## What Changed - Added `ui/src/lib/clipboard.ts` with a shared `copyTextToClipboard()` helper that tries the async Clipboard API first and falls back to `document.execCommand("copy")` when that path is blocked. - Updated `IssueDetail.tsx` so the task-level "Copy task as markdown" action uses the shared helper. - Updated both `IssueChatThread.tsx` and `IssueChatThreadClassic.tsx` so task comment copy actions, code-block copy actions, and system-notice copy actions use the shared helper. - Added focused regression coverage in `IssueDetail.test.tsx` and `IssueChatThread.test.tsx` for insecure-context fallback behavior. ## Verification - `corepack pnpm exec vitest run ui/src/pages/IssueDetail.test.tsx ui/src/components/IssueChatThread.test.tsx ui/src/components/IssueChatThreadSystemNotice.test.tsx` - `corepack pnpm exec tsc -p ui/tsconfig.json --noEmit` - Manual reviewer check: - Run Paperclip over HTTP. - Open an issue detail page. - Use `Copy task as markdown` and a comment `Copy message` action. - Confirm both copy successfully instead of silently failing. ## Risks - Low risk. The change is scoped to clipboard helpers and the specific issue-detail / issue-thread copy actions. - The fallback relies on `document.execCommand("copy")`, which is legacy browser behavior, but it is only used when the modern clipboard path is unavailable or blocked. ## Model Used - OpenAI GPT-5 Codex via Codex local adapter, tool-enabled coding workflow with terminal, git, and file-editing support. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [ ] 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 - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: FMAI Agents Co-authored-by: Paperclip --- ui/src/components/IssueChatThread.test.tsx | 77 ++++++++++++++++++++ ui/src/components/IssueChatThread.tsx | 54 ++++++++++++-- ui/src/components/IssueChatThreadClassic.tsx | 53 ++++++++++++-- ui/src/lib/clipboard.ts | 31 ++++++++ ui/src/pages/IssueDetail.test.tsx | 62 ++++++++++++++++ ui/src/pages/IssueDetail.tsx | 17 ++++- 6 files changed, 278 insertions(+), 16 deletions(-) create mode 100644 ui/src/lib/clipboard.ts diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 241a7fae..5d1b7b4a 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -353,6 +353,83 @@ describe("IssueChatThread", () => { }); }); + it("falls back to execCommand for comment copy actions in insecure contexts", async () => { + const clipboardWrite = vi.fn(async () => { + throw new Error("Clipboard API blocked"); + }); + const execCommand = vi.fn(() => true); + const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + const originalExecCommand = Object.getOwnPropertyDescriptor(document, "execCommand"); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: clipboardWrite }, + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + + try { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const copyButton = container.querySelector('button[aria-label="Copy message"]') as HTMLButtonElement | null; + expect(copyButton).not.toBeNull(); + + await act(async () => { + copyButton?.click(); + await Promise.resolve(); + }); + + expect(clipboardWrite).toHaveBeenCalledWith("Copy this comment"); + expect(execCommand).toHaveBeenCalledWith("copy"); + + act(() => { + root.unmount(); + }); + } finally { + if (originalClipboard) { + Object.defineProperty(navigator, "clipboard", originalClipboard); + } else { + // @ts-expect-error test cleanup for optional browser API + delete navigator.clipboard; + } + if (originalExecCommand) { + Object.defineProperty(document, "execCommand", originalExecCommand); + } else { + // @ts-expect-error test cleanup for optional browser API + delete document.execCommand; + } + } + }); + it("renders footer content inside the thread viewport before the bottom anchor", () => { const root = createRoot(container); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 8d897730..2c60dcd4 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -45,6 +45,8 @@ import type { import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from "../hooks/usePaperclipIssueRuntime"; +import { useOptionalToastActions } from "../context/ToastContext"; +import { copyTextToClipboard } from "../lib/clipboard"; import { buildIssueChatMessages, formatDurationWords, @@ -1047,6 +1049,7 @@ function IssueChatRollingToolPart({ toolParts }: { toolParts: ToolCallMessagePar function CopyablePreBlock({ children, className }: { children: string; className?: string }) { const [copied, setCopied] = useState(false); + const toastActions = useOptionalToastActions(); return (
{children}
@@ -1059,9 +1062,15 @@ function CopyablePreBlock({ children, className }: { children: string; className title="Copy" aria-label="Copy" onClick={() => { - void navigator.clipboard.writeText(children).then(() => { + void copyTextToClipboard(children).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); + }).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy text", + tone: "error", + }); }); }} > @@ -1310,6 +1319,7 @@ function IssueChatUserMessage({ const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; const [copied, setCopied] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const toastActions = useOptionalToastActions(); const { isCurrentUser, authorName: resolvedAuthorName, @@ -1436,9 +1446,15 @@ function IssueChatUserMessage({ .filter((p): p is { type: "text"; text: string } => p.type === "text") .map((p) => p.text) .join("\n\n"); - void navigator.clipboard.writeText(text).then(() => { + void copyTextToClipboard(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); + }).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy message", + tone: "error", + }); }); }} > @@ -1551,6 +1567,7 @@ function IssueChatAssistantMessage({ const [folded, setFolded] = useState(isFoldable); const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable }); const [copied, setCopied] = useState(false); + const toastActions = useOptionalToastActions(); const copyText = deleted ? "" : getThreadMessageCopyText(message); // Derive fold state synchronously during render (not in useEffect) so the @@ -1609,9 +1626,15 @@ function IssueChatAssistantMessage({ title="Copy message" aria-label="Copy message" onClick={() => { - void navigator.clipboard.writeText(copyText).then(() => { + void copyTextToClipboard(copyText).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); + }).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy message", + tone: "error", + }); }); }} > @@ -1653,7 +1676,13 @@ function IssueChatAssistantMessage({ { - void navigator.clipboard.writeText(copyText); + void copyTextToClipboard(copyText).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy message", + tone: "error", + }); + }); }} > @@ -2411,6 +2440,7 @@ function SystemNoticeCommentRow({ anchorId?: string; }) { const { onImageClick, agentMap, issueStatus, successfulRunHandoff } = useContext(IssueChatCtx); + const toastActions = useOptionalToastActions(); const custom = message.metadata.custom as Record; const presentation = isIssueCommentPresentation(custom.presentation) ? custom.presentation : null; const commentMetadata = isIssueCommentMetadata(custom.commentMetadata) ? custom.commentMetadata : null; @@ -2460,18 +2490,30 @@ function SystemNoticeCommentRow({ }); const handleCopy = () => { - void navigator.clipboard.writeText(bodyText).then(() => { + void copyTextToClipboard(bodyText).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); + }).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy system notice", + tone: "error", + }); }); }; const handleCopyLink = () => { if (!anchorId || typeof window === "undefined") return; const url = `${window.location.origin}${window.location.pathname}#${anchorId}`; - void navigator.clipboard.writeText(url).then(() => { + void copyTextToClipboard(url).then(() => { setCopiedLink(true); setTimeout(() => setCopiedLink(false), 2000); + }).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy system notice link", + tone: "error", + }); }); }; diff --git a/ui/src/components/IssueChatThreadClassic.tsx b/ui/src/components/IssueChatThreadClassic.tsx index 157e8e70..fc68fb92 100644 --- a/ui/src/components/IssueChatThreadClassic.tsx +++ b/ui/src/components/IssueChatThreadClassic.tsx @@ -56,6 +56,7 @@ import type { import type { ActiveRunForIssue, LiveRunForIssue } from "../api/heartbeats"; import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts"; import { usePaperclipIssueRuntime, type PaperclipIssueRuntimeReassignment } from "../hooks/usePaperclipIssueRuntime"; +import { copyTextToClipboard } from "../lib/clipboard"; import { buildIssueChatMessages, formatDurationWords, @@ -1072,6 +1073,7 @@ function IssueChatRollingToolPart({ toolParts }: { toolParts: ToolCallMessagePar function CopyablePreBlock({ children, className }: { children: string; className?: string }) { const [copied, setCopied] = useState(false); + const toastActions = useOptionalToastActions(); return (
{children}
@@ -1084,9 +1086,15 @@ function CopyablePreBlock({ children, className }: { children: string; className title="Copy" aria-label="Copy" onClick={() => { - void navigator.clipboard.writeText(children).then(() => { + void copyTextToClipboard(children).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); + }).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy text", + tone: "error", + }); }); }} > @@ -1332,6 +1340,7 @@ function IssueChatUserMessage({ const queueTargetRunId = typeof custom.queueTargetRunId === "string" ? custom.queueTargetRunId : null; const [copied, setCopied] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const toastActions = useOptionalToastActions(); const { isCurrentUser, authorName: resolvedAuthorName, @@ -1452,9 +1461,15 @@ function IssueChatUserMessage({ .filter((p): p is { type: "text"; text: string } => p.type === "text") .map((p) => p.text) .join("\n\n"); - void navigator.clipboard.writeText(text).then(() => { + void copyTextToClipboard(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); + }).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy message", + tone: "error", + }); }); }} > @@ -1567,6 +1582,7 @@ function IssueChatAssistantMessage({ const [folded, setFolded] = useState(isFoldable); const [prevFoldKey, setPrevFoldKey] = useState({ messageId: message.id, isFoldable }); const [copied, setCopied] = useState(false); + const toastActions = useOptionalToastActions(); const copyText = deleted ? "" : getThreadMessageCopyText(message); // Derive fold state synchronously during render (not in useEffect) so the @@ -1685,9 +1701,15 @@ function IssueChatAssistantMessage({ title="Copy message" aria-label="Copy message" onClick={() => { - void navigator.clipboard.writeText(copyText).then(() => { + void copyTextToClipboard(copyText).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); + }).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy message", + tone: "error", + }); }); }} > @@ -1729,7 +1751,13 @@ function IssueChatAssistantMessage({ { - void navigator.clipboard.writeText(copyText); + void copyTextToClipboard(copyText).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy message", + tone: "error", + }); + }); }} > @@ -2369,6 +2397,7 @@ function SystemNoticeCommentRow({ anchorId?: string; }) { const { onImageClick, agentMap, issueStatus, successfulRunHandoff } = useContext(IssueChatCtx); + const toastActions = useOptionalToastActions(); const custom = message.metadata.custom as Record; const presentation = isIssueCommentPresentation(custom.presentation) ? custom.presentation : null; const commentMetadata = isIssueCommentMetadata(custom.commentMetadata) ? custom.commentMetadata : null; @@ -2418,18 +2447,30 @@ function SystemNoticeCommentRow({ }); const handleCopy = () => { - void navigator.clipboard.writeText(bodyText).then(() => { + void copyTextToClipboard(bodyText).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); + }).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy system notice", + tone: "error", + }); }); }; const handleCopyLink = () => { if (!anchorId || typeof window === "undefined") return; const url = `${window.location.origin}${window.location.pathname}#${anchorId}`; - void navigator.clipboard.writeText(url).then(() => { + void copyTextToClipboard(url).then(() => { setCopiedLink(true); setTimeout(() => setCopiedLink(false), 2000); + }).catch((error) => { + toastActions?.pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy system notice link", + tone: "error", + }); }); }; diff --git a/ui/src/lib/clipboard.ts b/ui/src/lib/clipboard.ts new file mode 100644 index 00000000..168d3290 --- /dev/null +++ b/ui/src/lib/clipboard.ts @@ -0,0 +1,31 @@ +export async function copyTextToClipboard(text: string): Promise { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text); + return; + } catch { + // Fall back for environments where the Clipboard API exists but is blocked. + } + } + + if (typeof document === "undefined") { + throw new Error("Clipboard unavailable"); + } + + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.readOnly = true; + textarea.style.position = "fixed"; + textarea.style.top = "-9999px"; + textarea.style.left = "-9999px"; + document.body.appendChild(textarea); + + try { + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + const success = document.execCommand("copy"); + if (!success) throw new Error("execCommand copy failed"); + } finally { + document.body.removeChild(textarea); + } +} diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx index c9570d33..b8a1125d 100644 --- a/ui/src/pages/IssueDetail.test.tsx +++ b/ui/src/pages/IssueDetail.test.tsx @@ -1637,6 +1637,68 @@ describe("IssueDetail", () => { expect(container.textContent).toContain("Plan mode"); }); + it("falls back to execCommand when copying the task from an insecure context", async () => { + const clipboardWrite = vi.fn(async () => { + throw new Error("Clipboard API blocked"); + }); + const execCommand = vi.fn(() => true); + const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + const originalExecCommand = Object.getOwnPropertyDescriptor(document, "execCommand"); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: clipboardWrite }, + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + mockIssuesApi.get.mockResolvedValue(createIssue({ + identifier: "PAP-1", + title: "Copy me", + description: "Task body", + })); + + try { + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const copyButton = Array.from(container.querySelectorAll("button")) + .find((button) => button.getAttribute("title") === "Copy task as markdown"); + expect(copyButton).toBeTruthy(); + + await act(async () => { + copyButton!.click(); + await Promise.resolve(); + }); + + expect(clipboardWrite).toHaveBeenCalledWith("# PAP-1: Copy me\n\nTask body"); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({ + title: "Copied to clipboard", + tone: "success", + })); + } finally { + if (originalClipboard) { + Object.defineProperty(navigator, "clipboard", originalClipboard); + } else { + // @ts-expect-error test cleanup for optional browser API + delete navigator.clipboard; + } + if (originalExecCommand) { + Object.defineProperty(document, "execCommand", originalExecCommand); + } else { + // @ts-expect-error test cleanup for optional browser API + delete document.execCommand; + } + } + }); + // PAP-140 flag-off parity: with the Conference Room Chat flag off, the task // thread surfaces must render master's behavior (classic fork, master copy, // master mention set). diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index 0bac5e2a..6a96cc04 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -118,6 +118,7 @@ import { } from "@/components/ui/dialog"; import { Textarea } from "@/components/ui/textarea"; import { formatIssueActivityAction } from "@/lib/activity-format"; +import { copyTextToClipboard } from "../lib/clipboard"; import { buildIssuePropertiesPanelKey } from "../lib/issue-properties-panel-key"; import { buildIssueSiblingNavigation, shouldRenderRichSubIssuesSection } from "../lib/issue-detail-subissues"; import { filterIssueDescendants } from "../lib/issue-tree"; @@ -3094,10 +3095,18 @@ export function IssueDetail() { const title = decodeEntities(issue.title); const body = decodeEntities(issue.description ?? ""); const md = `# ${issue.identifier}: ${title}\n\n${body}`.trimEnd(); - await navigator.clipboard.writeText(md); - setCopied(true); - pushToast({ title: "Copied to clipboard", tone: "success" }); - setTimeout(() => setCopied(false), 2000); + try { + await copyTextToClipboard(md); + setCopied(true); + pushToast({ title: "Copied to clipboard", tone: "success" }); + setTimeout(() => setCopied(false), 2000); + } catch (error) { + pushToast({ + title: "Copy failed", + body: error instanceof Error ? error.message : "Unable to copy task markdown", + tone: "error", + }); + } }; // Gmail-style mobile toolbar when viewing an issue from inbox.