diff --git a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx index 40b96249b4..203d6db0af 100644 --- a/packages/plugins/plugin-workspace-diff/src/ui/index.tsx +++ b/packages/plugins/plugin-workspace-diff/src/ui/index.tsx @@ -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" }); diff --git a/packages/plugins/sdk/README.md b/packages/plugins/sdk/README.md index 8e340f416c..f352df1d90 100644 --- a/packages/plugins/sdk/README.md +++ b/packages/plugins/sdk/README.md @@ -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: diff --git a/packages/plugins/sdk/src/ui/clipboard.ts b/packages/plugins/sdk/src/ui/clipboard.ts new file mode 100644 index 0000000000..1f42069c97 --- /dev/null +++ b/packages/plugins/sdk/src/ui/clipboard.ts @@ -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 { + const copy = getSdkUiRuntimeValue<(value: string) => Promise>("copyTextToClipboard"); + return copy(text); +} diff --git a/packages/plugins/sdk/src/ui/index.ts b/packages/plugins/sdk/src/ui/index.ts index e2ce8f63ae..cffdb233dc 100644 --- a/packages/plugins/sdk/src/ui/index.ts +++ b/packages/plugins/sdk/src/ui/index.ts @@ -57,6 +57,8 @@ export { usePluginToast, } from "./hooks.js"; +export { copyTextToClipboard } from "./clipboard.js"; + export { MetricCard, StatusBadge, diff --git a/packages/plugins/sdk/tests/ui-clipboard.test.ts b/packages/plugins/sdk/tests/ui-clipboard.test.ts new file mode 100644 index 0000000000..ddfc59701c --- /dev/null +++ b/packages/plugins/sdk/tests/ui-clipboard.test.ts @@ -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"); + }); +}); diff --git a/ui/src/components/AgentActionButtons.tsx b/ui/src/components/AgentActionButtons.tsx index 48e444ca92..2460e4e8eb 100644 --- a/ui/src/components/AgentActionButtons.tsx +++ b/ui/src/components/AgentActionButtons.tsx @@ -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({ diff --git a/ui/src/pages/SkillStudio.tsx b/ui/src/pages/SkillStudio.tsx index 953d43a4ee..eb211b2894 100644 --- a/ui/src/pages/SkillStudio.tsx +++ b/ui/src/pages/SkillStudio.tsx @@ -2097,7 +2097,7 @@ function InputPane({ { const input = inputs.find((i) => i.id === id); - if (input) navigator.clipboard?.writeText(input.content).catch(() => {}); + if (input) void copyTextToClipboard(input.content).catch(() => {}); }} > Copy content diff --git a/ui/src/pages/apps/AppsConnect.tsx b/ui/src/pages/apps/AppsConnect.tsx index 0b1783d34f..a1fbd41ea3 100644 --- a/ui/src/pages/apps/AppsConnect.tsx +++ b/ui/src/pages/apps/AppsConnect.tsx @@ -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 diff --git a/ui/src/pages/apps/gateways/ConnectClientDialog.tsx b/ui/src/pages/apps/gateways/ConnectClientDialog.tsx index 9274b52e50..111ba52454 100644 --- a/ui/src/pages/apps/gateways/ConnectClientDialog.tsx +++ b/ui/src/pages/apps/gateways/ConnectClientDialog.tsx @@ -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({ diff --git a/ui/src/pages/apps/gateways/panels/GatewayAdvancedPanel.tsx b/ui/src/pages/apps/gateways/panels/GatewayAdvancedPanel.tsx index f421af4967..edce968500 100644 --- a/ui/src/pages/apps/gateways/panels/GatewayAdvancedPanel.tsx +++ b/ui/src/pages/apps/gateways/panels/GatewayAdvancedPanel.tsx @@ -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" }); diff --git a/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx b/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx index cb940afe28..a1ef5cc37f 100644 --- a/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx +++ b/ui/src/pages/apps/gateways/panels/OverviewPanel.tsx @@ -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" }); diff --git a/ui/src/pages/apps/gateways/panels/TokensPanel.tsx b/ui/src/pages/apps/gateways/panels/TokensPanel.tsx index 418fe6eff0..030bdbe504 100644 --- a/ui/src/pages/apps/gateways/panels/TokensPanel.tsx +++ b/ui/src/pages/apps/gateways/panels/TokensPanel.tsx @@ -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({ diff --git a/ui/src/pages/tools/GatewaysTab.tsx b/ui/src/pages/tools/GatewaysTab.tsx index 4c561db496..0dcab1e57d 100644 --- a/ui/src/pages/tools/GatewaysTab.tsx +++ b/ui/src/pages/tools/GatewaysTab.tsx @@ -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" }); diff --git a/ui/src/plugins/bridge-init.ts b/ui/src/plugins/bridge-init.ts index 4c8f876900..819287024d 100644 --- a/ui/src/plugins/bridge-init.ts +++ b/ui/src/plugins/bridge-init.ts @@ -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, diff --git a/ui/src/plugins/slots.tsx b/ui/src/plugins/slots.tsx index 82cbeb2c0a..d93455a883 100644 --- a/ui/src/plugins/slots.tsx +++ b/ui/src/plugins/slots.tsx @@ -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; }