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:
gsxdsm
2026-07-12 17:59:19 -07:00
parent 6ea53966f6
commit db9a9453db
3 changed files with 183 additions and 9 deletions

View 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.

View File

@@ -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;

View File

@@ -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");
});
});