mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
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:
parent
383829df2e
commit
570337c099
3 changed files with 95 additions and 2 deletions
71
apps/desktop/src/app/chat/composer/empty-composer.test.ts
Normal file
71
apps/desktop/src/app/chat/composer/empty-composer.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue