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:
parent
14d755824c
commit
1fa36be353
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -57,6 +57,8 @@ export {
|
|||
usePluginToast,
|
||||
} from "./hooks.js";
|
||||
|
||||
export { copyTextToClipboard } from "./clipboard.js";
|
||||
|
||||
export {
|
||||
MetricCard,
|
||||
StatusBadge,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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", {});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue