diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index 0f73893a4..556c929f3 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -3,7 +3,10 @@ import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning' import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage' import { CliOutputBlock } from '@/components/CliOutputBlock' +import { CopyIcon, CheckIcon } from '@/components/icons' +import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' +import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText' const TOOL_COMPONENTS = { Fallback: HappyToolMessage @@ -17,6 +20,7 @@ const MESSAGE_PART_COMPONENTS = { } as const export function HappyAssistantMessage() { + const { copied, copy } = useCopyToClipboard() const isCliOutput = useAssistantState(({ message }) => { const custom = message.metadata.custom as Partial | undefined return custom?.kind === 'cli-output' @@ -31,6 +35,10 @@ export function HappyAssistantMessage() { const parts = message.content return parts.length > 0 && parts.every((part) => part.type === 'tool-call') }) + const copyText = useAssistantState(({ message }) => { + if (message.role !== 'assistant') return '' + return getAssistantCopyText(message.content) + }) const rootClass = toolOnly ? 'py-1 min-w-0 max-w-full overflow-x-hidden' : 'px-1 min-w-0 max-w-full overflow-x-hidden' @@ -44,8 +52,22 @@ export function HappyAssistantMessage() { } return ( - - + +
+ {copyText && ( + + )} + +
) } diff --git a/web/src/components/AssistantChat/messages/assistantCopyText.test.ts b/web/src/components/AssistantChat/messages/assistantCopyText.test.ts new file mode 100644 index 000000000..407eeaba7 --- /dev/null +++ b/web/src/components/AssistantChat/messages/assistantCopyText.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import type { ThreadAssistantMessagePart } from '@assistant-ui/react' +import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText' + +describe('getAssistantCopyText', () => { + it('joins assistant text parts and ignores non-text parts', () => { + const parts = [ + { type: 'text', text: 'First paragraph.' }, + { type: 'reasoning', text: 'Hidden chain of thought' }, + { type: 'tool-call', toolCallId: 'tool-1', toolName: 'search', args: {}, argsText: '{}' }, + { type: 'text', text: 'Second paragraph.' } + ] satisfies ThreadAssistantMessagePart[] + + expect(getAssistantCopyText(parts)).toBe('First paragraph.\n\nSecond paragraph.') + }) + + it('returns empty string when no assistant text exists', () => { + const parts = [ + { type: 'reasoning', text: 'Thinking' }, + { type: 'tool-call', toolCallId: 'tool-1', toolName: 'search', args: {}, argsText: '{}' } + ] satisfies ThreadAssistantMessagePart[] + + expect(getAssistantCopyText(parts)).toBe('') + }) +}) diff --git a/web/src/components/AssistantChat/messages/assistantCopyText.ts b/web/src/components/AssistantChat/messages/assistantCopyText.ts new file mode 100644 index 000000000..141f60e95 --- /dev/null +++ b/web/src/components/AssistantChat/messages/assistantCopyText.ts @@ -0,0 +1,9 @@ +import type { ThreadAssistantMessagePart } from '@assistant-ui/react' + +export function getAssistantCopyText(parts: readonly ThreadAssistantMessagePart[]): string { + return parts + .filter((part) => part.type === 'text') + .map((part) => part.text.trim()) + .filter((text) => text.length > 0) + .join('\n\n') +}