diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index a426706323..5bac5bb821 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -217,6 +217,7 @@ import { replaceLeadingSlashCommandWithSkillToken, } from '../utils/skillPromptReference'; import { resolveChatInputQuickSkillShortcuts } from '../utils/chatInputQuickSkills'; +import { contextPickerOwnsKey } from '../utils/chatInputKeyOwnership'; import { useDeepReviewConsent } from './DeepReviewConsentDialog'; import { useSessionReviewActivity } from '../hooks/useSessionReviewActivity'; import { shouldBlockReviewCommand } from '../utils/deepReviewCommandGuard'; @@ -5619,6 +5620,12 @@ export const ChatInput: React.FC = ({ return; } + // The '@' reference picker owns its navigation and acceptance keys through + // its overlay layer, which the coordinator routes after React handlers. + if (contextPickerOwnsKey({ contextPickerActive: contextTriggerState.isActive, key: e.key })) { + return; + } + if (slashCommandState.isActive) { const items = getActiveSlashPickerItems(); const maxIndex = Math.max(0, items.length - 1); @@ -5813,7 +5820,7 @@ export const ChatInput: React.FC = ({ handleSendOrCancel(); } - }, [canUseThreadGoal, handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, slashCommandState, getActiveSlashPickerItems, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, getRichTextTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, hasSendableInput, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); + }, [canUseThreadGoal, handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, dispatchInput, slashCommandState, contextTriggerState.isActive, getActiveSlashPickerItems, selectSlashCommandAction, selectSlashExternalPromptCommand, selectSlashPromptCommand, selectSlashAcpCommand, selectSlashSkill, getRichTextTriggerController, historyIndex, inputHistory, savedDraft, inputState.value, hasSendableInput, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, removeContext, t]); const handleImeCompositionStart = useCallback(() => { isImeComposingRef.current = true; diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.scss b/src/web-ui/src/flow_chat/components/RichTextInput.scss index 3047d24b39..38a1e1a365 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.scss +++ b/src/web-ui/src/flow_chat/components/RichTextInput.scss @@ -153,6 +153,10 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + // Centering the label line box leaves the lowercase text body about 1px below + // the optically centered badge icon and dismiss glyph. Lift the label so all + // three read as one row. + transform: translateY(-1px); } .rich-text-tag-pill__badge { diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx index ea1d15c976..33bd060286 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.test.tsx @@ -138,19 +138,38 @@ describeWithJsdom('RichTextInput external sync', () => { function paste( editor: HTMLDivElement, - options: { items?: Array<{ kind: string; type: string; getAsFile: () => File | null }>; types?: string[]; text?: string }, + options: { + items?: Array<{ kind: string; type: string; getAsFile: () => File | null }>; + types?: string[]; + text?: string; + html?: string; + }, ) { const event = new window.Event('paste', { bubbles: true, cancelable: true }); Object.defineProperty(event, 'clipboardData', { value: { items: options.items ?? [], types: options.types ?? [], - getData: (type: string) => type === 'text/plain' ? options.text ?? '' : '', + getData: (type: string) => { + if (type === 'text/plain') return options.text ?? ''; + if (type === 'text/html') return options.html ?? ''; + return ''; + }, }, }); editor.dispatchEvent(event); } + function copy(editor: HTMLDivElement) { + const clipboard = new Map(); + const event = new window.Event('copy', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { + value: { setData: (type: string, value: string) => clipboard.set(type, value) }, + }); + editor.dispatchEvent(event); + return clipboard; + } + function setCaret(editor: HTMLDivElement, offset: number) { const selection = window.getSelection(); const range = document.createRange(); @@ -263,6 +282,89 @@ describeWithJsdom('RichTextInput external sync', () => { expect(editor.querySelector('[data-large-paste-placeholder]')).toBeTruthy(); }); + it('rebuilds capsules from pasted inline token text', async () => { + const onChange = vi.fn(); + await act(async () => { + root.render( + {}} + />, + ); + }); + const editor = container.querySelector('.rich-text-input') as HTMLDivElement; + setCaret(editor, 0); + + paste(editor, { types: ['text/plain'], text: 'run [$pdf] and [$doc] please' }); + + const pills = Array.from( + editor.querySelectorAll('[data-inline-token-type="skill-ref"]'), + ); + expect(pills.map(pill => pill.dataset.tagFormat)).toEqual(['[$pdf]', '[$doc]']); + expect(editor.textContent).toBe('run pdf× and doc× please'); + expect(onChange).toHaveBeenLastCalledWith('run [$pdf] and [$doc] please', emptyContexts); + }); + + it('restores capsules from the composer clipboard payload of a copied message', async () => { + const onChange = vi.fn(); + await act(async () => { + root.render( + {}} + />, + ); + }); + const editor = container.querySelector('.rich-text-input') as HTMLDivElement; + setCaret(editor, 0); + + paste(editor, { + types: ['text/plain', 'text/html'], + text: '[Skill: pdf] summarize it', + html: '
' + + '[Skill: pdf] summarize it
', + }); + + expect(editor.querySelector('[data-inline-token-type="skill-ref"]')?.dataset.tagFormat) + .toBe('[$pdf]'); + expect(onChange).toHaveBeenLastCalledWith('[$pdf] summarize it', emptyContexts); + }); + + it('copies a selection as composer token text with a marked payload', async () => { + const inputRef = createRef(); + await act(async () => { + root.render( + {}} + contexts={emptyContexts} + onRemoveContext={() => {}} + />, + ); + }); + const editor = inputRef.current!; + + const selection = window.getSelection()!; + const range = document.createRange(); + range.selectNodeContents(editor); + selection.removeAllRanges(); + selection.addRange(range); + + const clipboard = copy(editor); + + expect(clipboard.get('text/plain')).toBe('compare [$pdf] with [$doc]'); + const html = clipboard.get('text/html') ?? ''; + expect(html).toContain('data-openbitfun-composer-clipboard="1"'); + expect(html).toContain('data-openbitfun-composer-clipboard-tokens="compare [$pdf] with [$doc]"'); + expect(html).not.toContain('rich-text-tag-pill__remove'); + }); + it('keeps the existing DOM node when parent echoes local input', async () => { const harnessRef = createRef(); const editor = await renderHarness(harnessRef); diff --git a/src/web-ui/src/flow_chat/components/RichTextInput.tsx b/src/web-ui/src/flow_chat/components/RichTextInput.tsx index cd62996029..752ec2b181 100644 --- a/src/web-ui/src/flow_chat/components/RichTextInput.tsx +++ b/src/web-ui/src/flow_chat/components/RichTextInput.tsx @@ -29,6 +29,11 @@ import { type ComposerPresentation, type ComposerPresentationSegment, } from '../utils/composerPresentation'; +import { + getComposerInlineTokenMatches, + readComposerClipboardTokens, + writeComposerClipboardData, +} from '../utils/composerClipboard'; import './RichTextInput.scss'; const SKILL_REFERENCE_BADGE_ICON = renderToStaticMarkup( @@ -137,6 +142,43 @@ function trimEdgeLineBreaks(text: string): string { return text.replace(/^[\r\n]+/, '').replace(/[\r\n]+$/, ''); } +/** + * Serializes editor-shaped DOM back into composer token text. Capsules carry + * their canonical token in `data-tag-format`, so reading it keeps copy and + * paste lossless instead of leaking label text and remove buttons. + */ +function readComposerDomText(root: Node): string { + let text = ''; + const traverse = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + text += node.textContent || ''; + return; + } + if (node.nodeType !== Node.ELEMENT_NODE) { + return; + } + + const element = node as HTMLElement; + const isBlock = element.tagName === 'DIV' || element.tagName === 'P'; + if (isBlock && text.length > 0 && !text.endsWith('\n')) { + text += '\n'; + } + + if (element.hasAttribute('data-tag-format')) { + text += element.getAttribute('data-tag-format') || ''; + return; + } + if (element.tagName === 'BR') { + text += '\n'; + return; + } + node.childNodes.forEach(traverse); + }; + + root.childNodes.forEach(traverse); + return text; +} + function getContextDisplayName(context: ContextItem): string { switch (context.type) { case 'file': return context.fileName; @@ -831,35 +873,8 @@ export const RichTextInput = React.forwardRef { if (!internalRef.current) return ''; - - let text = ''; - const traverse = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - text += node.textContent || ''; - } else if (node.nodeType === Node.ELEMENT_NODE) { - const element = node as HTMLElement; - - const isBlock = element.tagName === 'DIV' || element.tagName === 'P'; - if (isBlock && text.length > 0 && !text.endsWith('\n')) { - text += '\n'; - } - - // For tag elements, use the stored full format with # prefix - if (element.hasAttribute('data-tag-format')) { - const tagFormat = element.getAttribute('data-tag-format'); - if (tagFormat) { - text += tagFormat; - } - } else if (element.tagName === 'BR') { - text += '\n'; - } else { - node.childNodes.forEach(traverse); - } - } - }; - - internalRef.current.childNodes.forEach(traverse); - const sanitizedText = sanitizeText(text); + + const sanitizedText = sanitizeText(readComposerDomText(internalRef.current)); const extractedText = sanitizedText.startsWith('/') ? trimEdgeLineBreaks(sanitizedText) : sanitizedText.trim(); @@ -1094,6 +1109,66 @@ export const RichTextInput = React.forwardRef { + const editor = internalRef.current; + const matches = getComposerInlineTokenMatches(text); + if (!editor || matches.length === 0) { + return false; + } + + const selection = window.getSelection(); + const selectedRange = selection?.rangeCount ? selection.getRangeAt(0) : null; + const range = selectedRange && editor.contains(selectedRange.commonAncestorContainer) + ? selectedRange + : (() => { + const fallback = document.createRange(); + fallback.selectNodeContents(editor); + fallback.collapse(false); + return fallback; + })(); + range.deleteContents(); + + const fragment = document.createDocumentFragment(); + const appendText = (value: string) => { + value.split('\n').forEach((line, index) => { + if (index > 0) fragment.appendChild(document.createElement('br')); + if (line) fragment.appendChild(document.createTextNode(line)); + }); + }; + + let cursor = 0; + for (const match of matches) { + if (match.start < cursor) continue; + if (match.start > cursor) appendText(text.slice(cursor, match.start)); + const tokenElement = createInlineTokenElement(match.token); + if (tokenElement) { + fragment.appendChild(tokenElement); + } else { + appendText(match.token); + } + cursor = match.end; + } + if (cursor < text.length) appendText(text.slice(cursor)); + + const lastInserted = fragment.lastChild; + range.insertNode(fragment); + if (selection && lastInserted) { + const caretRange = document.createRange(); + caretRange.setStartAfter(lastInserted); + caretRange.collapse(true); + selection.removeAllRanges(); + selection.addRange(caretRange); + } + + handleInput(); + return true; + }, [createInlineTokenElement, handleInput, internalRef]); + const handlePaste = useCallback((e: React.ClipboardEvent) => { e.preventDefault(); @@ -1128,7 +1203,10 @@ export const RichTextInput = React.forwardRef { isComposingRef.current = false; }); - }, [closeContextPicker, closeInlineTrigger, createLargePasteElement, handleInput, internalRef, onLargePaste, onPasteFiles]); + }, [closeContextPicker, closeInlineTrigger, createLargePasteElement, handleInput, insertTextWithInlineTokens, internalRef, onLargePaste, onPasteFiles]); + + /** + * Copies the selection as composer token text, so capsules keep their + * canonical form instead of exposing their label and remove button. The + * matching HTML flavor marks the payload for an in-app paste. + */ + const handleCopy = useCallback((e: React.ClipboardEvent) => { + const editor = internalRef.current; + const selection = window.getSelection(); + if (!editor || !selection || selection.isCollapsed || selection.rangeCount === 0) { + return; + } + + const range = selection.getRangeAt(0); + if (!editor.contains(range.commonAncestorContainer)) { + return; + } + + const body = document.createElement('div'); + body.appendChild(range.cloneContents()); + body.querySelectorAll('[data-openbitfun-part="tagRemove"]').forEach(node => node.remove()); + + const sanitizedText = sanitizeText(readComposerDomText(body)); + const tokens = sanitizedText.startsWith('/') + ? trimEdgeLineBreaks(sanitizedText) + : sanitizedText.trim(); + if (!tokens) { + return; + } + + if (writeComposerClipboardData(e.clipboardData, { text: tokens, tokens, body })) { + e.preventDefault(); + } + }, [internalRef]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const nativeEvent = e.nativeEvent as KeyboardEvent; @@ -1634,6 +1746,7 @@ export const RichTextInput = React.forwardRef { expect(content?.querySelectorAll('.user-message-item__reference')).toHaveLength(2); }); + it('copies a message with the readable text and a restorable token payload', async () => { + const writeText = vi.fn(async () => {}); + const write = vi.fn(async (_items: unknown[]) => {}); + vi.stubGlobal('navigator', { clipboard: { writeText, write } }); + vi.stubGlobal('ClipboardItem', class { + constructor(readonly items: Record) {} + }); + + act(() => { + root.render( + + + , + ); + }); + + const copyButton = container.querySelector('.user-message-item__copy-btn')!; + await act(async () => { + copyButton.click(); + }); + + expect(write).toHaveBeenCalledTimes(1); + const item = write.mock.calls[0][0][0] as { items: Record }; + expect(await item.items['text/plain'].text()).toBe('[Skill: pdf] summarize it'); + const html = await item.items['text/html'].text(); + expect(html).toContain('data-openbitfun-composer-clipboard-tokens="[$pdf] summarize it"'); + }); + it('restores persisted references and images from a failed message to the input', () => { const composerPresentation = { version: 1, diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index cd22235689..7c37940c0c 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -47,6 +47,7 @@ import { resolveSessionDriverId } from '../../session-drivers/resolve'; import { absoluteSessionTurnIndexForId } from '../../utils/flowChatTurnOrdinal'; import { composerPresentationToAccessibleText, + composerPresentationToClipboardText, composerPresentationContexts, composerPresentationSessionReferences, composerPresentationToEditorText, @@ -56,6 +57,7 @@ import { type ComposerPresentation, } from '../../utils/composerPresentation'; import { restoreImageContextsFromPayload } from '../../utils/imageContextRestoration'; +import { writeComposerClipboardPayload } from '../../utils/composerClipboard'; import { buildImagePayload } from '../../utils/imagePayload'; import { UserMessagePresentationContent, UserMessageTextContent } from './UserMessagePresentationContent'; import { UserMessageImage } from './UserMessageImage'; @@ -284,6 +286,12 @@ export const UserMessageItem = React.memo( const copyText = composerPresentation ? composerPresentationToAccessibleText(composerPresentation) : messageContent; + // The readable value stays in text/plain while the canonical token text + // rides along in the HTML flavor, so pasting a copied message back into the + // composer rebuilds its capsules instead of leaving their source text. + const copyTokens = composerPresentation + ? composerPresentationToClipboardText(composerPresentation) + : messageContent; // Check whether content overflows. Uses the shared ResizeObserver instead // of a per-message window resize listener: observer callbacks run after @@ -316,13 +324,13 @@ export const UserMessageItem = React.memo( const handleCopy = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); // Prevent toggle via bubbling. try { - await navigator.clipboard.writeText(copyText); + await writeComposerClipboardPayload({ text: copyText, tokens: copyTokens }); setCopied(true); setTimeout(() => setCopied(false), 2000); } catch (error) { log.error('Failed to copy', error); } - }, [copyText]); + }, [copyText, copyTokens]); const handleRollback = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); diff --git a/src/web-ui/src/flow_chat/utils/chatInputKeyOwnership.test.ts b/src/web-ui/src/flow_chat/utils/chatInputKeyOwnership.test.ts new file mode 100644 index 0000000000..27c7408b28 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/chatInputKeyOwnership.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { contextPickerOwnsKey } from './chatInputKeyOwnership'; + +describe('context picker key ownership', () => { + it('releases navigation and acceptance keys so the open picker handles them', () => { + for (const key of ['ArrowUp', 'ArrowDown', 'Enter', 'Tab']) { + expect(contextPickerOwnsKey({ contextPickerActive: true, key })).toBe(true); + } + }); + + it('keeps history navigation and send while the picker is closed', () => { + for (const key of ['ArrowUp', 'ArrowDown', 'Enter', 'Tab']) { + expect(contextPickerOwnsKey({ contextPickerActive: false, key })).toBe(false); + } + }); + + it('leaves typing and caret keys with the editor', () => { + for (const key of ['a', 'Backspace', 'ArrowLeft', 'ArrowRight', 'Escape']) { + expect(contextPickerOwnsKey({ contextPickerActive: true, key })).toBe(false); + } + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/chatInputKeyOwnership.ts b/src/web-ui/src/flow_chat/utils/chatInputKeyOwnership.ts new file mode 100644 index 0000000000..e88888cbbd --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/chatInputKeyOwnership.ts @@ -0,0 +1,22 @@ +/** + * Keys the '@' reference picker consumes while it is open. + */ +const CONTEXT_PICKER_OWNED_KEYS: ReadonlySet = new Set([ + 'ArrowUp', + 'ArrowDown', + 'Enter', + 'Tab', +]); + +/** + * The '@' reference picker is an overlay layer, so the overlay coordinator routes + * its keyboard from the document after React handlers. The composer must release + * every key the picker consumes instead of relying on event order; otherwise the + * picker loses ArrowDown to history navigation and Enter to send. + */ +export function contextPickerOwnsKey(params: { + contextPickerActive: boolean; + key: string; +}): boolean { + return params.contextPickerActive && CONTEXT_PICKER_OWNED_KEYS.has(params.key); +} diff --git a/src/web-ui/src/flow_chat/utils/composerClipboard.test.ts b/src/web-ui/src/flow_chat/utils/composerClipboard.test.ts new file mode 100644 index 0000000000..179a322d12 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/composerClipboard.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMcpPromptReference } from './mcpPromptReference'; +import { + getComposerInlineTokenMatches, + readComposerClipboardTokens, + writeComposerClipboardData, + writeComposerClipboardPayload, +} from './composerClipboard'; + +let JSDOMCtor: (new ( + html?: string, + options?: { pretendToBeVisual?: boolean } +) => { window: Window & typeof globalThis }) | null = null; + +try { + const jsdom = await import('jsdom'); + JSDOMCtor = jsdom.JSDOM as typeof JSDOMCtor; +} catch { + JSDOMCtor = null; +} + +const describeWithJsdom = JSDOMCtor ? describe : describe.skip; + +describeWithJsdom('composer clipboard payload', () => { + beforeEach(() => { + const dom = new JSDOMCtor!(''); + vi.stubGlobal('document', dom.window.document); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('matches every inline token family in text order', () => { + const mcp = createMcpPromptReference({ serverName: 'Docs', serverId: 'docs' }); + const text = `[$pdf] ${mcp} [[openbitfun-additional-mode:review]] plain`; + + expect(getComposerInlineTokenMatches(text).map(match => match.token)).toEqual([ + '[$pdf]', + mcp, + '[[openbitfun-additional-mode:review]]', + ]); + expect(getComposerInlineTokenMatches('plain text')).toEqual([]); + }); + + it('keeps the readable value and the canonical tokens in separate flavors', () => { + const clipboard = new Map(); + const clipboardData = { + setData: (type: string, value: string) => clipboard.set(type, value), + } as unknown as DataTransfer; + + expect(writeComposerClipboardData(clipboardData, { + text: '[Skill: pdf] summarize it', + tokens: '[$pdf] summarize it', + })).toBe(true); + + expect(clipboard.get('text/plain')).toBe('[Skill: pdf] summarize it'); + expect(readComposerClipboardTokens(clipboard.get('text/html') ?? '')) + .toBe('[$pdf] summarize it'); + }); + + it('ignores foreign or unmarked html', () => { + expect(readComposerClipboardTokens('')).toBeNull(); + expect(readComposerClipboardTokens('

[$pdf]

')).toBeNull(); + }); + + it('falls back to plain text when the rich clipboard is unavailable', async () => { + const writeText = vi.fn(async () => {}); + vi.stubGlobal('navigator', { clipboard: { writeText } }); + + await writeComposerClipboardPayload({ text: '[Skill: pdf]', tokens: '[$pdf]' }); + + expect(writeText).toHaveBeenCalledWith('[Skill: pdf]'); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/composerClipboard.ts b/src/web-ui/src/flow_chat/utils/composerClipboard.ts new file mode 100644 index 0000000000..898fc1b9cc --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/composerClipboard.ts @@ -0,0 +1,111 @@ +import { getAdditionalModePromptReferenceMatches } from './additionalModePromptReference'; +import { getMcpPromptReferenceMatches } from './mcpPromptReference'; +import { getSkillPromptReferenceMatches } from './skillPromptReference'; +import { getWidgetPromptReferenceMatches } from '@/tools/generative-widget/widgetPromptReference'; + +/** + * Composer clipboard payload. + * + * The user-visible flavor stays human readable, while the matching text/html + * flavor carries the canonical editor text in an attribute. An in-app paste + * rebuilds inline capsules (skill, widget, additional mode, MCP) from that + * payload instead of leaving their source text behind. + */ + +const CLIPBOARD_MARKER_ATTRIBUTE = 'data-openbitfun-composer-clipboard'; +const CLIPBOARD_TOKEN_ATTRIBUTE = 'data-openbitfun-composer-clipboard-tokens'; +const CLIPBOARD_PAYLOAD_VERSION = '1'; + +export interface ComposerInlineTokenMatch { + token: string; + start: number; + end: number; +} + +export interface ComposerClipboardPayload { + /** Human-readable value read by other applications. */ + text: string; + /** Canonical editor text used to rebuild capsules on an in-app paste. */ + tokens: string; + /** Optional readable body for rich-text consumers. */ + body?: Node; +} + +/** Inline token families the editor can render back into capsules. */ +export function getComposerInlineTokenMatches(text: string): ComposerInlineTokenMatch[] { + return [ + ...getWidgetPromptReferenceMatches(text), + ...getSkillPromptReferenceMatches(text), + ...getMcpPromptReferenceMatches(text), + ...getAdditionalModePromptReferenceMatches(text), + ] + .map(match => ({ token: match.token, start: match.start, end: match.end })) + .sort((a, b) => a.start - b.start || b.end - a.end); +} + +function buildPayloadHtml(payload: ComposerClipboardPayload): string { + const container = document.createElement('div'); + container.setAttribute(CLIPBOARD_MARKER_ATTRIBUTE, CLIPBOARD_PAYLOAD_VERSION); + container.setAttribute(CLIPBOARD_TOKEN_ATTRIBUTE, payload.tokens); + if (payload.body) { + container.appendChild(payload.body); + return container.outerHTML; + } + + // Keep the readable line structure for rich-text consumers. + payload.text.split('\n').forEach((line, index) => { + if (index > 0) container.appendChild(document.createElement('br')); + container.appendChild(document.createTextNode(line)); + }); + return container.outerHTML; +} + +/** + * Reads the canonical text of an in-app payload. Foreign HTML is ignored and + * never inserted into the editor. + */ +export function readComposerClipboardTokens(html: string): string | null { + if (!html || !html.includes(CLIPBOARD_MARKER_ATTRIBUTE)) { + return null; + } + + const template = document.createElement('template'); + template.innerHTML = html; + const payload = template.content.querySelector(`[${CLIPBOARD_MARKER_ATTRIBUTE}]`); + return payload?.getAttribute(CLIPBOARD_TOKEN_ATTRIBUTE) ?? null; +} + +/** Writes both clipboard flavors from a synchronous copy event. */ +export function writeComposerClipboardData( + clipboardData: DataTransfer, + payload: ComposerClipboardPayload, +): boolean { + try { + clipboardData.setData('text/plain', payload.text); + clipboardData.setData('text/html', buildPayloadHtml(payload)); + return true; + } catch { + return false; + } +} + +/** Writes both clipboard flavors, falling back to plain text where unsupported. */ +export async function writeComposerClipboardPayload( + payload: ComposerClipboardPayload, +): Promise { + if (typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard?.write === 'function') { + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/plain': new Blob([payload.text], { type: 'text/plain' }), + 'text/html': new Blob([buildPayloadHtml(payload)], { type: 'text/html' }), + }), + ]); + return; + } catch { + // Fall through so the copy still lands as plain text. + } + } + + await navigator.clipboard.writeText(payload.text); +} diff --git a/src/web-ui/src/flow_chat/utils/composerPresentation.ts b/src/web-ui/src/flow_chat/utils/composerPresentation.ts index 36f83acdaf..2a592df2bd 100644 --- a/src/web-ui/src/flow_chat/utils/composerPresentation.ts +++ b/src/web-ui/src/flow_chat/utils/composerPresentation.ts @@ -140,8 +140,14 @@ export function composerPresentationToModelText( ); } -export function composerPresentationToAccessibleText( +/** + * Readable rendering of the presentation. Inline tokens can keep their + * canonical form so that a clipboard round trip back into the composer can + * rebuild the matching capsules. + */ +function composerPresentationToText( presentation: ComposerPresentation, + canonicalInlineTokens: boolean, ): string { return trimComposerText( presentation.segments.map(segment => { @@ -149,7 +155,9 @@ export function composerPresentationToAccessibleText( return segment.text; } if (segment.kind === 'inline-token') { - return `[${segment.tokenType === 'skill' ? 'Skill' : 'Widget'}: ${segment.label}]`; + return canonicalInlineTokens + ? segment.token + : `[${segment.tokenType === 'skill' ? 'Skill' : 'Widget'}: ${segment.label}]`; } if (isConversationExcerpt(segment.context)) return '\n\n' + formatConversationExcerpt(segment.context); const type = segment.context.type === 'session-reference' @@ -160,6 +168,19 @@ export function composerPresentationToAccessibleText( ); } +export function composerPresentationToAccessibleText( + presentation: ComposerPresentation, +): string { + return composerPresentationToText(presentation, false); +} + +/** Clipboard text that keeps inline tokens restorable by a composer paste. */ +export function composerPresentationToClipboardText( + presentation: ComposerPresentation, +): string { + return composerPresentationToText(presentation, true); +} + export function composerPresentationContexts( presentation: ComposerPresentation, ): ContextItem[] {