diff --git a/web/src/components/AssistantChat/RichComposerInput.segments.test.ts b/web/src/components/AssistantChat/RichComposerInput.segments.test.ts
index 73b09d3c..036729f6 100644
--- a/web/src/components/AssistantChat/RichComposerInput.segments.test.ts
+++ b/web/src/components/AssistantChat/RichComposerInput.segments.test.ts
@@ -40,6 +40,28 @@ describe('segmentsFromEditor', () => {
expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\nb')
})
+ it('ignores the filler br browsers park in an emptied editor', () => {
+ // Deleting the last character leaves Chromium/WebKit with `
` as the
+ // sole child. Counting it made an empty composer hold "\n".
+ const root = document.createElement('div')
+ root.innerHTML = '
'
+ expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('')
+ expect(mirrorOffsetFromPoint(root, root, 0)).toBe(0)
+ expect(mirrorOffsetFromPoint(root, root, 1)).toBe(0)
+
+ const moz = document.createElement('div')
+ moz.innerHTML = '
'
+ expect(serializeComposerSegments(segmentsFromEditor(moz))).toBe('')
+ })
+
+ it('keeps a trailing br that carries our caret pad (real trailing newline)', () => {
+ // renderSegmentsToEditor maps "a\n" → text +
+ ZWSP; the pad is
+ // what separates a deliberate break from browser filler.
+ const root = document.createElement('div')
+ root.innerHTML = 'a
'
+ expect(serializeComposerSegments(segmentsFromEditor(root))).toBe('a\n')
+ })
+
it('preserves blank-line endings from renderSegments (br+br+pad)', () => {
// renderSegmentsToEditor maps "...\n\n" → text +
+
+ ZWSP.
// Must not strip a real trailing blank line on re-serialize.
diff --git a/web/src/components/AssistantChat/RichComposerInput.tsx b/web/src/components/AssistantChat/RichComposerInput.tsx
index dff97fb4..9403e561 100644
--- a/web/src/components/AssistantChat/RichComposerInput.tsx
+++ b/web/src/components/AssistantChat/RichComposerInput.tsx
@@ -142,6 +142,29 @@ function selectionIsAfterCaretPad(
&& caretIsAfterCaretPad(root)
}
+/**
+ * True for the bogus `
` Chromium/WebKit/Gecko park in an editor whose
+ * content was just deleted (Firefox marks it `type="_moz"`, Blink leaves it
+ * bare). It is a caret placeholder, not user content: counting it as a newline
+ * leaves a visually empty composer holding `"\n"` forever, which then persists
+ * as a draft and repaints as a real blank first line.
+ *
+ * Our own renderer never emits a naked trailing `
` — a real trailing
+ * newline always carries a CARET_PAD text node after it (see
+ * `renderSegmentsToEditor` / `insertLineBreakAtCaret`), so "nothing at all
+ * after this node" is exactly the filler shape.
+ */
+function isFillerLineBreak(root: HTMLElement, br: Node): boolean {
+ for (let node: Node | null = br; node && node !== root; node = node.parentNode) {
+ for (let next = node.nextSibling; next; next = next.nextSibling) {
+ if (next.nodeType !== Node.TEXT_NODE) return false
+ // Raw length, not stripCaretPad: the pad marks a deliberate break.
+ if ((next.textContent ?? '').length > 0) return false
+ }
+ }
+ return true
+}
+
type ComposerDomSpan = {
/** Mirror offset at the point inside this node where its visible content begins. */
start: number
@@ -225,6 +248,11 @@ function mapComposerEditorDom(root: HTMLElement): ComposerDomMapping {
return
}
if (el.tagName === 'BR') {
+ if (isFillerLineBreak(root, node)) {
+ // Zero-width: the caret placeholder owns no mirror position.
+ spans.set(node, { start: mirrorLength, end: mirrorLength, producesOutput: false })
+ return
+ }
pushNewlineIfNeeded()
const start = mirrorLength
pushText('\n')
@@ -565,6 +593,9 @@ function setMirrorSelection(root: HTMLElement, selection: ComposerSelection) {
if (el.tagName === 'BR') {
const parent = el.parentNode
if (!parent) return true
+ // Mirror mapping gives a filler br zero width; consuming one here
+ // would shift every later offset by one.
+ if (isFillerLineBreak(root, el)) return false
if (remaining === 0) {
place(parent, Array.from(parent.childNodes).indexOf(el))
return true