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:
Kelvin Amoaba
2026-10-07 20:08:08 -07:00
committed by GitHub
co-authored by Brennan Benson
parent 8fdad2a3af
commit 4f4f021f8a
23 changed files with 492 additions and 35 deletions
@@ -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.
@@ -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),
+1
View File
@@ -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…",
+1
View File
@@ -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…",
+1
View File
@@ -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…",
+1
View File
@@ -18324,6 +18324,7 @@
"estimated": "最後の応答から推定。"
},
"dismissDeliveryNotice": "閉じる",
"addSelectionToChat": "チャットに追加",
"messageNotSent": "メッセージは送信されませんでした",
"deliveryUnconfirmed": "配信を確認できません — 再試行する前にチャットを確認してください",
"messageSending": "送信中…",
+1
View File
@@ -18324,6 +18324,7 @@
"estimated": "마지막 응답 기준 추정치입니다."
},
"dismissDeliveryNotice": "닫기",
"addSelectionToChat": "채팅에 추가",
"messageNotSent": "메시지가 전송되지 않음",
"deliveryUnconfirmed": "전달 확인 안 됨 — 다시 시도하기 전에 채팅을 확인하세요",
"messageSending": "전송 중…",
+1
View File
@@ -18289,6 +18289,7 @@
"estimated": "根据上一次响应的估算值。"
},
"dismissDeliveryNotice": "关闭",
"addSelectionToChat": "添加到聊天",
"messageNotSent": "消息未发送",
"deliveryUnconfirmed": "无法确认送达 — 重试前请检查聊天",
"messageSending": "正在发送…",