diff --git a/apps/studio/src/ipc-handlers.ts b/apps/studio/src/ipc-handlers.ts index 96401ee5d1..63fc6f6ada 100644 --- a/apps/studio/src/ipc-handlers.ts +++ b/apps/studio/src/ipc-handlers.ts @@ -2466,3 +2466,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 5bab9cce7e..59bb89d441 100644 --- a/apps/studio/src/preload.ts +++ b/apps/studio/src/preload.ts @@ -190,6 +190,7 @@ const api: IpcApi = { ipcRendererInvoke( 'extractBlueprintBundle', zipFilePath ), cleanupBlueprintTempDir: ( tempDir ) => ipcRendererInvoke( 'cleanupBlueprintTempDir', tempDir ), showSiteContextMenu: ( context ) => ipcRendererSend( 'showSiteContextMenu', 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 new file mode 100644 index 0000000000..f8ccbd1d60 --- /dev/null +++ b/apps/studio/src/tests/text-context-menu.test.ts @@ -0,0 +1,230 @@ +/** + * @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'; + +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(), quoteSelection: 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', + 'separator', + 'Quote in composer', + ] ); + } ); + + 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', + 'separator', + 'Quote in composer', + ] ); + } + } ); + + 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( '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 ' } ), + 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', + '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', isEditable: true } ), + 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( [] ); + } ); +} ); + +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 new file mode 100644 index 0000000000..2838a242d8 --- /dev/null +++ b/apps/studio/src/text-context-menu.ts @@ -0,0 +1,131 @@ +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; + quoteSelection: () => void; +} + +export interface TextContextMenuEnvironment { + platform: NodeJS.Platform; + 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 = + 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( { label: __( 'Copy' ), role: 'copy' } ); + } + if ( messageText ) { + clipboardItems.push( { + label: __( 'Copy All' ), + click: () => actions.copyMessage( messageText ), + } ); + } + if ( context.isEditable && environment.canPaste ) { + 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 async function showTextContextMenu( + event: IpcMainInvokeEvent, + context: TextContextMenuContext +): 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 undefined; + } + + const window = BrowserWindow.fromWebContents( event.sender ); + 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 53ec379208..6c886f7fa0 100644 --- a/apps/ui/src/data/core/connectors/ipc/index.ts +++ b/apps/ui/src/data/core/connectors/ipc/index.ts @@ -828,6 +828,10 @@ export function createIpcConnector(): Connector { await ipcApi.copyText( text ); }, + async showTextContextMenu( context ) { + return 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 9d4ee6eff3..950f4bb30e 100644 --- a/apps/ui/src/data/core/types.ts +++ b/apps/ui/src/data/core/types.ts @@ -404,6 +404,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; + } ): Promise< { action: 'quote-selection'; selectionText: string } | undefined >; + openSiteUrl( siteId: string, relativeUrl?: 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 new file mode 100644 index 0000000000..b819046e16 --- /dev/null +++ b/apps/ui/src/hooks/use-text-context-menu.test.tsx @@ -0,0 +1,129 @@ +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'; + +const showTextContextMenu = vi.fn().mockResolvedValue( undefined ); + +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.mockReset().mockResolvedValue( undefined ); + 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, + } ); + } ); + + 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 new file mode 100644 index 0000000000..62230ed44e --- /dev/null +++ b/apps/ui/src/hooks/use-text-context-menu.ts @@ -0,0 +1,78 @@ +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. +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 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 ''; + } + return selection.getRangeAt( 0 ).intersectsNode( target ) ? selection.toString() : ''; +} + +/** + * 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 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() ) { + 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 ) => { + if ( result?.action === 'quote-selection' ) { + emitComposerTextQuote( result.selectionText ); + } + } ); + }; + + document.addEventListener( 'contextmenu', handleContextMenu ); + return () => document.removeEventListener( 'contextmenu', handleContextMenu ); + }, [ showTextContextMenu ] ); +} 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/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.test.ts b/apps/ui/src/ui-classic/components/session-view/conversation/index.test.ts index 7e22c55cd7..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,6 +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.' + ); 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 9744621f0d..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 @@ -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'; @@ -95,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; @@ -277,6 +278,7 @@ export function entriesToRenderItems( kind: 'assistant-text', key: `${ entryIndex }:${ blockIndex }:text`, text, + messageText: fullMessageText, copyText: block === lastTextBlock ? fullMessageText : undefined, } ); } @@ -404,7 +406,7 @@ function UserTurn( { attachments?: StudioChatAttachmentSummary[]; } ) { return ( -
+
{ text }
{ attachments && attachments.length > 0 ? (
    @@ -438,11 +440,13 @@ function UserTurn( { function AssistantText( { text, + messageText, copyText, showActions, onToggleSelect, }: { text: string; + messageText: string; copyText?: string; showActions: boolean; onToggleSelect: () => void; @@ -469,6 +473,7 @@ function AssistantText( {
    { text } @@ -1264,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;