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'
}