diff --git a/web/src/components/AssistantChat/RichComposerInput.segments.test.ts b/web/src/components/AssistantChat/RichComposerInput.segments.test.ts index 3d957d29..73b09d3c 100644 --- a/web/src/components/AssistantChat/RichComposerInput.segments.test.ts +++ b/web/src/components/AssistantChat/RichComposerInput.segments.test.ts @@ -118,6 +118,8 @@ describe('insertLineBreakAtCaret', () => { document.body.appendChild(root) const hello = document.createTextNode('hello') root.appendChild(hello) + Object.defineProperty(root, 'scrollHeight', { value: 120 }) + root.scrollTop = 10 placeCaretAtEnd(root, hello) insertLineBreakAtCaret(root) @@ -125,6 +127,7 @@ describe('insertLineBreakAtCaret', () => { const texts = Array.from(root.childNodes).map((n) => n.textContent ?? '') expect(texts).toContain(CARET_PAD) expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\n') + expect(root.scrollTop).toBe(120) }) it('does not pad when there is meaningful content after the caret', () => { @@ -132,12 +135,41 @@ describe('insertLineBreakAtCaret', () => { document.body.appendChild(root) const text = document.createTextNode('helloworld') root.appendChild(text) + Object.defineProperty(root, 'scrollHeight', { value: 120 }) + root.scrollTop = 10 placeCaretInText(text, 5) // between hello|world insertLineBreakAtCaret(root) expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('hello\nworld') expect(Array.from(root.childNodes).some((n) => n.textContent === CARET_PAD)).toBe(false) + expect(root.scrollTop).toBe(10) + }) + + it('does not scroll for a nested middle-line break', () => { + const root = document.createElement('div') + document.body.appendChild(root) + root.innerHTML = '
first
second
' + Object.defineProperty(root, 'scrollHeight', { value: 120 }) + root.scrollTop = 10 + placeCaretAtEnd(root, root.firstElementChild!.firstChild as Text) + + insertLineBreakAtCaret(root) + + expect(root.scrollTop).toBe(10) + }) + + it('scrolls for a line break at the end of nested blocks', () => { + const root = document.createElement('div') + document.body.appendChild(root) + root.innerHTML = '
first
second
' + Object.defineProperty(root, 'scrollHeight', { value: 120 }) + root.scrollTop = 10 + placeCaretAtEnd(root, root.lastElementChild!.firstChild as Text) + + insertLineBreakAtCaret(root) + + expect(root.scrollTop).toBe(120) }) }) diff --git a/web/src/components/AssistantChat/RichComposerInput.test.tsx b/web/src/components/AssistantChat/RichComposerInput.test.tsx index 3c7908a1..a5c60b93 100644 --- a/web/src/components/AssistantChat/RichComposerInput.test.tsx +++ b/web/src/components/AssistantChat/RichComposerInput.test.tsx @@ -1,10 +1,11 @@ import { fireEvent, render, screen } from '@testing-library/react' import { flushSync } from 'react-dom' -import { useState } from 'react' -import { afterEach, describe, expect, it } from 'vitest' +import { useRef, useState } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' import { mirrorOffsetFromPoint, RichComposerInput, + type RichComposerInputHandle, segmentsFromEditor, } from './RichComposerInput' import { serializeComposerSegments } from '@/lib/composerSegments' @@ -50,6 +51,43 @@ function SynchronousControlledHarness() { ) } +function ProgrammaticEditHarness() { + const [value, setValue] = useState('') + const ref = useRef(null) + + return ( + <> + + {}} + /> + + ) +} + +function LineBreakDeletionHarness() { + const [value, setValue] = useState('hello') + + return ( + <> + {value} + { + flushSync(() => setValue(next)) + }} + onMirrorChange={() => {}} + /> + + ) +} + describe('RichComposerInput controlled synchronization', () => { afterEach(() => { window.getSelection()?.removeAllRanges() @@ -82,4 +120,128 @@ describe('RichComposerInput controlled synchronization', () => { expect(serializeComposerSegments(segmentsFromEditor(editor))).toBe('external draft') expect(selectionOffset(editor)).toBe('external draft'.length) }) + + it('tracks placeholder visibility from the DOM during composition', () => { + render( + {}} + onMirrorChange={() => {}} + /> + ) + + const editor = screen.getByTestId('rich-composer-input') + expect(screen.getByText('Type a message')).toBeInTheDocument() + + fireEvent.compositionStart(editor) + editor.textContent = 'dictated text' + fireEvent.input(editor) + expect(screen.queryByText('Type a message')).not.toBeInTheDocument() + + editor.replaceChildren(document.createElement('br')) + fireEvent.input(editor) + expect(screen.getByText('Type a message')).toBeInTheDocument() + }) + + it('tracks placeholder visibility for programmatic insertion and deletion', () => { + render() + + const editor = screen.getByTestId('rich-composer-input') + expect(screen.getByText('Type a message')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Insert suggestion' })) + expect(screen.queryByText('Type a message')).not.toBeInTheDocument() + + const text = editor.firstChild + expect(text).toBeInstanceOf(Text) + const range = document.createRange() + range.selectNodeContents(editor) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + fireEvent.keyDown(editor, { key: 'Backspace' }) + + expect(screen.getByText('Type a message')).toBeInTheDocument() + }) + + it('deletes exactly one trailing line break per Backspace', () => { + render() + + const editor = screen.getByTestId('rich-composer-input') + const text = editor.firstChild + expect(text).toBeInstanceOf(Text) + editor.focus() + placeCaret(text as Text, text?.textContent?.length ?? 0) + + fireEvent.keyDown(editor, { key: 'Enter' }) + expect(screen.getByTestId('controlled-value').textContent).toBe('hello\n') + + fireEvent.keyDown(editor, { key: 'Enter' }) + expect(screen.getByTestId('controlled-value').textContent).toBe('hello\n\n') + + fireEvent.keyDown(editor, { key: 'Backspace' }) + expect(screen.getByTestId('controlled-value').textContent).toBe('hello\n') + + fireEvent.keyDown(editor, { key: 'Backspace' }) + expect(screen.getByTestId('controlled-value').textContent).toBe('hello') + expect(selectionOffset(editor)).toBe('hello'.length) + }) + + it('handles soft-keyboard backward deletion through beforeinput', () => { + render() + + const editor = screen.getByTestId('rich-composer-input') + const text = editor.firstChild + expect(text).toBeInstanceOf(Text) + editor.focus() + placeCaret(text as Text, text?.textContent?.length ?? 0) + fireEvent.keyDown(editor, { key: 'Enter' }) + + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'deleteContentBackward', + }) + expect(editor.dispatchEvent(event)).toBe(false) + expect(screen.getByTestId('controlled-value').textContent).toBe('hello') + expect(selectionOffset(editor)).toBe('hello'.length) + }) + + it('leaves ordinary beforeinput deletion to the browser', () => { + render() + + const editor = screen.getByTestId('rich-composer-input') + const text = editor.firstChild + expect(text).toBeInstanceOf(Text) + editor.focus() + placeCaret(text as Text, text?.textContent?.length ?? 0) + + const event = new InputEvent('beforeinput', { + bubbles: true, + cancelable: true, + inputType: 'deleteContentBackward', + }) + expect(editor.dispatchEvent(event)).toBe(true) + expect(event.defaultPrevented).toBe(false) + }) + + it('does not forward Enter while composition is active', () => { + const onKeyDown = vi.fn() + render( + {}} + onMirrorChange={() => {}} + onKeyDown={onKeyDown} + /> + ) + + const editor = screen.getByTestId('rich-composer-input') + fireEvent.compositionStart(editor) + fireEvent.keyDown(editor, { key: 'Enter', isComposing: false }) + + expect(onKeyDown).not.toHaveBeenCalled() + expect(serializeComposerSegments(segmentsFromEditor(editor))).toBe('hello') + }) }) diff --git a/web/src/components/AssistantChat/RichComposerInput.tsx b/web/src/components/AssistantChat/RichComposerInput.tsx index 2f297ef4..7b696f91 100644 --- a/web/src/components/AssistantChat/RichComposerInput.tsx +++ b/web/src/components/AssistantChat/RichComposerInput.tsx @@ -111,6 +111,35 @@ function stripCaretPad(text: string): string { return text.replaceAll(CARET_PAD, '') } +function caretIsAfterCaretPad(root: HTMLElement): boolean { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0) return false + const range = selection.getRangeAt(0) + if (!range.collapsed || !root.contains(range.startContainer)) return false + + const { startContainer, startOffset } = range + if (startContainer.nodeType === Node.TEXT_NODE) { + const text = startContainer.textContent ?? '' + return startOffset > 0 && text[startOffset - 1] === CARET_PAD + } + if (startContainer.nodeType !== Node.ELEMENT_NODE || startOffset === 0) return false + + const previous = startContainer.childNodes[startOffset - 1] + return previous?.nodeType === Node.TEXT_NODE + && (previous.textContent ?? '').endsWith(CARET_PAD) +} + +function selectionIsAfterCaretPad( + root: HTMLElement, + mirror: string, + selection: ComposerSelection +): boolean { + return selection.start === selection.end + && selection.start > 0 + && mirror[selection.start - 1] === '\n' + && caretIsAfterCaretPad(root) +} + type ComposerDomSpan = { /** Mirror offset at the point inside this node where its visible content begins. */ start: number @@ -277,6 +306,13 @@ export function insertLineBreakAtCaret(root: HTMLElement): void { root.focus() const range = sel.getRangeAt(0) + const trailingRange = range.cloneRange() + trailingRange.collapse(false) + trailingRange.setEnd(root, root.childNodes.length) + const trailingRoot = document.createElement('div') + trailingRoot.append(trailingRange.cloneContents()) + const selectionEndsAtEditorEnd = + mirrorComposerSegments(segmentsFromEditor(trailingRoot)).length === 0 range.deleteContents() const nl = document.createTextNode('\n') range.insertNode(nl) @@ -290,6 +326,8 @@ export function insertLineBreakAtCaret(root: HTMLElement): void { range.collapse(true) sel.removeAllRanges() sel.addRange(range) + // Restore native textarea scrolling for trailing line breaks. + if (selectionEndsAtEditorEnd) root.scrollTop = root.scrollHeight } function renderSegmentsToEditor( @@ -419,6 +457,10 @@ function mirrorOffsetFromMappedPoint( return mapping.mirrorLength } +function editorDomIsEmpty(root: HTMLElement): boolean { + return (root.textContent ?? '').length === 0 +} + /** Exported for unit tests — maps a DOM caret point into mirror-string offset. */ export function mirrorOffsetFromPoint(root: HTMLElement, endContainer: Node, endOffset: number): number { const mapping = mapComposerEditorDom(root) @@ -589,6 +631,7 @@ export const RichComposerInput = forwardRef(func const tooltipTimerRef = useRef | null>(null) const hoveredChipRef = useRef(null) const [mentionTooltip, setMentionTooltip] = useState(null) + const [domIsEmpty, setDomIsEmpty] = useState(value.length === 0) const clearMentionTooltip = useCallback(() => { if (tooltipTimerRef.current) { @@ -599,9 +642,15 @@ export const RichComposerInput = forwardRef(func setMentionTooltip(null) }, []) + const renderEditorSegments = useCallback((root: HTMLElement, segments: readonly ComposerSegment[]) => { + renderSegmentsToEditor(root, segments, resolveSessionMentionTooltip) + setDomIsEmpty(editorDomIsEmpty(root)) + }, [resolveSessionMentionTooltip]) + const emitFromDom = useCallback(() => { const root = rootRef.current if (!root) return + setDomIsEmpty(editorDomIsEmpty(root)) const segments = segmentsFromEditor(root) const serialized = serializeComposerSegments(segments) const selection = getMirrorSelection(root) @@ -615,7 +664,7 @@ export const RichComposerInput = forwardRef(func const root = rootRef.current if (!root) return const segments = parseComposerSegments(next) - renderSegmentsToEditor(root, segments, resolveSessionMentionTooltip) + renderEditorSegments(root, segments) lastEmittedRef.current = next clearMentionTooltip() const mirror = mirrorComposerSegments(segments) @@ -627,7 +676,7 @@ export const RichComposerInput = forwardRef(func setMirrorSelection(root, sel) } onMirrorChange({ text: mirror, selection: sel }) - }, [clearMentionTooltip, onMirrorChange, resolveSessionMentionTooltip]) + }, [clearMentionTooltip, onMirrorChange, renderEditorSegments]) useLayoutEffect(() => { if (value === lastEmittedRef.current) return @@ -671,7 +720,7 @@ export const RichComposerInput = forwardRef(func const selection = getMirrorSelection(root) const result = insertSessionMentionInComposerSegments(segments, selection, mention, prefixes) const serialized = serializeComposerSegments(result.segments) - renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip) + renderEditorSegments(root, result.segments) lastEmittedRef.current = serialized setMirrorSelection(root, result.selection) onValueChange(serialized) @@ -690,7 +739,7 @@ export const RichComposerInput = forwardRef(func const selection = getMirrorSelection(root) const result = insertPlainTextInComposerSegments(segments, selection, suggestionText, prefixes) const serialized = serializeComposerSegments(result.segments) - renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip) + renderEditorSegments(root, result.segments) lastEmittedRef.current = serialized setMirrorSelection(root, result.selection) onValueChange(serialized) @@ -700,7 +749,7 @@ export const RichComposerInput = forwardRef(func }) return { text: serialized, selection: result.selection } }, - }), [onMirrorChange, onValueChange, resolveSessionMentionTooltip, value]) + }), [onMirrorChange, onValueChange, renderEditorSegments, value]) useEffect(() => () => { if (tooltipTimerRef.current) clearTimeout(tooltipTimerRef.current) @@ -776,7 +825,11 @@ export const RichComposerInput = forwardRef(func const handleInput = useCallback((_e: ReactFormEvent) => { clearMentionTooltip() - if (composingRef.current) return + if (composingRef.current) { + const root = rootRef.current + if (root) setDomIsEmpty(editorDomIsEmpty(root)) + return + } onEdit?.() emitFromDom() }, [clearMentionTooltip, emitFromDom, onEdit]) @@ -794,7 +847,7 @@ export const RichComposerInput = forwardRef(func parseComposerSegments(text), ) const serialized = serializeComposerSegments(result.segments) - renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip) + renderEditorSegments(root, result.segments) lastEmittedRef.current = serialized setMirrorSelection(root, result.selection) onValueChange(serialized) @@ -803,7 +856,7 @@ export const RichComposerInput = forwardRef(func selection: result.selection, }) onEdit?.() - }, [onEdit, onMirrorChange, onValueChange, resolveSessionMentionTooltip]) + }, [onEdit, onMirrorChange, onValueChange, renderEditorSegments]) const handleCopyOrCut = useCallback((e: ReactClipboardEvent, cut: boolean) => { const root = rootRef.current @@ -818,7 +871,7 @@ export const RichComposerInput = forwardRef(func clearMentionTooltip() const result = deleteBackwardInComposerSegments(segments, selection) const serialized = serializeComposerSegments(result.segments) - renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip) + renderEditorSegments(root, result.segments) lastEmittedRef.current = serialized setMirrorSelection(root, result.selection) onValueChange(serialized) @@ -832,7 +885,7 @@ export const RichComposerInput = forwardRef(func onEdit, onMirrorChange, onValueChange, - resolveSessionMentionTooltip, + renderEditorSegments, ]) const handlePaste = useCallback((e: ReactClipboardEvent) => { @@ -848,12 +901,53 @@ export const RichComposerInput = forwardRef(func insertPlainClipboardText(e.clipboardData?.getData('text/plain') ?? '') }, [insertPlainClipboardText, onPaste]) + const applyBackwardDelete = useCallback(( + root: HTMLElement, + segments: readonly ComposerSegment[], + selection: ComposerSelection + ) => { + clearMentionTooltip() + const result = deleteBackwardInComposerSegments(segments, selection) + const serialized = serializeComposerSegments(result.segments) + renderEditorSegments(root, result.segments) + lastEmittedRef.current = serialized + setMirrorSelection(root, result.selection) + onValueChange(serialized) + onMirrorChange({ + text: mirrorComposerSegments(result.segments), + selection: result.selection, + }) + onEdit?.() + }, [clearMentionTooltip, onEdit, onMirrorChange, onValueChange, renderEditorSegments]) + + useEffect(() => { + const root = rootRef.current + if (!root) return + const handleBeforeInput = (event: InputEvent) => { + if ( + event.inputType !== 'deleteContentBackward' + || event.isComposing + || composingRef.current + || !event.cancelable + ) return + + const segments = segmentsFromEditor(root) + const selection = getMirrorSelection(root) + const mirror = mirrorComposerSegments(segments) + if (!selectionIsAfterCaretPad(root, mirror, selection)) return + + event.preventDefault() + applyBackwardDelete(root, segments, selection) + } + root.addEventListener('beforeinput', handleBeforeInput) + return () => root.removeEventListener('beforeinput', handleBeforeInput) + }, [applyBackwardDelete]) + // No onDrop: intercepting without caretRangeFromPoint appends at EOF / no-ops // in-editor moves. Native CE drop + plaintext-only / paste path is enough for #1215. const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { - if (e.nativeEvent.isComposing) { - onKeyDown?.(e) + if (e.nativeEvent.isComposing || composingRef.current) { return } if (e.key === 'Backspace' && !e.metaKey && !e.ctrlKey && !e.altKey) { @@ -862,24 +956,14 @@ export const RichComposerInput = forwardRef(func const segments = segmentsFromEditor(root) const selection = getMirrorSelection(root) const mirror = mirrorComposerSegments(segments) + const afterCaretPad = selectionIsAfterCaretPad(root, mirror, selection) const againstAtom = selection.start === selection.end && selection.start > 0 && mirror[selection.start - 1] === COMPOSER_MENTION_MIRROR_CHAR - if (againstAtom || selection.start !== selection.end) { + if (afterCaretPad || againstAtom || selection.start !== selection.end) { e.preventDefault() - clearMentionTooltip() - const result = deleteBackwardInComposerSegments(segments, selection) - const serialized = serializeComposerSegments(result.segments) - renderSegmentsToEditor(root, result.segments, resolveSessionMentionTooltip) - lastEmittedRef.current = serialized - setMirrorSelection(root, result.selection) - onValueChange(serialized) - onMirrorChange({ - text: mirrorComposerSegments(result.segments), - selection: result.selection, - }) - onEdit?.() + applyBackwardDelete(root, segments, selection) return } } @@ -900,18 +984,15 @@ export const RichComposerInput = forwardRef(func emitFromDom() } }, [ + applyBackwardDelete, emitFromDom, onEdit, onKeyDown, - onMirrorChange, - onValueChange, - resolveSessionMentionTooltip, - clearMentionTooltip, ]) return (
- {(!value || value.length === 0) && placeholder ? ( + {domIsEmpty && placeholder ? (