From 4f7c17b77664f73c07de61f2fdab5eb6e32dd968 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Wed, 29 Jul 2026 13:13:43 -0400 Subject: [PATCH 1/7] Agentic UI: add a native text context menu with copy, copy all and look up Co-Authored-By: Claude Opus 5 (1M context) --- apps/studio/src/constants.ts | 1 + apps/studio/src/ipc-handlers.ts | 2 + apps/studio/src/preload.ts | 1 + .../src/tests/text-context-menu.test.ts | 163 ++++++++++++++++++ apps/studio/src/text-context-menu.ts | 114 ++++++++++++ apps/ui/src/data/core/connectors/ipc/index.ts | 4 + apps/ui/src/data/core/types.ts | 9 + apps/ui/src/hooks/use-text-context-menu.ts | 52 ++++++ apps/ui/src/ui-classic/app.tsx | 3 + .../session-view/conversation/index.tsx | 7 +- 10 files changed, 354 insertions(+), 2 deletions(-) create mode 100644 apps/studio/src/tests/text-context-menu.test.ts create mode 100644 apps/studio/src/text-context-menu.ts create mode 100644 apps/ui/src/hooks/use-text-context-menu.ts diff --git a/apps/studio/src/constants.ts b/apps/studio/src/constants.ts index b860b7b0df..ff13cfb3a7 100644 --- a/apps/studio/src/constants.ts +++ b/apps/studio/src/constants.ts @@ -61,6 +61,7 @@ export const IPC_VOID_HANDLERS = [ 'setWindowButtonVisibility', 'showErrorMessageBox', 'showSiteContextMenu', + 'showTextContextMenu', 'showItemInFolder', 'showNotification', 'authenticate', diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 59aa476a8e..0db841391a 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -2456,3 +2456,5 @@ export async function stopRemoteSessionDaemon( emitter.on( 'error', ( { error } ) => reject( error ) ); } ); } + +export { showTextContextMenu } from 'src/text-context-menu'; diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index c0da6112f7..8af56fc2d9 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -188,6 +188,7 @@ const api: IpcApi = { ipcRendererInvoke( 'extractBlueprintBundle', zipFilePath ), cleanupBlueprintTempDir: ( tempDir ) => ipcRendererInvoke( 'cleanupBlueprintTempDir', tempDir ), showSiteContextMenu: ( context ) => ipcRendererSend( 'showSiteContextMenu', context ), + showTextContextMenu: ( context ) => ipcRendererSend( 'showTextContextMenu', context ), setWindowControlVisibility: ( visible ) => ipcRendererInvoke( 'setWindowControlVisibility', visible ), setTitleBarBackdropEffect: ( enabled ) => diff --git a/apps/studio/src/tests/text-context-menu.test.ts b/apps/studio/src/tests/text-context-menu.test.ts new file mode 100644 index 0000000000..0de252aadd --- /dev/null +++ b/apps/studio/src/tests/text-context-menu.test.ts @@ -0,0 +1,163 @@ +/** + * @vitest-environment node + */ +import { vi } from 'vitest'; +import { + buildTextContextMenuTemplate, + type TextContextMenuContext, + type TextContextMenuEnvironment, +} from 'src/text-context-menu'; + +vi.mock( 'electron', () => ( { + BrowserWindow: { fromWebContents: vi.fn() }, + Menu: { buildFromTemplate: vi.fn() }, + clipboard: { readText: vi.fn(), writeText: vi.fn() }, +} ) ); + +function makeContext( overrides: Partial< TextContextMenuContext > = {} ): TextContextMenuContext { + return { selectionText: '', isEditable: false, ...overrides }; +} + +function makeEnvironment( + overrides: Partial< TextContextMenuEnvironment > = {} +): TextContextMenuEnvironment { + return { platform: 'darwin', canPaste: false, ...overrides }; +} + +const actions = { lookUpSelection: vi.fn(), copyMessage: vi.fn() }; + +function labelsOf( template: ReturnType< typeof buildTextContextMenuTemplate > ) { + return template.map( ( item ) => item.role ?? item.label ?? item.type ); +} + +describe( 'buildTextContextMenuTemplate', () => { + it( 'offers Look Up, Copy and Copy All for a selection on a message on macOS', () => { + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist', messageText: 'Dark mode and core coexist.' } ), + actions, + makeEnvironment() + ); + + expect( labelsOf( template ) ).toEqual( [ + 'Look Up “coexist”', + 'separator', + 'copy', + 'Copy All', + ] ); + } ); + + it( 'omits Look Up on Windows and Linux, which have no system dictionary', () => { + for ( const platform of [ 'win32', 'linux' ] as const ) { + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist', messageText: 'Dark mode and core coexist.' } ), + actions, + makeEnvironment( { platform } ) + ); + + expect( labelsOf( template ) ).toEqual( [ 'copy', 'Copy All' ] ); + } + } ); + + it( 'runs showDefinitionForSelection when Look Up is chosen', () => { + const lookUpSelection = vi.fn(); + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist' } ), + { ...actions, lookUpSelection }, + makeEnvironment() + ); + + ( template[ 0 ].click as () => void )(); + + expect( lookUpSelection ).toHaveBeenCalledOnce(); + } ); + + it( 'copies the whole message, not the selection, from Copy All', () => { + const copyMessage = vi.fn(); + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist', messageText: 'Dark mode and core coexist.' } ), + { ...actions, copyMessage }, + makeEnvironment( { platform: 'linux' } ) + ); + const copyAll = template.find( ( item ) => item.label === 'Copy All' ); + + ( copyAll?.click as () => void )(); + + expect( copyMessage ).toHaveBeenCalledWith( 'Dark mode and core coexist.' ); + } ); + + it( 'collapses and truncates a long selection in the Look Up label', () => { + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: ' core color\n schemes and dark mode now coexist ' } ), + actions, + makeEnvironment() + ); + + expect( template[ 0 ].label ).toBe( 'Look Up “core color schemes and…”' ); + } ); + + it( 'offers Paste only in an editable field with something on the clipboard', () => { + expect( + labelsOf( + buildTextContextMenuTemplate( + makeContext( { isEditable: true } ), + actions, + makeEnvironment( { canPaste: true } ) + ) + ) + ).toEqual( [ 'paste' ] ); + + expect( + labelsOf( + buildTextContextMenuTemplate( + makeContext( { isEditable: false } ), + actions, + makeEnvironment( { canPaste: true } ) + ) + ) + ).toEqual( [] ); + + expect( + labelsOf( + buildTextContextMenuTemplate( + makeContext( { isEditable: true } ), + actions, + makeEnvironment( { canPaste: false } ) + ) + ) + ).toEqual( [] ); + } ); + + it( 'leaves no stray separator when a section drops out', () => { + // Look Up applies but there is no message to copy, so the divider must + // still sit between two populated sections rather than trailing. + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist' } ), + actions, + makeEnvironment() + ); + + expect( labelsOf( template ) ).toEqual( [ 'Look Up “coexist”', 'separator', 'copy' ] ); + expect( template[ 0 ].type ).not.toBe( 'separator' ); + expect( template.at( -1 )?.type ).not.toBe( 'separator' ); + } ); + + it( 'drops the divider when only the clipboard section applies', () => { + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist' } ), + actions, + makeEnvironment( { platform: 'win32' } ) + ); + + expect( labelsOf( template ) ).toEqual( [ 'copy' ] ); + } ); + + it( 'returns nothing to show when no text action applies', () => { + const template = buildTextContextMenuTemplate( + makeContext(), + actions, + makeEnvironment( { platform: 'win32' } ) + ); + + expect( template ).toEqual( [] ); + } ); +} ); diff --git a/apps/studio/src/text-context-menu.ts b/apps/studio/src/text-context-menu.ts new file mode 100644 index 0000000000..53f203317b --- /dev/null +++ b/apps/studio/src/text-context-menu.ts @@ -0,0 +1,114 @@ +import { + BrowserWindow, + clipboard, + Menu, + type MenuItemConstructorOptions, + IpcMainInvokeEvent, +} from 'electron'; +import { __, sprintf } from '@wordpress/i18n'; + +// Electron ships no default context menu — the one Chrome shows is built by +// Chrome's browser layer, which isn't part of the embedded content layer. The +// items below are declared by us, but the menu itself is the real native +// widget on every platform (NSMenu, Win32, GTK). +// +// The renderer drives this rather than `webContents.on( 'context-menu' )`, +// matching `showSiteContextMenu`: only the renderer knows which message was +// clicked, and pushing that to the main process afterwards would race the +// browser's own context-menu request. + +// Long enough to recognise the phrase, short enough that the menu doesn't +// stretch across the screen. macOS truncates its own Look Up label similarly. +const LOOK_UP_LABEL_MAX_LENGTH = 24; + +export interface TextContextMenuContext { + selectionText: string; + isEditable: boolean; + // The full message the click landed on, when it landed on one at all. + messageText?: string; +} + +export interface TextContextMenuActions { + lookUpSelection: () => void; + copyMessage: ( text: string ) => void; +} + +export interface TextContextMenuEnvironment { + platform: NodeJS.Platform; + canPaste: boolean; +} + +function toLookUpLabel( selection: string ): string { + const collapsed = selection.replace( /\s+/g, ' ' ).trim(); + const truncated = + collapsed.length > LOOK_UP_LABEL_MAX_LENGTH + ? `${ collapsed.slice( 0, LOOK_UP_LABEL_MAX_LENGTH - 1 ).trimEnd() }…` + : collapsed; + /* translators: %s: the text the user selected. */ + return sprintf( __( 'Look Up “%s”' ), truncated ); +} + +/** + * Text-only context menu: copy the selection, copy the whole message, and look + * a word up. Look Up is macOS-only because Windows and Linux expose no system + * dictionary to apps — their native text menus really are just the edit + * commands, so gating on platform yields what each OS would natively show. + */ +export function buildTextContextMenuTemplate( + context: TextContextMenuContext, + actions: TextContextMenuActions, + environment: TextContextMenuEnvironment +): MenuItemConstructorOptions[] { + const selection = context.selectionText.trim(); + const messageText = context.messageText; + + // Built as sections and joined with separators, so an inapplicable section + // can't leave a stray divider behind. + const sections: MenuItemConstructorOptions[][] = []; + + if ( environment.platform === 'darwin' && selection ) { + sections.push( [ { label: toLookUpLabel( selection ), click: actions.lookUpSelection } ] ); + } + + const clipboardItems: MenuItemConstructorOptions[] = []; + if ( selection ) { + clipboardItems.push( { role: 'copy' } ); + } + if ( messageText ) { + clipboardItems.push( { + label: __( 'Copy All' ), + click: () => actions.copyMessage( messageText ), + } ); + } + if ( context.isEditable && environment.canPaste ) { + clipboardItems.push( { role: 'paste' } ); + } + if ( clipboardItems.length > 0 ) { + sections.push( clipboardItems ); + } + + return sections.flatMap( ( section, index ) => + index === 0 ? section : [ { type: 'separator' }, ...section ] + ); +} + +export function showTextContextMenu( + event: IpcMainInvokeEvent, + context: TextContextMenuContext +): void { + const template = buildTextContextMenuTemplate( + context, + { + lookUpSelection: () => event.sender.showDefinitionForSelection(), + copyMessage: ( text ) => clipboard.writeText( text ), + }, + { platform: process.platform, canPaste: clipboard.readText().length > 0 } + ); + + if ( template.length === 0 ) { + return; + } + + const window = BrowserWindow.fromWebContents( event.sender ); + Menu.buildFromTemplate( template ).popup( window ? { window } : undefined ); +} diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index 0acb72cc58..05ae1f0fdd 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -814,6 +814,10 @@ export function createIpcConnector(): Connector { await ipcApi.copyText( text ); }, + showTextContextMenu( context ): void { + ipcApi.showTextContextMenu( context ); + }, + async confirmDeleteAllPreviewSites(): Promise< boolean > { const CANCEL_BUTTON_INDEX = 0; const DELETE_BUTTON_INDEX = 1; diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index 2c2ec1c2a3..e24c740551 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -398,6 +398,15 @@ export interface Connector { // Clipboard — routed to the host so it works where the renderer's // `navigator.clipboard` is unavailable (e.g. Electron permission denial). copyText( text: string ): Promise< void >; + + // Pops the host's native text context menu. Absent in the browser builds, + // which already have a real one — there the right-click is left alone. + showTextContextMenu?( context: { + selectionText: string; + isEditable: boolean; + messageText?: string; + } ): void; + openSiteUrl( siteId: string, relativeUrl?: string, diff --git a/apps/ui/src/hooks/use-text-context-menu.ts b/apps/ui/src/hooks/use-text-context-menu.ts new file mode 100644 index 0000000000..d14f3f3fb1 --- /dev/null +++ b/apps/ui/src/hooks/use-text-context-menu.ts @@ -0,0 +1,52 @@ +import { useEffect } from 'react'; +import { useConnector } from '@/data/core'; + +// Elements carrying a message's full text opt in with this attribute, so a +// right-click anywhere inside one can offer to copy the whole thing. +export const MESSAGE_TEXT_ATTRIBUTE = 'data-message-text'; + +const EDITABLE_SELECTOR = 'input, textarea, [contenteditable]:not([contenteditable="false"])'; + +/** + * Routes right-clicks to the host's native text context menu. + * + * Only the renderer knows which message the pointer landed on, so it drives the + * menu rather than the main process listening for `context-menu` — pushing the + * message text over afterwards would race the browser's own menu request. + * Hosts without a native menu to pop (the browser builds, which already have a + * real one) don't implement the method, and the default menu is left alone. + */ +export function useTextContextMenu(): void { + const connector = useConnector(); + const showTextContextMenu = connector.showTextContextMenu; + + useEffect( () => { + if ( ! showTextContextMenu ) { + return; + } + + const handleContextMenu = ( event: MouseEvent ) => { + const target = event.target instanceof Element ? event.target : null; + if ( ! target ) { + return; + } + + const messageHost = target.closest( `[${ MESSAGE_TEXT_ATTRIBUTE }]` ); + const messageText = messageHost?.getAttribute( MESSAGE_TEXT_ATTRIBUTE ) || undefined; + const selectionText = window.getSelection()?.toString() ?? ''; + const isEditable = Boolean( target.closest( EDITABLE_SELECTOR ) ); + + if ( ! messageText && ! selectionText.trim() && ! isEditable ) { + return; + } + + // Nothing else would handle it, but claiming the event keeps a host + // menu from ever stacking on top of ours. + event.preventDefault(); + showTextContextMenu( { selectionText, isEditable, messageText } ); + }; + + document.addEventListener( 'contextmenu', handleContextMenu ); + return () => document.removeEventListener( 'contextmenu', handleContextMenu ); + }, [ showTextContextMenu ] ); +} diff --git a/apps/ui/src/ui-classic/app.tsx b/apps/ui/src/ui-classic/app.tsx index ad68a25e09..d97692781d 100644 --- a/apps/ui/src/ui-classic/app.tsx +++ b/apps/ui/src/ui-classic/app.tsx @@ -1,6 +1,7 @@ import { RouterProvider } from '@tanstack/react-router'; import { useMemo } from 'react'; import { queryClient } from '@/data/core'; +import { useTextContextMenu } from '@/hooks/use-text-context-menu'; import { createAppRouter } from '@/ui-classic/router/router'; import type { Connector } from '@/data/core'; @@ -11,6 +12,8 @@ interface ClassicUiAppProps { export function ClassicUiApp( { connector }: ClassicUiAppProps ) { const router = useMemo( () => createAppRouter( { queryClient, connector } ), [ connector ] ); + useTextContextMenu(); + return (
diff --git a/apps/ui/src/ui-classic/components/session-view/conversation/index.tsx b/apps/ui/src/ui-classic/components/session-view/conversation/index.tsx index 0c7fc5a9cc..5d5ef44efd 100644 --- a/apps/ui/src/ui-classic/components/session-view/conversation/index.tsx +++ b/apps/ui/src/ui-classic/components/session-view/conversation/index.tsx @@ -75,6 +75,7 @@ import { Markdown } from '@/components/markdown'; import { useConnector, type LoadedAiSession } from '@/data/core'; import { useStudioAssistantQuota } from '@/data/queries/use-assistant-quota'; import { useLocalMediaDataUrl } from '@/data/queries/use-local-media'; +import { MESSAGE_TEXT_ATTRIBUTE } from '@/hooks/use-text-context-menu'; import { refreshIcon } from '@/lib/icons'; import { ThinkingIndicator } from '../thinking-indicator'; import styles from './style.module.css'; @@ -403,7 +404,7 @@ function UserTurn( { attachments?: StudioChatAttachmentSummary[]; } ) { return ( -
+
{ text }
{ attachments && attachments.length > 0 ? (
    @@ -437,7 +438,9 @@ function UserTurn( { function AssistantText( { text, copyText }: { text: string; copyText?: string } ) { return ( -
    + // The attribute carries the whole message so a right-click anywhere + // inside it can offer Copy All, not just the selection. +
    { text } { copyText ? ( Date: Wed, 29 Jul 2026 13:31:24 -0400 Subject: [PATCH 2/7] Agentic UI: limit the text context menu to text, not every right-click Co-Authored-By: Claude Opus 5 (1M context) --- .../src/hooks/use-text-context-menu.test.tsx | 96 +++++++++++++++++++ apps/ui/src/hooks/use-text-context-menu.ts | 18 +++- 2 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 apps/ui/src/hooks/use-text-context-menu.test.tsx diff --git a/apps/ui/src/hooks/use-text-context-menu.test.tsx b/apps/ui/src/hooks/use-text-context-menu.test.tsx new file mode 100644 index 0000000000..f490ba0e11 --- /dev/null +++ b/apps/ui/src/hooks/use-text-context-menu.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { MESSAGE_TEXT_ATTRIBUTE, useTextContextMenu } from './use-text-context-menu'; + +const showTextContextMenu = vi.fn(); + +vi.mock( '@/data/core', () => ( { + useConnector: () => ( { showTextContextMenu } ), +} ) ); + +function Harness() { + useTextContextMenu(); + return ( +
    +
    +

    The whole reply.

    +
    +
    wp plugin list
    + + +
    + ); +} + +/** Stubs a selection that intersects only the given node. */ +function selectWithin( selected: Node | null, text = 'whole' ) { + vi.spyOn( window, 'getSelection' ).mockReturnValue( { + isCollapsed: selected === null, + rangeCount: selected === null ? 0 : 1, + getRangeAt: () => ( { intersectsNode: ( node: Node ) => node === selected } ), + toString: () => text, + } as unknown as Selection ); +} + +describe( 'useTextContextMenu', () => { + beforeEach( () => { + vi.restoreAllMocks(); + showTextContextMenu.mockClear(); + selectWithin( null ); + } ); + + it( 'offers the whole message when right-clicking inside one', () => { + const { getByTestId } = render( ); + + fireEvent.contextMenu( getByTestId( 'message-paragraph' ) ); + + expect( showTextContextMenu ).toHaveBeenCalledWith( { + selectionText: '', + isEditable: false, + messageText: 'The whole reply.', + } ); + } ); + + it( 'stays out of the way on non-text UI like menus and buttons', () => { + const { getByTestId } = render( ); + + fireEvent.contextMenu( getByTestId( 'menu-item' ) ); + + expect( showTextContextMenu ).not.toHaveBeenCalled(); + } ); + + it( 'ignores a selection left behind somewhere else in the app', () => { + const { getByTestId } = render( ); + selectWithin( getByTestId( 'message-paragraph' ) ); + + fireEvent.contextMenu( getByTestId( 'menu-item' ) ); + + expect( showTextContextMenu ).not.toHaveBeenCalled(); + } ); + + it( 'offers Copy for a selection the pointer is inside, even outside a message', () => { + const { getByTestId } = render( ); + const toolOutput = getByTestId( 'tool-output' ); + selectWithin( toolOutput, 'wp plugin list' ); + + fireEvent.contextMenu( toolOutput ); + + expect( showTextContextMenu ).toHaveBeenCalledWith( { + selectionText: 'wp plugin list', + isEditable: false, + messageText: undefined, + } ); + } ); + + it( 'reports an editable field so the host can offer Paste', () => { + const { getByTestId } = render( ); + + fireEvent.contextMenu( getByTestId( 'field' ) ); + + expect( showTextContextMenu ).toHaveBeenCalledWith( { + selectionText: '', + isEditable: true, + messageText: undefined, + } ); + } ); +} ); diff --git a/apps/ui/src/hooks/use-text-context-menu.ts b/apps/ui/src/hooks/use-text-context-menu.ts index d14f3f3fb1..06121e50e6 100644 --- a/apps/ui/src/hooks/use-text-context-menu.ts +++ b/apps/ui/src/hooks/use-text-context-menu.ts @@ -7,6 +7,17 @@ export const MESSAGE_TEXT_ATTRIBUTE = 'data-message-text'; const EDITABLE_SELECTOR = 'input, textarea, [contenteditable]:not([contenteditable="false"])'; +// Only a selection the pointer is actually inside counts. A highlight left +// behind elsewhere in the app must not put Copy on an unrelated right-click, +// where choosing it would copy something the user can't even see. +function getSelectionTextAt( target: Element ): string { + const selection = window.getSelection(); + if ( ! selection || selection.isCollapsed || selection.rangeCount === 0 ) { + return ''; + } + return selection.getRangeAt( 0 ).intersectsNode( target ) ? selection.toString() : ''; +} + /** * Routes right-clicks to the host's native text context menu. * @@ -33,10 +44,13 @@ export function useTextContextMenu(): void { const messageHost = target.closest( `[${ MESSAGE_TEXT_ATTRIBUTE }]` ); const messageText = messageHost?.getAttribute( MESSAGE_TEXT_ATTRIBUTE ) || undefined; - const selectionText = window.getSelection()?.toString() ?? ''; const isEditable = Boolean( target.closest( EDITABLE_SELECTOR ) ); + const selectionText = getSelectionTextAt( target ); - if ( ! messageText && ! selectionText.trim() && ! isEditable ) { + // Right-clicking something that isn't text — a menu, a button, the + // sidebar, empty canvas — has nothing to offer, so stay out of the + // way entirely rather than opening a menu of unrelated actions. + if ( ! messageText && ! isEditable && ! selectionText.trim() ) { return; } From 06566ec55e162b3dac26c03610439b559e26ef06 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Fri, 31 Jul 2026 17:14:32 -0400 Subject: [PATCH 3/7] Improve native text context menu actions --- apps/studio/src/constants.ts | 1 - apps/studio/src/preload.ts | 2 +- .../src/tests/text-context-menu.test.ts | 75 ++++++++++++++++++- apps/studio/src/text-context-menu.ts | 29 +++++-- apps/ui/src/data/core/connectors/ipc/index.ts | 4 +- apps/ui/src/data/core/types.ts | 2 +- .../src/hooks/use-text-context-menu.test.tsx | 41 +++++++++- apps/ui/src/hooks/use-text-context-menu.ts | 14 +++- apps/ui/src/lib/composer-text-quote.test.ts | 26 +++++++ apps/ui/src/lib/composer-text-quote.ts | 22 ++++++ .../session-view/conversation/index.test.ts | 6 ++ .../session-view/conversation/index.tsx | 8 +- .../components/session-view/index.tsx | 8 ++ 13 files changed, 217 insertions(+), 21 deletions(-) create mode 100644 apps/ui/src/lib/composer-text-quote.test.ts create mode 100644 apps/ui/src/lib/composer-text-quote.ts diff --git a/apps/studio/src/constants.ts b/apps/studio/src/constants.ts index ff13cfb3a7..b860b7b0df 100644 --- a/apps/studio/src/constants.ts +++ b/apps/studio/src/constants.ts @@ -61,7 +61,6 @@ export const IPC_VOID_HANDLERS = [ 'setWindowButtonVisibility', 'showErrorMessageBox', 'showSiteContextMenu', - 'showTextContextMenu', 'showItemInFolder', 'showNotification', 'authenticate', diff --git a/apps/studio/src/preload.ts b/apps/studio/src/preload.ts index 8af56fc2d9..9b43490d78 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -188,7 +188,7 @@ const api: IpcApi = { ipcRendererInvoke( 'extractBlueprintBundle', zipFilePath ), cleanupBlueprintTempDir: ( tempDir ) => ipcRendererInvoke( 'cleanupBlueprintTempDir', tempDir ), showSiteContextMenu: ( context ) => ipcRendererSend( 'showSiteContextMenu', context ), - showTextContextMenu: ( context ) => ipcRendererSend( 'showTextContextMenu', context ), + showTextContextMenu: ( context ) => ipcRendererInvoke( 'showTextContextMenu', context ), setWindowControlVisibility: ( visible ) => ipcRendererInvoke( 'setWindowControlVisibility', visible ), setTitleBarBackdropEffect: ( enabled ) => diff --git a/apps/studio/src/tests/text-context-menu.test.ts b/apps/studio/src/tests/text-context-menu.test.ts index 0de252aadd..f8ccbd1d60 100644 --- a/apps/studio/src/tests/text-context-menu.test.ts +++ b/apps/studio/src/tests/text-context-menu.test.ts @@ -1,9 +1,11 @@ /** * @vitest-environment node */ +import { BrowserWindow, clipboard, Menu, type IpcMainInvokeEvent } from 'electron'; import { vi } from 'vitest'; import { buildTextContextMenuTemplate, + showTextContextMenu, type TextContextMenuContext, type TextContextMenuEnvironment, } from 'src/text-context-menu'; @@ -24,7 +26,7 @@ function makeEnvironment( return { platform: 'darwin', canPaste: false, ...overrides }; } -const actions = { lookUpSelection: vi.fn(), copyMessage: vi.fn() }; +const actions = { lookUpSelection: vi.fn(), copyMessage: vi.fn(), quoteSelection: vi.fn() }; function labelsOf( template: ReturnType< typeof buildTextContextMenuTemplate > ) { return template.map( ( item ) => item.role ?? item.label ?? item.type ); @@ -43,6 +45,8 @@ describe( 'buildTextContextMenuTemplate', () => { 'separator', 'copy', 'Copy All', + 'separator', + 'Quote in composer', ] ); } ); @@ -54,7 +58,12 @@ describe( 'buildTextContextMenuTemplate', () => { makeEnvironment( { platform } ) ); - expect( labelsOf( template ) ).toEqual( [ 'copy', 'Copy All' ] ); + expect( labelsOf( template ) ).toEqual( [ + 'copy', + 'Copy All', + 'separator', + 'Quote in composer', + ] ); } } ); @@ -85,6 +94,31 @@ describe( 'buildTextContextMenuTemplate', () => { expect( copyMessage ).toHaveBeenCalledWith( 'Dark mode and core coexist.' ); } ); + it( 'offers a translated label for role-based clipboard actions', () => { + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist', isEditable: true } ), + actions, + makeEnvironment( { platform: 'linux', canPaste: true } ) + ); + + expect( template.find( ( item ) => item.role === 'copy' )?.label ).toBe( 'Copy' ); + expect( template.find( ( item ) => item.role === 'paste' )?.label ).toBe( 'Paste' ); + } ); + + it( 'offers quoting for a read-only selection and runs its action', () => { + const quoteSelection = vi.fn(); + const template = buildTextContextMenuTemplate( + makeContext( { selectionText: 'coexist' } ), + { ...actions, quoteSelection }, + makeEnvironment( { platform: 'linux' } ) + ); + const quote = template.find( ( item ) => item.label === 'Quote in composer' ); + + ( quote?.click as () => void )(); + + expect( quoteSelection ).toHaveBeenCalledOnce(); + } ); + it( 'collapses and truncates a long selection in the Look Up label', () => { const template = buildTextContextMenuTemplate( makeContext( { selectionText: ' core color\n schemes and dark mode now coexist ' } ), @@ -136,14 +170,20 @@ describe( 'buildTextContextMenuTemplate', () => { makeEnvironment() ); - expect( labelsOf( template ) ).toEqual( [ 'Look Up “coexist”', 'separator', 'copy' ] ); + expect( labelsOf( template ) ).toEqual( [ + 'Look Up “coexist”', + 'separator', + 'copy', + 'separator', + 'Quote in composer', + ] ); expect( template[ 0 ].type ).not.toBe( 'separator' ); expect( template.at( -1 )?.type ).not.toBe( 'separator' ); } ); it( 'drops the divider when only the clipboard section applies', () => { const template = buildTextContextMenuTemplate( - makeContext( { selectionText: 'coexist' } ), + makeContext( { selectionText: 'coexist', isEditable: true } ), actions, makeEnvironment( { platform: 'win32' } ) ); @@ -161,3 +201,30 @@ describe( 'buildTextContextMenuTemplate', () => { expect( template ).toEqual( [] ); } ); } ); + +describe( 'showTextContextMenu', () => { + it( 'returns the selected text when Quote in composer is chosen', async () => { + const popup = vi.fn(); + vi.mocked( BrowserWindow.fromWebContents ).mockReturnValue( null ); + vi.mocked( clipboard.readText ).mockReturnValue( '' ); + vi.mocked( Menu.buildFromTemplate ).mockReturnValue( { popup } as unknown as Menu ); + const event = { + sender: { showDefinitionForSelection: vi.fn() }, + } as unknown as IpcMainInvokeEvent; + + const resultPromise = showTextContextMenu( + event, + makeContext( { selectionText: 'Selected reply' } ) + ); + const template = vi.mocked( Menu.buildFromTemplate ).mock.calls[ 0 ][ 0 ]; + const quote = template.find( ( item ) => item.label === 'Quote in composer' ); + ( quote?.click as () => void )(); + const popupOptions = popup.mock.calls[ 0 ][ 0 ]; + popupOptions.callback(); + + await expect( resultPromise ).resolves.toEqual( { + action: 'quote-selection', + selectionText: 'Selected reply', + } ); + } ); +} ); diff --git a/apps/studio/src/text-context-menu.ts b/apps/studio/src/text-context-menu.ts index 53f203317b..2838a242d8 100644 --- a/apps/studio/src/text-context-menu.ts +++ b/apps/studio/src/text-context-menu.ts @@ -31,6 +31,7 @@ export interface TextContextMenuContext { export interface TextContextMenuActions { lookUpSelection: () => void; copyMessage: ( text: string ) => void; + quoteSelection: () => void; } export interface TextContextMenuEnvironment { @@ -38,6 +39,10 @@ export interface TextContextMenuEnvironment { canPaste: boolean; } +export type TextContextMenuResult = + | { action: 'quote-selection'; selectionText: string } + | undefined; + function toLookUpLabel( selection: string ): string { const collapsed = selection.replace( /\s+/g, ' ' ).trim(); const truncated = @@ -72,7 +77,7 @@ export function buildTextContextMenuTemplate( const clipboardItems: MenuItemConstructorOptions[] = []; if ( selection ) { - clipboardItems.push( { role: 'copy' } ); + clipboardItems.push( { label: __( 'Copy' ), role: 'copy' } ); } if ( messageText ) { clipboardItems.push( { @@ -81,34 +86,46 @@ export function buildTextContextMenuTemplate( } ); } if ( context.isEditable && environment.canPaste ) { - clipboardItems.push( { role: 'paste' } ); + clipboardItems.push( { label: __( 'Paste' ), role: 'paste' } ); } if ( clipboardItems.length > 0 ) { sections.push( clipboardItems ); } + if ( selection && ! context.isEditable ) { + sections.push( [ { label: __( 'Quote in composer' ), click: actions.quoteSelection } ] ); + } return sections.flatMap( ( section, index ) => index === 0 ? section : [ { type: 'separator' }, ...section ] ); } -export function showTextContextMenu( +export async function showTextContextMenu( event: IpcMainInvokeEvent, context: TextContextMenuContext -): void { +): Promise< TextContextMenuResult > { + let result: TextContextMenuResult; const template = buildTextContextMenuTemplate( context, { lookUpSelection: () => event.sender.showDefinitionForSelection(), copyMessage: ( text ) => clipboard.writeText( text ), + quoteSelection: () => { + result = { action: 'quote-selection', selectionText: context.selectionText.trim() }; + }, }, { platform: process.platform, canPaste: clipboard.readText().length > 0 } ); if ( template.length === 0 ) { - return; + return undefined; } const window = BrowserWindow.fromWebContents( event.sender ); - Menu.buildFromTemplate( template ).popup( window ? { window } : undefined ); + return new Promise( ( resolve ) => { + Menu.buildFromTemplate( template ).popup( { + ...( window ? { window } : {} ), + callback: () => resolve( result ), + } ); + } ); } diff --git a/apps/ui/src/data/core/connectors/ipc/index.ts b/apps/ui/src/data/core/connectors/ipc/index.ts index 05ae1f0fdd..61ff7f052d 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -814,8 +814,8 @@ export function createIpcConnector(): Connector { await ipcApi.copyText( text ); }, - showTextContextMenu( context ): void { - ipcApi.showTextContextMenu( context ); + async showTextContextMenu( context ) { + return ipcApi.showTextContextMenu( context ); }, async confirmDeleteAllPreviewSites(): Promise< boolean > { diff --git a/apps/ui/src/data/core/types.ts b/apps/ui/src/data/core/types.ts index e24c740551..e91d2af5a7 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -405,7 +405,7 @@ export interface Connector { selectionText: string; isEditable: boolean; messageText?: string; - } ): void; + } ): Promise< { action: 'quote-selection'; selectionText: string } | undefined >; openSiteUrl( siteId: string, diff --git a/apps/ui/src/hooks/use-text-context-menu.test.tsx b/apps/ui/src/hooks/use-text-context-menu.test.tsx index f490ba0e11..460cd2cf79 100644 --- a/apps/ui/src/hooks/use-text-context-menu.test.tsx +++ b/apps/ui/src/hooks/use-text-context-menu.test.tsx @@ -1,8 +1,9 @@ -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MESSAGE_TEXT_ATTRIBUTE, useTextContextMenu } from './use-text-context-menu'; +import { watchComposerTextQuote } from '@/lib/composer-text-quote'; -const showTextContextMenu = vi.fn(); +const showTextContextMenu = vi.fn().mockResolvedValue( undefined ); vi.mock( '@/data/core', () => ( { useConnector: () => ( { showTextContextMenu } ), @@ -35,7 +36,7 @@ function selectWithin( selected: Node | null, text = 'whole' ) { describe( 'useTextContextMenu', () => { beforeEach( () => { vi.restoreAllMocks(); - showTextContextMenu.mockClear(); + showTextContextMenu.mockReset().mockResolvedValue( undefined ); selectWithin( null ); } ); @@ -93,4 +94,38 @@ describe( 'useTextContextMenu', () => { messageText: undefined, } ); } ); + + it( 'reports selected text inside an editable field so the host can offer Copy', () => { + const { getByTestId } = render( ); + const field = getByTestId( 'field' ) as HTMLInputElement; + field.value = 'Copy this text'; + field.setSelectionRange( 5, 9 ); + + fireEvent.contextMenu( field ); + + expect( showTextContextMenu ).toHaveBeenCalledWith( { + selectionText: 'this', + isEditable: true, + messageText: undefined, + } ); + } ); + + it( 'routes a native Quote action back to the composer', async () => { + const quoteListener = vi.fn(); + const stopWatching = watchComposerTextQuote( quoteListener ); + showTextContextMenu.mockResolvedValueOnce( { + action: 'quote-selection', + selectionText: 'The selected reply.', + } ); + const { getByTestId } = render( ); + const toolOutput = getByTestId( 'tool-output' ); + selectWithin( toolOutput, 'The selected reply.' ); + + fireEvent.contextMenu( toolOutput ); + + await waitFor( () => + expect( quoteListener ).toHaveBeenCalledWith( 'The selected reply.' ) + ); + stopWatching(); + } ); } ); diff --git a/apps/ui/src/hooks/use-text-context-menu.ts b/apps/ui/src/hooks/use-text-context-menu.ts index 06121e50e6..62230ed44e 100644 --- a/apps/ui/src/hooks/use-text-context-menu.ts +++ b/apps/ui/src/hooks/use-text-context-menu.ts @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { useConnector } from '@/data/core'; +import { emitComposerTextQuote } from '@/lib/composer-text-quote'; // Elements carrying a message's full text opt in with this attribute, so a // right-click anywhere inside one can offer to copy the whole thing. @@ -11,6 +12,13 @@ const EDITABLE_SELECTOR = 'input, textarea, [contenteditable]:not([contenteditab // behind elsewhere in the app must not put Copy on an unrelated right-click, // where choosing it would copy something the user can't even see. function getSelectionTextAt( target: Element ): string { + const editable = target.closest( EDITABLE_SELECTOR ); + if ( editable instanceof HTMLInputElement || editable instanceof HTMLTextAreaElement ) { + const start = editable.selectionStart; + const end = editable.selectionEnd; + return start === null || end === null ? '' : editable.value.slice( start, end ); + } + const selection = window.getSelection(); if ( ! selection || selection.isCollapsed || selection.rangeCount === 0 ) { return ''; @@ -57,7 +65,11 @@ export function useTextContextMenu(): void { // Nothing else would handle it, but claiming the event keeps a host // menu from ever stacking on top of ours. event.preventDefault(); - showTextContextMenu( { selectionText, isEditable, messageText } ); + void showTextContextMenu( { selectionText, isEditable, messageText } ).then( ( result ) => { + if ( result?.action === 'quote-selection' ) { + emitComposerTextQuote( result.selectionText ); + } + } ); }; document.addEventListener( 'contextmenu', handleContextMenu ); diff --git a/apps/ui/src/lib/composer-text-quote.test.ts b/apps/ui/src/lib/composer-text-quote.test.ts new file mode 100644 index 0000000000..9259233ebc --- /dev/null +++ b/apps/ui/src/lib/composer-text-quote.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + emitComposerTextQuote, + formatComposerTextQuote, + watchComposerTextQuote, +} from './composer-text-quote'; + +describe( 'composer text quotes', () => { + it( 'formats every selected line as a Markdown blockquote', () => { + expect( formatComposerTextQuote( ' First line\nSecond line ' ) ).toBe( + '> First line\n> Second line' + ); + } ); + + it( 'notifies active composer listeners', () => { + const listener = vi.fn(); + const stopWatching = watchComposerTextQuote( listener ); + + emitComposerTextQuote( 'Selected text' ); + stopWatching(); + emitComposerTextQuote( 'Ignored text' ); + + expect( listener ).toHaveBeenCalledOnce(); + expect( listener ).toHaveBeenCalledWith( 'Selected text' ); + } ); +} ); diff --git a/apps/ui/src/lib/composer-text-quote.ts b/apps/ui/src/lib/composer-text-quote.ts new file mode 100644 index 0000000000..deba7077c4 --- /dev/null +++ b/apps/ui/src/lib/composer-text-quote.ts @@ -0,0 +1,22 @@ +type ComposerTextQuoteListener = ( text: string ) => void; + +const listeners = new Set< ComposerTextQuoteListener >(); + +export function emitComposerTextQuote( text: string ): void { + for ( const listener of listeners ) { + listener( text ); + } +} + +export function watchComposerTextQuote( listener: ComposerTextQuoteListener ): () => void { + listeners.add( listener ); + return () => listeners.delete( listener ); +} + +export function formatComposerTextQuote( text: string ): string { + return text + .trim() + .split( /\r?\n/ ) + .map( ( line ) => `> ${ line }` ) + .join( '\n' ); +} diff --git a/apps/ui/src/ui-classic/components/session-view/conversation/index.test.ts b/apps/ui/src/ui-classic/components/session-view/conversation/index.test.ts index 7e22c55cd7..681845216a 100644 --- a/apps/ui/src/ui-classic/components/session-view/conversation/index.test.ts +++ b/apps/ui/src/ui-classic/components/session-view/conversation/index.test.ts @@ -62,6 +62,12 @@ describe( 'Assistant message copy button', () => { const buttons = screen.getAllByRole( 'button', { name: 'Copy message' } ); expect( buttons ).toHaveLength( 1 ); + expect( + screen.getByText( 'First part.' ).closest( '[data-message-text]' ) + ).toHaveAttribute( 'data-message-text', 'First part.\n\nSecond part.' ); + expect( + screen.getByText( 'Second part.' ).closest( '[data-message-text]' ) + ).toHaveAttribute( 'data-message-text', 'First part.\n\nSecond part.' ); fireEvent.click( buttons[ 0 ] ); expect( connectorMocks.copyText ).toHaveBeenCalledWith( 'First part.\n\nSecond part.' ); diff --git a/apps/ui/src/ui-classic/components/session-view/conversation/index.tsx b/apps/ui/src/ui-classic/components/session-view/conversation/index.tsx index 32d96e1e6d..56b640f0b7 100644 --- a/apps/ui/src/ui-classic/components/session-view/conversation/index.tsx +++ b/apps/ui/src/ui-classic/components/session-view/conversation/index.tsx @@ -96,7 +96,7 @@ type RenderItem = text: string; attachments?: StudioChatAttachmentSummary[]; } - | { kind: 'assistant-text'; key: string; text: string; copyText?: string } + | { kind: 'assistant-text'; key: string; text: string; messageText: string; copyText?: string } | { kind: 'tool-use'; key: string; @@ -278,6 +278,7 @@ export function entriesToRenderItems( kind: 'assistant-text', key: `${ entryIndex }:${ blockIndex }:text`, text, + messageText: fullMessageText, copyText: block === lastTextBlock ? fullMessageText : undefined, } ); } @@ -439,11 +440,13 @@ function UserTurn( { function AssistantText( { text, + messageText, copyText, showActions, onToggleSelect, }: { text: string; + messageText: string; copyText?: string; showActions: boolean; onToggleSelect: () => void; @@ -470,7 +473,7 @@ function AssistantText( {
    { text } @@ -1266,6 +1269,7 @@ export function Conversation( { diff --git a/apps/ui/src/ui-classic/components/session-view/index.tsx b/apps/ui/src/ui-classic/components/session-view/index.tsx index 97e6b78783..3b9ea640d6 100644 --- a/apps/ui/src/ui-classic/components/session-view/index.tsx +++ b/apps/ui/src/ui-classic/components/session-view/index.tsx @@ -32,6 +32,7 @@ import { useSessionCommands } from '@/hooks/use-session-commands'; import { SessionUIProvider, useSessionPreviewAnnotations } from '@/hooks/use-session-ui'; import { useSidebarCollapsed } from '@/hooks/use-sidebar-collapsed'; import { useTrafficLightSpace } from '@/hooks/use-traffic-light-space'; +import { formatComposerTextQuote, watchComposerTextQuote } from '@/lib/composer-text-quote'; import { formatAnnotationsAsPrompt, formatAnnotationsSubmittedMessage } from './annotations'; import { Composer, ComposerSkeleton, type ComposerHandle } from './composer'; import { Conversation } from './conversation'; @@ -243,6 +244,13 @@ function SessionViewContent( { sessionId }: { sessionId: string } ) { ); const scrollRef = useRef< HTMLDivElement >( null ); const composerRef = useRef< ComposerHandle >( null ); + useEffect( + () => + watchComposerTextQuote( ( text ) => { + composerRef.current?.appendDraft( formatComposerTextQuote( text ) ); + } ), + [] + ); const [ isScrolledAway, setIsScrolledAway ] = useState( false ); const hasSession = !! data; From 7fdcf3ee905eb058edb4ccc1b659654c7239e6ff Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Fri, 31 Jul 2026 17:17:40 -0400 Subject: [PATCH 4/7] Format context menu tests after trunk merge --- apps/ui/src/hooks/use-text-context-menu.test.tsx | 6 ++---- .../session-view/conversation/index.test.ts | 14 ++++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/ui/src/hooks/use-text-context-menu.test.tsx b/apps/ui/src/hooks/use-text-context-menu.test.tsx index 460cd2cf79..b819046e16 100644 --- a/apps/ui/src/hooks/use-text-context-menu.test.tsx +++ b/apps/ui/src/hooks/use-text-context-menu.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { MESSAGE_TEXT_ATTRIBUTE, useTextContextMenu } from './use-text-context-menu'; import { watchComposerTextQuote } from '@/lib/composer-text-quote'; +import { MESSAGE_TEXT_ATTRIBUTE, useTextContextMenu } from './use-text-context-menu'; const showTextContextMenu = vi.fn().mockResolvedValue( undefined ); @@ -123,9 +123,7 @@ describe( 'useTextContextMenu', () => { fireEvent.contextMenu( toolOutput ); - await waitFor( () => - expect( quoteListener ).toHaveBeenCalledWith( 'The selected reply.' ) - ); + await waitFor( () => expect( quoteListener ).toHaveBeenCalledWith( 'The selected reply.' ) ); stopWatching(); } ); } ); diff --git a/apps/ui/src/ui-classic/components/session-view/conversation/index.test.ts b/apps/ui/src/ui-classic/components/session-view/conversation/index.test.ts index 681845216a..9a19b496f8 100644 --- a/apps/ui/src/ui-classic/components/session-view/conversation/index.test.ts +++ b/apps/ui/src/ui-classic/components/session-view/conversation/index.test.ts @@ -62,12 +62,14 @@ describe( 'Assistant message copy button', () => { const buttons = screen.getAllByRole( 'button', { name: 'Copy message' } ); expect( buttons ).toHaveLength( 1 ); - expect( - screen.getByText( 'First part.' ).closest( '[data-message-text]' ) - ).toHaveAttribute( 'data-message-text', 'First part.\n\nSecond part.' ); - expect( - screen.getByText( 'Second part.' ).closest( '[data-message-text]' ) - ).toHaveAttribute( 'data-message-text', 'First part.\n\nSecond part.' ); + expect( screen.getByText( 'First part.' ).closest( '[data-message-text]' ) ).toHaveAttribute( + 'data-message-text', + 'First part.\n\nSecond part.' + ); + expect( screen.getByText( 'Second part.' ).closest( '[data-message-text]' ) ).toHaveAttribute( + 'data-message-text', + 'First part.\n\nSecond part.' + ); fireEvent.click( buttons[ 0 ] ); expect( connectorMocks.copyText ).toHaveBeenCalledWith( 'First part.\n\nSecond part.' ); From 667436d6fad08bc5d8fc568b95f4455c5a1a49a2 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Tue, 4 Aug 2026 14:01:57 -0400 Subject: [PATCH 5/7] Add code-block copy action and translator context --- .../src/tests/text-context-menu.test.ts | 25 ++++++++++++++++++- apps/studio/src/text-context-menu.ts | 12 +++++++++ apps/ui/src/components/markdown/index.tsx | 3 ++- apps/ui/src/data/core/types.ts | 1 + .../src/hooks/use-text-context-menu.test.tsx | 22 +++++++++++++++- apps/ui/src/hooks/use-text-context-menu.ts | 12 +++++++-- 6 files changed, 70 insertions(+), 5 deletions(-) diff --git a/apps/studio/src/tests/text-context-menu.test.ts b/apps/studio/src/tests/text-context-menu.test.ts index f8ccbd1d60..1e064a3a9e 100644 --- a/apps/studio/src/tests/text-context-menu.test.ts +++ b/apps/studio/src/tests/text-context-menu.test.ts @@ -26,7 +26,12 @@ function makeEnvironment( return { platform: 'darwin', canPaste: false, ...overrides }; } -const actions = { lookUpSelection: vi.fn(), copyMessage: vi.fn(), quoteSelection: vi.fn() }; +const actions = { + lookUpSelection: vi.fn(), + copyMessage: vi.fn(), + copyCode: vi.fn(), + quoteSelection: vi.fn(), +}; function labelsOf( template: ReturnType< typeof buildTextContextMenuTemplate > ) { return template.map( ( item ) => item.role ?? item.label ?? item.type ); @@ -94,6 +99,24 @@ describe( 'buildTextContextMenuTemplate', () => { expect( copyMessage ).toHaveBeenCalledWith( 'Dark mode and core coexist.' ); } ); + it( 'offers Copy code within a code block and copies only that code', () => { + const copyCode = vi.fn(); + const template = buildTextContextMenuTemplate( + makeContext( { + messageText: 'Before.\n\nconst answer = 42;\n\nAfter.', + codeText: 'const answer = 42;', + } ), + { ...actions, copyCode }, + makeEnvironment( { platform: 'linux' } ) + ); + const copyCodeItem = template.find( ( item ) => item.label === 'Copy code' ); + + ( copyCodeItem?.click as () => void )(); + + expect( labelsOf( template ) ).toEqual( [ 'Copy code', 'Copy All' ] ); + expect( copyCode ).toHaveBeenCalledWith( 'const answer = 42;' ); + } ); + it( 'offers a translated label for role-based clipboard actions', () => { const template = buildTextContextMenuTemplate( makeContext( { selectionText: 'coexist', isEditable: true } ), diff --git a/apps/studio/src/text-context-menu.ts b/apps/studio/src/text-context-menu.ts index 2838a242d8..afc2c4ab10 100644 --- a/apps/studio/src/text-context-menu.ts +++ b/apps/studio/src/text-context-menu.ts @@ -26,11 +26,14 @@ export interface TextContextMenuContext { isEditable: boolean; // The full message the click landed on, when it landed on one at all. messageText?: string; + // The code block the click landed on, when it landed on one at all. + codeText?: string; } export interface TextContextMenuActions { lookUpSelection: () => void; copyMessage: ( text: string ) => void; + copyCode: ( text: string ) => void; quoteSelection: () => void; } @@ -66,6 +69,7 @@ export function buildTextContextMenuTemplate( ): MenuItemConstructorOptions[] { const selection = context.selectionText.trim(); const messageText = context.messageText; + const codeText = context.codeText; // Built as sections and joined with separators, so an inapplicable section // can't leave a stray divider behind. @@ -79,6 +83,12 @@ export function buildTextContextMenuTemplate( if ( selection ) { clipboardItems.push( { label: __( 'Copy' ), role: 'copy' } ); } + if ( codeText ) { + clipboardItems.push( { + label: __( 'Copy code' ), + click: () => actions.copyCode( codeText ), + } ); + } if ( messageText ) { clipboardItems.push( { label: __( 'Copy All' ), @@ -92,6 +102,7 @@ export function buildTextContextMenuTemplate( sections.push( clipboardItems ); } if ( selection && ! context.isEditable ) { + /* translators: Context-menu action that inserts selected text into the message composer as a quote. */ sections.push( [ { label: __( 'Quote in composer' ), click: actions.quoteSelection } ] ); } @@ -110,6 +121,7 @@ export async function showTextContextMenu( { lookUpSelection: () => event.sender.showDefinitionForSelection(), copyMessage: ( text ) => clipboard.writeText( text ), + copyCode: ( text ) => clipboard.writeText( text ), quoteSelection: () => { result = { action: 'quote-selection', selectionText: context.selectionText.trim() }; }, diff --git a/apps/ui/src/components/markdown/index.tsx b/apps/ui/src/components/markdown/index.tsx index eaf0a8c2b1..95ae9a3672 100644 --- a/apps/ui/src/components/markdown/index.tsx +++ b/apps/ui/src/components/markdown/index.tsx @@ -5,6 +5,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { CopyButton } from '@/components/copy-button'; import { useConnector } from '@/data/core'; +import { CODE_TEXT_ATTRIBUTE } from '@/hooks/use-text-context-menu'; import styles from './style.module.css'; import type { MouseEvent, ReactNode } from 'react'; import type { Components } from 'react-markdown'; @@ -31,7 +32,7 @@ function CodeBlock( { children }: { children?: ReactNode } ) { const text = useMemo( () => extractText( children ).replace( /\n$/, '' ), [ children ] ); return ( -
    +
    { children }
    { text ? ( ; openSiteUrl( diff --git a/apps/ui/src/hooks/use-text-context-menu.test.tsx b/apps/ui/src/hooks/use-text-context-menu.test.tsx index b819046e16..27ecca96e6 100644 --- a/apps/ui/src/hooks/use-text-context-menu.test.tsx +++ b/apps/ui/src/hooks/use-text-context-menu.test.tsx @@ -1,7 +1,11 @@ import { fireEvent, render, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { watchComposerTextQuote } from '@/lib/composer-text-quote'; -import { MESSAGE_TEXT_ATTRIBUTE, useTextContextMenu } from './use-text-context-menu'; +import { + CODE_TEXT_ATTRIBUTE, + MESSAGE_TEXT_ATTRIBUTE, + useTextContextMenu, +} from './use-text-context-menu'; const showTextContextMenu = vi.fn().mockResolvedValue( undefined ); @@ -15,6 +19,9 @@ function Harness() {

    The whole reply.

    +
    +
    const answer = 42;
    +
    wp plugin list
    @@ -60,6 +67,19 @@ describe( 'useTextContextMenu', () => { expect( showTextContextMenu ).not.toHaveBeenCalled(); } ); + it( 'reports the code block and whole message when right-clicking rendered code', () => { + const { getByTestId } = render( ); + + fireEvent.contextMenu( getByTestId( 'message-code' ) ); + + expect( showTextContextMenu ).toHaveBeenCalledWith( { + selectionText: '', + isEditable: false, + messageText: 'The whole reply.', + codeText: 'const answer = 42;', + } ); + } ); + it( 'ignores a selection left behind somewhere else in the app', () => { const { getByTestId } = render( ); selectWithin( getByTestId( 'message-paragraph' ) ); diff --git a/apps/ui/src/hooks/use-text-context-menu.ts b/apps/ui/src/hooks/use-text-context-menu.ts index 62230ed44e..dd9db2e81f 100644 --- a/apps/ui/src/hooks/use-text-context-menu.ts +++ b/apps/ui/src/hooks/use-text-context-menu.ts @@ -5,6 +5,7 @@ import { emitComposerTextQuote } from '@/lib/composer-text-quote'; // Elements carrying a message's full text opt in with this attribute, so a // right-click anywhere inside one can offer to copy the whole thing. export const MESSAGE_TEXT_ATTRIBUTE = 'data-message-text'; +export const CODE_TEXT_ATTRIBUTE = 'data-code-text'; const EDITABLE_SELECTOR = 'input, textarea, [contenteditable]:not([contenteditable="false"])'; @@ -52,20 +53,27 @@ export function useTextContextMenu(): void { const messageHost = target.closest( `[${ MESSAGE_TEXT_ATTRIBUTE }]` ); const messageText = messageHost?.getAttribute( MESSAGE_TEXT_ATTRIBUTE ) || undefined; + const codeHost = target.closest( `[${ CODE_TEXT_ATTRIBUTE }]` ); + const codeText = codeHost?.getAttribute( CODE_TEXT_ATTRIBUTE ) || undefined; const isEditable = Boolean( target.closest( EDITABLE_SELECTOR ) ); const selectionText = getSelectionTextAt( target ); // Right-clicking something that isn't text — a menu, a button, the // sidebar, empty canvas — has nothing to offer, so stay out of the // way entirely rather than opening a menu of unrelated actions. - if ( ! messageText && ! isEditable && ! selectionText.trim() ) { + if ( ! messageText && ! codeText && ! isEditable && ! selectionText.trim() ) { return; } // Nothing else would handle it, but claiming the event keeps a host // menu from ever stacking on top of ours. event.preventDefault(); - void showTextContextMenu( { selectionText, isEditable, messageText } ).then( ( result ) => { + void showTextContextMenu( { + selectionText, + isEditable, + messageText, + ...( codeText ? { codeText } : {} ), + } ).then( ( result ) => { if ( result?.action === 'quote-selection' ) { emitComposerTextQuote( result.selectionText ); } From 28845d4482a23074f8d0696b22e37365ab23765c Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Tue, 4 Aug 2026 14:36:05 -0400 Subject: [PATCH 6/7] Polish native text context menu behavior --- apps/studio/src/text-context-menu.ts | 10 ++-- .../ui/src/components/markdown/index.test.tsx | 5 ++ .../src/hooks/use-text-context-menu.test.tsx | 38 +++++++++++++ apps/ui/src/hooks/use-text-context-menu.ts | 53 +++++++++++++++---- apps/ui/src/lib/composer-text-quote.test.ts | 4 +- apps/ui/src/lib/composer-text-quote.ts | 3 +- 6 files changed, 96 insertions(+), 17 deletions(-) diff --git a/apps/studio/src/text-context-menu.ts b/apps/studio/src/text-context-menu.ts index afc2c4ab10..6be8333a9a 100644 --- a/apps/studio/src/text-context-menu.ts +++ b/apps/studio/src/text-context-menu.ts @@ -57,10 +57,9 @@ function toLookUpLabel( selection: string ): string { } /** - * Text-only context menu: copy the selection, copy the whole message, and look - * a word up. Look Up is macOS-only because Windows and Linux expose no system - * dictionary to apps — their native text menus really are just the edit - * commands, so gating on platform yields what each OS would natively show. + * Native actions for selections, editable fields, messages, and code blocks. + * Look Up is macOS-only because Windows and Linux expose no system dictionary + * to apps. */ export function buildTextContextMenuTemplate( context: TextContextMenuContext, @@ -103,7 +102,8 @@ export function buildTextContextMenuTemplate( } if ( selection && ! context.isEditable ) { /* translators: Context-menu action that inserts selected text into the message composer as a quote. */ - sections.push( [ { label: __( 'Quote in composer' ), click: actions.quoteSelection } ] ); + const quoteInComposerLabel = __( 'Quote in composer' ); + sections.push( [ { label: quoteInComposerLabel, click: actions.quoteSelection } ] ); } return sections.flatMap( ( section, index ) => diff --git a/apps/ui/src/components/markdown/index.test.tsx b/apps/ui/src/components/markdown/index.test.tsx index 4d057c1d0e..c9065dbb82 100644 --- a/apps/ui/src/components/markdown/index.test.tsx +++ b/apps/ui/src/components/markdown/index.test.tsx @@ -1,6 +1,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { Tooltip } from '@wordpress/ui'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CODE_TEXT_ATTRIBUTE } from '@/hooks/use-text-context-menu'; import { Markdown } from '.'; const { copyText } = vi.hoisted( () => ( { @@ -32,6 +33,10 @@ describe( 'Markdown', () => { const button = screen.getByRole( 'button', { name: 'Copy code' } ); expect( button ).toBeInTheDocument(); + expect( button.closest( `[${ CODE_TEXT_ATTRIBUTE }]` ) ).toHaveAttribute( + CODE_TEXT_ATTRIBUTE, + 'const a = 1;' + ); fireEvent.click( button ); diff --git a/apps/ui/src/hooks/use-text-context-menu.test.tsx b/apps/ui/src/hooks/use-text-context-menu.test.tsx index 27ecca96e6..bfd29c53d0 100644 --- a/apps/ui/src/hooks/use-text-context-menu.test.tsx +++ b/apps/ui/src/hooks/use-text-context-menu.test.tsx @@ -25,6 +25,9 @@ function Harness() {
    wp plugin list
    + + +
    ); @@ -115,6 +118,18 @@ describe( 'useTextContextMenu', () => { } ); } ); + it( 'reports a contenteditable region so the host can offer Paste', () => { + const { getByTestId } = render( ); + + fireEvent.contextMenu( getByTestId( 'editable' ) ); + + expect( showTextContextMenu ).toHaveBeenCalledWith( { + selectionText: '', + isEditable: true, + messageText: undefined, + } ); + } ); + it( 'reports selected text inside an editable field so the host can offer Copy', () => { const { getByTestId } = render( ); const field = getByTestId( 'field' ) as HTMLInputElement; @@ -130,6 +145,29 @@ describe( 'useTextContextMenu', () => { } ); } ); + it( 'allows Copy but not Paste for selected text in a read-only field', () => { + const { getByTestId } = render( ); + const field = getByTestId( 'readonly-field' ) as HTMLInputElement; + field.value = 'Read-only text'; + field.setSelectionRange( 0, 9 ); + + fireEvent.contextMenu( field ); + + expect( showTextContextMenu ).toHaveBeenCalledWith( { + selectionText: 'Read-only', + isEditable: false, + messageText: undefined, + } ); + } ); + + it( 'stays out of the way on non-text inputs', () => { + const { getByTestId } = render( ); + + fireEvent.contextMenu( getByTestId( 'checkbox' ) ); + + expect( showTextContextMenu ).not.toHaveBeenCalled(); + } ); + it( 'routes a native Quote action back to the composer', async () => { const quoteListener = vi.fn(); const stopWatching = watchComposerTextQuote( quoteListener ); diff --git a/apps/ui/src/hooks/use-text-context-menu.ts b/apps/ui/src/hooks/use-text-context-menu.ts index dd9db2e81f..bab25c25fe 100644 --- a/apps/ui/src/hooks/use-text-context-menu.ts +++ b/apps/ui/src/hooks/use-text-context-menu.ts @@ -7,17 +7,51 @@ import { emitComposerTextQuote } from '@/lib/composer-text-quote'; export const MESSAGE_TEXT_ATTRIBUTE = 'data-message-text'; export const CODE_TEXT_ATTRIBUTE = 'data-code-text'; -const EDITABLE_SELECTOR = 'input, textarea, [contenteditable]:not([contenteditable="false"])'; +const TEXT_INPUT_TYPES = new Set( [ + 'email', + 'number', + 'password', + 'search', + 'tel', + 'text', + 'url', +] ); + +function getTextControlAt( target: Element ): HTMLInputElement | HTMLTextAreaElement | null { + const control = target.closest( 'input, textarea' ); + if ( control instanceof HTMLTextAreaElement ) { + return control; + } + if ( control instanceof HTMLInputElement && TEXT_INPUT_TYPES.has( control.type ) ) { + return control; + } + return null; +} + +function isEditableAt( + target: Element, + textControl: HTMLInputElement | HTMLTextAreaElement | null +): boolean { + if ( textControl ) { + return ! textControl.disabled && ! textControl.readOnly; + } + const contentEditable = target.closest( '[contenteditable]' ); + return Boolean( + contentEditable && contentEditable.getAttribute( 'contenteditable' ) !== 'false' + ); +} // Only a selection the pointer is actually inside counts. A highlight left // behind elsewhere in the app must not put Copy on an unrelated right-click, // where choosing it would copy something the user can't even see. -function getSelectionTextAt( target: Element ): string { - const editable = target.closest( EDITABLE_SELECTOR ); - if ( editable instanceof HTMLInputElement || editable instanceof HTMLTextAreaElement ) { - const start = editable.selectionStart; - const end = editable.selectionEnd; - return start === null || end === null ? '' : editable.value.slice( start, end ); +function getSelectionTextAt( + target: Element, + textControl: HTMLInputElement | HTMLTextAreaElement | null +): string { + if ( textControl ) { + const start = textControl.selectionStart; + const end = textControl.selectionEnd; + return start === null || end === null ? '' : textControl.value.slice( start, end ); } const selection = window.getSelection(); @@ -55,8 +89,9 @@ export function useTextContextMenu(): void { const messageText = messageHost?.getAttribute( MESSAGE_TEXT_ATTRIBUTE ) || undefined; const codeHost = target.closest( `[${ CODE_TEXT_ATTRIBUTE }]` ); const codeText = codeHost?.getAttribute( CODE_TEXT_ATTRIBUTE ) || undefined; - const isEditable = Boolean( target.closest( EDITABLE_SELECTOR ) ); - const selectionText = getSelectionTextAt( target ); + const textControl = getTextControlAt( target ); + const isEditable = isEditableAt( target, textControl ); + const selectionText = getSelectionTextAt( target, textControl ); // Right-clicking something that isn't text — a menu, a button, the // sidebar, empty canvas — has nothing to offer, so stay out of the diff --git a/apps/ui/src/lib/composer-text-quote.test.ts b/apps/ui/src/lib/composer-text-quote.test.ts index 9259233ebc..b93058443b 100644 --- a/apps/ui/src/lib/composer-text-quote.test.ts +++ b/apps/ui/src/lib/composer-text-quote.test.ts @@ -6,9 +6,9 @@ import { } from './composer-text-quote'; describe( 'composer text quotes', () => { - it( 'formats every selected line as a Markdown blockquote', () => { + it( 'formats every selected line as a Markdown blockquote followed by a blank line', () => { expect( formatComposerTextQuote( ' First line\nSecond line ' ) ).toBe( - '> First line\n> Second line' + '> First line\n> Second line\n\n' ); } ); diff --git a/apps/ui/src/lib/composer-text-quote.ts b/apps/ui/src/lib/composer-text-quote.ts index deba7077c4..ed14449e01 100644 --- a/apps/ui/src/lib/composer-text-quote.ts +++ b/apps/ui/src/lib/composer-text-quote.ts @@ -14,9 +14,10 @@ export function watchComposerTextQuote( listener: ComposerTextQuoteListener ): ( } export function formatComposerTextQuote( text: string ): string { - return text + const quote = text .trim() .split( /\r?\n/ ) .map( ( line ) => `> ${ line }` ) .join( '\n' ); + return `${ quote }\n\n`; } From 683ef18a03cae4bb1fb4fe8024ee34e04cefe806 Mon Sep 17 00:00:00 2001 From: Shaun Andrews Date: Tue, 4 Aug 2026 14:38:45 -0400 Subject: [PATCH 7/7] Avoid reading clipboard contents for context menu --- apps/studio/src/tests/text-context-menu.test.ts | 14 ++++++++++++-- apps/studio/src/text-context-menu.ts | 12 +++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/apps/studio/src/tests/text-context-menu.test.ts b/apps/studio/src/tests/text-context-menu.test.ts index 1e064a3a9e..d0c61c8392 100644 --- a/apps/studio/src/tests/text-context-menu.test.ts +++ b/apps/studio/src/tests/text-context-menu.test.ts @@ -5,6 +5,7 @@ import { BrowserWindow, clipboard, Menu, type IpcMainInvokeEvent } from 'electro import { vi } from 'vitest'; import { buildTextContextMenuTemplate, + hasTextClipboardFormat, showTextContextMenu, type TextContextMenuContext, type TextContextMenuEnvironment, @@ -13,7 +14,7 @@ import { vi.mock( 'electron', () => ( { BrowserWindow: { fromWebContents: vi.fn() }, Menu: { buildFromTemplate: vi.fn() }, - clipboard: { readText: vi.fn(), writeText: vi.fn() }, + clipboard: { availableFormats: vi.fn(), writeText: vi.fn() }, } ) ); function makeContext( overrides: Partial< TextContextMenuContext > = {} ): TextContextMenuContext { @@ -225,11 +226,20 @@ describe( 'buildTextContextMenuTemplate', () => { } ); } ); +describe( 'hasTextClipboardFormat', () => { + it( 'detects normalized and native plain-text formats without reading clipboard contents', () => { + expect( hasTextClipboardFormat( [ 'text/plain' ] ) ).toBe( true ); + expect( hasTextClipboardFormat( [ 'text/plain;charset=utf-8' ] ) ).toBe( true ); + expect( hasTextClipboardFormat( [ 'public.utf8-plain-text' ] ) ).toBe( true ); + expect( hasTextClipboardFormat( [ 'image/png' ] ) ).toBe( false ); + } ); +} ); + describe( 'showTextContextMenu', () => { it( 'returns the selected text when Quote in composer is chosen', async () => { const popup = vi.fn(); vi.mocked( BrowserWindow.fromWebContents ).mockReturnValue( null ); - vi.mocked( clipboard.readText ).mockReturnValue( '' ); + vi.mocked( clipboard.availableFormats ).mockReturnValue( [] ); vi.mocked( Menu.buildFromTemplate ).mockReturnValue( { popup } as unknown as Menu ); const event = { sender: { showDefinitionForSelection: vi.fn() }, diff --git a/apps/studio/src/text-context-menu.ts b/apps/studio/src/text-context-menu.ts index 6be8333a9a..27cf891166 100644 --- a/apps/studio/src/text-context-menu.ts +++ b/apps/studio/src/text-context-menu.ts @@ -56,6 +56,13 @@ function toLookUpLabel( selection: string ): string { return sprintf( __( 'Look Up “%s”' ), truncated ); } +export function hasTextClipboardFormat( formats: string[] ): boolean { + return formats.some( ( format ) => { + const normalized = format.toLowerCase(); + return normalized.startsWith( 'text/plain' ) || normalized.includes( 'plain-text' ); + } ); +} + /** * Native actions for selections, editable fields, messages, and code blocks. * Look Up is macOS-only because Windows and Linux expose no system dictionary @@ -126,7 +133,10 @@ export async function showTextContextMenu( result = { action: 'quote-selection', selectionText: context.selectionText.trim() }; }, }, - { platform: process.platform, canPaste: clipboard.readText().length > 0 } + { + platform: process.platform, + canPaste: hasTextClipboardFormat( clipboard.availableFormats() ), + } ); if ( template.length === 0 ) {