diff --git a/.changeset/fn-7887-chatview-clipboard.md b/.changeset/fn-7887-chatview-clipboard.md new file mode 100644 index 0000000000..bb3d3c46a3 --- /dev/null +++ b/.changeset/fn-7887-chatview-clipboard.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix chat "Copy response" falsely reporting failure on non-secure origins (mobile/HTTP). +category: fix +dev: Migrated ChatView handleCopyResponse from direct navigator clipboard access to the shared copyTextToClipboard helper (secure-context guard + execCommand fallback, boolean-driven success/error feedback), the last direct clipboard caller found during the FN-7885 preflight. diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index d7836b900a..5f3e30a6b2 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -42,6 +42,7 @@ import { matchesAgentMentionFilter } from "./mentionMatching"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; import { estimateChatTokens, formatTokenCount } from "../utils/estimateChatTokens"; +import { copyTextToClipboard } from "../utils/copyToClipboard"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { ViewHeader } from "./ViewHeader"; @@ -2396,16 +2397,13 @@ export function ChatView({ projectId, addToast, floating = false, compactLayout copyFeedbackTimeoutsRef.current.set(messageId, timeoutId); }, []); + /* + FNXC:Chat 2026-07-12-17:50: + Direct Clipboard API calls mis-report "Copy failed" on non-secure origins such as mobile http://fusionstudio:4040, where navigator.clipboard is undefined. Route provider-response copies through copyTextToClipboard so the secure-context guard and execCommand fallback drive the existing success/error feedback. + */ const handleCopyResponse = useCallback(async (messageId: string, content: string) => { - try { - if (!navigator.clipboard?.writeText) { - throw new Error("Clipboard API unavailable"); - } - await navigator.clipboard.writeText(content); - setCopyFeedback(messageId, "success"); - } catch { - setCopyFeedback(messageId, "error"); - } + const copied = await copyTextToClipboard(content); + setCopyFeedback(messageId, copied ? "success" : "error"); }, [setCopyFeedback]); const showProviderResponseCopy = activeSession?.agentId === FN_AGENT_ID; diff --git a/packages/dashboard/app/components/__tests__/ChatView.copy-response.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.copy-response.test.tsx new file mode 100644 index 0000000000..4db07e6b0e --- /dev/null +++ b/packages/dashboard/app/components/__tests__/ChatView.copy-response.test.tsx @@ -0,0 +1,169 @@ +/* +FNXC:DashboardTests 2026-07-12-17:50: +ChatView provider-response copy regressions must recreate secure Clipboard API and non-secure-origin fallback paths so the shared copyTextToClipboard invariant stays covered at the real affordance. +*/ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { ChatView } from "../ChatView"; +import { renderWithAct, setupMockChat, installChatViewEnv } from "./ChatView.test-harness"; + +vi.mock("../../hooks/useChat"); +vi.mock("../../hooks/useChatRooms"); +vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }), + }; +}); + +vi.mock("lucide-react", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + Copy: ({ "data-testid": testId, ...props }: any) => , + Check: ({ "data-testid": testId, ...props }: any) => , + }; +}); + +vi.mock("../../api", () => ({ + fetchModels: vi.fn().mockResolvedValue({ + models: [ + { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, + ], + favoriteProviders: [], + favoriteModels: [], + defaultProvider: "anthropic", + defaultModelId: "claude-sonnet-4-5", + }), + fetchAgents: vi.fn().mockResolvedValue([]), + fetchDiscoveredSkills: vi.fn().mockResolvedValue([]), + fetchTasks: vi.fn().mockResolvedValue([]), + searchFiles: vi.fn().mockResolvedValue({ files: [] }), + updateGlobalSettings: vi.fn().mockResolvedValue({}), +})); + +installChatViewEnv(); + +const originalClipboard = navigator.clipboard; +const originalExecCommand = document.execCommand; +const copiedContent = "Copyable provider response"; + +function setupProviderResponse() { + setupMockChat({ + sessions: [ + { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Provider chat", + createdAt: "2026-07-12T00:00:00.000Z", + updatedAt: "2026-07-12T00:00:00.000Z", + }, + ], + filteredSessions: [ + { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Provider chat", + createdAt: "2026-07-12T00:00:00.000Z", + updatedAt: "2026-07-12T00:00:00.000Z", + }, + ], + activeSession: { + id: "session-001", + agentId: "__fn_agent__", + status: "active", + title: "Provider chat", + createdAt: "2026-07-12T00:00:00.000Z", + updatedAt: "2026-07-12T00:00:00.000Z", + }, + messages: [ + { + id: "msg-copy", + sessionId: "session-001", + role: "assistant", + content: copiedContent, + createdAt: "2026-07-12T00:01:00.000Z", + }, + ], + }); +} + +function mockClipboard(value: Clipboard | undefined) { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value, + }); +} + +function mockExecCommand(result: boolean) { + const execCommand = vi.fn().mockReturnValue(result); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: execCommand, + }); + return execCommand; +} + +async function renderAndClickCopy() { + setupProviderResponse(); + await renderWithAct(); + const copyButton = await screen.findByTestId("chat-copy-response-msg-copy"); + await userEvent.click(copyButton); + return copyButton; +} + +afterEach(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: originalClipboard, + }); + Object.defineProperty(document, "execCommand", { + configurable: true, + value: originalExecCommand, + }); +}); + +describe("ChatView copy response", () => { + it("uses Clipboard API and shows success feedback in secure contexts", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + mockClipboard({ writeText } as unknown as Clipboard); + + const copyButton = await renderAndClickCopy(); + + await waitFor(() => expect(copyButton).toHaveClass("chat-message-copy-action--success")); + expect(copyButton).toHaveAttribute("aria-label", "Response copied"); + expect(writeText).toHaveBeenCalledWith(copiedContent); + }); + + it("uses execCommand fallback and shows success when navigator.clipboard is undefined", async () => { + mockClipboard(undefined); + const execCommand = mockExecCommand(true); + + const click = renderAndClickCopy(); + await expect(click).resolves.toBeInstanceOf(HTMLButtonElement); + const copyButton = await click; + + await waitFor(() => expect(copyButton).toHaveClass("chat-message-copy-action--success")); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(copyButton).toHaveAttribute("aria-label", "Response copied"); + expect(copyButton).not.toHaveClass("chat-message-copy-action--error"); + }); + + it("shows error feedback without throwing when Clipboard API and fallback fail", async () => { + mockClipboard(undefined); + const execCommand = mockExecCommand(false); + + const click = renderAndClickCopy(); + await expect(click).resolves.toBeInstanceOf(HTMLButtonElement); + const copyButton = await click; + + await waitFor(() => expect(copyButton).toHaveClass("chat-message-copy-action--error")); + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(copyButton).toHaveAttribute("aria-label", "Copy failed"); + expect(copyButton).not.toHaveClass("chat-message-copy-action--success"); + }); +});