Merge pull request #74455 from NousResearch/bb/composer-chip-stability

fix(desktop): stop composer chips demoting to plaintext
This commit is contained in:
brooklyn! 2026-07-29 19:22:05 -05:00 committed by GitHub
commit 22ccebc4c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 388 additions and 115 deletions

View file

@ -223,3 +223,72 @@ describe('useComposerTrigger — free-text slash arguments', () => {
expect(editor.querySelector('[data-slash-kind]')?.getAttribute('data-ref-text')).toBe('/personality creative')
})
})
describe('useComposerTrigger — chip survival (the plaintext-demotion bug class)', () => {
it('keeps a leading command pill through a Backspace path-ascend', () => {
// The reported repro: `/work @folder…` then Backspace — both chips went
// plaintext because ascend re-rendered the whole editor from text.
const editor = mountEditor('/work @Desktop/')
const { hook } = mountTrigger(editor, [])
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
act(() => hook.result.current.refreshTrigger())
expect(hook.result.current.trigger).toMatchObject({ kind: '@', query: 'Desktop/' })
let ran = false
act(() => {
ran = hook.result.current.ascendTriggerPath()
})
expect(ran).toBe(true)
expect(composerPlainText(editor)).toBe('/work @')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
})
it('keeps a leading command pill when a folder pick commits its ref chip', () => {
const editor = mountEditor('/work @Desk')
const folder: Unstable_TriggerItem = {
id: 'folder:Desktop',
type: 'folder',
label: 'Desktop',
metadata: { rawText: '@folder:Desktop', insertId: 'Desktop' }
}
const { hook } = mountTrigger(editor, [folder])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(folder))
expect(composerPlainText(editor)).toBe('/work @folder:`Desktop` ')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
})
it('commits in place when Chromium has split the token across text nodes', () => {
// Chromium fragments text nodes around contenteditable=false chips; the
// commit path must span the fragments instead of bailing to a full
// re-render.
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('please run /c'), document.createTextNode('le'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 2)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
const { hook } = mountTrigger(editor, [item('/clean')])
act(() => hook.result.current.refreshTrigger())
act(() => hook.result.current.replaceTriggerWithChip(item('/clean')))
expect(composerPlainText(editor)).toBe('please run /clean ')
expect(editor.querySelector('[data-slash-kind]')).not.toBeNull()
})
})

View file

@ -16,6 +16,7 @@ import {
placeCaretEnd,
refChipElement,
renderComposerContents,
replaceBeforeCaret,
slashChipElement
} from '../rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '../text-utils'
@ -29,6 +30,8 @@ interface UseComposerTriggerOptions {
at: CompletionSource
draftRef: MutableRefObject<string>
editorRef: RefObject<HTMLDivElement | null>
/** Bank the pre-commit state so a popover pick is a single undo step. */
recordUndoPoint?: () => void
requestMainFocus: () => void
setComposerText: (text: string) => void
slash: CompletionSource
@ -47,6 +50,7 @@ export function useComposerTrigger({
at,
draftRef,
editorRef,
recordUndoPoint,
requestMainFocus,
setComposerText,
slash
@ -214,17 +218,31 @@ export function useComposerTrigger({
return
}
// Bank the pre-commit state first — every path below mutates the editor,
// and a pick must be exactly one undo step.
recordUndoPoint?.()
// Rebuild-from-text fallback for carets the range walk can't anchor (a
// non-collapsed selection, a caret not preceded by contiguous text). It
// re-renders the whole editor from serialized text, so it only runs when
// the in-place path reports failure — never as the default.
const rebuildWith = (render: (prefix: string) => void) => {
const current = composerPlainText(editor)
render(current.slice(0, Math.max(0, current.length - trigger.tokenLength)))
placeCaretEnd(editor)
}
// Action items (e.g. "Browse all sessions…") run a side effect instead of
// inserting a chip: strip the typed trigger token, then fire the action.
const completionAction = (item.metadata as { action?: unknown } | undefined)?.action
const runAction = typeof completionAction === 'string' ? COMPLETION_ACTIONS[completionAction] : undefined
if (runAction) {
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
if (!replaceBeforeCaret(editor, trigger.tokenLength, document.createDocumentFragment())) {
rebuildWith(prefix => renderComposerContents(editor, prefix))
}
renderComposerContents(editor, prefix)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
closeTrigger()
@ -247,19 +265,24 @@ export function useComposerTrigger({
? String((item.metadata as { insertId?: unknown } | undefined)?.insertId ?? '')
: ''
if (descendInto) {
const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/`
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
renderComposerContents(editor, `${prefix}@${path}`)
placeCaretEnd(editor)
const finish = (keepOpen: boolean) => {
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
requestMainFocus()
window.setTimeout(refreshTrigger, 0)
keepOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
return
if (descendInto) {
const path = descendInto.endsWith('/') ? descendInto : `${descendInto}/`
const fragment = document.createDocumentFragment()
fragment.append(document.createTextNode(`@${path}`))
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
rebuildWith(prefix => renderComposerContents(editor, `${prefix}@${path}`))
}
return finish(true)
}
// Picking a bare arg-taking command (e.g. `/personality`) shouldn't commit
@ -280,67 +303,32 @@ export function useComposerTrigger({
const slashKind = !expandsToArgs && trigger.kind === '/' ? slashChipKindForItem(item) : null
const keepTriggerOpen = starter || (expandsToArgs && argumentMode !== 'text')
const finish = () => {
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
requestMainFocus()
keepTriggerOpen ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
const sel = window.getSelection()
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
const node = range?.startContainer
const offset = range?.startOffset ?? 0
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
if (slashKind) {
// Two-step arg picks (e.g. `/handoff` pill already inserted, now picking
// the platform) land here because the caret sits past a contenteditable
// chip. Rebuild the prefix and re-emit a single pill for the full command.
renderComposerContents(editor, prefix)
editor.append(slashChipElement(serialized, slashKind), document.createTextNode(' '))
placeCaretEnd(editor)
return finish()
}
renderComposerContents(editor, `${prefix}${text}`)
placeCaretEnd(editor)
return finish()
}
const replaceRange = document.createRange()
replaceRange.setStart(node, offset - trigger.tokenLength)
replaceRange.setEnd(node, offset)
replaceRange.deleteContents()
const chip = slashKind
? slashChipElement(serialized, slashKind)
: directive
? refChipElement(directive[1], directive[2])
: null
if (chip) {
const space = document.createTextNode(' ')
const fragment = document.createDocumentFragment()
fragment.append(chip, space)
replaceRange.insertNode(fragment)
const fragment = document.createDocumentFragment()
const caret = document.createRange()
caret.setStart(space, 1)
caret.collapse(true)
sel.removeAllRanges()
sel.addRange(caret)
chip ? fragment.append(chip, document.createTextNode(' ')) : fragment.append(document.createTextNode(text))
return finish()
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
rebuildWith(prefix => {
if (chip) {
// The failed in-place attempt never consumed the fragment, so the
// chip + trailing space land here instead. Appending the element
// keeps mid-message slash pills alive — they have no text
// hydration, unlike `@` refs and the leading command.
renderComposerContents(editor, prefix)
editor.append(fragment)
} else {
renderComposerContents(editor, `${prefix}${text}`)
}
})
}
document.execCommand('insertText', false, text)
finish()
finish(keepTriggerOpen)
}
/** Backspace inside an `@` path drops the last segment (`a/b/` `a/`)
@ -359,11 +347,23 @@ export function useComposerTrigger({
const trimmed = trigger.query.replace(/\/$/, '')
const parent = trimmed.slice(0, trimmed.lastIndexOf('/') + 1)
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
recordUndoPoint?.()
const fragment = document.createDocumentFragment()
fragment.append(document.createTextNode(`@${parent}`))
// In place first: the destructive re-render fallback rebuilds the editor
// from text, which is exactly what used to demote a leading command pill
// to plaintext on every Backspace inside a path.
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
const current = composerPlainText(editor)
const prefix = current.slice(0, Math.max(0, current.length - trigger.tokenLength))
renderComposerContents(editor, `${prefix}@${parent}`)
placeCaretEnd(editor)
}
renderComposerContents(editor, `${prefix}@${parent}`)
placeCaretEnd(editor)
draftRef.current = composerPlainText(editor)
setComposerText(draftRef.current)
window.setTimeout(refreshTrigger, 0)

View file

@ -346,7 +346,7 @@ export function ChatBar({
triggerItems,
triggerKeyConsumedRef,
triggerLoading
} = useComposerTrigger({ at, draftRef, editorRef, requestMainFocus, setComposerText, slash })
} = useComposerTrigger({ at, draftRef, editorRef, recordUndoPoint, requestMainFocus, setComposerText, slash })
// Pull the live contentEditable text into draftRef + the AUI composer state
// (which drives `hasComposerPayload` → the send button). Shared by the input
@ -569,6 +569,17 @@ export function ChatBar({
return
}
// The popover is open but its items are still in flight (debounce + RPC).
// Tab must not fall through to the browser — it would move focus out of
// the composer mid-completion, which reads as the popover "eating" the
// keypress. Swallow it; the refresh lands with the items.
if (trigger && triggerLoading && triggerItems.length === 0 && event.key === 'Tab') {
event.preventDefault()
triggerKeyConsumedRef.current = true
return
}
if (trigger && triggerItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()

View file

@ -35,6 +35,89 @@ describe('renderComposerContents', () => {
expect(editor.textContent).toContain('<b>raw</b>')
expect(composerPlainText(editor)).toBe('@file:`<img src=x onerror=alert(1)>` <b>raw</b>')
})
it('hydrates a committed leading slash command back to its pill', () => {
// Text-hydration parity with @ refs: a re-render from serialized text
// (draft restore, undo, the trigger commit fallback) must not demote a
// committed no-arg command chip to plain text.
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
renderComposerContents(editor, '/some-skill @folder:`Desktop` ')
const pill = editor.querySelector('[data-slash-kind]')
expect(pill?.getAttribute('data-ref-text')).toBe('/some-skill')
expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull()
expect(composerPlainText(editor)).toBe('/some-skill @folder:`Desktop` ')
})
it('keeps a still-typed leading slash token as editable text', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
// No trailing whitespace — not committed yet.
renderComposerContents(editor, '/some-skil')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('/some-skil')
})
it('keeps an arg-taking command as text — its tail may be uncommitted prose', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
renderComposerContents(editor, '/goal ship the redesign')
expect(editor.querySelector('[data-slash-kind]')).toBeNull()
expect(composerPlainText(editor)).toBe('/goal ship the redesign')
})
})
describe('replaceBeforeCaret across split text nodes', () => {
it('replaces a token that Chromium fragmented into multiple text nodes', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('see @Desk'), document.createTextNode('top/'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 4)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
const fragment = document.createDocumentFragment()
fragment.append(refChipElement('folder', '`Desktop`'), document.createTextNode(' '))
// Token `@Desktop/` (9 chars) spans both text nodes.
expect(replaceBeforeCaret(editor, 9, fragment)).toBe(true)
expect(composerPlainText(editor)).toBe('see @folder:`Desktop` ')
editor.remove()
})
it('refuses when a chip interrupts the span — the token is not contiguous text', () => {
const editor = document.createElement('div')
editor.dataset.slot = RICH_INPUT_SLOT
editor.contentEditable = 'true'
document.body.append(editor)
editor.append(document.createTextNode('a'), refChipElement('file', '`x`'), document.createTextNode('bc'))
const caret = document.createRange()
caret.setStart(editor.lastChild!, 2)
caret.collapse(true)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(caret)
expect(replaceBeforeCaret(editor, 5, document.createDocumentFragment())).toBe(false)
expect(composerPlainText(editor)).toBe('a@file:`x`bc')
editor.remove()
})
})
describe('normalizeComposerEditorDom', () => {

View file

@ -16,11 +16,22 @@ import {
type SlashChipKind,
slashIconElement
} from '@/components/assistant-ui/directive-text'
import {
desktopSlashCommandArgumentMode,
isDesktopSlashCommand,
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
export const RICH_INPUT_SLOT = 'composer-rich-input'
export const REF_RE = /@(file|folder|url|image|tool|line|terminal|session):(`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)/g
/** A committed leading slash command: `/name` followed by whitespace. The
* whitespace requirement is what separates a committed command (chips always
* serialize with their auto-inserted trailing space) from one still being
* typed, which must stay editable text. */
const LEADING_SLASH_COMMAND_RE = /^\/[a-zA-Z][\w-]*(?=\s)/
const ESC: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }
export function escapeHtml(value: string) {
@ -129,6 +140,24 @@ export function appendComposerContents(target: DocumentFragment | HTMLElement, t
export function renderComposerContents(target: HTMLElement, text: string) {
target.replaceChildren()
// A leading `/command` hydrates back to its pill — parity with REF_RE for
// `@` refs, so a full re-render from serialized text (draft restore, undo,
// the trigger commit fallback) doesn't demote a committed command chip to
// plain text. Only commands with NO argument stage qualify (skills, quick
// commands, no-arg built-ins): their committed pill is exactly the bare
// `/name`, so the boundary is unambiguous. Arg-taking commands (`/goal ship
// it`, `/personality alice`) stay text — their tail may be prose that was
// never committed. The trailing whitespace is load-bearing too: a committed
// pill always serializes with its auto-inserted space, while a half-typed
// `/wor` must stay editable text.
const command = LEADING_SLASH_COMMAND_RE.exec(text)?.[0]
if (command && isDesktopSlashCommand(command) && desktopSlashCommandArgumentMode(command) === null) {
target.append(slashChipElement(command, resolveDesktopCommand(command) ? 'command' : 'skill'))
text = text.slice(command.length)
}
appendComposerContents(target, text)
}
@ -173,27 +202,84 @@ export function insertComposerContentsAtCaret(editor: HTMLElement, text: string)
}
}
/** Swap the `length` characters immediately before a collapsed caret for
* `fragment`, leaving the caret after it. Returns whether it ran a caret that
* isn't inside a text node holding the whole token is left alone. */
export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment: DocumentFragment) {
/** Range covering exactly `length` serialized characters immediately before a
* collapsed caret, spanning Chromium's split text nodes. Null when the caret
* isn't a collapsed selection in `editor`, or when a chip/<br>/block boundary
* interrupts before `length` characters are covered a trigger token is
* always contiguous text, so anything else means "don't touch the DOM here".
*
* This is what keeps chip insertion stable: Chromium fragments text nodes
* around contenteditable=false chips on every edit, so any commit path that
* demands the whole token inside ONE text node (the old check) degrades to a
* full re-render as soon as a chip exists anywhere in the line. */
export function rangeBeforeCaret(editor: HTMLElement, length: number): Range | null {
const hit = composerSelectionRange(editor)
if (!hit?.range.collapsed) {
return false
if (!hit?.range.collapsed || length <= 0) {
return null
}
const { startContainer, startOffset } = hit.range
let node: Node | null = hit.range.startContainer
let offset = hit.range.startOffset
if (startContainer.nodeType !== Node.TEXT_NODE || startOffset < length) {
return false
// An element-positioned caret (common right after programmatic caret moves)
// resolves to the end of the text node before it. A chip or <br> there means
// no text token precedes the caret — bail rather than guess.
if (node.nodeType !== Node.TEXT_NODE) {
node = node.childNodes[offset - 1] ?? null
if (node?.nodeType !== Node.TEXT_NODE) {
return null
}
offset = (node.textContent || '').length
}
let startNode = node as Text
let startOffset = offset
let remaining = length
while (remaining > 0) {
if (startOffset >= remaining) {
startOffset -= remaining
remaining = 0
break
}
remaining -= startOffset
const prev: Node | null = startNode.previousSibling
if (prev?.nodeType !== Node.TEXT_NODE) {
return null
}
startNode = prev as Text
startOffset = (prev.textContent || '').length
}
const range = document.createRange()
range.setStart(startNode, startOffset)
range.setEnd(hit.range.startContainer, hit.range.startOffset)
return range
}
/** Swap the `length` characters immediately before a collapsed caret for
* `fragment`, leaving the caret after it. Returns whether it ran. Spans split
* text nodes (see rangeBeforeCaret) a token typed around existing chips
* still commits in place instead of falling back to a full re-render. */
export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment: DocumentFragment) {
const range = rangeBeforeCaret(editor, length)
if (!range) {
return false
}
const tail = fragment.lastChild
range.setStart(startContainer, startOffset - length)
range.setEnd(startContainer, startOffset)
range.deleteContents()
range.insertNode(fragment)
@ -202,8 +288,11 @@ export function replaceBeforeCaret(editor: HTMLElement, length: number, fragment
}
range.collapse(true)
hit.selection.removeAllRanges()
hit.selection.addRange(range)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
return true
}

View file

@ -65,6 +65,18 @@ describe('detectTrigger', () => {
})
})
it('treats a chip edge as a token boundary, like whitespace', () => {
// U+FFFC is textBeforeCaret's placeholder for a committed pill. Upstream
// assistant-ui's Lexical DirectivePlugin gets the same semantics from node
// boundaries: typing a trigger right after a chip (no space) still opens
// the popover, and a chip inside a token ends it.
expect(detectTrigger('\uFFFC@Desk')).toEqual({ kind: '@', query: 'Desk', tokenLength: 5 })
// Not position 0, so it's an inline reference — not a command invocation.
expect(detectTrigger('\uFFFC/cle')).toEqual({ inline: true, kind: '/', query: 'cle', tokenLength: 4 })
// The placeholder itself never leaks into a query.
expect(detectTrigger('@a\uFFFCb')).toBeNull()
})
it('keeps the at-mention live for a typed ref kind with a path', () => {
expect(detectTrigger('@file:src/main.tsx')).toEqual({
kind: '@',

View file

@ -36,9 +36,14 @@ export interface TriggerState {
// The inline shape is what makes skills reachable anywhere in a prompt. Both
// shapes need the trailing `$`: detection runs against the text BEFORE the
// caret, so the match must end where the user is typing.
const AT_TRIGGER_RE = /(?:^|[\s])(@)([^\s@]*)$/
//
// U+FFFC is the placeholder textBeforeCaret emits for a committed chip. A chip
// edge is a token boundary just like whitespace (upstream assistant-ui's
// Lexical DirectivePlugin gets the same semantics from node boundaries), so
// `@` or `/` typed immediately after a pill still opens the popover.
const AT_TRIGGER_RE = /(?:^|[\s\uFFFC])(@)([^\s@\uFFFC]*)$/
const SLASH_COMMAND_TRIGGER_RE = /^(\/)((?:[a-zA-Z][\w-]*(?:\s+\S*)*)?)$/
const SLASH_INLINE_TRIGGER_RE = /[\s](\/)([a-zA-Z][\w-]*)?$/
const SLASH_INLINE_TRIGGER_RE = /[\s\uFFFC](\/)([a-zA-Z][\w-]*)?$/
/** Stable key for paste dedupe — `items` and `files` often mirror the same image as different objects. */
export function blobDedupeKey(blob: Blob): string {
@ -112,7 +117,17 @@ export function extractClipboardImageBlobs(clipboard: DataTransfer): Blob[] {
return blobs
}
/** Caret-anchored text before the cursor, or null if the selection isn't a collapsed caret inside `editor`. */
/** Caret-anchored text before the cursor, or null if the selection isn't a
* collapsed caret inside `editor`.
*
* Chips are ATOMIC to trigger detection: a committed pill must not leak its
* label text into the string the trigger regexes see. A `/work` pill whose
* label serialized into this text made the `^`-anchored command regex treat
* everything after it as that command's argument which silenced the `@`
* popover for the rest of the message (`/work @Desk` no trigger the
* typed path never chips and submits as plain text). Each chip contributes
* an object-replacement placeholder instead, and <br> contributes a newline
* so a trigger at the start of a wrapped line still detects. */
export function textBeforeCaret(editor: HTMLDivElement): string | null {
const sel = window.getSelection()
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
@ -125,7 +140,19 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null {
before.selectNodeContents(editor)
before.setEnd(range.startContainer, range.startOffset)
return before.toString()
const scratch = document.createElement('div')
scratch.append(before.cloneContents())
for (const chip of scratch.querySelectorAll('[data-ref-text]')) {
chip.replaceWith('\uFFFC')
}
for (const br of scratch.querySelectorAll('br')) {
br.replaceWith('\n')
}
return scratch.textContent ?? ''
}
export function detectTrigger(textBefore: string): TriggerState | null {

View file

@ -38,6 +38,7 @@ import {
placeCaretEnd,
refChipElement,
renderComposerContents,
replaceBeforeCaret,
RICH_INPUT_SLOT
} from '@/app/chat/composer/rich-editor'
import { detectTrigger, textBeforeCaret, type TriggerState } from '@/app/chat/composer/text-utils'
@ -321,41 +322,22 @@ export const UserEditComposer: FC<UserEditComposerProps> = ({ cwd, gateway, sess
starter ? window.setTimeout(refreshTrigger, 0) : closeTrigger()
}
const sel = window.getSelection()
const range = sel?.rangeCount ? sel.getRangeAt(0) : null
const node = range?.startContainer
const offset = range?.startOffset ?? 0
// In place first, spanning Chromium's split text nodes (see
// rangeBeforeCaret). The re-render fallback only runs when the caret
// genuinely can't anchor the token — it rebuilds from serialized text,
// which re-chips `@` refs but resets the caret to the end.
const fragment = document.createDocumentFragment()
if (!sel || !range || node?.nodeType !== Node.TEXT_NODE || offset < trigger.tokenLength) {
directive
? fragment.append(refChipElement(directive[1], directive[2]), document.createTextNode(' '))
: fragment.append(document.createTextNode(text))
if (!replaceBeforeCaret(editor, trigger.tokenLength, fragment)) {
const current = composerPlainText(editor)
renderComposerContents(editor, `${current.slice(0, Math.max(0, current.length - trigger.tokenLength))}${text}`)
placeCaretEnd(editor)
return finish()
}
const replaceRange = document.createRange()
replaceRange.setStart(node, offset - trigger.tokenLength)
replaceRange.setEnd(node, offset)
replaceRange.deleteContents()
if (directive) {
const chip = refChipElement(directive[1], directive[2])
const space = document.createTextNode(' ')
const fragment = document.createDocumentFragment()
fragment.append(chip, space)
replaceRange.insertNode(fragment)
const caret = document.createRange()
caret.setStart(space, 1)
caret.collapse(true)
sel.removeAllRanges()
sel.addRange(caret)
return finish()
}
document.execCommand('insertText', false, text)
finish()
},
[aui, closeTrigger, recordUndoPoint, refreshTrigger, rememberInitialDraft, requestEditFocus, trigger]