diff --git a/apps/desktop/src/app/chat/composer/inline-refs.ts b/apps/desktop/src/app/chat/composer/inline-refs.ts
index 5fd62f4cc94..5e282f6a040 100644
--- a/apps/desktop/src/app/chat/composer/inline-refs.ts
+++ b/apps/desktop/src/app/chat/composer/inline-refs.ts
@@ -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
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)
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.test.ts b/apps/desktop/src/app/chat/composer/rich-editor.test.ts
index aa4e80aedb9..842765388ff 100644
--- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts
+++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts
@@ -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', () => {
diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts
index 738201fc394..7bf770eda17 100644
--- a/apps/desktop/src/app/chat/composer/rich-editor.ts
+++ b/apps/desktop/src/app/chat/composer/rich-editor.ts
@@ -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
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) {