Skip to content

Add copy button to copy the full assistant message in Studio Code - #4113

Merged
sejas merged 6 commits into
trunkfrom
stu-1838-copy-message-button
Jul 10, 2026
Merged

Add copy button to copy the full assistant message in Studio Code#4113
sejas merged 6 commits into
trunkfrom
stu-1838-copy-message-button

Conversation

@sejas

@sejas sejas commented Jul 7, 2026

Copy link
Copy Markdown
Member

Related issues

How AI was used in this PR

Proposed Changes

  • Assistant responses in the Studio Code chat can now be copied in full with one click, in both the classic desktop UI and the new agentic UI. Previously only fenced code blocks offered a copy button, so grabbing a whole answer meant manual text selection.
  • The button appears on hover (or keyboard focus) below each assistant message and copies the original markdown source. When a response is split by tool calls into several text segments, the copy still yields the complete message, not just the last fragment.
  • Copying announces "Copied" to screen readers and gives visual check-icon feedback. The code-block and message copy buttons now share one component, which also fixes a subtle timer bug where clicking copy twice in quick succession cut the "Copied" state short.

Testing Instructions

  1. Start a Studio Code session and ask something that produces a response with tool calls between paragraphs (e.g. "list my plugins, then summarize").
  2. Hover an assistant message → a copy icon appears bottom-left, aligned with the text edge; click it and paste somewhere: the full message should paste, including text from before the tool call.
  3. Ask for a response containing a code block → the existing code-block copy button still works.
  4. Repeat in the agentic UI. Verify both light and dark mode.
copy-message.mp4
Screenshot 2026-07-07 at 21 41 40

Pre-merge Checklist

  • Have you checked for TypeScript, React or other console errors?
sejas and others added 3 commits July 7, 2026 16:08
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sejas sejas self-assigned this Jul 7, 2026
@sejas
sejas requested a review from nightnei July 7, 2026 20:45
@wpmobilebot

wpmobilebot commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

📊 Performance Test Results

Comparing e0be115 vs trunk

app-size

Metric trunk e0be115 Diff Change
App Size (Mac) 1419.28 MB 1419.29 MB +0.00 MB ⚪ 0.0%

site-editor

Metric trunk e0be115 Diff Change
load 1072 ms 1093 ms +21 ms ⚪ 0.0%

site-startup

Metric trunk e0be115 Diff Change
siteCreation 7011 ms 7003 ms 8 ms ⚪ 0.0%
siteStartup 2363 ms 2369 ms +6 ms ⚪ 0.0%

Results are median values from multiple test runs.

Legend: 🟢 Improvement (faster) | 🔴 Regression (slower) | ⚪ No change (<50ms diff)

.userEditSendButton:hover {
background-color: var(--color-frame-theme-hover);
align-items: center;
/* Cancel the icon-centering inset of the 1.75rem button so the 16px icon

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure this comment is needed, but it can be kept if you find it useful

role: 'assistant',
content: [ { type: 'text', text } ],
},
} as unknown as SessionEntry;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can avoid using as unknown as in tests by doing something like:

index.test.tsx
index faace818e..01674c1ed 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
@@ -25,15 +25,17 @@ vi.mock( '@wordpress/ui', () => ( {
        Icon: () => null,
 } ) );
 
-function customEntry( customType: string, data: unknown ): SessionEntry {
+function mockEntry( partial: Record< string, unknown > ): SessionEntry {
        return {
-               type: 'custom',
                id: Math.random().toString( 36 ).slice( 2 ),
                parentId: null,
                timestamp: '2026-06-19T00:00:00.000Z',
-               customType,
-               data,
-       } as unknown as SessionEntry;
+               ...partial,
+       } as SessionEntry;
+}
+
+function customEntry( customType: string, data: unknown ): SessionEntry {
+       return mockEntry( { type: 'custom', customType, data } );
 }
 
 function question( q: string, options: string[] ): SessionEntry {
@@ -48,11 +50,9 @@ function answer( text: string ): SessionEntry {
 }
 
 function assistantToolCallEntry( name: string ): SessionEntry {
-       return {
+       return mockEntry( {
                type: 'message',
                id: `assistant-${ name }`,
-               parentId: null,
-               timestamp: '2026-06-19T00:00:00.000Z',
                message: {
                        role: 'assistant',
                        content: [
@@ -64,21 +64,20 @@ function assistantToolCallEntry( name: string ): SessionEntry {
                                },
                        ],
                },
-       } as unknown as SessionEntry;
+       } );
 }
 
 function toolResultEntry( text: string ): SessionEntry {
-       return {
+       return mockEntry( {
                type: 'message',
                id: 'tool-result',
-               parentId: null,
                timestamp: '2026-06-19T00:00:01.000Z',
                message: {
                        role: 'toolResult',
                        toolCallId: 'tool-call-1',
                        content: [ { type: 'text', text } ],
                },
-       } as unknown as SessionEntry;
+       } );
 }
 
 describe( 'entriesToRenderItems – persisted picked answers', () => {
@@ -279,24 +278,20 @@ describe( 'Conversation – assistant message copy button', () => {
        } );
 
        function assistantTextEntry( text: string ): SessionEntry {
-               return {
+               return mockEntry( {
                        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 {
+               return mockEntry( {
                        type: 'message',
                        id: 'assistant-multi-block',
-                       parentId: null,
-                       timestamp: '2026-06-19T00:00:00.000Z',
                        message: {
                                role: 'assistant',

@katinthehatsite katinthehatsite left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In terms of the functionality, I think it works well. I left some comments regarding code improvements

sejas added 3 commits July 9, 2026 23:36
…e-button

# Conflicts:
#	apps/studio/src/components/studio-code-session/conversation/index.test.tsx
#	apps/studio/src/components/studio-code-session/conversation/style.module.css
@sejas
sejas merged commit 0ac16e6 into trunk Jul 10, 2026
11 checks passed
@sejas
sejas deleted the stu-1838-copy-message-button branch July 10, 2026 07:00
shaunandrews added a commit that referenced this pull request Jul 10, 2026
Reconciles the July 6–10 trunk sync: adopts trunk's productionized
screenshot/theme tooling (#3982/#4097), grafts the copy-button (#4113),
edit-message (#4094), and inline chat artifacts into the redesigned
conversation views, keeps the merged Connector surface over trunk's
menu-action wiring (#4085), and keeps the blueprint-selector deletion.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants