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 (
+ <>
+
+ {
+ 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 ? (