diff --git a/frontend-modern/src/components/AI/Chat/MessageItem.tsx b/frontend-modern/src/components/AI/Chat/MessageItem.tsx index b401dfa27..63c7378ff 100644 --- a/frontend-modern/src/components/AI/Chat/MessageItem.tsx +++ b/frontend-modern/src/components/AI/Chat/MessageItem.tsx @@ -1,4 +1,4 @@ -import { Component, Show, For, Switch, Match, createMemo } from 'solid-js'; +import { Component, Show, For, Switch, Match, createMemo, createSignal } from 'solid-js'; import { renderMarkdown } from '../aiChatUtils'; import { ThinkingBlock } from './ThinkingBlock'; import { ToolExecutionBlock } from './ToolExecutionBlock'; @@ -61,6 +61,20 @@ export const MessageItem: Component = (props) => { props.message.isStreaming && (!props.message.pendingTools || props.message.pendingTools.length === 0); + // Copy-to-clipboard for a completed assistant answer. + const [copied, setCopied] = createSignal(false); + const canCopy = () => !props.message.isStreaming && !!props.message.content?.trim(); + const copyMessage = async () => { + const text = props.message.content || ''; + try { + await navigator.clipboard?.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard can be unavailable (permissions / insecure context); fail quietly. + } + }; + return (
{/* User message - compact bubble */} @@ -74,6 +88,36 @@ export const MessageItem: Component = (props) => {
+ {/* Copy answer - hover-revealed, top-right */} + + + + {/* Assistant indicator */}
diff --git a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx index 4e5e3a57c..3bac340c7 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/MessageItem.test.tsx @@ -175,6 +175,47 @@ describe('MessageItem', () => { }); }); + describe('copy button', () => { + it('copies the message content when clicked', () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText }, + configurable: true, + }); + + render(() => ( + + )); + + const copy = screen.getByRole('button', { name: /copy message/i }); + fireEvent.click(copy); + expect(writeText).toHaveBeenCalledWith('The cluster is healthy.'); + }); + + it('does not show a copy button while streaming', () => { + render(() => ( + + )); + expect(screen.queryByRole('button', { name: /copy message/i })).not.toBeInTheDocument(); + }); + + it('does not show a copy button when there is no content', () => { + render(() => ( + + )); + expect(screen.queryByRole('button', { name: /copy message/i })).not.toBeInTheDocument(); + }); + }); + describe('assistant message rendering', () => { it('renders assistant indicator with label', () => { render(() => (