fix(ui): use HTTP-safe clipboard copy everywhere (#10875)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Operators often open self-hosted Paperclip over plain HTTP on a LAN
or private network.
> - Browser Clipboard API writes are not reliable in that insecure
context.
> - Paperclip already has one shared helper with a legacy copy fallback,
but many current copy actions bypass it.
> - This pull request routes every core UI copy action and the
first-party workspace-diff plugin through the shared helper.
> - The benefit is consistent copy behavior on HTTPS, localhost, and
plain-HTTP private deployments.

## Linked Issues or Issue Description

Refs #3529.

This change supersedes the stale prior attempt in #3531. Current master
has more copy surfaces and a first-party plugin UI bridge that the prior
branch does not cover.

## What Changed

- Replaced direct Clipboard API writes and duplicate fallback
implementations across the current core UI with `copyTextToClipboard`.
- Added an HTTP-safe clipboard function to the plugin UI SDK and wired
the host bridge to the same implementation.
- Migrated the first-party workspace-diff plugin to the plugin SDK
clipboard function.
- Added unit coverage for native rejection fallback and plugin host
delegation.
- Added a source-level regression test that rejects new direct clipboard
writes outside the shared implementation.
- Documented the plugin UI clipboard function.

## Verification

- `NODE_ENV=test pnpm exec vitest run ...` for 14 affected suites: 164
tests passed.
- `pnpm exec vitest run tests/ui-clipboard.test.ts` in
`packages/plugins/sdk`: 1 test passed.
- `NODE_ENV=test pnpm -r typecheck`: passed for 31 workspace projects.
- `NODE_ENV=test pnpm test:run`: passed.
- `NODE_ENV=production pnpm build`: passed.
- `pnpm check:token-gates`: passed with all gates clean.

## Risks

Low risk. Secure contexts still use the modern Clipboard API. Plain HTTP
and rejected modern writes use the existing `execCommand("copy")`
fallback. That API is deprecated, but it is the compatibility path
required for insecure contexts. The change has no schema, API, or visual
design effect.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, `gpt-5.6-sol`. The runtime did not expose a context-window
size. Reasoning, tool use, repository editing, test execution, and
GitHub CLI access were enabled.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-05 10:45:08 -05:00 committed by GitHub
parent 14d755824c
commit 1fa36be353
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 266 additions and 179 deletions

View File

@ -1,5 +1,5 @@
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
import { copyTextToClipboard, usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
import type { PatchDiffProps } from "@pierre/diffs/react";
import { useFileDiffInstance } from "@pierre/diffs/react";
@ -593,7 +593,7 @@ export function ChangesTab({ context }: PluginDetailTabProps) {
const copyPath = async (filePath: string) => {
try {
await navigator.clipboard.writeText(filePath);
await copyTextToClipboard(filePath);
toast({ title: "Path copied", body: filePath });
} catch {
toast({ title: "Copy failed", body: filePath, tone: "error" });

View File

@ -15,7 +15,7 @@ Reference: `doc/plugins/PLUGIN_SPEC.md`
| Import | Purpose |
|--------|--------|
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, `useHostNavigation`, slot prop types |
| `@paperclipai/plugin-sdk/ui` | UI entry: hooks, host navigation, HTTP-safe clipboard copy, shared components, and slot prop types |
| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
@ -764,6 +764,16 @@ The host provides selected shared UI components through `@paperclipai/plugin-sdk
Plugins can also use normal React components, their own CSS, or small design
primitives inside the plugin package.
Use `copyTextToClipboard` for every plugin copy action. The host selects the
modern Clipboard API in secure contexts and a compatible fallback in plain-HTTP
deployments.
```tsx
import { copyTextToClipboard } from "@paperclipai/plugin-sdk/ui";
await copyTextToClipboard("text to copy");
```
Use the shared components when the plugin needs to look and behave like a native
Paperclip surface:

View File

@ -0,0 +1,12 @@
import { getSdkUiRuntimeValue } from "./runtime.js";
/**
* Copy text through the host's HTTP-safe clipboard implementation.
*
* Plugin UI code must use this helper instead of calling the browser Clipboard
* API directly so copy actions also work in Paperclip's plain-HTTP deployments.
*/
export function copyTextToClipboard(text: string): Promise<void> {
const copy = getSdkUiRuntimeValue<(value: string) => Promise<void>>("copyTextToClipboard");
return copy(text);
}

View File

@ -57,6 +57,8 @@ export {
usePluginToast,
} from "./hooks.js";
export { copyTextToClipboard } from "./clipboard.js";
export {
MetricCard,
StatusBadge,

View File

@ -0,0 +1,23 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { copyTextToClipboard } from "../src/ui/clipboard.js";
type GlobalWithPluginBridge = typeof globalThis & {
__paperclipPluginBridge__?: unknown;
};
afterEach(() => {
delete (globalThis as GlobalWithPluginBridge).__paperclipPluginBridge__;
});
describe("copyTextToClipboard", () => {
it("delegates clipboard writes to the host UI runtime", async () => {
const copy = vi.fn(async () => undefined);
(globalThis as GlobalWithPluginBridge).__paperclipPluginBridge__ = {
sdkUi: { copyTextToClipboard: copy },
};
await copyTextToClipboard("src/index.ts");
expect(copy).toHaveBeenCalledWith("src/index.ts");
});
});

View File

@ -33,6 +33,7 @@ import { agentsApi } from "../api/agents";
import { ApiError } from "../api/client";
import { queryKeys } from "../lib/queryKeys";
import { agentRouteRef } from "../lib/utils";
import { copyTextToClipboard } from "../lib/clipboard";
import { useDialogActions } from "../context/DialogContext";
import { useToastActions } from "../context/ToastContext";
import {
@ -399,7 +400,9 @@ export function AgentActionButtons({
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
navigator.clipboard.writeText(agent.id);
void copyTextToClipboard(agent.id).catch(() => {
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });
});
setMoreOpen(false);
}}
>

View File

@ -5,6 +5,7 @@ import type {
} from "@paperclipai/shared";
import { cn, formatShortDate } from "../lib/utils";
import { timeAgo } from "../lib/timeAgo";
import { copyTextToClipboard } from "../lib/clipboard";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@ -88,10 +89,12 @@ export function AgentBubbleActionRow({
title="Copy message"
aria-label="Copy message"
onClick={() => {
void navigator.clipboard.writeText(copyText).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
void copyTextToClipboard(copyText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
@ -134,7 +137,7 @@ export function AgentBubbleActionRow({
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
void navigator.clipboard.writeText(copyText);
void copyTextToClipboard(copyText).catch(() => {});
}}
>
<Copy className="mr-2 h-3.5 w-3.5" />

View File

@ -24,6 +24,7 @@ import { formatTimelineWorkspaceLabel, type IssueTimelineAssignee, type IssueTim
import { timeAgo } from "../lib/timeAgo";
import { cn, formatDateTime } from "../lib/utils";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import { copyTextToClipboard } from "../lib/clipboard";
import { PluginSlotOutlet } from "@/plugins/slots";
interface CommentWithRunMeta extends IssueComment {
@ -244,27 +245,6 @@ 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 [status, setStatus] = useState<"idle" | "copied" | "failed">("idle");
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -291,7 +271,7 @@ function CopyMarkdownButton({ text }: { text: string }) {
title={label}
aria-label="Copy comment as markdown"
onClick={() => {
void copyTextWithFallback(text)
void copyTextToClipboard(text)
.then(() => setStatus("copied"))
.catch(() => setStatus("failed"));

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
interface CopyTextProps {
text: string;
@ -31,23 +32,7 @@ export function CopyText({
const handleClick = useCallback(async () => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// Fallback for non-secure contexts (e.g. HTTP on non-localhost)
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);
}
}
await copyTextToClipboard(text);
setLabel(copiedLabel);
} catch {
setLabel("Copy failed");

View File

@ -26,6 +26,7 @@ import { cn, relativeTime } from "@/lib/utils";
import { documentAnnotationsApi, type DocumentAnnotationTarget } from "@/api/document-annotations";
import { authApi } from "@/api/auth";
import { queryKeys } from "@/lib/queryKeys";
import { copyTextToClipboard } from "@/lib/clipboard";
import { AgentIcon } from "./AgentIconPicker";
import { deriveInitials } from "./Identity";
import { MarkdownBody } from "./MarkdownBody";
@ -780,11 +781,11 @@ function truncate(value: string, limit: number) {
}
async function copyAnnotationLink(documentKey: string, threadId: string) {
if (typeof window === "undefined" || !navigator.clipboard) return;
if (typeof window === "undefined") return;
const { pathname } = window.location;
const hash = `#document-${encodeURIComponent(documentKey)}&thread=${encodeURIComponent(threadId)}`;
try {
await navigator.clipboard.writeText(`${window.location.origin}${pathname}${hash}`);
await copyTextToClipboard(`${window.location.origin}${pathname}${hash}`);
} catch {
/* swallow */
}

View File

@ -40,6 +40,7 @@ import { cn } from "@/lib/utils";
import { fileResourcesApi } from "@/api/file-resources";
import { ApiError } from "@/api/client";
import { queryKeys } from "@/lib/queryKeys";
import { copyTextToClipboard } from "@/lib/clipboard";
import {
useRequiredFileViewer,
type FileViewerUrlState,
@ -115,27 +116,6 @@ function isMarkdownResource(resource: ResolvedWorkspaceResource): boolean {
return /\.(md|markdown|mdown|mkdn|mkd)$/.test(path);
}
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);
}
}
export function describeDenial(code: string, fallback: string): { title: string; body: string; icon: ReactNode } {
const lower = code.toLowerCase();
if (lower.includes("policy") || lower.includes("denied") || lower.includes("sensitive")) {
@ -660,7 +640,7 @@ export function FileViewerSheet({
const copyToClipboard = useCallback(async (value: string, field: "content" | "link", message: string) => {
try {
setCopyingField(field);
await copyTextWithFallback(value);
await copyTextToClipboard(value);
showCopyFeedback(field, message);
} catch {
showCopyFeedback(null, "Copy failed");

View File

@ -6,6 +6,7 @@ import { cn, relativeTime } from "../lib/utils";
import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody";
import { Check, ChevronDown, ChevronRight, Copy, History } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { copyTextToClipboard } from "@/lib/clipboard";
type IssueContinuationHandoffProps = {
document: IssueDocument | null | undefined;
@ -43,7 +44,11 @@ export function IssueContinuationHandoff({
const copyBody = useCallback(async () => {
if (!document) return;
await navigator.clipboard?.writeText(document.body);
try {
await copyTextToClipboard(document.body);
} catch {
return;
}
setCopied(true);
if (copiedTimerRef.current) {
clearTimeout(copiedTimerRef.current);

View File

@ -342,6 +342,81 @@ describe("IssueDocumentsSection", () => {
queryClient.clear();
});
it("copies document bodies through the plain HTTP clipboard fallback", async () => {
const issue = createIssue();
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
const writeText = vi.fn(async () => {});
const execCommand = vi.fn(() => true);
const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard");
const originalSecureContext = Object.getOwnPropertyDescriptor(window, "isSecureContext");
const originalExecCommand = Object.getOwnPropertyDescriptor(document, "execCommand");
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: false,
});
Object.defineProperty(document, "execCommand", {
configurable: true,
value: execCommand,
});
mockIssuesApi.listDocuments.mockResolvedValue([
createIssueDocument({ body: "# Copy over HTTP" }),
]);
try {
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
</QueryClientProvider>,
);
});
await flush();
await flush();
const copyButton = container.querySelector('button[title="Copy document"]');
expect(copyButton).toBeTruthy();
await act(async () => {
copyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(writeText).not.toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledWith("copy");
expect(container.querySelector('button[title="Copied"]')).toBeTruthy();
} finally {
await act(async () => {
root.unmount();
});
queryClient.clear();
if (originalClipboard) {
Object.defineProperty(navigator, "clipboard", originalClipboard);
} else {
delete (navigator as { clipboard?: Clipboard }).clipboard;
}
if (originalSecureContext) {
Object.defineProperty(window, "isSecureContext", originalSecureContext);
} else {
delete (window as { isSecureContext?: boolean }).isSecureContext;
}
if (originalExecCommand) {
Object.defineProperty(document, "execCommand", originalExecCommand);
} else {
delete (document as { execCommand?: (command: string) => boolean }).execCommand;
}
}
});
it("locks documents from the document header action", async () => {
const unlockedDocument = createIssueDocument({
body: "Draftable plan body",

View File

@ -15,6 +15,7 @@ import { useLocation } from "@/lib/router";
import { ApiError } from "../api/client";
import { issuesApi } from "../api/issues";
import { useAutosaveIndicator } from "../hooks/useAutosaveIndicator";
import { copyTextToClipboard } from "../lib/clipboard";
import { deriveDocumentRevisionState } from "../lib/document-revisions";
import type { CompanyUserProfile } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
@ -690,7 +691,7 @@ export function IssueDocumentsSection({
const copyDocumentBody = useCallback(async (key: string, body: string) => {
try {
await navigator.clipboard.writeText(body);
await copyTextToClipboard(body);
setCopiedDocumentKey(key);
if (copiedDocumentTimerRef.current) {
clearTimeout(copiedDocumentTimerRef.current);

View File

@ -7,6 +7,7 @@ import { environmentsApi } from "../api/environments";
import { instanceSettingsApi } from "../api/instanceSettings";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { copyTextToClipboard } from "../lib/clipboard";
import {
defaultExecutionWorkspaceModeForProject,
issueExecutionWorkspaceModeForExistingWorkspace,
@ -66,7 +67,7 @@ function CopyableInline({ value, label, mono }: { value: string; label?: string;
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
await copyTextToClipboard(value);
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);

View File

@ -32,6 +32,7 @@ import {
externalObjectProviderLabel,
} from "../lib/external-objects";
import { normalizeExternalObjectHref } from "../lib/external-object-href";
import { copyTextToClipboard } from "../lib/clipboard";
import type {
ExternalObjectLivenessState,
ExternalObjectStatusCategory,
@ -556,22 +557,7 @@ function CodeBlock({
const handleCopy = useCallback(async () => {
const text = preRef.current?.innerText ?? flattenText(children);
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
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);
}
}
await copyTextToClipboard(text);
setFailed(false);
setCopied(true);
} catch {

View File

@ -21,6 +21,7 @@ import {
Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { buildAgentOnboardingPrompt } from "@/lib/agent-onboarding-prompt";
import { listUIAdapters } from "../adapters";
import { isVisualAdapterChoice } from "../adapters/metadata";
@ -141,20 +142,16 @@ export function NewAgentDialog() {
async function copyText(text: string, unavailableBody: string) {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
await copyTextToClipboard(text);
return true;
} catch {
// Fall through to the unavailable message below.
pushToast({
title: "Clipboard unavailable",
body: unavailableBody,
tone: "warn",
});
return false;
}
pushToast({
title: "Clipboard unavailable",
body: unavailableBody,
tone: "warn",
});
return false;
}
const createAgentInviteMutation = useMutation({

View File

@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useOptionalToastActions } from "../context/ToastContext";
import { CHROMELESS_DISPLAY_MODES, isChromelessDisplayMode } from "../lib/pwa-display-mode";
import { copyTextToClipboard } from "../lib/clipboard";
function ControlButton({
label,
@ -71,12 +72,8 @@ export function StandaloneBrowserControls({ mobile }: { mobile: boolean }) {
await navigator.share({ title: document.title || "Paperclip", url });
return;
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url);
toastActions?.pushToast({ title: "Link copied", tone: "success" });
return;
}
toastActions?.pushToast({ title: "Sharing is unavailable", body: url, tone: "warn" });
await copyTextToClipboard(url);
toastActions?.pushToast({ title: "Link copied", tone: "success" });
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") return;
toastActions?.pushToast({ title: "Share failed", body: "Try opening the page in your browser.", tone: "error" });

View File

@ -13,6 +13,7 @@ import {
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
export type WorkspaceServiceControlState =
| "stopped"
@ -121,8 +122,7 @@ function CopyUrlButton({ url, disabled }: { url: string; disabled?: boolean }) {
className="text-muted-foreground hover:text-foreground"
onClick={async () => {
try {
if (!navigator.clipboard) throw new Error("Clipboard API unavailable");
await navigator.clipboard.writeText(url);
await copyTextToClipboard(url);
setCopyState("copied");
} catch {
setCopyState("failed");

View File

@ -1,5 +1,6 @@
import { useCallback, useState } from "react";
import { getWorktreeUiBranding } from "../lib/worktree-branding";
import { copyTextToClipboard } from "../lib/clipboard";
export function WorktreeBanner() {
const branding = getWorktreeUiBranding();
@ -7,10 +8,12 @@ export function WorktreeBanner() {
const handleCopyName = useCallback(() => {
if (!branding) return;
navigator.clipboard.writeText(branding.name).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
void copyTextToClipboard(branding.name)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
}, [branding]);
if (!branding) return null;

View File

@ -3,6 +3,7 @@ import { createPortal } from "react-dom";
import { PROPERTIES_PANE_HEADER_SLOT_ID } from "../PropertiesPanel";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { issueStatusText } from "@/lib/status-colors";
import { copyTextToClipboard } from "@/lib/clipboard";
import { Link } from "@/lib/router";
import { deriveOriginatingActor, type Issue, type IssueLabel } from "@paperclipai/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@ -98,7 +99,7 @@ function TruncatedCopyable({ value, icon: Icon }: { value: string; icon: Compone
useEffect(() => () => clearTimeout(timerRef.current), []);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(value);
await copyTextToClipboard(value);
setCopied(true);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1500);

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { Copy, GripHorizontal, Minus, Plus, RotateCcw } from "lucide-react";
import {
EASING_PRESETS,
@ -220,7 +221,7 @@ export function TweakPanel() {
value={exportText}
onFocus={(e) => {
e.currentTarget.select();
void navigator.clipboard?.writeText(exportText).catch(() => {});
void copyTextToClipboard(exportText).catch(() => {});
}}
/>
) : null}

View File

@ -0,0 +1,26 @@
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const DIRECT_CLIPBOARD_WRITE = /navigator\.clipboard|document\.execCommand/;
function sourceFiles(root: URL): string[] {
return readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
const url = new URL(entry.name + (entry.isDirectory() ? "/" : ""), root);
if (entry.isDirectory()) return sourceFiles(url);
if (!/\.tsx?$/.test(entry.name) || /\.test\.tsx?$/.test(entry.name)) return [];
return [fileURLToPath(url)];
});
}
describe("clipboard usage", () => {
it("routes core and first-party plugin copy actions through shared helpers", () => {
const coreSource = new URL("../", import.meta.url);
const pluginSource = new URL("../../../packages/plugins/plugin-workspace-diff/src/ui/", import.meta.url);
const violations = [...sourceFiles(coreSource), ...sourceFiles(pluginSource)]
.filter((file) => !file.replaceAll("\\", "/").endsWith("/lib/clipboard.ts"))
.filter((file) => DIRECT_CLIPBOARD_WRITE.test(readFileSync(file, "utf8")));
expect(violations).toEqual([]);
});
});

View File

@ -60,6 +60,20 @@ describe("copyTextToClipboard", () => {
expect(textarea.style.opacity).toBeUndefined();
});
it("falls back when the secure-context Clipboard API rejects the write", async () => {
const writeText = vi.fn(async () => {
throw new Error("permission denied");
});
vi.stubGlobal("window", { isSecureContext: true });
vi.stubGlobal("navigator", { clipboard: { writeText } });
const { doc } = installDocumentStub(() => true);
await copyTextToClipboard("retry through fallback");
expect(writeText).toHaveBeenCalledWith("retry through fallback");
expect(doc.execCommand).toHaveBeenCalledWith("copy");
});
it("throws when the execCommand fallback reports failure", async () => {
vi.stubGlobal("window", { isSecureContext: false });
vi.stubGlobal("navigator", {});

View File

@ -3,7 +3,7 @@ export async function copyTextToClipboard(text: string): Promise<void> {
// HTTP on a non-localhost host (e.g. a Tailscale name) `writeText` may resolve
// without actually writing, so gate on `isSecureContext` and otherwise fall
// through to the execCommand path below.
const isSecure = typeof window === "undefined" || window.isSecureContext;
const isSecure = typeof window === "undefined" || window.isSecureContext !== false;
if (isSecure && typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);

View File

@ -24,6 +24,7 @@ import { useCompany } from "../context/CompanyContext";
import { useToastActions } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { copyTextToClipboard } from "../lib/clipboard";
import { AgentSkillsTab } from "./agent-skills/AgentSkillsTab";
import { AgentConfigForm } from "../components/AgentConfigForm";
import { PageTabBar } from "../components/PageTabBar";
@ -4081,6 +4082,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }) {
const queryClient = useQueryClient();
const { pushToast } = useToastActions();
const [newKeyName, setNewKeyName] = useState("");
const [newToken, setNewToken] = useState<string | null>(null);
const [tokenVisible, setTokenVisible] = useState(false);
@ -4110,9 +4112,14 @@ function KeysTab({ agentId, companyId }: { agentId: string; companyId?: string }
function copyToken() {
if (!newToken) return;
navigator.clipboard.writeText(newToken);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
void copyTextToClipboard(newToken)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });
});
}
const activeKeys = (keys ?? []).filter((k: AgentKey) => !k.revokedAt);

View File

@ -9,6 +9,7 @@ import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { Link } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
import { copyTextToClipboard } from "@/lib/clipboard";
import { Badge } from "@/components/ui/badge";
const inviteRoleOptions = [
@ -70,41 +71,11 @@ export function CompanyInvites() {
async function copyText(text: string, unavailableBody: string, afterFallback?: () => void) {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
await copyTextToClipboard(text);
return true;
} catch {
// Fall through to the unavailable message below.
afterFallback?.();
}
const canUseLegacyCopy =
typeof document !== "undefined" &&
typeof document.execCommand === "function" &&
(typeof document.queryCommandSupported !== "function" || document.queryCommandSupported("copy"));
if (canUseLegacyCopy) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
try {
const copied = document.execCommand("copy");
document.body.removeChild(textarea);
afterFallback?.();
if (copied) return true;
} catch {
document.body.removeChild(textarea);
}
}
afterFallback?.();
pushToast({
title: "Clipboard unavailable",
body: unavailableBody,

View File

@ -28,6 +28,7 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs, type Breadcrumb } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { copyTextToClipboard } from "../lib/clipboard";
import { EmptyState } from "../components/EmptyState";
import { MarkdownBody } from "../components/MarkdownBody";
import { MarkdownEditor } from "../components/MarkdownEditor";
@ -2662,10 +2663,12 @@ function SkillLocationCard({
size="sm"
variant="outline"
onClick={() => {
void navigator.clipboard?.writeText(canonical).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
});
void copyTextToClipboard(canonical)
.then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
})
.catch(() => {});
}}
>
<Copy className="mr-1.5 h-3.5 w-3.5" />

View File

@ -19,6 +19,7 @@ import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToastActions } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { copyTextToClipboard } from "../lib/clipboard";
import { buildMarkdownMentionOptions } from "../lib/company-members";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { EmptyState } from "../components/EmptyState";
@ -369,7 +370,7 @@ export function RoutineDetail() {
const copySecretValue = useCallback(
async (label: string, value: string) => {
try {
await navigator.clipboard.writeText(value);
await copyTextToClipboard(value);
pushToast({ title: `${label} copied`, tone: "success" });
} catch (copyError) {
pushToast({

View File

@ -3886,7 +3886,7 @@ function AwsProviderVaultDiscoveryError({
const detailsText = JSON.stringify(safeDetails, null, 2);
const copyDetails = () => {
void navigator.clipboard?.writeText(detailsText);
void copyTextToClipboard(detailsText).catch(() => {});
};
return (
@ -4035,7 +4035,7 @@ function SecretCreateError({
type="button"
variant="ghost"
size="sm"
onClick={() => void navigator.clipboard?.writeText(detailsText)}
onClick={() => void copyTextToClipboard(detailsText).catch(() => {})}
>
Copy
</Button>

View File

@ -2097,7 +2097,7 @@ function InputPane({
<DropdownMenuItem
onClick={() => {
const input = inputs.find((i) => i.id === id);
if (input) navigator.clipboard?.writeText(input.content).catch(() => {});
if (input) void copyTextToClipboard(input.content).catch(() => {});
}}
>
<Copy className="mr-2 h-4 w-4" /> Copy content

View File

@ -39,6 +39,7 @@ import { Textarea } from "@/components/ui/textarea";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { AppLogo } from "./AppLogo";
import { parseGoogleSheetIds } from "./google-sheets";
import { autoExtendNotice, INSTALL_ALL_WARNING, installInfoNotice, installPayload } from "@/lib/tool-installs";
@ -1092,7 +1093,7 @@ function KeyStep({
type="button"
variant="outline"
className="shrink-0"
onClick={() => void navigator.clipboard?.writeText(robotEmail)}
onClick={() => void copyTextToClipboard(robotEmail).catch(() => {})}
>
<Copy className="mr-2 h-4 w-4" />
Copy

View File

@ -12,6 +12,7 @@ import {
} from "@/components/ui/dialog";
import { useToast } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { formatSnippetConfig, maskedTokenLabel, orderedSnippets } from "./gateway-helpers";
type PanelKey = string; // snippet client key, or "raw_url"
@ -52,10 +53,7 @@ export function ConnectClientDialog({
async function copyText(value: string, label: string) {
try {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
throw new Error("Clipboard access is unavailable.");
}
await navigator.clipboard.writeText(value);
await copyTextToClipboard(value);
pushToast({ title: "Copied", body: label, tone: "success" });
} catch (error) {
pushToast({

View File

@ -7,6 +7,7 @@ import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/context/ToastContext";
import { copyTextToClipboard } from "@/lib/clipboard";
import { gatewaysQueryKey } from "../NewGatewayDialog";
/**
@ -64,7 +65,7 @@ export function GatewayAdvancedPanel({
async function copy(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
await copyTextToClipboard(value);
pushToast({ title: "Copied", body: label, tone: "success" });
} catch {
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });

View File

@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { useToast } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import {
activeTokenCount,
allowedToolsLabel,
@ -52,7 +53,7 @@ export function OverviewPanel({
async function copy(value: string, label: string) {
try {
await navigator.clipboard.writeText(value);
await copyTextToClipboard(value);
pushToast({ title: "Copied", body: label, tone: "success" });
} catch {
pushToast({ title: "Copy failed", body: "Clipboard access is unavailable.", tone: "error" });

View File

@ -12,6 +12,7 @@ import { Input } from "@/components/ui/input";
import { useToast } from "@/context/ToastContext";
import { RelativeTime } from "@/pages/tools/shared";
import { cn } from "@/lib/utils";
import { copyTextToClipboard } from "@/lib/clipboard";
import { gatewaysQueryKey } from "../NewGatewayDialog";
import { maskedTokenLabel, TOKEN_STATUS_LABEL, tokenStatus, type TokenStatus } from "../gateway-helpers";
@ -126,10 +127,7 @@ export function TokensPanel({
async function copyToken(value: string) {
try {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
throw new Error("Clipboard access is unavailable.");
}
await navigator.clipboard.writeText(value);
await copyTextToClipboard(value);
pushToast({ title: "Copied", body: "Access token", tone: "success" });
} catch (error) {
pushToast({

View File

@ -14,6 +14,7 @@ import { toolsApi } from "@/api/tools";
import { Button } from "@/components/ui/button";
import { useToast } from "@/context/ToastContext";
import { queryKeys } from "@/lib/queryKeys";
import { copyTextToClipboard } from "@/lib/clipboard";
import { ErrorState, LoadingState, RelativeTime, ToolsPageHeader } from "./shared";
type CreateGatewayDraft = {
@ -215,10 +216,7 @@ export function GatewaysTab({ companyId }: { companyId: string }) {
async function copyText(value: string, label: string) {
try {
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
throw new Error("Clipboard access is unavailable.");
}
await navigator.clipboard.writeText(value);
await copyTextToClipboard(value);
pushToast({ title: "Copied to clipboard", body: label, tone: "success" });
} catch (error) {
pushToast({ title: "Copy failed", body: error instanceof Error ? error.message : "Clipboard access is unavailable.", tone: "error" });

View File

@ -58,6 +58,7 @@ import {
trackRecentAssigneeUser,
} from "@/lib/recent-assignees";
import { getRecentProjectIds, trackRecentProject } from "@/lib/recent-projects";
import { copyTextToClipboard } from "@/lib/clipboard";
// ---------------------------------------------------------------------------
// Global bridge registry
@ -682,6 +683,7 @@ export function initPluginBridge(
useHostNavigation,
usePluginStream,
usePluginToast,
copyTextToClipboard,
MarkdownBlock: ({
content,
className,

View File

@ -336,7 +336,7 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" |
throw new Error('Paperclip plugin UI runtime is not initialized for "' + name + '". Ensure the host loaded the plugin bridge before rendering this UI module.');
};
}
const { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast } = SDK;
const { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast, copyTextToClipboard } = SDK;
const MetricCard = SDK.MetricCard ?? missing("MetricCard");
const StatusBadge = SDK.StatusBadge ?? missing("StatusBadge");
const DataTable = SDK.DataTable ?? missing("DataTable");
@ -354,7 +354,7 @@ function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" |
const AssigneePicker = SDK.AssigneePicker ?? missing("AssigneePicker");
const ProjectPicker = SDK.ProjectPicker ?? missing("ProjectPicker");
const ManagedRoutinesList = SDK.ManagedRoutinesList ?? missing("ManagedRoutinesList");
export { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast, MetricCard, StatusBadge, DataTable, TimeseriesChart, MarkdownBlock, MarkdownEditor, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary, FileTree, IssuesList, AssigneePicker, ProjectPicker, ManagedRoutinesList };
export { usePluginData, usePluginAction, useHostContext, useHostLocation, useHostNavigation, usePluginStream, usePluginToast, copyTextToClipboard, MetricCard, StatusBadge, DataTable, TimeseriesChart, MarkdownBlock, MarkdownEditor, KeyValueList, ActionBar, LogView, JsonTree, Spinner, ErrorBoundary, FileTree, IssuesList, AssigneePicker, ProjectPicker, ManagedRoutinesList };
`;
break;
}