fix(desktop): the placeholder comes back when you clear the composer

Select-all + Cut emptied the text and left the composer blank — no draft,
no prompt. Delete had the same hole.

The placeholder is painted on `:empty`, and a cleared editor keeps a
scaffolding <br> so the contenteditable can't collapse to a sliver. Those
two facts collide: the moment the break lands the editor has a child,
`:empty` goes false, and the prompt never comes back.

CSS can't infer emptiness on its own either. A text node is invisible to
selectors, so `one<br>` and a lone `<br>` are the same shape — a structural
rule like `:has(> br:only-child)` paints the placeholder straight over the
user's text. The code that empties the editor is what knows, so it marks
the root and the condition reads `:is(:empty, [data-empty])`.

Both writers that reshape that root maintain the marker through one helper:
the normalizer, and renderComposerContents for a restored draft or an undo.
The message-edit composer shares the slot and the rule, so it takes the
same shared class instead of drifting on its own copy.

#74815 fixed the draft this stashed; the placeholder is a separate seam.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 22:33:22 -05:00
parent cc4cab2f59
commit 0b4bd3c7c7
5 changed files with 103 additions and 4 deletions

View file

@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest'
import { composerPlainText, normalizeComposerEditorDom, RICH_INPUT_SLOT } from './rich-editor'
import {
composerPlainText,
normalizeComposerEditorDom,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
function editor(): HTMLDivElement {
const el = document.createElement('div')
@ -69,3 +74,66 @@ describe('an emptied composer reads as empty', () => {
expect(composerPlainText(el)).toBe('one\n\n')
})
})
/** The rule the stylesheet paints the placeholder with. `:empty` alone goes
* false the instant the scaffolding <br> lands. */
const PLACEHOLDER_SHOWS = ':is(:empty, [data-empty])'
describe('an emptied composer shows its placeholder again', () => {
it('advertises emptiness once the scaffolding break is in place', () => {
expect(emptied().matches(PLACEHOLDER_SHOWS)).toBe(true)
})
it('advertises emptiness for a truly childless editor', () => {
expect(editor().matches(PLACEHOLDER_SHOWS)).toBe(true)
})
it('stops advertising it once something is typed', () => {
const el = emptied()
el.replaceChildren(document.createTextNode('hi'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
// A text node is invisible to selectors, so `one<br>` and `<br>` are the same
// shape to any pure-CSS rule (`:has(> br:only-child)` matches both and paints
// the placeholder straight over the user's text). The DOM writer has to say.
it('does not advertise emptiness for a trailing break after text', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
it('does not advertise emptiness for a Shift+Enter break between text', () => {
const el = editor()
el.append(document.createTextNode('one'), document.createElement('br'), document.createTextNode('two'))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
// Repainting from text (restored draft, undo, completion rebuild) is the
// other writer that reshapes the editor root — it must not strand the marker.
it('drops the marker when a draft is painted back in', () => {
const el = emptied()
renderComposerContents(el, 'restored draft')
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
it('re-advertises emptiness when a draft is painted back out', () => {
const el = editor()
renderComposerContents(el, 'temporary')
renderComposerContents(el, '')
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
})
})

View file

@ -57,6 +57,7 @@ import { ActionBadges } from './micro-actions'
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
import { QueuePanel } from './queue-panel'
import {
COMPOSER_PLACEHOLDER_CLASS,
composerPlainText,
deleteChipBeforeCaret,
deleteSelectionInEditor,
@ -946,7 +947,7 @@ export function ChatBar({
autoCorrect="off"
className={cn(
'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
COMPOSER_PLACEHOLDER_CLASS,
'**:data-ref-text:cursor-default',
stacked && 'pl-3',
stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1'

View file

@ -21,6 +21,28 @@ import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
export const RICH_INPUT_SLOT = 'composer-rich-input'
/** Paints `data-placeholder` while the editor is empty.
*
* `:empty` can't be the whole test: a cleared editor keeps a scaffolding <br>
* so the contenteditable doesn't collapse, and that break makes `:empty`
* false. Nor can CSS infer it on its own a text node is invisible to
* selectors, so `one<br>` and a lone `<br>` are the same shape, and
* `:has(> br:only-child)` would paint the placeholder straight over the
* user's text. The code that empties the editor is what knows, so it marks it.
*
* @see markEditorEmptiness */
export const COMPOSER_PLACEHOLDER_CLASS =
'[&:is(:empty,[data-empty])]:before:content-[attr(data-placeholder)] [&:is(:empty,[data-empty])]:before:text-muted-foreground/60'
/** Keep that marker in step with the editor root's contents. */
export function markEditorEmptiness(editor: HTMLElement) {
if (editor.childNodes.length === 0) {
editor.dataset.empty = ''
} else {
delete editor.dataset.empty
}
}
/** @see referenceRe the shared pattern every surface recognises a reference
* with. Module-level `/g` regexes carry `lastIndex`, so call sites reset it. */
export const REF_RE = referenceRe()
@ -165,6 +187,10 @@ export function renderComposerContents(target: HTMLElement, text: string, option
// typed (`/wor`) and must stay editable. Callers repainting inert text (a
// restored draft, a sent message opened for edit) pass `trailingCommitted`.
appendComposerContents(target, text, options)
// The other writer that reshapes the editor root: painting a restored draft
// in clears the marker, clearing back to '' sets it.
markEditorEmptiness(target)
}
/** Caret range when the selection lives inside `editor`; else null. */
@ -681,6 +707,9 @@ export function normalizeComposerEditorDom(editor: HTMLElement) {
// composer to appear as a tiny dot/pixel. Ensure there's always at least
// one <br> so the element maintains intrinsic height. The CSS min-height
// is a belt; the <br> is suspenders — together they prevent the shrink.
// That break is also why emptiness has to be marked, not inferred.
markEditorEmptiness(editor)
if (editor.childNodes.length === 0) {
editor.appendChild(document.createElement('br'))
}

View file

@ -35,6 +35,7 @@ import {
} from '@/app/chat/composer/inline-refs'
import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs'
import {
COMPOSER_PLACEHOLDER_CLASS,
composerPlainText,
insertComposerContentsAtCaret,
placeCaretEnd,
@ -772,7 +773,7 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
autoCorrect="off"
className={cn(
'ui-prompt-input-editor__input max-h-48 w-full resize-none bg-transparent p-0 pr-7 text-[length:var(--conversation-text-font-size)] text-foreground/95 outline-none',
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
COMPOSER_PLACEHOLDER_CLASS,
'**:data-ref-text:cursor-default',
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
)}

View file

@ -1436,7 +1436,7 @@ text-* variant utilities. */ .btn-arc {
font-size: 0.8125rem;
}
[data-slot='composer-rich-input']:empty::before {
[data-slot='composer-rich-input']:is(:empty, [data-empty])::before {
color: var(--ui-text-tertiary) !important;
}