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.