fix(desktop): clearing the composer lands on the same empty state every way

Select-all + Delete cleared the composer; select-all + Cut left it holding
a draft. Both produce identical DOM, so the split was in the reader.

An emptied editor keeps a placeholder <br> \u2014 scaffolding
normalizeComposerEditorDom adds so the contenteditable doesn't collapse to
a sliver, not a line the user typed. composerPlainText read it as "\n",
and syncDraftFromEditor (session swap, pagehide) skipped the
normalize+sanitize its rAF twin runs. Cut's residue reached that reader;
Delete's went through the flush path and got cleaned.

Fixed where the two disagree rather than at each call site: an editor
holding nothing but its placeholder break reads as empty. A real
Shift+Enter break, a trailing break after text, and a nested lone <br>
are all unchanged \u2014 the exemption is scoped to the editor root by its slot
marker. syncDraftFromEditor now normalizes and sanitizes like the flush
path, so both readers see one truth.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 07:29:31 -05:00
parent 383829df2e
commit 570337c099
3 changed files with 95 additions and 2 deletions

View file

@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest'
import { composerPlainText, normalizeComposerEditorDom, RICH_INPUT_SLOT } from './rich-editor'
function editor(): HTMLDivElement {
const el = document.createElement('div')
el.dataset.slot = RICH_INPUT_SLOT
el.contentEditable = 'true'
document.body.append(el)
return el
}
/** Whatever emptied it Delete, cut, Chromium's own selection-delete the
* normalizer lands on the same DOM. */
function emptied(): HTMLDivElement {
const el = editor()
el.append(document.createTextNode('hello'))
el.replaceChildren()
normalizeComposerEditorDom(el)
return el
}
describe('an emptied composer reads as empty', () => {
it('keeps the placeholder <br> so the contenteditable holds its height', () => {
// The scaffolding is deliberate: a childless contenteditable collapses to a
// sliver in Chromium. It just must not read as content.
expect(emptied().innerHTML).toBe('<br>')
})
it('reads that editor as empty, not as a newline', () => {
expect(composerPlainText(emptied())).toBe('')
})
it('reads a truly childless editor as empty', () => {
expect(composerPlainText(editor())).toBe('')
})
it('still reads a real Shift+Enter line break as a newline', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two'))
expect(composerPlainText(el)).toBe('one\ntwo')
})
it('still reads a trailing break after text as a newline', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'))
expect(composerPlainText(el)).toBe('one\n')
})
it('only treats the EDITOR\u2019s lone <br> as scaffolding, not a nested one', () => {
// A lone <br> inside some other element is a real line break; the exemption
// is scoped to the editor root by its slot marker. (The block wrapper adds
// its own trailing newline — unchanged behavior, asserted so the exemption
// can't quietly widen to nested nodes.)
const el = editor()
const inner = document.createElement('div')
inner.append(document.createElement('br'))
el.append(document.createTextNode('one'), inner)
expect(composerPlainText(el)).toBe('one\n\n')
})
})

View file

@ -2,6 +2,7 @@ import { useAui, useAuiState, useComposerRuntime } from '@assistant-ui/react'
import { type RefObject, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { SLASH_COMMAND_RE } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { type ComposerAttachment, stashSessionDraft, takeSessionDraft } from '@/store/composer'
import { isBrowsingHistory } from '@/store/composer-input-history'
@ -21,7 +22,13 @@ import {
releaseActiveComposer
} from '../focus'
import { type InlineRefInput, insertInlineRefsIntoEditor } from '../inline-refs'
import { composerPlainText, placeCaretEnd, REF_RE, renderComposerContents } from '../rich-editor'
import {
composerPlainText,
normalizeComposerEditorDom,
placeCaretEnd,
REF_RE,
renderComposerContents
} from '../rich-editor'
import { useComposerScope } from '../scope'
import type { ChatBarProps } from '../types'
@ -237,7 +244,13 @@ export function useComposerDraft({
return draftRef.current
}
const text = composerPlainText(editor)
// Same normalize-then-sanitize the rAF flush does. An emptied editor still
// holds the placeholder <br> that keeps the contenteditable from collapsing
// to a sliver, and that serializes as "\n" — so an editor the user just
// cleared would otherwise stash a one-newline draft and come back non-empty.
normalizeComposerEditorDom(editor)
const text = sanitizeComposerInput(composerPlainText(editor))
if (text !== draftRef.current) {
draftRef.current = text

View file

@ -480,6 +480,15 @@ export function composerPlainText(node: Node): string {
return el.dataset.refText
}
// An editor holding nothing but the placeholder <br> is EMPTY. That <br> is
// scaffolding normalizeComposerEditorDom adds so the contenteditable keeps
// its height — not a line the user typed. Reading it as "\n" is how a
// just-cleared composer stayed non-empty: the newline got stashed as the
// session's draft and painted back on return.
if (el.dataset.slot === RICH_INPUT_SLOT && el.childNodes.length === 1 && el.firstChild?.nodeName === 'BR') {
return ''
}
if (el.tagName === 'BR') {
return '\n'
}