fix(desktop): stop a chip inserted after a word from swallowing its space

`plainTextInRange` serialized the caret's preceding content through a bare
<div>, but `composerPlainText` appends "\n" to any block element that isn't the
editor slot. So `beforeText` always looked like it ended in whitespace and the
separating space was never inserted — dragging a file in after a word produced
`review@file:...` glued together.

Marking the scratch container with RICH_INPUT_SLOT makes it serialize in the
same coordinates as the editor. Same fix lands in the new `caretOffsetInEditor`,
which measures caret offsets the same way.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 16:10:00 -05:00
parent 48bdde1deb
commit 8896fc7500
3 changed files with 136 additions and 1 deletions

View file

@ -4,7 +4,13 @@ import { contextPath } from '@/lib/chat-runtime'
import type { DroppedFile } from '../hooks/use-composer-actions'
import { composerPlainText, normalizeComposerEditorDom, placeCaretEnd, refChipElement } from './rich-editor'
import {
composerPlainText,
normalizeComposerEditorDom,
placeCaretEnd,
refChipElement,
RICH_INPUT_SLOT
} from './rich-editor'
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }
@ -92,7 +98,12 @@ function plainTextInRange(editor: HTMLDivElement, range: Range, edge: 'after' |
slice.setStart(range.endContainer, range.endOffset)
}
// Carry the editor's slot marker: composerPlainText appends a trailing "\n"
// to any other block element, so a bare <div> made `beforeText` always look
// like it ended in whitespace and the separating space was never inserted —
// a chip dropped after a word came out glued to it (`review@file:...`).
const container = document.createElement('div')
container.dataset.slot = RICH_INPUT_SLOT
container.appendChild(slice.cloneContents())
return composerPlainText(container)

View file

@ -71,6 +71,30 @@ describe('insertInlineRefsIntoEditor', () => {
expect(editor.querySelector(':scope > div')).toBeNull()
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
})
it('separates a chip from the word the caret sits after', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(document.createTextNode('review'))
document.body.append(editor)
caretIn(editor)
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
editor.remove()
})
it('does not double the space when one is already there', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.append(document.createTextNode('review '))
document.body.append(editor)
caretIn(editor)
expect(insertInlineRefsIntoEditor(editor, ['@file:`src/a.ts`'])).toBe('review @file:`src/a.ts` ')
editor.remove()
})
})
describe('insertComposerContentsAtCaret', () => {

View file

@ -332,6 +332,106 @@ export function placeCaretEnd(element: HTMLElement) {
selection?.addRange(range)
}
/** The caret's offset in `composerPlainText` coordinates, so it can be restored
* after the editor is re-rendered from text (undo/redo). A chip counts as its
* whole `@kind:value` text the same units the snapshot measures. */
export function caretOffsetInEditor(editor: HTMLElement): number {
const selection = window.getSelection()
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
if (!range || !editor.contains(range.commonAncestorContainer)) {
return composerPlainText(editor).length
}
const before = range.cloneRange()
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
// The scratch container must carry the editor's slot marker: composerPlainText
// appends a trailing "\n" to any other block element, which would inflate
// every offset by one and land the restored caret a character late.
const container = document.createElement('div')
container.dataset.slot = RICH_INPUT_SLOT
container.append(before.cloneContents())
return composerPlainText(container).length
}
/** Place the caret `offset` characters into the editor, in the same
* `composerPlainText` coordinates `caretOffsetInEditor` reports. Lands after a
* chip it would otherwise split, since a chip is a single atomic unit. */
export function placeCaretAtOffset(editor: HTMLElement, offset: number) {
const selection = window.getSelection()
if (!selection) {
return
}
let remaining = offset
const walk = (node: Node): Range | null => {
for (const child of Array.from(node.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const length = (child.textContent || '').length
if (remaining <= length) {
const range = document.createRange()
range.setStart(child, remaining)
range.collapse(true)
return range
}
remaining -= length
continue
}
if (child.nodeType !== Node.ELEMENT_NODE) {
continue
}
const el = child as HTMLElement
// Chips and <br> are atomic: consume their serialized length whole.
if (el.dataset.refText || el.tagName === 'BR') {
const length = el.dataset.refText ? el.dataset.refText.length : 1
if (remaining < length) {
const range = document.createRange()
range.setStartBefore(el)
range.collapse(true)
return range
}
remaining -= length
continue
}
const hit = walk(el)
if (hit) {
return hit
}
}
return null
}
const range = walk(editor)
if (range) {
selection.removeAllRanges()
selection.addRange(range)
return
}
placeCaretEnd(editor)
}
/** Nothing but a break / whitespace (recursively) — i.e. no real text or chip. */
function isBlankNode(node: ChildNode | null): boolean {
if (!node) {