Merge pull request #74815 from NousResearch/bb/composer-clear-parity

Clearing the composer empties it whichever way you do it
This commit is contained in:
brooklyn! 2026-07-30 07:36:28 -05:00 committed by GitHub
commit fd90ef77be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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

@ -482,6 +482,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'
}