diff --git a/apps/desktop/src/app/chat/composer/empty-composer.test.ts b/apps/desktop/src/app/chat/composer/empty-composer.test.ts new file mode 100644 index 00000000000..4e5146c174d --- /dev/null +++ b/apps/desktop/src/app/chat/composer/empty-composer.test.ts @@ -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
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('
') + }) + + 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
as scaffolding, not a nested one', () => { + // A lone
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') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts index 82464ffd5d4..b5bf87dc466 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-draft.ts @@ -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
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 diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 6285a4dcfd0..3b3ba5db2fa 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -480,6 +480,15 @@ export function composerPlainText(node: Node): string { return el.dataset.refText } + // An editor holding nothing but the placeholder
is EMPTY. That
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' }