feat(FN-3712): add provider response copy action to chat bubble

The merge delivers four distinct features: a copy-to-clipboard action on provider responses in the chat bubble (FN-3712, with styling and tests across ChatView), richer agent onboarding drafts documented and wired into the dashboard onboarding modal (FN-3555), assigned-agent triage inheritance so ag

Fusion-Task-Id: FN-3712
This commit is contained in:
Fusion
2026-05-07 11:36:10 -07:00
committed by gsxdsm
parent af0fc5fc95
commit 011ae14aa7
3 changed files with 218 additions and 0 deletions

View File

@@ -406,6 +406,46 @@
overflow-wrap: anywhere;
}
.chat-message-copy-action {
display: flex;
align-items: center;
justify-content: center;
margin-top: var(--space-xs);
margin-left: auto;
width: calc(var(--space-lg) * 2);
height: calc(var(--space-lg) * 2);
min-width: calc(var(--space-lg) * 2);
min-height: calc(var(--space-lg) * 2);
padding: 0;
border: none;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 35%, transparent);
color: var(--text-muted);
cursor: pointer;
opacity: 0.85;
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}
.chat-message-copy-action:hover {
opacity: 1;
color: var(--text);
background: color-mix(in srgb, var(--surface) 55%, transparent);
}
.chat-message-copy-action:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
opacity: 1;
}
.chat-message-copy-action--success {
color: var(--color-success);
}
.chat-message-copy-action--error {
color: var(--color-error);
}
.chat-message-content--markdown > :first-child {
margin-top: 0;
}
@@ -1162,6 +1202,14 @@
opacity: 1;
}
.chat-message-copy-action {
opacity: 1;
width: calc(var(--space-lg) * 2.25);
height: calc(var(--space-lg) * 2.25);
min-width: calc(var(--space-lg) * 2.25);
min-height: calc(var(--space-lg) * 2.25);
}
.chat-tool-calls-group-summary,
.chat-tool-call summary {
flex-wrap: nowrap;

View File

@@ -20,6 +20,8 @@ import {
File,
Wrench,
ChevronDown,
Copy,
Check,
} from "lucide-react";
import { useChat, type ChatMessageInfo, type ToolCallInfo } from "../hooks/useChat";
import { useViewportMode } from "./Header";
@@ -541,6 +543,8 @@ function NewChatDialog({ projectId, onClose, onCreate }: NewChatDialogProps) {
type CopyFeedbackState = "success" | "error" | null;
interface ChatMessageItemProps {
message: ChatMessageInfo;
/**
@@ -562,6 +566,7 @@ interface ChatMessageItemProps {
activeModelProvider: string | null;
activeSessionId: string | null;
mentionAgentsByName: Map<string, Agent>;
copyAction?: ReactNode;
}
// Renders a single chat message bubble. Memoized so the streaming bubble's
@@ -577,6 +582,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
activeModelProvider,
activeSessionId,
mentionAgentsByName,
copyAction,
}: ChatMessageItemProps) {
const isAssistantMessage = message.role === "assistant";
@@ -684,6 +690,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
{isAssistantMessage
? assistantBody
: <div className="chat-message-content">{renderedUserContent}</div>}
{copyAction}
{renderToolCalls(message.toolCalls)}
{message.thinkingOutput && (
<details className="chat-message-thinking">
@@ -745,6 +752,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
const [isDragOver, setIsDragOver] = useState(false);
const [isUserScrolling, setIsUserScrolling] = useState(false);
const [copyFeedbackByMessageId, setCopyFeedbackByMessageId] = useState<Record<string, CopyFeedbackState>>({});
// File mention state and hook
const [, setFileMentionPopupVisible] = useState(false);
@@ -775,6 +783,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const pendingAttachmentsRef = useRef<PendingAttachment[]>([]);
const mentionCursorPosRef = useRef(0);
const copyFeedbackTimeoutsRef = useRef<Map<string, number>>(new Map());
const mode = useViewportMode();
const isMobile = mode === "mobile";
@@ -1013,6 +1022,10 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
URL.revokeObjectURL(attachment.previewUrl);
}
}
for (const timeoutId of copyFeedbackTimeoutsRef.current.values()) {
window.clearTimeout(timeoutId);
}
copyFeedbackTimeoutsRef.current.clear();
};
}, []);
@@ -1567,6 +1580,37 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
setShowAllAsPlain((value) => !value);
}, []);
const setCopyFeedback = useCallback((messageId: string, feedback: CopyFeedbackState) => {
const existingTimeout = copyFeedbackTimeoutsRef.current.get(messageId);
if (existingTimeout) {
window.clearTimeout(existingTimeout);
}
setCopyFeedbackByMessageId((current) => ({ ...current, [messageId]: feedback }));
const timeoutId = window.setTimeout(() => {
setCopyFeedbackByMessageId((current) => {
const { [messageId]: _removed, ...rest } = current;
return rest;
});
copyFeedbackTimeoutsRef.current.delete(messageId);
}, 2000);
copyFeedbackTimeoutsRef.current.set(messageId, timeoutId);
}, []);
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");
}
}, [setCopyFeedback]);
const renderAssistantContent = useCallback(
(content: string, forcePlain = false) => {
const showPlainText = forcePlain;
@@ -1585,6 +1629,22 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
[],
);
const showProviderResponseCopy = activeSession?.agentId === FN_AGENT_ID;
const renderCopyAction = useCallback((messageId: string, content: string, testId?: string) => (
<button
type="button"
className={`btn-icon chat-message-copy-action${copyFeedbackByMessageId[messageId] === "success" ? " chat-message-copy-action--success" : ""}${copyFeedbackByMessageId[messageId] === "error" ? " chat-message-copy-action--error" : ""}`}
data-testid={testId ?? `chat-copy-response-${messageId}`}
aria-label={copyFeedbackByMessageId[messageId] === "success" ? "Response copied" : copyFeedbackByMessageId[messageId] === "error" ? "Copy failed" : "Copy response"}
onClick={() => {
void handleCopyResponse(messageId, content);
}}
>
{copyFeedbackByMessageId[messageId] === "success" ? <Check size={14} /> : <Copy size={14} />}
</button>
), [copyFeedbackByMessageId, handleCopyResponse]);
return (
<div className="chat-view">
{/* Sidebar */}
@@ -1798,6 +1858,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
activeModelProvider={activeModelProvider}
activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
/>
))}
<div className="chat-message chat-message--assistant chat-message--streaming">
@@ -1815,6 +1876,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
{streamingThinking ? "Thinking…" : "Connecting…"}
</div>
)}
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
{renderToolCalls(streamingToolCalls)}
{streamingThinking && (
<details className="chat-message-thinking">
@@ -1851,6 +1913,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
activeModelProvider={activeModelProvider}
activeSessionId={activeSession?.id ?? null}
mentionAgentsByName={mentionAgentsByName}
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
/>
))}
</>

View File

@@ -24,6 +24,7 @@ const mockUseChat = vi.mocked(useChatModule.useChat);
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
const mockCreateObjectURL = vi.fn();
const mockRevokeObjectURL = vi.fn();
const mockClipboardWriteText = vi.fn();
// Mock lucide-react icons - spread actual module and override specific icons
vi.mock("lucide-react", async (importOriginal) => {
@@ -45,6 +46,8 @@ vi.mock("lucide-react", async (importOriginal) => {
EyeOff: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-eye-off"} {...props} />,
Paperclip: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-paperclip"} {...props} />,
File: ({ "data-testid": testId, ...props }: any) => <svg data-testid={testId || "icon-file"} {...props} />,
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} />,
};
});
@@ -178,6 +181,11 @@ beforeEach(() => {
mockCreateObjectURL.mockImplementation((file: File) => `blob:${file.name}`);
Object.defineProperty(URL, "createObjectURL", { value: mockCreateObjectURL, writable: true });
Object.defineProperty(URL, "revokeObjectURL", { value: mockRevokeObjectURL, writable: true });
mockClipboardWriteText.mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText: mockClipboardWriteText },
configurable: true,
});
});
afterEach(() => {
@@ -920,6 +928,99 @@ describe("ChatView", () => {
});
});
it("shows copy actions only for assistant responses in provider/model chats", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Fusion Chat",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-user", sessionId: "session-001", role: "user", content: "Question", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "msg-assistant", sessionId: "session-001", role: "assistant", content: "Answer", createdAt: "2026-04-08T00:00:01.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-copy-response-msg-assistant")).toBeInTheDocument();
expect(screen.queryByTestId("chat-copy-response-msg-user")).not.toBeInTheDocument();
});
it("copies raw provider response content and shows feedback for success/failure", async () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Fusion Chat",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-assistant", sessionId: "session-001", role: "assistant", content: "**Raw** output", createdAt: "2026-04-08T00:00:01.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const copyButton = screen.getByTestId("chat-copy-response-msg-assistant");
expect(copyButton).not.toHaveTextContent("Copy");
await userEvent.click(copyButton);
expect(mockClipboardWriteText).toHaveBeenCalledWith("**Raw** output");
expect(screen.getByLabelText("Response copied")).toBeInTheDocument();
mockClipboardWriteText.mockRejectedValueOnce(new Error("denied"));
await userEvent.click(screen.getByTestId("chat-copy-response-msg-assistant"));
expect(screen.getByLabelText("Copy failed")).toBeInTheDocument();
});
it("shows streaming copy action for provider chats", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__fn_agent__",
status: "active",
title: "Fusion Chat",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-04-08T00:00:00.000Z",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [],
isStreaming: true,
streamingText: "Live answer",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.getByTestId("chat-copy-response-streaming")).toBeInTheDocument();
});
it("does not show copy actions for non-provider sessions", () => {
setupMockChat({
activeSession: activeSessionFixture,
messages: [
{ id: "msg-assistant", sessionId: "session-001", role: "assistant", content: "Answer", createdAt: "2026-04-08T00:00:01.000Z" },
],
isStreaming: true,
streamingText: "Live answer",
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
expect(screen.queryByTestId("chat-copy-response-msg-assistant")).not.toBeInTheDocument();
expect(screen.queryByTestId("chat-copy-response-streaming")).not.toBeInTheDocument();
});
it("shows resolved agent name in streaming assistant avatar", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Agent Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
@@ -3148,4 +3249,10 @@ describe("ChatView mobile CSS contract", () => {
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-identity\s*\{[^}]*flex:\s*1\s+1\s+auto[^}]*white-space:\s*nowrap/);
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-thread-header-render-toggle\s*\{[^}]*flex-shrink:\s*0/);
});
it("mobile keeps response copy action visible and touch-friendly", () => {
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message-copy-action\s*\{[^}]*opacity:\s*1/);
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message-copy-action\s*\{[^}]*min-width:\s*calc\(var\(--space-lg\)\s*\*\s*2\.25\)/);
expect(css).toMatch(/@media\s*\(max-width:\s*768px\)[\s\S]*?\.chat-message-copy-action\s*\{[^}]*min-height:\s*calc\(var\(--space-lg\)\s*\*\s*2\.25\)/);
});
});