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 <joegalbert-ai@users.noreply.github.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread
|
||||
comments={[{
|
||||
id: "comment-copy",
|
||||
companyId: "company-1",
|
||||
issueId: "issue-1",
|
||||
authorAgentId: null,
|
||||
authorUserId: "user-1",
|
||||
authorType: "user",
|
||||
body: "Copy this comment",
|
||||
presentation: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-06T12:00:00.000Z"),
|
||||
}]}
|
||||
linkedRuns={[]}
|
||||
timelineEvents={[]}
|
||||
liveRuns={[]}
|
||||
onAdd={async () => {}}
|
||||
showComposer={false}
|
||||
enableLiveTranscriptPolling={false}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="group/pre relative">
|
||||
<pre className={className}>{children}</pre>
|
||||
@@ -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({
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
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",
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
@@ -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<string, unknown>;
|
||||
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",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className="group/pre relative">
|
||||
<pre className={className}>{children}</pre>
|
||||
@@ -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({
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
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",
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
@@ -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<string, unknown>;
|
||||
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",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export async function copyTextToClipboard(text: string): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user