From e4b21efa566758debfd2a546717fd47835c9bfd3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 19:01:12 -0500 Subject: [PATCH] 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: '@',