FN-7887: migrate ChatView copy-response to shared clipboard helper
Fixes ChatView's provider-response copy button falsely reporting failure on non-secure origins (mobile/HTTP) by routing through the shared clipboard utility instead of calling navigator.clipboard directly. - Replaced direct navigator.clipboard.writeText call in handleCopyResponse with the shared copyTextToClipboard helper (secure-context guard + execCommand fallback) - Added regression tests covering secure Clipboard API success, execCommand fallback success, and combined failure paths - Added changeset documenting the fix as the last direct clipboard caller found during the FN-7885 preflight Files changed: .changeset/fn-7887-chatview-clipboard.md | 7 + packages/dashboard/app/components/ChatView.tsx | 16 +- .../__tests__/ChatView.copy-response.test.tsx | 169 +++++++++++++++++++++ 3 files changed, 183 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-7887 Fusion-Task-Lineage: bff1084b-7c64-420d-9479-bb8c4c584175 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7887-chatview-clipboard.md
Normal file
7
.changeset/fn-7887-chatview-clipboard.md
Normal file
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("lucide-react", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("lucide-react")>();
|
||||
return {
|
||||
...actual,
|
||||
Copy: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-copy"} {...props} />,
|
||||
Check: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-check"} {...props} />,
|
||||
};
|
||||
});
|
||||
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user