Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/plugins/plugin-workspace-diff/src/ui/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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" });
Expand Down
12 changes: 11 additions & 1 deletion packages/plugins/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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:

Expand Down
12 changes: 12 additions & 0 deletions packages/plugins/sdk/src/ui/clipboard.ts
Original file line number Diff line number Diff line change
@@ -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);
}
2 changes: 2 additions & 0 deletions packages/plugins/sdk/src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export {
usePluginToast,
} from "./hooks.js";

export { copyTextToClipboard } from "./clipboard.js";

export {
MetricCard,
StatusBadge,
Expand Down
23 changes: 23 additions & 0 deletions packages/plugins/sdk/tests/ui-clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
5 changes: 4 additions & 1 deletion ui/src/components/AgentActionButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}}
>
Expand Down
13 changes: 8 additions & 5 deletions ui/src/components/AgentBubbleActionRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" />}
Expand Down Expand Up @@ -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" />
Expand Down
24 changes: 2 additions & 22 deletions ui/src/components/CommentThread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand All @@ -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"));

Expand Down
19 changes: 2 additions & 17 deletions ui/src/components/CopyText.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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");
Expand Down
5 changes: 3 additions & 2 deletions ui/src/components/DocumentAnnotationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 */
}
Expand Down
24 changes: 2 additions & 22 deletions ui/src/components/FileViewerSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -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");
Expand Down
7 changes: 6 additions & 1 deletion ui/src/components/IssueContinuationHandoff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
75 changes: 75 additions & 0 deletions ui/src/components/IssueDocumentsSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading