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:
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user