mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
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>
This commit is contained in:
co-authored by
Brennan Benson
parent
8fdad2a3af
commit
4f4f021f8a
+2
@@ -309,6 +309,8 @@ describe('reader navigation', () => {
|
||||
const composer = {
|
||||
focus,
|
||||
insertTypedText,
|
||||
acceptsText: () => true,
|
||||
appendText: vi.fn(),
|
||||
handlePasteEvent: vi.fn(),
|
||||
pasteFromClipboard: vi.fn(),
|
||||
contains: () => false
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
@@ -51,6 +51,8 @@ vi.mock('./NativeChatComposer', async () => {
|
||||
return true
|
||||
},
|
||||
insertTypedText: composer.typed,
|
||||
acceptsText: () => true,
|
||||
appendText: () => {},
|
||||
handlePasteEvent: () => {},
|
||||
pasteFromClipboard: () => {},
|
||||
contains: () => false
|
||||
|
||||
@@ -132,13 +132,10 @@ export function NativeChatResolvedView({
|
||||
// replaces the composer.
|
||||
const questionAnswerInputRef = useRef<HTMLInputElement>(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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<HTMLDivElement>(null)
|
||||
return (
|
||||
<div ref={rootRef}>
|
||||
<p data-testid="user">my own prompt</p>
|
||||
<div {...NATIVE_CHAT_QUOTE_SOURCE_PROPS}>
|
||||
<pre data-testid="first">{'Use the draft store.\n\nIt already appends.'}</pre>
|
||||
</div>
|
||||
<time data-testid="time" style={{ userSelect: 'none' }}>
|
||||
10:42
|
||||
</time>
|
||||
<p data-testid="next">a tool line</p>
|
||||
<NativeChatSelectionQuote rootRef={rootRef} composerRef={{ current: composer }} enabled />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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(<Chat />)
|
||||
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(<Chat />)
|
||||
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(<Chat />)
|
||||
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(<Chat />)
|
||||
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(<Chat />)
|
||||
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(<Chat />)
|
||||
releaseSelection(screen.getByTestId('first'))
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Add to chat' })).toBeNull()
|
||||
})
|
||||
|
||||
it('withdraws the offer once the selection is gone', () => {
|
||||
render(<Chat />)
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLElement | null>
|
||||
composerRef: RefObject<NativeChatComposerHandle | null>
|
||||
enabled: boolean
|
||||
}): React.JSX.Element {
|
||||
const [request, setRequest] = useState<QuoteRequest | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(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 (
|
||||
<Popover open={request !== null} onOpenChange={(open) => !open && setRequest(null)}>
|
||||
<PopoverAnchor virtualRef={virtualRef} />
|
||||
{request ? (
|
||||
<PopoverContent
|
||||
ref={contentRef}
|
||||
align="start"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
collisionPadding={8}
|
||||
hideWhenDetached
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
// Keeps the selection, and the focus, where they are until the quote is taken.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
>
|
||||
<div className="p-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
// Taken or not (the composer may have locked since), the offer is spent.
|
||||
composerRef.current?.appendText(formatNativeChatQuote(request.text))
|
||||
setRequest(null)
|
||||
}}
|
||||
>
|
||||
<MessageSquarePlus />
|
||||
{translate('components.native-chat.addSelectionToChat', 'Add to chat')}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
) : null}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -51,6 +51,8 @@ function Harness(props: HarnessProps): React.JSX.Element {
|
||||
return true
|
||||
},
|
||||
insertTypedText: () => true,
|
||||
appendText: () => {},
|
||||
acceptsText: () => true,
|
||||
handlePasteEvent: () => {},
|
||||
pasteFromClipboard: () => {},
|
||||
contains: () => false
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>()
|
||||
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<HTMLDivElement>()
|
||||
const { menu, onContextMenuCapture, onPointerDownCapture } = useNativeChatContextMenu({
|
||||
rootRef,
|
||||
composerRef: createRef(),
|
||||
enabled,
|
||||
actions: { ...emptyNativeChatContextMenuActions, onPaste: vi.fn() }
|
||||
})
|
||||
|
||||
@@ -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<HTMLElement | null>
|
||||
/** Where a selection from an agent's reply is quoted. */
|
||||
composerRef: RefObject<NativeChatComposerHandle | null>
|
||||
enabled?: boolean
|
||||
/** Bridge-only escape hatch; structured sessions have no terminal view. */
|
||||
onSwitchToTerminal?: () => void
|
||||
@@ -112,6 +116,7 @@ export const emptyNativeChatContextMenuActions: Omit<NativeChatContextMenuAction
|
||||
|
||||
export function useNativeChatContextMenu({
|
||||
rootRef,
|
||||
composerRef,
|
||||
enabled = true,
|
||||
onSwitchToTerminal,
|
||||
actions,
|
||||
@@ -184,6 +189,12 @@ export function useNativeChatContextMenu({
|
||||
onPointerDownCapture: keepSelectionThroughMenuPress,
|
||||
menu: (
|
||||
<DropdownMenu open={enabled && state.open && hasItems} onOpenChange={setOpen} modal={false}>
|
||||
{/* Mounted here as the menu root adds no DOM; it steps aside while the menu is open. */}
|
||||
<NativeChatSelectionQuote
|
||||
rootRef={rootRef}
|
||||
composerRef={composerRef}
|
||||
enabled={enabled && !(state.open && hasItems)}
|
||||
/>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-hidden
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type NativeChatComposerInput
|
||||
} from './native-chat-composer-input'
|
||||
import { useCallback, type Dispatch, type RefObject, type SetStateAction } from 'react'
|
||||
import { appendReturnedDraftText } from '../../../../shared/returned-draft-text'
|
||||
|
||||
/** Imperative text insertion and focus for the composer textarea, used by the
|
||||
* paste pipeline and the composer's imperative handle. */
|
||||
@@ -15,22 +16,21 @@ export function useNativeChatTypedInsertion(args: {
|
||||
setActiveSuggestion: Dispatch<SetStateAction<number>>
|
||||
}): {
|
||||
insertTypedText: (text: string) => boolean
|
||||
appendText: (text: string) => void
|
||||
acceptsText: () => boolean
|
||||
insertPastedText: (text: string) => boolean
|
||||
focus: () => boolean
|
||||
contains: (node: Node | null) => boolean
|
||||
} {
|
||||
const { textareaRef, caret, draft, setDraft, setCaret, setActiveSuggestion } = args
|
||||
|
||||
const insertTypedText = useCallback(
|
||||
(text: string): boolean => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea || textarea.disabled) {
|
||||
return false
|
||||
}
|
||||
const selectionStart = textarea.selectionStart ?? caret
|
||||
const selectionEnd = textarea.selectionEnd ?? selectionStart
|
||||
const next = `${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}`
|
||||
const nextCaret = selectionStart + text.length
|
||||
const usableInput = useCallback((): NativeChatComposerInput | null => {
|
||||
const textarea = textareaRef.current
|
||||
return textarea && !textarea.disabled ? textarea : null
|
||||
}, [textareaRef])
|
||||
|
||||
const showDraft = useCallback(
|
||||
(textarea: NativeChatComposerInput, next: string, nextCaret: number): void => {
|
||||
textarea.focus()
|
||||
setDraft(next)
|
||||
setCaret(nextCaret)
|
||||
@@ -38,9 +38,37 @@ export function useNativeChatTypedInsertion(args: {
|
||||
requestAnimationFrame(() => {
|
||||
textarea.setSelectionRange(nextCaret, nextCaret)
|
||||
})
|
||||
},
|
||||
[setActiveSuggestion, setCaret, setDraft]
|
||||
)
|
||||
|
||||
const insertTypedText = useCallback(
|
||||
(text: string): boolean => {
|
||||
const textarea = usableInput()
|
||||
if (!textarea) {
|
||||
return false
|
||||
}
|
||||
const selectionStart = textarea.selectionStart ?? caret
|
||||
const selectionEnd = textarea.selectionEnd ?? selectionStart
|
||||
showDraft(
|
||||
textarea,
|
||||
`${draft.slice(0, selectionStart)}${text}${draft.slice(selectionEnd)}`,
|
||||
selectionStart + text.length
|
||||
)
|
||||
return true
|
||||
},
|
||||
[caret, draft, setActiveSuggestion, setCaret, setDraft, textareaRef]
|
||||
[caret, draft, showDraft, usableInput]
|
||||
)
|
||||
|
||||
const appendText = useCallback(
|
||||
(text: string): void => {
|
||||
const textarea = usableInput()
|
||||
if (textarea) {
|
||||
const next = appendReturnedDraftText(draft, text)
|
||||
showDraft(textarea, next, next.length)
|
||||
}
|
||||
},
|
||||
[draft, showDraft, usableInput]
|
||||
)
|
||||
|
||||
// Reads the live input when a delayed clipboard read settles.
|
||||
@@ -49,19 +77,18 @@ export function useNativeChatTypedInsertion(args: {
|
||||
[textareaRef]
|
||||
)
|
||||
|
||||
const acceptsText = useCallback((): boolean => usableInput() !== null, [usableInput])
|
||||
|
||||
const focus = useCallback((): boolean => {
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea || textarea.disabled) {
|
||||
return false
|
||||
}
|
||||
textarea.focus()
|
||||
return true
|
||||
}, [textareaRef])
|
||||
const textarea = usableInput()
|
||||
textarea?.focus()
|
||||
return textarea !== null
|
||||
}, [usableInput])
|
||||
|
||||
const contains = useCallback(
|
||||
(node: Node | null): boolean => textareaRef.current?.contains?.(node) === true,
|
||||
[textareaRef]
|
||||
)
|
||||
|
||||
return { insertTypedText, insertPastedText, focus, contains }
|
||||
return { insertTypedText, appendText, acceptsText, insertPastedText, focus, contains }
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export function useStructuredNativeChatPaneCommands({
|
||||
},
|
||||
enabled: isVisible,
|
||||
resolveOrcaSessionId,
|
||||
composerRef,
|
||||
showTerminalPaneActions: terminalPaneActions !== undefined,
|
||||
splitShortcutLabels: {
|
||||
right: formatShortcutLabel('terminal.splitRight', keybindings),
|
||||
|
||||
@@ -18545,6 +18545,7 @@
|
||||
"orcaSessionIdTooltip": "Orca's ID for this chat, separate from the agent CLI's own session ID. Agents use it to refer to each other through Orca."
|
||||
},
|
||||
"dismissDeliveryNotice": "Dismiss",
|
||||
"addSelectionToChat": "Add to chat",
|
||||
"messageNotSent": "Message not sent",
|
||||
"deliveryUnconfirmed": "Delivery unconfirmed — check chat before retrying",
|
||||
"messageSending": "Sending…",
|
||||
|
||||
@@ -18132,6 +18132,7 @@
|
||||
"estimated": "Estimado a partir de la última respuesta."
|
||||
},
|
||||
"dismissDeliveryNotice": "Descartar",
|
||||
"addSelectionToChat": "Añadir al chat",
|
||||
"messageNotSent": "Mensaje no enviado",
|
||||
"deliveryUnconfirmed": "Entrega sin confirmar — revisa el chat antes de reintentar",
|
||||
"messageSending": "Enviando…",
|
||||
|
||||
@@ -18390,6 +18390,7 @@
|
||||
"estimated": "Estimation basée sur la dernière réponse."
|
||||
},
|
||||
"dismissDeliveryNotice": "Ignorer",
|
||||
"addSelectionToChat": "Ajouter au chat",
|
||||
"messageNotSent": "Message non envoyé",
|
||||
"deliveryUnconfirmed": "Livraison non confirmée — vérifiez le chat avant de réessayer",
|
||||
"messageSending": "Envoi…",
|
||||
|
||||
@@ -18324,6 +18324,7 @@
|
||||
"estimated": "最後の応答から推定。"
|
||||
},
|
||||
"dismissDeliveryNotice": "閉じる",
|
||||
"addSelectionToChat": "チャットに追加",
|
||||
"messageNotSent": "メッセージは送信されませんでした",
|
||||
"deliveryUnconfirmed": "配信を確認できません — 再試行する前にチャットを確認してください",
|
||||
"messageSending": "送信中…",
|
||||
|
||||
@@ -18324,6 +18324,7 @@
|
||||
"estimated": "마지막 응답 기준 추정치입니다."
|
||||
},
|
||||
"dismissDeliveryNotice": "닫기",
|
||||
"addSelectionToChat": "채팅에 추가",
|
||||
"messageNotSent": "메시지가 전송되지 않음",
|
||||
"deliveryUnconfirmed": "전달 확인 안 됨 — 다시 시도하기 전에 채팅을 확인하세요",
|
||||
"messageSending": "전송 중…",
|
||||
|
||||
@@ -18289,6 +18289,7 @@
|
||||
"estimated": "根据上一次响应的估算值。"
|
||||
},
|
||||
"dismissDeliveryNotice": "关闭",
|
||||
"addSelectionToChat": "添加到聊天",
|
||||
"messageNotSent": "消息未发送",
|
||||
"deliveryUnconfirmed": "无法确认送达 — 重试前请检查聊天",
|
||||
"messageSending": "正在发送…",
|
||||
|
||||
Reference in New Issue
Block a user