From 4f4f021f8a19fdd3786cdf98072b6ee802a82e03 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Thu, 8 Oct 2026 03:08:08 +0000 Subject: [PATCH] feat(native-chat): quote a selection from an agent reply into the composer (#26018) * feat(native-chat): quote a selection from an agent reply into the composer * fix(native-chat): label the selection action "Add to chat" --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> --- ...ChatMessageList.reader-navigation.test.tsx | 2 + .../native-chat/NativeChatMessageRow.test.tsx | 14 ++ .../native-chat/NativeChatMessageRow.tsx | 2 + ...ResolvedView.prompt-card-composer.test.tsx | 2 + .../native-chat/NativeChatResolvedView.tsx | 9 +- .../NativeChatSelectionQuote.test.tsx | 148 +++++++++++++++++ .../native-chat/NativeChatSelectionQuote.tsx | 155 ++++++++++++++++++ ...iveChatStructuredSession.test-composer.tsx | 2 + ...native-chat-composer-reveal-focus.test.tsx | 2 + .../native-chat/native-chat-composer-types.ts | 5 + .../native-chat-paste-routing.test.tsx | 2 + .../native-chat-quote-selection.ts | 60 +++++++ .../use-native-chat-composer-handle.ts | 37 +++-- .../use-native-chat-context-menu.test.tsx | 4 + .../use-native-chat-context-menu.tsx | 11 ++ .../use-native-chat-typed-insertion.ts | 65 +++++--- ...se-structured-native-chat-pane-commands.ts | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + 23 files changed, 492 insertions(+), 35 deletions(-) create mode 100644 src/renderer/src/components/native-chat/NativeChatSelectionQuote.test.tsx create mode 100644 src/renderer/src/components/native-chat/NativeChatSelectionQuote.tsx create mode 100644 src/renderer/src/components/native-chat/native-chat-quote-selection.ts diff --git a/src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx index 91ef38b065a..ebf753e1956 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageList.reader-navigation.test.tsx @@ -309,6 +309,8 @@ describe('reader navigation', () => { const composer = { focus, insertTypedText, + acceptsText: () => true, + appendText: vi.fn(), handlePasteEvent: vi.fn(), pasteFromClipboard: vi.fn(), contains: () => false diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx index 9343db01538..44ad235f4aa 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.test.tsx @@ -6,6 +6,7 @@ import type { NativeChatMessage } from '../../../../shared/native-chat-types' import { MessageRow, type NativeChatDeliveryNotice } from './NativeChatMessageRow' import { TooltipProvider } from '@/components/ui/tooltip' import type { NativeChatRewindSurface } from './use-native-chat-rewind' +import { readNativeChatQuotableSelection } from './native-chat-quote-selection' const confirm = vi.hoisted(() => vi.fn()) vi.mock('@/components/confirmation-dialog-context', () => ({ @@ -158,6 +159,19 @@ describe('MessageRow control visibility', () => { ) }) +describe('which messages can be quoted', () => { + it.each([ + ['assistant', 'Message text'], + ['user', undefined], + ['system', undefined] + ] as const)('a selection in a %s message', (role, quoted) => { + const { container } = renderMessage(role) + window.getSelection()!.selectAllChildren(screen.getByText('Message text')) + + expect(readNativeChatQuotableSelection(container)?.text).toBe(quoted) + }) +}) + describe('MessageRow send mode', () => { function renderUser(sentAs?: NativeChatMessage['sentAs']) { return render( diff --git a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx index 977c734cbd4..5bfdc76d76c 100644 --- a/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx +++ b/src/renderer/src/components/native-chat/NativeChatMessageRow.tsx @@ -5,6 +5,7 @@ import { Goal } from 'lucide-react' import { Button } from '@/components/ui/button' import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown' import { NativeChatMarkdown } from './NativeChatMarkdown' +import { NATIVE_CHAT_QUOTE_SOURCE_PROPS } from './native-chat-quote-selection' import { cn } from '@/lib/utils' import { translate } from '@/i18n/i18n' import type { @@ -328,6 +329,7 @@ export const MessageRow = memo(function MessageRow({ onLinkClick={onLinkClick} allowFileUriLinks={allowFileUriLinks} linkifyFilePaths={onLinkClick !== undefined} + {...(isSystem ? {} : NATIVE_CHAT_QUOTE_SOURCE_PROPS)} visualMessageId={message.role === 'assistant' ? message.id : undefined} // Structured text streams in place with no per-row state: only the live turn's frontier // row, still ending in prose, can be mid-sentence. diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx index aa8b7750803..e7ed96426f3 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.prompt-card-composer.test.tsx @@ -51,6 +51,8 @@ vi.mock('./NativeChatComposer', async () => { return true }, insertTypedText: composer.typed, + acceptsText: () => true, + appendText: () => {}, handlePasteEvent: () => {}, pasteFromClipboard: () => {}, contains: () => false diff --git a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx index 2cc7b4e980a..83977dee7fe 100644 --- a/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatResolvedView.tsx @@ -132,13 +132,10 @@ export function NativeChatResolvedView({ // replaces the composer. const questionAnswerInputRef = useRef(null) const fileLinkContext = useNativeChatFileLinkContext(terminalTabId) - const pasteClipboardIntoComposer = useNativeChatPasteBridge({ - rootRef, - composerRef, - questionAnswerInputRef - }) + const onPaste = useNativeChatPasteBridge({ rootRef, composerRef, questionAnswerInputRef }) const contextMenu = useNativeChatContextMenu({ rootRef, + composerRef, enabled: isVisible, onSwitchToTerminal, splitShortcutLabels: { @@ -146,7 +143,7 @@ export function NativeChatResolvedView({ down: formatShortcutLabel('terminal.splitDown', keybindings) }, actions: { - onPaste: pasteClipboardIntoComposer, + onPaste, ...(contextMenuActions ?? emptyNativeChatContextMenuActions) } }) diff --git a/src/renderer/src/components/native-chat/NativeChatSelectionQuote.test.tsx b/src/renderer/src/components/native-chat/NativeChatSelectionQuote.test.tsx new file mode 100644 index 00000000000..e08011eed66 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSelectionQuote.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { useRef } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NativeChatComposerHandle } from './native-chat-composer-types' +import { NativeChatSelectionQuote } from './NativeChatSelectionQuote' +import { NATIVE_CHAT_QUOTE_SOURCE_PROPS } from './native-chat-quote-selection' + +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) + +const appendText = vi.fn<(text: string) => void>() +const acceptsText = vi.fn(() => true) +const composer: NativeChatComposerHandle = { + focus: () => true, + insertTypedText: () => true, + appendText, + acceptsText, + handlePasteEvent: () => {}, + pasteFromClipboard: () => {}, + contains: () => false +} + +function Chat(): React.JSX.Element { + const rootRef = useRef(null) + return ( +
+

my own prompt

+
+
{'Use the draft store.\n\nIt already appends.'}
+
+ +

a tool line

+ +
+ ) +} + +function select(from: HTMLElement, to: HTMLElement = from, endOffset?: number): void { + const range = document.createRange() + range.setStart(from.firstChild!, 0) + range.setEnd(to.firstChild!, endOffset ?? to.textContent!.length) + const selection = window.getSelection()! + selection.removeAllRanges() + selection.addRange(range) + document.dispatchEvent(new Event('selectionchange')) +} + +function settle(): void { + act(() => { + vi.runOnlyPendingTimers() + }) +} + +function releaseSelection(from: HTMLElement, to: HTMLElement = from, endOffset?: number): void { + fireEvent.pointerDown(from, { button: 0 }) + select(from, to, endOffset) + fireEvent.mouseUp(to, { button: 0 }) + settle() +} + +describe('NativeChatSelectionQuote', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + cleanup() + window.getSelection()?.removeAllRanges() + appendText.mockClear() + acceptsText.mockReset().mockReturnValue(true) + vi.useRealTimers() + }) + + it('quotes a selection from an agent reply into the composer as a blockquote', () => { + render() + releaseSelection(screen.getByTestId('first')) + + const quote = screen.getByRole('button', { name: 'Add to chat' }) + fireEvent.pointerDown(quote, { button: 0 }) + fireEvent.click(quote) + + expect(appendText).toHaveBeenCalledWith('> Use the draft store.\n>\n> It already appends.\n\n') + expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull() + }) + + it('offers nothing for a selection outside an agent reply', () => { + render() + releaseSelection(screen.getByTestId('user')) + expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull() + + releaseSelection(screen.getByTestId('user'), screen.getByTestId('first')) + expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull() + }) + + it('takes a selection that runs on over text that cannot be selected', () => { + render() + releaseSelection(screen.getByTestId('first'), screen.getByTestId('time')) + expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy() + + // A triple-click's range: it ends at the very start of the next block. + releaseSelection(screen.getByTestId('first'), screen.getByTestId('next'), 0) + expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy() + + releaseSelection(screen.getByTestId('first'), screen.getByTestId('next')) + expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull() + }) + + it('offers nothing when a press leaves the selection as it was', () => { + render() + releaseSelection(screen.getByTestId('first')) + fireEvent.pointerDown(screen.getByTestId('time'), { button: 0 }) + fireEvent.mouseUp(screen.getByTestId('time'), { button: 0 }) + settle() + + expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull() + }) + + it('offers a selection made with the keyboard', () => { + render() + select(screen.getByTestId('first')) + fireEvent.keyUp(screen.getByTestId('first'), { key: 'ArrowRight', shiftKey: true }) + settle() + + expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy() + }) + + it('offers nothing while the composer cannot take text', () => { + acceptsText.mockReturnValue(false) + render() + releaseSelection(screen.getByTestId('first')) + + expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull() + }) + + it('withdraws the offer once the selection is gone', () => { + render() + releaseSelection(screen.getByTestId('first')) + expect(screen.getByRole('button', { name: 'Add to chat' })).toBeTruthy() + + act(() => { + window.getSelection()!.removeAllRanges() + document.dispatchEvent(new Event('selectionchange')) + }) + expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull() + }) +}) diff --git a/src/renderer/src/components/native-chat/NativeChatSelectionQuote.tsx b/src/renderer/src/components/native-chat/NativeChatSelectionQuote.tsx new file mode 100644 index 00000000000..a5311357a63 --- /dev/null +++ b/src/renderer/src/components/native-chat/NativeChatSelectionQuote.tsx @@ -0,0 +1,155 @@ +import { useEffect, useMemo, useRef, useState, type RefObject } from 'react' +import { MessageSquarePlus } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { addViewportSizeChangeListener } from '@/hooks/viewport-size-change-listener' +import { translate } from '@/i18n/i18n' +import type { NativeChatComposerHandle } from './native-chat-composer-types' +import { + formatNativeChatQuote, + readNativeChatQuotableSelection +} from './native-chat-quote-selection' + +// Why: shown at once, a double-click's action would sit under the pointer and take the third click. +const DOUBLE_CLICK_SETTLE_MS = 500 + +/** The selected text, and the point the offer is drawn at, which moves with that text. */ +type QuoteRequest = { + text: string + anchor: { getBoundingClientRect: () => DOMRect; contextElement?: Element } +} + +/** Offers "Add to chat" beside a selection made in an agent's reply. */ +export function NativeChatSelectionQuote({ + rootRef, + composerRef, + enabled +}: { + rootRef: RefObject + composerRef: RefObject + enabled: boolean +}): React.JSX.Element { + const [request, setRequest] = useState(null) + const contentRef = useRef(null) + const virtualRef = useMemo(() => ({ current: request?.anchor ?? null }), [request]) + + useEffect(() => { + const root = rootRef.current + if (!enabled || !root) { + return + } + let selectionChanged = false + let timer: number | undefined + const close = (): void => { + window.clearTimeout(timer) + setRequest(null) + } + const onPointerDown = (event: PointerEvent): void => { + if (event.target instanceof Node && contentRef.current?.contains(event.target)) { + return + } + close() + selectionChanged = false + } + // `point` is where the pointer was released; a keyboard selection is offered at its end. + const offer = (point: { x: number; y: number } | null, delay = 0): void => { + // A press or key that left the selection as it was (a scrollbar, a timestamp) selected nothing. + if (!selectionChanged) { + return + } + selectionChanged = false + window.clearTimeout(timer) + timer = window.setTimeout(() => { + // Only offered where the text would land. + const quotable = composerRef.current?.acceptsText() + ? readNativeChatQuotableSelection(root) + : null + if (!quotable) { + setRequest(null) + return + } + const { text, range } = quotable + const held = range.getBoundingClientRect() + const end = [...range.getClientRects()].at(-1) ?? held + const dx = (point?.x ?? end.right) - held.left + const dy = (point?.y ?? end.top) - held.top + setRequest({ + text, + anchor: { + getBoundingClientRect: () => { + const now = range.getBoundingClientRect() + return new DOMRect(now.left + dx, now.top + dy) + }, + // Why: lets the popover follow the text's scroller, and hide once it is clipped. + contextElement: range.startContainer.parentElement ?? undefined + } + }) + }, delay) + } + const onMouseUp = (event: MouseEvent): void => { + if (event.button === 0) { + offer( + { x: event.clientX, y: event.clientY }, + event.detail === 2 ? DOUBLE_CLICK_SETTLE_MS : 0 + ) + } + } + const onKeyUp = (): void => offer(null) + const onSelectionChange = (): void => { + selectionChanged = true + setRequest((current) => + current && readNativeChatQuotableSelection(root)?.text !== current.text ? null : current + ) + } + document.addEventListener('pointerdown', onPointerDown, true) + document.addEventListener('selectionchange', onSelectionChange) + window.addEventListener('mouseup', onMouseUp) + document.addEventListener('keyup', onKeyUp) + window.addEventListener('blur', close) + const removeResizeListener = addViewportSizeChangeListener(close) + return () => { + close() + document.removeEventListener('pointerdown', onPointerDown, true) + document.removeEventListener('selectionchange', onSelectionChange) + window.removeEventListener('mouseup', onMouseUp) + document.removeEventListener('keyup', onKeyUp) + window.removeEventListener('blur', close) + removeResizeListener() + } + }, [composerRef, enabled, rootRef]) + + return ( + !open && setRequest(null)}> + + {request ? ( + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + // Keeps the selection, and the focus, where they are until the quote is taken. + onMouseDown={(event) => event.preventDefault()} + > +
+ +
+
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx index 5a0a6fd5365..c490cd6b691 100644 --- a/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx +++ b/src/renderer/src/components/native-chat/NativeChatStructuredSession.test-composer.tsx @@ -21,6 +21,8 @@ export function createStructuredSessionComposerMock(mocks: { return true }, insertTypedText: () => true, + appendText: () => {}, + acceptsText: () => true, handlePasteEvent: mocks.handlePasteEvent, pasteFromClipboard: mocks.pasteFromClipboard, contains: (node: Node | null) => fieldRef.current?.contains(node) === true diff --git a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx index c52bf701ab0..466082fe0c7 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-composer-reveal-focus.test.tsx @@ -51,6 +51,8 @@ function Harness(props: HarnessProps): React.JSX.Element { return true }, insertTypedText: () => true, + appendText: () => {}, + acceptsText: () => true, handlePasteEvent: () => {}, pasteFromClipboard: () => {}, contains: () => false diff --git a/src/renderer/src/components/native-chat/native-chat-composer-types.ts b/src/renderer/src/components/native-chat/native-chat-composer-types.ts index 27680471290..1841e691510 100644 --- a/src/renderer/src/components/native-chat/native-chat-composer-types.ts +++ b/src/renderer/src/components/native-chat/native-chat-composer-types.ts @@ -140,6 +140,11 @@ export type NativeChatLaunchSeed = { export type NativeChatComposerHandle = { focus: () => boolean insertTypedText: (text: string) => boolean + /** Whether the input is there and enabled, so text given to it lands. */ + acceptsText: () => boolean + /** Adds text after the draft, a blank line apart, and leaves the caret at its end. Text the + * draft already ends with is not added again. */ + appendText: (text: string) => void /** Routes pane-level paste events back to the composer field. */ handlePasteEvent: (event: { clipboardData: DataTransfer | null diff --git a/src/renderer/src/components/native-chat/native-chat-paste-routing.test.tsx b/src/renderer/src/components/native-chat/native-chat-paste-routing.test.tsx index 67276c32d33..206090f7138 100644 --- a/src/renderer/src/components/native-chat/native-chat-paste-routing.test.tsx +++ b/src/renderer/src/components/native-chat/native-chat-paste-routing.test.tsx @@ -82,6 +82,8 @@ function composerHandle(): NativeChatComposerHandle & { element: HTMLElement } { return true }), insertTypedText: vi.fn(() => true), + appendText: vi.fn(), + acceptsText: () => true, handlePasteEvent: vi.fn(), pasteFromClipboard: vi.fn(), contains: (node) => element.contains(node) diff --git a/src/renderer/src/components/native-chat/native-chat-quote-selection.ts b/src/renderer/src/components/native-chat/native-chat-quote-selection.ts new file mode 100644 index 00000000000..c5e3b44b408 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-quote-selection.ts @@ -0,0 +1,60 @@ +/** Marks the prose of an agent reply; only a selection inside one of these can be quoted. */ +export const NATIVE_CHAT_QUOTE_SOURCE_PROPS = { 'data-native-chat-quote-source': '' } as const + +const QUOTE_SOURCE_SELECTOR = '[data-native-chat-quote-source]' + +/** The selection when all of it sits inside one agent reply of this chat, else null. */ +export function readNativeChatQuotableSelection( + root: HTMLElement +): { text: string; range: Range } | null { + const selection = window.getSelection() + if (!selection || selection.isCollapsed || selection.rangeCount !== 1) { + return null + } + const range = selection.getRangeAt(0) + if (!root.contains(range.commonAncestorContainer)) { + return null + } + const edges = selectedTextEdges(range) + const source = edges?.first.parentElement?.closest(QUOTE_SOURCE_SELECTOR) + const text = selection.toString().trim() + if (!edges || !source || !source.contains(edges.last) || text === '') { + return null + } + return { text, range } +} + +// Why: a triple-click ends its range at the start of the next block, often outside the reply, so +// the reply is judged by the text actually selected and not by the range's endpoints. +function selectedTextEdges(range: Range): { first: Text; last: Text } | null { + const container = range.commonAncestorContainer + if (container instanceof Text) { + return { first: container, last: container } + } + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT) + const covered: Text[] = [] + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + if (!(node instanceof Text) || !range.intersectsNode(node)) { + continue + } + const start = node === range.startContainer ? range.startOffset : 0 + const end = node === range.endContainer ? range.endOffset : node.length + if (start < end) { + covered.push(node) + } + } + const first = covered.find(isSelectableText) + const last = covered.findLast(isSelectableText) + return first && last ? { first, last } : null +} + +// Why: text that cannot be selected (a reply's timestamp) is in the range but not in the selection. +function isSelectableText(node: Text): boolean { + return node.parentElement !== null && getComputedStyle(node.parentElement).userSelect !== 'none' +} + +/** The text as a markdown blockquote, then the blank line that ends it so a reply can follow. */ +export function formatNativeChatQuote(text: string): string { + const lines = text.trim().split(/\r?\n/) + return `${lines.map((line) => (line.trim() ? `> ${line}` : '>')).join('\n')}\n\n` +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts b/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts index a21cd58e7cd..ca6d66cef9b 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-composer-handle.ts @@ -17,14 +17,15 @@ export function useNativeChatComposerHandle( args: UseNativeChatComposerHandleArgs ): (event: ClipboardEventLike) => void { const { textareaRef, draft, setDraft, setActiveSuggestion, ...pasteArgs } = args - const { insertTypedText, insertPastedText, focus, contains } = useNativeChatTypedInsertion({ - textareaRef, - caret: args.caret, - draft, - setDraft, - setCaret: args.setCaret, - setActiveSuggestion - }) + const { insertTypedText, appendText, acceptsText, insertPastedText, focus, contains } = + useNativeChatTypedInsertion({ + textareaRef, + caret: args.caret, + draft, + setDraft, + setCaret: args.setCaret, + setActiveSuggestion + }) const { handlePaste: handlePasteEvent, pasteFromClipboard } = useNativeChatComposerPaste({ ...pasteArgs, @@ -33,8 +34,24 @@ export function useNativeChatComposerHandle( useImperativeHandle( ref, - () => ({ focus, insertTypedText, handlePasteEvent, pasteFromClipboard, contains }), - [focus, insertTypedText, handlePasteEvent, pasteFromClipboard, contains] + () => ({ + focus, + insertTypedText, + acceptsText, + appendText, + handlePasteEvent, + pasteFromClipboard, + contains + }), + [ + focus, + insertTypedText, + acceptsText, + appendText, + handlePasteEvent, + pasteFromClipboard, + contains + ] ) return handlePasteEvent } diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx index a8768ab59df..8b7a0a1672f 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.test.tsx @@ -79,6 +79,8 @@ vi.mock('@/lib/image-blob-png', async (importOriginal) => ({ convertImageBlobToPng: imageCopy.convertImageBlobToPng })) +vi.mock('./NativeChatSelectionQuote', () => ({ NativeChatSelectionQuote: () => null })) + vi.mock('@/components/tab-bar/TabWorkspaceLayoutMenuSection', () => ({ TabWorkspaceLayoutMenuSection: () => 'Move Tab to Split' })) @@ -108,6 +110,7 @@ function Harness({ const rootRef = createRef() const { menu } = useNativeChatContextMenu({ rootRef, + composerRef: createRef(), onSwitchToTerminal, showTerminalPaneActions: !structured, workspaceLayout: structured ? { unifiedTabId: 'chat-tab', groupId: 'group-1' } : undefined, @@ -130,6 +133,7 @@ function ImageHarness({ const rootRef = createRef() const { menu, onContextMenuCapture, onPointerDownCapture } = useNativeChatContextMenu({ rootRef, + composerRef: createRef(), enabled, actions: { ...emptyNativeChatContextMenuActions, onPaste: vi.fn() } }) diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 0addae6f091..fcbf30d1436 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -41,6 +41,8 @@ import { TabWorkspaceLayoutMenuSection } from '@/components/tab-bar/TabWorkspace import { canMoveTabToNewPaneColumn } from '@/components/tab-bar/tab-move-to-pane-column' import { isEditableTarget } from '@/lib/editable-target' import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem' +import { NativeChatSelectionQuote } from './NativeChatSelectionQuote' +import type { NativeChatComposerHandle } from './native-chat-composer-types' import type { TabSplitDirection } from '@/store/slices/tabs' type NativeChatContextMenuState = { @@ -53,6 +55,8 @@ type NativeChatContextMenuState = { type UseNativeChatContextMenuArgs = { rootRef: RefObject + /** Where a selection from an agent's reply is quoted. */ + composerRef: RefObject enabled?: boolean /** Bridge-only escape hatch; structured sessions have no terminal view. */ onSwitchToTerminal?: () => void @@ -112,6 +116,7 @@ export const emptyNativeChatContextMenuActions: Omit + {/* Mounted here as the menu root adds no DOM; it steps aside while the menu is open. */} +