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