fix(desktop): the composer hint stops acting like text you typed

The empty-composer prompt was painted with an inline `::before`, which puts a
real box in the contenteditable's text flow. Click an empty composer and the
caret lands past the hint instead of at the field's left edge, and a hint that
wraps — a narrow composer, a long locale string — makes the empty composer two
lines tall. It is a hint, so it now sits out of flow: absolutely positioned,
clipped to one line, unselectable and untouchable by the pointer. Measured in
Chromium against the built stylesheet, the caret lands at the left edge for a
wide composer, a narrow one, and a Japanese hint alike.

Backspace on a fresh `@folder:` chip had a second, related problem. Committing
a completion empties the typed token's text node rather than removing it, and
`Range.insertNode` splits the line around the caret, so the chip ends up
between zero-length text nodes. Those read as content: the atomic chip-delete
declined, Chromium's own backspace bounced between the leftovers, and the chip
took extra presses to remove — leaving a `"\n"` draft with the hint still
hidden behind it. Emptiness, the chip-delete, and the DOM normalizer now all
step over that litter.

The stylesheet owns the rule now that it needs `position: relative` on the
editor, so the utility-class constant both composers imported is gone.
This commit is contained in:
Brooklyn Nicholson 2026-08-01 16:34:46 -05:00
parent c5be6e7792
commit 414e5af114
5 changed files with 210 additions and 18 deletions

View File

@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest'
import { composerPlainText, normalizeComposerEditorDom, renderComposerContents, RICH_INPUT_SLOT } from './rich-editor'
import {
composerPlainText,
deleteChipBeforeCaret,
normalizeComposerEditorDom,
renderComposerContents,
RICH_INPUT_SLOT
} from './rich-editor'
function editor(): HTMLDivElement {
const el = document.createElement('div')
@ -131,4 +137,113 @@ describe('an emptied composer shows its placeholder again', () => {
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
})
// Chromium leaves zero-length text nodes behind whenever an edit lands next
// to a contenteditable=false chip. They render as nothing, so an editor
// holding only those is empty to the user — counting them as contents left
// the placeholder hidden under a composer that looked blank.
it('advertises emptiness for an editor holding only zero-length text nodes', () => {
const el = editor()
el.append(document.createTextNode(''), document.createTextNode(''))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(true)
})
it('does not advertise emptiness while real text sits beside that litter', () => {
const el = editor()
el.append(document.createTextNode(''), document.createTextNode('one'), document.createTextNode(''))
normalizeComposerEditorDom(el)
expect(el.matches(PLACEHOLDER_SHOWS)).toBe(false)
})
})
/** A directive chip, as `refChipElement` builds it. */
function chip(): HTMLSpanElement {
const el = document.createElement('span')
el.contentEditable = 'false'
el.dataset.refText = '@folder:`apps/desktop/`'
el.append(document.createTextNode('apps/desktop/'))
return el
}
function caretAt(node: Node, offset: number) {
const range = document.createRange()
range.setStart(node, offset)
range.collapse(true)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
/** Committing a completion empties the typed token's text node instead of
* removing it, and `Range.insertNode` splits the line around the caret so a
* freshly-chipped directive sits between zero-length text nodes. Backspace has
* to see past them or the chip can't be deleted at all. */
describe('backspace deletes a chip surrounded by Chromium litter', () => {
it('deletes the chip when the caret sits in a zero-length text node after it', () => {
const el = editor()
el.append(document.createTextNode(''), chip(), document.createTextNode(''))
caretAt(el.childNodes[2] as Node, 0)
expect(deleteChipBeforeCaret(el)).toBe(true)
expect(el.querySelector('[data-ref-text]')).toBeNull()
})
it('deletes the chip when the caret is past a zero-length text node at editor level', () => {
const el = editor()
el.append(chip(), document.createTextNode(''))
caretAt(el, 2)
expect(deleteChipBeforeCaret(el)).toBe(true)
expect(el.querySelector('[data-ref-text]')).toBeNull()
})
it('still swallows the auto-inserted trailing space through that litter', () => {
const el = editor()
el.append(chip(), document.createTextNode(''), document.createTextNode(' '))
caretAt(el, 2)
expect(deleteChipBeforeCaret(el)).toBe(true)
expect(composerPlainText(el)).toBe('')
})
it('keeps real following text when it deletes the chip', () => {
const el = editor()
el.append(chip(), document.createTextNode(''), document.createTextNode(' and this'))
caretAt(el, 2)
expect(deleteChipBeforeCaret(el)).toBe(true)
expect(composerPlainText(el)).toBe('and this')
})
it('leaves plain text to the native backspace', () => {
const el = editor()
el.append(document.createTextNode('hello'))
caretAt(el.firstChild as Node, 5)
expect(deleteChipBeforeCaret(el)).toBe(false)
})
it('sweeps the litter out of the editor when it normalizes', () => {
const el = editor()
el.append(document.createTextNode(''), chip(), document.createTextNode(''))
normalizeComposerEditorDom(el)
expect(Array.from(el.childNodes).map(node => node.nodeName)).toEqual(['SPAN'])
})
})

View File

@ -57,7 +57,6 @@ import { ActionBadges } from './micro-actions'
import { chipTypedPathOnSpace, pathifyRefs } from './path-refs'
import { QueuePanel } from './queue-panel'
import {
COMPOSER_PLACEHOLDER_CLASS,
composerPlainText,
deleteChipBeforeCaret,
deleteSelectionInEditor,
@ -947,7 +946,6 @@ 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',
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,22 +21,50 @@ import { slashCommandMatches, type SlashCommandScanOptions } from './slash-refs'
export const RICH_INPUT_SLOT = 'composer-rich-input'
/** Paints `data-placeholder` while the editor is empty.
/** Chromium's litter: editing beside a `contenteditable=false` chip splits the
* line and leaves zero-length text nodes behind. They render as nothing and
* serialize as nothing, so no reader of the editor should count them. */
function isEmptyTextNode(node: ChildNode | null): boolean {
return node?.nodeType === Node.TEXT_NODE && !node.textContent
}
/** The node before `node`, stepping over that litter. */
function meaningfulPreviousSibling(node: ChildNode | null): ChildNode | null {
let prev = node?.previousSibling ?? null
while (isEmptyTextNode(prev)) {
prev = prev?.previousSibling ?? null
}
return prev
}
/** The node after `node`, stepping over that litter. */
function meaningfulNextSibling(node: ChildNode | null): ChildNode | null {
let next = node?.nextSibling ?? null
while (isEmptyTextNode(next)) {
next = next?.nextSibling ?? null
}
return next
}
/** Keep the `data-empty` marker the placeholder paints on in step with the
* editor root's contents.
*
* `: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.
* `:has(> br:only-child)` would paint the placeholder 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. */
* Zero-length text nodes don't count as contents. Chromium leaves them behind
* whenever an edit lands next to a `contenteditable=false` chip, and counting
* them left an editor the user had emptied looking occupied. */
export function markEditorEmptiness(editor: HTMLElement) {
if (editor.childNodes.length === 0) {
if (Array.from(editor.childNodes).every(isEmptyTextNode)) {
editor.dataset.empty = ''
} else {
delete editor.dataset.empty
@ -407,7 +435,14 @@ export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment
/** Backspace at a collapsed caret immediately after a chip: delete the chip AND
* the single trailing space we auto-insert after it, atomically so removing a
* directive never strands an orphaned space (the contenteditable-driven cleanup
* was unreliable). Returns whether it ran. */
* was unreliable). Returns whether it ran.
*
* "Immediately after" has to be read through Chromium's litter. Committing a
* completion empties the typed token's text node rather than removing it, and
* `Range.insertNode` splits around the caret, so the chip routinely sits
* between zero-length text nodes. Reading those as content made the caret look
* like it was after plain text; the delete declined and Chromium's own
* backspace bounced between the leftovers instead of removing the chip. */
export function deleteChipBeforeCaret(editor: HTMLElement): boolean {
const hit = composerSelectionRange(editor)
@ -419,16 +454,20 @@ export function deleteChipBeforeCaret(editor: HTMLElement): boolean {
let chip: ChildNode | null = null
if (startContainer === editor) {
chip = startOffset > 0 ? editor.childNodes[startOffset - 1] : null
chip = startOffset > 0 ? (editor.childNodes[startOffset - 1] ?? null) : null
if (isEmptyTextNode(chip)) {
chip = meaningfulPreviousSibling(chip)
}
} else if (startContainer.nodeType === Node.TEXT_NODE && startOffset === 0) {
chip = startContainer.previousSibling
chip = meaningfulPreviousSibling(startContainer as ChildNode)
}
if (chip?.nodeType !== Node.ELEMENT_NODE || !(chip as HTMLElement).dataset.refText) {
return false
}
const after = chip.nextSibling
const after = meaningfulNextSibling(chip)
chip.remove()
// Drop the auto-inserted trailing space; keep any real following text.
@ -666,6 +705,14 @@ function isBlankNode(node: ChildNode | null): boolean {
* rendering emits (we use text nodes + <br> + chips). Real <br> line breaks
* (Shift+Enter, which sit after actual text) are preserved. */
export function normalizeComposerEditorDom(editor: HTMLElement) {
// Chromium's zero-length text nodes first: every check below reads siblings,
// and litter between them makes a chip look like it has text either side.
for (const child of Array.from(editor.childNodes)) {
if (isEmptyTextNode(child)) {
child.remove()
}
}
// A trailing block wrapper holding only a break/whitespace is the phantom
// "new line" Chromium adds after a chip on backspace — drop it.
const tailBlock = editor.lastChild as HTMLElement | null

View File

@ -35,7 +35,6 @@ import {
} from '@/app/chat/composer/inline-refs'
import { chipTypedPathOnSpace, pathifyRefs } from '@/app/chat/composer/path-refs'
import {
COMPOSER_PLACEHOLDER_CLASS,
composerPlainText,
insertComposerContentsAtCaret,
placeCaretEnd,
@ -773,7 +772,6 @@ 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',
COMPOSER_PLACEHOLDER_CLASS,
'**:data-ref-text:cursor-default',
expanded ? 'min-h-16' : 'min-h-[1.25rem]'
)}

View File

@ -732,6 +732,40 @@
display: none;
}
/*
Composer placeholder.
The prompt is a PAINTED HINT, never content. Rendering `::before` inline
the obvious way puts a box in the contenteditable's text flow, and the
caret sits after it: click an empty composer and the insertion point lands
at the right edge of the hint rather than the left edge of the field, and a
hint that wraps makes the empty composer two lines tall. Taking it out of
flow is what makes it a hint: the caret keeps the field's real origin, the
box keeps its real height, and no pointer or selection can reach the text.
`data-empty` carries emptiness because `:empty` can't: a cleared editor
keeps a scaffolding <br>. See markEditorEmptiness in rich-editor.ts.
*/
[data-slot='composer-rich-input'] {
position: relative;
}
[data-slot='composer-rich-input']:is(:empty, [data-empty])::before {
content: attr(data-placeholder);
position: absolute;
inset: 0;
padding: inherit;
overflow: hidden;
color: color-mix(in srgb, var(--muted-foreground) 60%, transparent);
/* One line, clipped the hint states the field's purpose; it never reflows
the box it sits in. A narrow composer or a long locale string would
otherwise wrap and double the empty composer's height. */
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: none;
user-select: none;
}
/* Primitive-level pointer cursor for every interactive control (buttons,
selects, menu items, switches, tabs, summaries). Keeps individual
components from having to hardcode `cursor-pointer`; explicit cursor