From 3ca4220fb3b223fb88345b9990ddded4232c2df3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 19:01:12 -0500 Subject: [PATCH 1/3] fix(desktop): stop composer chips demoting to plaintext on every re-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of one bug class: the composer treated 'rebuild the editor from serialized text' as routine, and serialized text couldn't express a slash pill. In-place commits: Chromium fragments text nodes around contenteditable=false chips, so the old commit path — whole token in ONE text node with the caret at its end — failed almost every keyboard pick and fell into the rebuild fallback. rangeBeforeCaret now walks the token backwards across sibling text nodes and refuses only at real boundaries (chip,
, block), making in-place replacement the default for picks, folder descends, Backspace path-ascends, and action items in both the main composer and the user-edit composer. Slash-pill hydration: renderComposerContents re-chipped @kind:value refs but never /command, so any surviving rebuild (draft restore, undo, the fallback above) demoted a committed command pill. A leading no-arg /command now hydrates back to its pill; arg-taking commands stay text since their tail may be uncommitted prose. Also: popover picks bank an undo point in the main composer, and Tab is swallowed while completions are in flight instead of moving focus out of the composer. --- .../composer/hooks/use-composer-trigger.ts | 136 +++++++++--------- apps/desktop/src/app/chat/composer/index.tsx | 13 +- .../src/app/chat/composer/rich-editor.ts | 115 +++++++++++++-- .../thread/user-edit-composer.tsx | 40 ++---- 4 files changed, 193 insertions(+), 111 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts index ea968fb3643..c59bcda6d7f 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.ts @@ -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 editorRef: RefObject + /** 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) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index cfb5e751889..478e40c281f 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -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() diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 7bf770eda17..bc6a1f26bc8 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } 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/
/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
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 } 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 8791ac3b75e..72e9b8e1ddb 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 @@ -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 = ({ 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] From 5fa03c6f3ecfb4ef45c28c1ec9324d39b95c7959 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 19:01:12 -0500 Subject: [PATCH 2/3] fix(desktop): keep chips atomic to composer trigger detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit textBeforeCaret serialized chip labels into the string the trigger regexes scan, so a leading /work pill made the anchored command regex swallow the rest of the line as its argument — silencing the @ popover for the whole message. Chips now contribute an object-replacement placeholder and
a newline, so committed pills can't poison detection. --- .../src/app/chat/composer/text-utils.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index bf91f1db6a4..e471daecf5e 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -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
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 { From e4b21efa566758debfd2a546717fd47835c9bfd3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 19:01:12 -0500 Subject: [PATCH 3/3] test(desktop): cover the composer chip plaintext-demotion bug class Backspace path-ascend keeping the leading command pill, folder picks alongside a command pill, commits spanning Chromium-split text nodes, slash-pill hydration boundaries (committed vs half-typed vs arg-taking), and replaceBeforeCaret refusing across chip boundaries. --- .../hooks/use-composer-trigger.test.ts | 69 +++++++++++++++ .../src/app/chat/composer/rich-editor.test.ts | 83 +++++++++++++++++++ .../src/app/chat/composer/text-utils.test.ts | 12 +++ 3 files changed, 164 insertions(+) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts index a778262d370..44c010cb4f1 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-composer-trigger.test.ts @@ -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() + }) +}) 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 842765388ff..e55f24e8cf1 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.test.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.test.ts @@ -35,6 +35,89 @@ describe('renderComposerContents', () => { expect(editor.textContent).toContain('raw') expect(composerPlainText(editor)).toBe('@file:`` raw') }) + + 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', () => { diff --git a/apps/desktop/src/app/chat/composer/text-utils.test.ts b/apps/desktop/src/app/chat/composer/text-utils.test.ts index 2a350593f45..ddb15ec677d 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -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: '@',