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: '@',