diff --git a/.changeset/fn-7885-clipboard-migration.md b/.changeset/fn-7885-clipboard-migration.md
new file mode 100644
index 0000000000..90ec84087c
--- /dev/null
+++ b/.changeset/fn-7885-clipboard-migration.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Fix copy actions crashing or mis-reporting on non-secure origins (mobile/HTTP).
+category: fix
+dev: Migrated remaining dashboard copy handlers (agent id, secrets, git manager, CLI binary, PR conflicts, stash ref, login instructions, agent-error modal) and the reports plugin share-blocks panel from direct navigator.clipboard.writeText to the shared copyTextToClipboard helper (secure-context guard + execCommand fallback, boolean result handling). Added ./app/utils/copyToClipboard subpath export from @fusion/dashboard.
diff --git a/packages/dashboard/app/components/AgentDetailView.tsx b/packages/dashboard/app/components/AgentDetailView.tsx
index 06c39d2c34..bcfa62c2ca 100644
--- a/packages/dashboard/app/components/AgentDetailView.tsx
+++ b/packages/dashboard/app/components/AgentDetailView.tsx
@@ -36,6 +36,7 @@ import { AgentTaskBadge } from "./AgentTaskBadge";
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
import { AgentPermissionPolicyEditor } from "./AgentPermissionPolicyEditor";
import { useFavorites } from "../hooks/useFavorites";
+import { copyTextToClipboard } from "../utils/copyToClipboard";
/**
* Simple className utility - joins class names conditionally
@@ -642,10 +643,19 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
return getAgentHealthStatus(agent);
};
- const copyAgentId = () => {
+ /*
+ FNXC:Clipboard 2026-07-12-00:00:
+ Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback.
+ */
+ const copyAgentId = async () => {
if (agent) {
- navigator.clipboard.writeText(agent.id);
- addToast(t("agents.idCopied", "Agent ID copied to clipboard"), "success");
+ const copied = await copyTextToClipboard(agent.id);
+ addToast(
+ copied
+ ? t("agents.idCopied", "Agent ID copied to clipboard")
+ : t("agents.idCopyFailed", "Failed to copy agent ID"),
+ copied ? "success" : "error"
+ );
}
};
diff --git a/packages/dashboard/app/components/AgentErrorDetailsModal.tsx b/packages/dashboard/app/components/AgentErrorDetailsModal.tsx
index 20a7724316..9a914d56a1 100644
--- a/packages/dashboard/app/components/AgentErrorDetailsModal.tsx
+++ b/packages/dashboard/app/components/AgentErrorDetailsModal.tsx
@@ -3,6 +3,7 @@ import { useMemo, useState } from "react";
import { AlertCircle, Check, Copy, ExternalLink } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
+import { copyTextToClipboard } from "../utils/copyToClipboard";
const DEFAULT_ISSUE_URL = "https://github.com/Runfusion/Fusion/issues/new";
@@ -78,7 +79,9 @@ export function AgentErrorDetailsModal({ open, onClose, errorText, issueContext
type="button"
className="btn btn-sm"
onClick={() => {
- void navigator.clipboard.writeText(errorText).then(() => {
+ /* FNXC:Clipboard 2026-07-12-00:00: Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback. */
+ void copyTextToClipboard(errorText).then((copiedToClipboard) => {
+ if (!copiedToClipboard) return;
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
diff --git a/packages/dashboard/app/components/CliBinaryPanel.tsx b/packages/dashboard/app/components/CliBinaryPanel.tsx
index a849eb6903..d9f586235d 100644
--- a/packages/dashboard/app/components/CliBinaryPanel.tsx
+++ b/packages/dashboard/app/components/CliBinaryPanel.tsx
@@ -6,6 +6,7 @@ import {
type FnBinaryInstallResult,
type FnBinaryStatus,
} from "../api/legacy";
+import { copyTextToClipboard } from "../utils/copyToClipboard";
import "./CliBinaryPanel.css";
interface Props {
@@ -87,14 +88,15 @@ export function CliBinaryPanel({ defer = false }: Props) {
}
}, []);
+ /*
+ FNXC:Clipboard 2026-07-12-00:00:
+ Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback.
+ */
const copy = useCallback(async (label: string, value: string) => {
- try {
- await navigator.clipboard.writeText(value);
- setCopied(label);
- setTimeout(() => setCopied((c) => (c === label ? null : c)), 1500);
- } catch {
- // Clipboard API unavailable — leave button silent rather than throwing.
- }
+ const copiedToClipboard = await copyTextToClipboard(value);
+ if (!copiedToClipboard) return;
+ setCopied(label);
+ setTimeout(() => setCopied((c) => (c === label ? null : c)), 1500);
}, []);
const stateMeta = status ? getStateLabel(status.state) : null;
diff --git a/packages/dashboard/app/components/GitManagerModal.tsx b/packages/dashboard/app/components/GitManagerModal.tsx
index 4ecfe502ef..eaa92f1794 100644
--- a/packages/dashboard/app/components/GitManagerModal.tsx
+++ b/packages/dashboard/app/components/GitManagerModal.tsx
@@ -12,6 +12,7 @@ import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
import { useEmbeddedPresentation, type ModalPresentation } from "../hooks/useEmbeddedPresentation";
import { useViewportMode } from "../hooks/useViewportMode";
+import { copyTextToClipboard } from "../utils/copyToClipboard";
import type {
GitStatus,
GitCommit,
@@ -149,14 +150,23 @@ function FileStatusBadge({ status }: { status: GitFileChange["status"] }) {
return {label};
}
-/** Copy text to clipboard with toast feedback */
+/**
+ * Copy text to clipboard with toast feedback.
+ *
+ * FNXC:Clipboard 2026-07-12-00:00:
+ * Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback.
+ */
function useCopyToClipboard(addToast: (msg: string, type?: ToastType) => void) {
const { t } = useTranslation("app");
return useCallback(
async (text: string, label?: string) => {
try {
- await navigator.clipboard.writeText(text);
- addToast(label ? t("git.copiedLabel", "Copied {{label}}", { label }) : t("git.copiedToClipboard", "Copied to clipboard"), "success");
+ const copied = await copyTextToClipboard(text);
+ if (copied) {
+ addToast(label ? t("git.copiedLabel", "Copied {{label}}", { label }) : t("git.copiedToClipboard", "Copied to clipboard"), "success");
+ } else {
+ addToast(t("git.failedToCopy", "Failed to copy"), "error");
+ }
} catch {
addToast(t("git.failedToCopy", "Failed to copy"), "error");
}
diff --git a/packages/dashboard/app/components/LoginInstructions.tsx b/packages/dashboard/app/components/LoginInstructions.tsx
index 4b877b0098..b2095dbbfa 100644
--- a/packages/dashboard/app/components/LoginInstructions.tsx
+++ b/packages/dashboard/app/components/LoginInstructions.tsx
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Copy, Check } from "lucide-react";
+import { copyTextToClipboard } from "../utils/copyToClipboard";
import "./LoginInstructions.css";
interface LoginInstructionsProps {
@@ -49,14 +50,14 @@ export function LoginInstructions({ instructions, "data-testid": testId }: Login
setTimeout(() => setCopied(false), 2000);
}, []);
+ /*
+ FNXC:Clipboard 2026-07-12-00:00:
+ Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback.
+ */
const handleCopy = useCallback(async () => {
const textToCopy = deviceCode ?? instructions;
- try {
- await navigator.clipboard.writeText(textToCopy);
- markCopied();
- } catch {
- // Ignore copy failures
- }
+ const copiedToClipboard = await copyTextToClipboard(textToCopy);
+ if (copiedToClipboard) markCopied();
}, [deviceCode, instructions, markCopied]);
useEffect(() => {
@@ -68,12 +69,8 @@ export function LoginInstructions({ instructions, "data-testid": testId }: Login
}
const autoCopy = async () => {
- try {
- await navigator.clipboard.writeText(deviceCode);
- markCopied();
- } catch {
- // Ignore copy failures
- }
+ const copiedToClipboard = await copyTextToClipboard(deviceCode);
+ if (copiedToClipboard) markCopied();
};
void autoCopy();
diff --git a/packages/dashboard/app/components/PrPanel.tsx b/packages/dashboard/app/components/PrPanel.tsx
index fa56778cbf..f1841dad73 100644
--- a/packages/dashboard/app/components/PrPanel.tsx
+++ b/packages/dashboard/app/components/PrPanel.tsx
@@ -7,6 +7,7 @@ import { usePrChecksStream } from "../hooks/usePrChecksStream";
import { PrChecksList } from "./PrChecksList";
import type { ToastType } from "../hooks/useToast";
import { linkifyFilePaths } from "../utils/filePathLinkify";
+import { copyTextToClipboard } from "../utils/copyToClipboard";
import "./PrPanel.css";
interface PrPanelProps {
@@ -289,7 +290,13 @@ function PrCard({
{conflictDiagnostics && (prInfo.mergeable === "conflicting" || hasConflictBlockingReason) ? (
- {conflictsExpanded ? <>
{conflictDiagnostics.conflictingFiles.map((file) => - {linkifyFilePaths(file, { keyPrefix: `pr-conflict-${file}` })}
)}
{conflictDiagnostics.suggestedCommands.join("\n")}
{t("git.capturedAt", "Captured:")} {new Date(conflictDiagnostics.capturedAt).toLocaleString()}
> : null}
+ {conflictsExpanded ? <>
{conflictDiagnostics.conflictingFiles.map((file) => - {linkifyFilePaths(file, { keyPrefix: `pr-conflict-${file}` })}
)}
{conflictDiagnostics.suggestedCommands.join("\n")}
{t("git.capturedAt", "Captured:")} {new Date(conflictDiagnostics.capturedAt).toLocaleString()}
> : null}
) : null}
diff --git a/packages/dashboard/app/components/SecretsView.tsx b/packages/dashboard/app/components/SecretsView.tsx
index ec501037dd..42fdcd955a 100644
--- a/packages/dashboard/app/components/SecretsView.tsx
+++ b/packages/dashboard/app/components/SecretsView.tsx
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, ChevronRight, Copy, Eye, EyeOff, Lock, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { ViewHeader } from "./ViewHeader";
+import { copyTextToClipboard } from "../utils/copyToClipboard";
type ToastKind = "info" | "success" | "error";
type SecretScope = "project" | "global";
@@ -254,10 +255,18 @@ export const SecretsView = ({ addToast }: SecretsViewProps) => {
revealTimersRef.current.set(secret.id, timer);
};
+ /*
+ FNXC:Clipboard 2026-07-12-00:00:
+ Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback.
+ */
const copySecret = async (secret: SecretRecord) => {
const revealed = revealedValues[secret.id];
if (!revealed) return;
- await navigator.clipboard.writeText(revealed);
+ const copied = await copyTextToClipboard(revealed);
+ if (!copied) {
+ addToast?.(t("secrets.copyFailed", "Failed to copy secret"), "error");
+ return;
+ }
setCopiedId(secret.id);
addToast?.(t("secrets.copied", "Copied"), "success");
const timer = setTimeout(() => {
diff --git a/packages/dashboard/app/components/StashConflictModal.tsx b/packages/dashboard/app/components/StashConflictModal.tsx
index 6e1f7dafc4..b39e59f721 100644
--- a/packages/dashboard/app/components/StashConflictModal.tsx
+++ b/packages/dashboard/app/components/StashConflictModal.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Copy } from "lucide-react";
import { ApiRequestError, api } from "../api";
import { useFileBrowser } from "../context/FileBrowserContext";
+import { copyTextToClipboard } from "../utils/copyToClipboard";
import "./StashConflictModal.css";
interface ResolveResponse {
@@ -222,13 +223,13 @@ export default function StashConflictModal({
}
};
+ /*
+ FNXC:Clipboard 2026-07-12-00:00:
+ Direct navigator.clipboard.writeText crashes or mis-reports on non-secure origins such as mobile http://fusionstudio:4040; copyTextToClipboard centralizes the secure-context guard and execCommand fallback.
+ */
const copyRef = async () => {
- try {
- await navigator.clipboard.writeText(stashSha);
- setCopyState("copied");
- } catch {
- setCopyState("failed");
- }
+ const copied = await copyTextToClipboard(stashSha);
+ setCopyState(copied ? "copied" : "failed");
};
return (
diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.copy.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.copy.test.tsx
new file mode 100644
index 0000000000..20f480eb48
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/AgentDetailView.copy.test.tsx
@@ -0,0 +1,56 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import {
+ mockFetchAgent,
+ setupAgentDetailMocks,
+} from "./AgentDetailView.test-helpers";
+import { AgentDetailView } from "../AgentDetailView";
+
+const originalClipboard = navigator.clipboard;
+const originalExecCommand = document.execCommand;
+
+function mockClipboardFallback(result: boolean) {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
+ const execCommand = vi.fn().mockReturnValue(result);
+ Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
+ return execCommand;
+}
+
+describe("AgentDetailView clipboard copy", () => {
+ beforeEach(() => {
+ setupAgentDetailMocks();
+ });
+
+ afterEach(() => {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
+ Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
+ });
+
+ it("copies the agent id through execCommand when Clipboard API is unavailable", async () => {
+ const execCommand = mockClipboardFallback(true);
+ const addToast = vi.fn();
+
+ render();
+ await waitFor(() => expect(mockFetchAgent).toHaveBeenCalled());
+
+ await userEvent.click(screen.getByTitle("Copy Agent ID"));
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ expect(addToast).toHaveBeenCalledWith("Agent ID copied to clipboard", "success");
+ });
+
+ it("shows the failure toast instead of false success when both clipboard paths fail", async () => {
+ const execCommand = mockClipboardFallback(false);
+ const addToast = vi.fn();
+
+ render();
+ await waitFor(() => expect(mockFetchAgent).toHaveBeenCalled());
+
+ await userEvent.click(screen.getByTitle("Copy Agent ID"));
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ expect(addToast).toHaveBeenCalledWith("Failed to copy agent ID", "error");
+ expect(addToast).not.toHaveBeenCalledWith("Agent ID copied to clipboard", "success");
+ });
+});
diff --git a/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx b/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx
index b9ac0c4e04..0c71a0f6ac 100644
--- a/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/AgentErrorDetailsModal.test.tsx
@@ -17,6 +17,8 @@ const issueContext = {
describe("AgentErrorDetailsModal", () => {
const originalClipboard = navigator.clipboard;
+ const originalExecCommand = document.execCommand;
+ const originalSecureContext = window.isSecureContext;
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
beforeEach(() => {
Object.defineProperty(navigator, "clipboard", {
@@ -28,6 +30,8 @@ describe("AgentErrorDetailsModal", () => {
afterEach(() => {
Object.defineProperty(navigator, "clipboard", { value: originalClipboard, configurable: true });
+ Object.defineProperty(document, "execCommand", { value: originalExecCommand, configurable: true });
+ Object.defineProperty(window, "isSecureContext", { value: originalSecureContext, configurable: true });
});
it("does not render when closed", () => {
@@ -52,6 +56,20 @@ describe("AgentErrorDetailsModal", () => {
});
});
+ it("copies error text through execCommand when Clipboard API is unavailable", async () => {
+ const execCommand = vi.fn().mockReturnValue(true);
+ Object.defineProperty(navigator, "clipboard", { value: undefined, configurable: true });
+ Object.defineProperty(window, "isSecureContext", { value: false, configurable: true });
+ Object.defineProperty(document, "execCommand", { value: execCommand, configurable: true });
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("button", { name: "Copy error to clipboard" }));
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ expect(screen.getByRole("button", { name: "Copied error to clipboard" })).toBeInTheDocument();
+ });
+
it("gates backdrop dismissal behind the global modal dismiss preference", () => {
const disabledClose = vi.fn();
const { unmount } = render();
diff --git a/packages/dashboard/app/components/__tests__/CliBinaryPanel.copy.test.tsx b/packages/dashboard/app/components/__tests__/CliBinaryPanel.copy.test.tsx
new file mode 100644
index 0000000000..3ec6f76b8f
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/CliBinaryPanel.copy.test.tsx
@@ -0,0 +1,68 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { CliBinaryPanel } from "../CliBinaryPanel";
+import { fetchFnBinaryStatus, installFnBinary } from "../../api/legacy";
+
+vi.mock("../../api/legacy", () => ({
+ fetchFnBinaryStatus: vi.fn(),
+ installFnBinary: vi.fn(),
+}));
+
+const originalClipboard = navigator.clipboard;
+const originalExecCommand = document.execCommand;
+
+function mockClipboardFallback(result: boolean) {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
+ const execCommand = vi.fn().mockReturnValue(result);
+ Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
+ return execCommand;
+}
+
+function mockStatus() {
+ vi.mocked(fetchFnBinaryStatus).mockResolvedValue({
+ binary: { binary: "fn", installed: false, path: null, version: null },
+ expectedVersion: "1.2.3",
+ state: "missing",
+ install: {
+ npm: "npm install -g @runfusion/fusion",
+ curl: "curl -fsSL https://example.test/install.sh | sh",
+ },
+ });
+ vi.mocked(installFnBinary).mockResolvedValue({} as never);
+}
+
+describe("CliBinaryPanel clipboard copy", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockStatus();
+ });
+
+ afterEach(() => {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
+ Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
+ });
+
+ it("shows Copied after the execCommand fallback succeeds", async () => {
+ const execCommand = mockClipboardFallback(true);
+ render();
+ await screen.findByText("npm install -g @runfusion/fusion");
+
+ await userEvent.click(screen.getAllByRole("button", { name: "Copy" })[0]);
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ expect(screen.getByRole("button", { name: "Copied" })).toBeInTheDocument();
+ });
+
+ it("stays silent and does not show Copied when both clipboard paths fail", async () => {
+ const execCommand = mockClipboardFallback(false);
+ render();
+ await screen.findByText("npm install -g @runfusion/fusion");
+
+ await userEvent.click(screen.getAllByRole("button", { name: "Copy" })[0]);
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
+ expect(screen.queryByRole("button", { name: "Copied" })).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx
index 26b3a7e2b3..7d7ce98209 100644
--- a/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/GitManagerModal.test.tsx
@@ -77,6 +77,16 @@ vi.mock("../../api", async () => {
};
});
+const originalClipboard = navigator.clipboard;
+const originalExecCommand = document.execCommand;
+
+function mockClipboardFallback(result: boolean) {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
+ const execCommand = vi.fn().mockReturnValue(result);
+ Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
+ return execCommand;
+}
+
const mockConfirm = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({
@@ -190,6 +200,8 @@ const mockTasks: Task[] = [
describe("GitManagerModal", () => {
afterEach(() => {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
+ Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
vi.useRealTimers();
});
@@ -582,6 +594,17 @@ describe("GitManagerModal", () => {
});
});
+ it("copies status commit hash through execCommand when Clipboard API is unavailable", async () => {
+ const execCommand = mockClipboardFallback(true);
+ render();
+
+ await waitFor(() => expect(screen.getByText("main")).toBeInTheDocument());
+ fireEvent.click(screen.getByTitle("Copy short commit hash"));
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ expect(mockAddToast).toHaveBeenCalledWith("Copied commit hash", "success");
+ });
+
it("shows dirty status when working tree is modified", async () => {
(fetchGitStatus as any).mockResolvedValue({
branch: "main",
diff --git a/packages/dashboard/app/components/__tests__/SecretsView.test.tsx b/packages/dashboard/app/components/__tests__/SecretsView.test.tsx
index 8c39588fa9..0a02c5dbc1 100644
--- a/packages/dashboard/app/components/__tests__/SecretsView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/SecretsView.test.tsx
@@ -29,6 +29,21 @@ function removeAllCss() {
document.head.querySelector('[data-test-all-app-css="true"]')?.remove();
}
+const originalClipboard = navigator.clipboard;
+const originalExecCommand = document.execCommand;
+
+function mockClipboardFallback(result: boolean) {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
+ const execCommand = vi.fn().mockReturnValue(result);
+ Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
+ return execCommand;
+}
+
+function restoreClipboardMocks() {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
+ Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
+}
+
function expectVisibleActionIcon(button: HTMLElement) {
const svg = button.querySelector("svg");
expect(svg).not.toBeNull();
@@ -57,6 +72,7 @@ describe("SecretsView", () => {
});
afterEach(() => {
+ restoreClipboardMocks();
removeAllCss();
delete document.documentElement.dataset.theme;
});
@@ -238,6 +254,72 @@ describe("SecretsView", () => {
expectVisibleActionIcon(screen.getByRole("button", { name: "Delete" }));
});
+ it("copies revealed secrets through the execCommand fallback when Clipboard API is unavailable", async () => {
+ const execCommand = mockClipboardFallback(true);
+ const addToast = vi.fn();
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockResolvedValueOnce(
+ mockJsonResponse({
+ ok: true,
+ body: {
+ secrets: [
+ { id: "secret-1", key: "VISIBLE", scope: "project", description: null, accessPolicy: "prompt", envExportable: false, envExportKey: null, lastReadAt: null },
+ ],
+ },
+ }),
+ )
+ .mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } }))
+ .mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { key: "VISIBLE", value: "super-secret-value" } })),
+ );
+
+ render();
+ await screen.findByText("VISIBLE");
+ await userEvent.click(screen.getByRole("button", { name: "Reveal" }));
+ expect(await screen.findByText("super-secret-value")).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole("button", { name: "Copy" }));
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ expect(addToast).toHaveBeenCalledWith("Copied", "success");
+ expect(screen.getByRole("button", { name: "Copy" }).querySelector(".lucide-check")).toBeInTheDocument();
+ });
+
+ it("shows a failure toast without marking a secret copied when both clipboard paths fail", async () => {
+ const execCommand = mockClipboardFallback(false);
+ const addToast = vi.fn();
+ vi.stubGlobal(
+ "fetch",
+ vi
+ .fn()
+ .mockResolvedValueOnce(
+ mockJsonResponse({
+ ok: true,
+ body: {
+ secrets: [
+ { id: "secret-1", key: "VISIBLE", scope: "project", description: null, accessPolicy: "prompt", envExportable: false, envExportKey: null, lastReadAt: null },
+ ],
+ },
+ }),
+ )
+ .mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { configured: false } }))
+ .mockResolvedValueOnce(mockJsonResponse({ ok: true, body: { key: "VISIBLE", value: "super-secret-value" } })),
+ );
+
+ render();
+ await screen.findByText("VISIBLE");
+ await userEvent.click(screen.getByRole("button", { name: "Reveal" }));
+ expect(await screen.findByText("super-secret-value")).toBeInTheDocument();
+
+ await userEvent.click(screen.getByRole("button", { name: "Copy" }));
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ expect(addToast).toHaveBeenCalledWith("Failed to copy secret", "error");
+ expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
+ });
+
it("revealed secret can be hidden again from the row toggle", async () => {
const fetchMock = vi
.fn()
diff --git a/packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx b/packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx
index 81f0bee6c9..d9cc812e08 100644
--- a/packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx
+++ b/packages/dashboard/app/components/__tests__/StashConflictModal.test.tsx
@@ -1,10 +1,20 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentProps } from "react";
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import StashConflictModal from "../StashConflictModal";
import { ApiRequestError } from "../../api";
+const originalClipboard = navigator.clipboard;
+const originalExecCommand = document.execCommand;
+
+function mockClipboardFallback(result: boolean) {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
+ const execCommand = vi.fn().mockReturnValue(result);
+ Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
+ return execCommand;
+}
+
const mocked = vi.hoisted(() => ({
api: vi.fn(),
openFile: vi.fn(),
@@ -41,6 +51,11 @@ function renderModal(overrides: Partial {
+ afterEach(() => {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
+ Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
+ });
+
beforeEach(() => {
mocked.api.mockReset();
mocked.openFile.mockReset();
@@ -148,6 +163,14 @@ describe("StashConflictModal", () => {
await waitFor(() => expect(mocked.writeText).toHaveBeenCalledWith("1234567890abcdef"));
});
+ it("copies the stash sha through execCommand when Clipboard API is unavailable", async () => {
+ const execCommand = mockClipboardFallback(true);
+ renderModal();
+ fireEvent.click(screen.getByRole("button", { name: "Copy stash reference" }));
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ expect(screen.getByRole("status")).toHaveTextContent("Stash SHA copied.");
+ });
+
it("supports Escape close, initial focus, focus return, and tab wrapping", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json
index 3795a23019..8ea5bfccc4 100644
--- a/packages/dashboard/package.json
+++ b/packages/dashboard/package.json
@@ -35,6 +35,10 @@
"types": "./app/utils/taskStuck.ts",
"import": "./app/utils/taskStuck.ts"
},
+ "./app/utils/copyToClipboard": {
+ "types": "./app/utils/copyToClipboard.ts",
+ "import": "./app/utils/copyToClipboard.ts"
+ },
"./app/utils/projectStorage": {
"types": "./app/utils/projectStorage.ts",
"import": "./app/utils/projectStorage.ts"
diff --git a/plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx b/plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx
index 8173294b3f..76748131a7 100644
--- a/plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx
+++ b/plugins/fusion-plugin-reports/src/dashboard/components/ShareBlocksPanel.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
+import { copyTextToClipboard } from "@fusion/dashboard/app/utils/copyToClipboard";
import { getShareBlocks } from "../api.js";
import type { ReportRecord } from "../types.js";
import type { ShareBlocks } from "../../share-blocks.js";
@@ -35,7 +36,9 @@ export function ShareBlocksPanel({ report }: { report: ReportRecord }) {
diff --git a/plugins/fusion-plugin-reports/src/dashboard/components/__tests__/ShareBlocksPanel.test.tsx b/plugins/fusion-plugin-reports/src/dashboard/components/__tests__/ShareBlocksPanel.test.tsx
index e9eb24014e..f152f11cf8 100644
--- a/plugins/fusion-plugin-reports/src/dashboard/components/__tests__/ShareBlocksPanel.test.tsx
+++ b/plugins/fusion-plugin-reports/src/dashboard/components/__tests__/ShareBlocksPanel.test.tsx
@@ -1,14 +1,23 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import { ShareBlocksPanel } from "../ShareBlocksPanel.js";
+const originalClipboard = navigator.clipboard;
+const originalExecCommand = document.execCommand;
+
const getShareBlocks = vi.fn();
vi.mock("../../api.js", () => ({ getShareBlocks: (...args: unknown[]) => getShareBlocks(...args) }));
describe("ShareBlocksPanel", () => {
+ afterEach(() => {
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: originalClipboard });
+ Object.defineProperty(document, "execCommand", { configurable: true, value: originalExecCommand });
+ getShareBlocks.mockReset();
+ });
+
it("renders tabs and copies selected block", async () => {
getShareBlocks.mockResolvedValue({ plainText: "a", markdown: "b", slack: "c", emailHtml: "d" });
- Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) } });
render();
await screen.findByText("Plain Text");
fireEvent.click(screen.getByText("Markdown"));
@@ -16,6 +25,36 @@ describe("ShareBlocksPanel", () => {
await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith("b"));
});
+ it("copies selected block through execCommand when Clipboard API is unavailable", async () => {
+ getShareBlocks.mockResolvedValue({ plainText: "a", markdown: "b", slack: "c", emailHtml: "d" });
+ const execCommand = vi.fn().mockReturnValue(true);
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
+ Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
+
+ render();
+ await screen.findByText("Plain Text");
+ fireEvent.click(screen.getByText("Markdown"));
+ fireEvent.click(screen.getByText("Copy"));
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ expect(screen.getByText("Copied")).toBeInTheDocument();
+ });
+
+ it("does not show copied when both clipboard paths fail", async () => {
+ getShareBlocks.mockResolvedValue({ plainText: "a", markdown: "b", slack: "c", emailHtml: "d" });
+ const execCommand = vi.fn().mockReturnValue(false);
+ Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
+ Object.defineProperty(document, "execCommand", { configurable: true, value: execCommand });
+
+ render();
+ await screen.findByText("Plain Text");
+ fireEvent.click(screen.getByText("Copy"));
+
+ await waitFor(() => expect(execCommand).toHaveBeenCalledWith("copy"));
+ expect(screen.getByText("Copy")).toBeInTheDocument();
+ expect(screen.queryByText("Copied")).not.toBeInTheDocument();
+ });
+
it("shows locked message on 409", async () => {
getShareBlocks.mockRejectedValue(new Error("409 Conflict"));
render();
diff --git a/plugins/fusion-plugin-reports/vitest.config.ts b/plugins/fusion-plugin-reports/vitest.config.ts
index c767f42288..4d47cbf725 100644
--- a/plugins/fusion-plugin-reports/vitest.config.ts
+++ b/plugins/fusion-plugin-reports/vitest.config.ts
@@ -15,6 +15,8 @@ export default defineConfig({
resolve: {
alias: {
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
+ // FNXC:Clipboard 2026-07-12-00:00: The reports plugin imports the dashboard clipboard helper through its package subpath export; keep this exact alias ahead of the package root alias so vitest does not collapse the subpath to src/index.ts.
+ "@fusion/dashboard/app/utils/copyToClipboard": fileURLToPath(new URL("../../packages/dashboard/app/utils/copyToClipboard.ts", import.meta.url)),
"@fusion/dashboard": fileURLToPath(new URL("../../packages/dashboard/src/index.ts", import.meta.url)),
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
},