diff --git a/apps/studio/src/components/studio-code-session/conversation/index.test.tsx b/apps/studio/src/components/studio-code-session/conversation/index.test.tsx index fbefed870a..faace818e8 100644 --- a/apps/studio/src/components/studio-code-session/conversation/index.test.tsx +++ b/apps/studio/src/components/studio-code-session/conversation/index.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Conversation, entriesToRenderItems } from './index'; import type { SessionEntry } from '@earendil-works/pi-coding-agent'; @@ -6,6 +6,7 @@ import type { LoadedAiSession } from '@studio/common/ai/sessions/types'; const ipcApiMocks = vi.hoisted( () => ( { readLocalMediaFile: vi.fn(), + copyText: vi.fn(), } ) ); vi.mock( 'src/lib/get-ipc-api', () => ( { @@ -271,3 +272,96 @@ describe( 'Conversation – inline media artifacts', () => { expect( await screen.findByRole( 'status' ) ).toHaveTextContent( 'Image unavailable' ); } ); } ); + +describe( 'Conversation – assistant message copy button', () => { + beforeEach( () => { + ipcApiMocks.copyText.mockClear(); + } ); + + function assistantTextEntry( text: string ): SessionEntry { + return { + type: 'message', + id: `assistant-text-${ text }`, + parentId: null, + timestamp: '2026-06-19T00:00:00.000Z', + message: { + role: 'assistant', + content: [ { type: 'text', text } ], + }, + } as unknown as SessionEntry; + } + + function assistantMultiBlockEntry(): SessionEntry { + return { + type: 'message', + id: 'assistant-multi-block', + parentId: null, + timestamp: '2026-06-19T00:00:00.000Z', + message: { + role: 'assistant', + content: [ + { type: 'text', text: 'First part.' }, + { type: 'toolCall', id: 'tool-call-1', name: 'read_file', arguments: {} }, + { type: 'text', text: 'Second part.' }, + ], + }, + } as unknown as SessionEntry; + } + + function userPromptEntry( text: string ): SessionEntry { + return customEntry( 'studio.user_prompt', { text, source: 'prompt' } ); + } + + function renderConversation( entries: SessionEntry[] ) { + render( + {} } + /> + ); + } + + it( 'copies the raw markdown of an assistant message', async () => { + renderConversation( [ assistantTextEntry( '# Hello\n\nSome **bold** text.' ) ] ); + + const button = screen.getByRole( 'button', { name: 'Copy message' } ); + expect( button ).toBeInTheDocument(); + + fireEvent.click( button ); + + expect( ipcApiMocks.copyText ).toHaveBeenCalledWith( '# Hello\n\nSome **bold** text.' ); + await waitFor( () => expect( screen.getByRole( 'status' ) ).toHaveTextContent( 'Copied' ) ); + } ); + + it( 'copies the full joined message for a message split across text blocks', () => { + renderConversation( [ assistantMultiBlockEntry() ] ); + + const buttons = screen.getAllByRole( 'button', { name: 'Copy message' } ); + expect( buttons ).toHaveLength( 1 ); + + fireEvent.click( buttons[ 0 ] ); + + expect( ipcApiMocks.copyText ).toHaveBeenCalledWith( 'First part.\n\nSecond part.' ); + } ); + + it( 'does not render a copy button for user messages', () => { + renderConversation( [ userPromptEntry( 'Hello there' ) ] ); + + expect( screen.queryByRole( 'button', { name: 'Copy message' } ) ).not.toBeInTheDocument(); + } ); + + it( 'shows a tooltip for the copy button', async () => { + renderConversation( [ assistantTextEntry( 'Plain reply.' ) ] ); + + fireEvent.mouseOver( screen.getByRole( 'button', { name: 'Copy message' } ) ); + + await waitFor( () => + expect( screen.getByRole( 'tooltip' ) ).toHaveTextContent( 'Copy message' ) + ); + } ); +} ); diff --git a/apps/studio/src/components/studio-code-session/conversation/index.tsx b/apps/studio/src/components/studio-code-session/conversation/index.tsx index 526a5d7a5f..c0461b204b 100644 --- a/apps/studio/src/components/studio-code-session/conversation/index.tsx +++ b/apps/studio/src/components/studio-code-session/conversation/index.tsx @@ -25,6 +25,7 @@ import { Icon } from '@wordpress/ui'; import { useEffect, useMemo, useState } from 'react'; import { cx } from 'src/lib/cx'; import { getIpcApi } from 'src/lib/get-ipc-api'; +import { CopyButton } from '../copy-button'; import { Markdown } from '../markdown'; import { ThinkingIndicator } from '../thinking-indicator'; import styles from './style.module.css'; @@ -38,7 +39,7 @@ type RenderItem = text: string; attachments?: StudioChatAttachmentSummary[]; } - | { kind: 'assistant-text'; key: string; text: string } + | { kind: 'assistant-text'; key: string; text: string; copyText?: string } | { kind: 'tool-use'; key: string; @@ -146,6 +147,15 @@ export function entriesToRenderItems( entries: SessionEntry[] ): RenderItem[] { if ( ! message || message.role !== 'assistant' || ! Array.isArray( message.content ) ) { return; } + // A single assistant message can hold several text blocks split by tool + // calls. Copy must yield the whole message, so join every text block and + // hang one copy button off the last one rather than one per fragment. + const textBlocks = message.content.filter( + ( block ) => block.type === 'text' && typeof block.text === 'string' && block.text.trim() + ); + const fullMessageText = textBlocks.map( ( block ) => block.text!.trim() ).join( '\n\n' ); + const lastTextBlock = textBlocks[ textBlocks.length - 1 ]; + message.content.forEach( ( block, blockIndex ) => { if ( block.type === 'text' && typeof block.text === 'string' ) { const text = block.text.trim(); @@ -154,6 +164,7 @@ export function entriesToRenderItems( entries: SessionEntry[] ): RenderItem[] { kind: 'assistant-text', key: `${ entryIndex }:${ blockIndex }:text`, text, + copyText: block === lastTextBlock ? fullMessageText : undefined, } ); } } else if ( @@ -280,8 +291,19 @@ function UserTurn( { ); } -function AssistantText( { text }: { text: string } ) { - return { text }; +function AssistantText( { text, copyText }: { text: string; copyText?: string } ) { + return ( +
+ { text } + { copyText ? ( + + ) : null } +
+ ); } const TOOL_RESULT_PREVIEW_MAX_LINES = 12; @@ -517,7 +539,7 @@ export function Conversation( { ); case 'assistant-text': - return ; + return ; case 'tool-use': return (