diff --git a/apps/desktop/src/app/chat/composer/empty-composer.test.ts b/apps/desktop/src/app/chat/composer/empty-composer.test.ts
index 60498efcd0b1f..887f14c9750d3 100644
--- a/apps/desktop/src/app/chat/composer/empty-composer.test.ts
+++ b/apps/desktop/src/app/chat/composer/empty-composer.test.ts
@@ -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'])
+ })
})
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index 747177592288a..d06f82f8d6936 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -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'
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts
index 0c9c7098870c8..8b5ca6cdb02c6 100644
--- a/apps/desktop/src/app/chat/composer/rich-editor.ts
+++ b/apps/desktop/src/app/chat/composer/rich-editor.ts
@@ -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
* 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
` and a lone `
` 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 +
+ chips). Real
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
diff --git a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
index 1e6ec792d016a..17b16565be3ed 100644
--- a/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread/user-edit-composer.tsx
@@ -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 = ({ 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]'
)}
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index 09aa77a6d1308..9b77346fa3182 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -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
. 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