diff --git a/apps/desktop/src/app/chat/composer/directive-scope.test.ts b/apps/desktop/src/app/chat/composer/directive-scope.test.ts new file mode 100644 index 00000000000..08d1804dfba --- /dev/null +++ b/apps/desktop/src/app/chat/composer/directive-scope.test.ts @@ -0,0 +1,164 @@ +import type { Unstable_TriggerItem } from '@assistant-ui/core' +import { act, renderHook } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { useComposerTrigger } from './hooks/use-composer-trigger' +import { pathifyRefs } from './path-refs' +import { composerPlainText, insertComposerContentsAtCaret, RICH_INPUT_SLOT } from './rich-editor' +import { detectTrigger, openDirectiveScope, textBeforeCaret } from './text-utils' +import { linkifyUrls } from './url-refs' + +function folderItem(rel: string): Unstable_TriggerItem { + const rawText = `@folder:${rel}/` + + return { + id: `${rawText}|0`, + type: 'folder', + label: rel.split('/').filter(Boolean).pop() ?? rel, + metadata: { icon: 'folder', display: `${rel}/`, meta: 'dir', rawText, insertId: `${rel}/` } + } +} + +/** Literally-typed text, caret `fromEnd` characters before the end. */ +function typed(text: string, fromEnd = 0) { + const editor = document.createElement('div') + + editor.contentEditable = 'true' + editor.dataset.slot = RICH_INPUT_SLOT + document.body.append(editor) + + const node = document.createTextNode(text) + + editor.append(node) + + const range = document.createRange() + + range.setStart(node, text.length - fromEnd) + range.collapse(true) + + const sel = window.getSelection() + + sel?.removeAllRanges() + sel?.addRange(range) + + return editor +} + +function withTrigger(editor: HTMLDivElement, draft: string) { + const editorRef = { current: editor as HTMLDivElement | null } + + const { result } = renderHook(() => + useComposerTrigger({ + at: { adapter: null, loading: false }, + draftRef: { current: draft }, + editorRef, + requestMainFocus: vi.fn(), + setComposerText: vi.fn(), + slash: { adapter: null, loading: false } + }) + ) + + act(() => result.current.refreshTrigger()) + + return result +} + +/** The composer's paste handler, minus the clipboard plumbing. */ +function paste(editor: HTMLDivElement, text: string) { + insertComposerContentsAtCaret(editor, pathifyRefs(linkifyUrls(text)), openDirectiveScope(editor)) +} + +describe('directive scope is a browse mode, not text to maintain', () => { + it('Tab-descend carries the scope down instead of dropping to a bare path', () => { + const editor = typed('@folder:apps/deskt') + const result = withTrigger(editor, '@folder:apps/deskt') + + expect(result.current.trigger).toMatchObject({ kind: '@', scope: 'folder', value: 'apps/deskt' }) + + act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop'), { descend: true })) + + expect(composerPlainText(editor)).toBe('@folder:apps/desktop/') + }) + + it('Backspace climbs the path, then drops the whole scope', () => { + const editor = typed('@folder:apps/desktop/') + const result = withTrigger(editor, '@folder:apps/desktop/') + + act(() => result.current.ascendTriggerPath()) + expect(composerPlainText(editor)).toBe('@folder:apps/') + + act(() => result.current.refreshTrigger()) + act(() => result.current.ascendTriggerPath()) + expect(composerPlainText(editor)).toBe('@folder:') + + // The scope is one unit: Backspace drops it whole rather than nibbling + // back through `:`, `r`, `e`, `d`, `l`, `o`, `f`. + act(() => result.current.refreshTrigger()) + act(() => result.current.ascendTriggerPath()) + expect(composerPlainText(editor)).toBe('@') + }) + + it('leaves Backspace alone when there is no scope and no path', () => { + const editor = typed('@apps') + const result = withTrigger(editor, '@apps') + + let handled = true + + act(() => { + handled = result.current.ascendTriggerPath() + }) + + expect(handled).toBe(false) + }) + + it('a pick mid-message keeps the trailing prose and consumes the whole token', () => { + const editor = typed('@folder:apps/deskt and some trailing words', 24) + const result = withTrigger(editor, '@folder:apps/deskt and some trailing words') + + act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop'))) + + expect(composerPlainText(editor)).toBe('@folder:`apps/desktop/` and some trailing words') + expect(editor.querySelector('[data-ref-kind="folder"]')).not.toBeNull() + }) + + it('pasting into an open @url: scope consumes it instead of stacking', () => { + const editor = typed('refer to @url:') + + paste(editor, 'https://github.com/NousResearch/hermes-agent/pull/74533') + + expect(composerPlainText(editor)).toBe('refer to @url:`https://github.com/NousResearch/hermes-agent/pull/74533`') + expect(editor.textContent).not.toContain('@url:@url:') + }) + + it('a normal paste with no open scope is untouched', () => { + const editor = typed('look at ') + + paste(editor, 'https://example.com/x') + + expect(composerPlainText(editor)).toBe('look at @url:`https://example.com/x`') + }) + + it('scope parsing leaves an unscoped @ query alone', () => { + expect(detectTrigger('@apps/desk')).toMatchObject({ kind: '@', value: 'apps/desk' }) + expect(detectTrigger('@apps/desk')?.scope).toBeUndefined() + }) + + it('openDirectiveScope only fires on an EMPTY scope', () => { + // The count is what a paste consumes: `@url:` is 5 characters of syntax + // the user never typed and shouldn't be left holding. + expect(openDirectiveScope(typed('@url:'))).toBe(5) + expect(openDirectiveScope(typed('@url:https://x.com'))).toBe(0) + expect(openDirectiveScope(typed('plain text'))).toBe(0) + }) + + it('chips stay atomic to scope detection', () => { + const editor = typed('@folder:apps/desktop/') + const result = withTrigger(editor, '@folder:apps/desktop/') + + act(() => result.current.replaceTriggerWithChip(folderItem('apps/desktop'))) + + // A committed chip is one object-replacement char, so a fresh `@` typed + // after it opens an unscoped browse rather than inheriting the old scope. + expect(detectTrigger(`${textBeforeCaret(editor)}@`)?.scope).toBeUndefined() + }) +}) 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 ddb15ec677d..a2e54b2c07e 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.test.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.test.ts @@ -4,19 +4,19 @@ import { blobDedupeKey, detectTrigger, extractClipboardImageBlobs } from './text describe('detectTrigger', () => { it('detects a bare slash trigger with an empty query', () => { - expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1 }) + expect(detectTrigger('/')).toEqual({ kind: '/', query: '', tokenLength: 1, value: '' }) }) it('detects a slash command query', () => { - expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6 }) + expect(detectTrigger('/skill')).toEqual({ kind: '/', query: 'skill', tokenLength: 6, value: 'skill' }) }) it('detects a bare at-mention trigger with an empty query', () => { - expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1 }) + expect(detectTrigger('@')).toEqual({ kind: '@', query: '', tokenLength: 1, value: '' }) }) it('detects an at-mention query', () => { - expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5 }) + expect(detectTrigger('@file')).toEqual({ kind: '@', query: 'file', tokenLength: 5, value: 'file' }) }) it('returns null for plain text', () => { @@ -27,17 +27,20 @@ describe('detectTrigger', () => { expect(detectTrigger('/personality ')).toEqual({ kind: '/', query: 'personality ', - tokenLength: 13 + tokenLength: 13, + value: 'personality ' }) expect(detectTrigger('/personality alic')).toEqual({ kind: '/', query: 'personality alic', - tokenLength: 17 + tokenLength: 17, + value: 'personality alic' }) expect(detectTrigger('/tools enable foo')).toEqual({ kind: '/', query: 'tools enable foo', - tokenLength: 17 + tokenLength: 17, + value: 'tools enable foo' }) }) @@ -54,14 +57,25 @@ describe('detectTrigger', () => { it('keeps the at-mention live while walking into subfolders', () => { // A `/` inside the query is path navigation, not the end of the token — // the popover has to stay open so the next directory level can load. - expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3 }) - expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6 }) - expect(detectTrigger('@~/Desktop/')).toEqual({ kind: '@', query: '~/Desktop/', tokenLength: 11 }) - expect(detectTrigger('@/usr/local')).toEqual({ kind: '@', query: '/usr/local', tokenLength: 11 }) + expect(detectTrigger('@./')).toEqual({ kind: '@', query: './', tokenLength: 3, value: './' }) + expect(detectTrigger('@./src')).toEqual({ kind: '@', query: './src', tokenLength: 6, value: './src' }) + expect(detectTrigger('@~/Desktop/')).toEqual({ + kind: '@', + query: '~/Desktop/', + tokenLength: 11, + value: '~/Desktop/' + }) + expect(detectTrigger('@/usr/local')).toEqual({ + kind: '@', + query: '/usr/local', + tokenLength: 11, + value: '/usr/local' + }) expect(detectTrigger('@apps/desktop/src')).toEqual({ kind: '@', query: 'apps/desktop/src', - tokenLength: 17 + tokenLength: 17, + value: 'apps/desktop/src' }) }) @@ -70,20 +84,48 @@ describe('detectTrigger', () => { // 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 }) + expect(detectTrigger('\uFFFC@Desk')).toEqual({ kind: '@', query: 'Desk', tokenLength: 5, value: 'Desk' }) // 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 }) + expect(detectTrigger('\uFFFC/cle')).toEqual({ + inline: true, + kind: '/', + query: 'cle', + tokenLength: 4, + value: 'cle' + }) // 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', () => { + it('splits a typed ref kind off as the browse scope', () => { + // `@folder:apps/` is ONE token with TWO parts. The kind is the mode the + // user is browsing in, so it's held as `scope` rather than left in `value` + // for every consumer to re-parse (or, worse, to preserve by hand). expect(detectTrigger('@file:src/main.tsx')).toEqual({ kind: '@', query: 'file:src/main.tsx', - tokenLength: 18 + scope: 'file', + tokenLength: 18, + value: 'src/main.tsx' }) - expect(detectTrigger('@folder:apps/')).toEqual({ kind: '@', query: 'folder:apps/', tokenLength: 13 }) + expect(detectTrigger('@folder:apps/')).toEqual({ + kind: '@', + query: 'folder:apps/', + scope: 'folder', + tokenLength: 13, + value: 'apps/' + }) + // A scope with nothing typed after it is the empty-browse state the + // popover renders a header for. + expect(detectTrigger('@url:')).toEqual({ kind: '@', query: 'url:', scope: 'url', tokenLength: 5, value: '' }) + }) + + it('only treats a KNOWN kind as a scope', () => { + // `@teknium1:` is a handle with a colon, not a directive — inventing a + // scope for it would make Backspace eat the whole word. + expect(detectTrigger('@teknium1:')?.scope).toBeUndefined() + expect(detectTrigger('@teknium1:')?.value).toBe('teknium1:') + expect(detectTrigger('@localhost:8080')?.scope).toBeUndefined() }) it('still ends the at-mention token at whitespace', () => { @@ -92,15 +134,28 @@ describe('detectTrigger', () => { expect(detectTrigger('look at @apps/desktop')).toEqual({ kind: '@', query: 'apps/desktop', - tokenLength: 13 + tokenLength: 13, + value: 'apps/desktop' }) }) it('treats a mid-message slash as an inline reference', () => { // Skills have to be reachable anywhere in a prompt, not just at position 0. - expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1 }) - expect(detectTrigger('hello /clean')).toEqual({ kind: '/', inline: true, query: 'clean', tokenLength: 6 }) - expect(detectTrigger('text\n/skill')).toEqual({ kind: '/', inline: true, query: 'skill', tokenLength: 6 }) + expect(detectTrigger('hello /')).toEqual({ kind: '/', inline: true, query: '', tokenLength: 1, value: '' }) + expect(detectTrigger('hello /clean')).toEqual({ + kind: '/', + inline: true, + query: 'clean', + tokenLength: 6, + value: 'clean' + }) + expect(detectTrigger('text\n/skill')).toEqual({ + kind: '/', + inline: true, + query: 'skill', + tokenLength: 6, + value: 'skill' + }) }) it('does not carry arg completion into an inline slash reference', () => {