From 570337c099018cd5935bd4d3dad3d7808d343347 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 07:29:31 -0500 Subject: [PATCH] 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
\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
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. --- .../app/chat/composer/empty-composer.test.ts | 71 +++++++++++++++++++ .../chat/composer/hooks/use-composer-draft.ts | 17 ++++- .../src/app/chat/composer/rich-editor.ts | 9 +++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/app/chat/composer/empty-composer.test.ts 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' }