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 4239f781afa9..a778262d370f 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 @@ -120,6 +120,29 @@ describe('useComposerTrigger — slash anywhere in the prompt', () => { expect(hook.result.current.trigger).toBeNull() }) + + it('opens the list for a second slash after a leading command', () => { + // `/work /cle`: the command regex's argument tail would otherwise swallow + // `/cle` as an argument to `/work`, and a no-arg command suppresses the + // popover — so every slash after the first went dead. + const editor = mountEditor('/work /cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.trigger).toMatchObject({ kind: '/', inline: true, query: 'cle' }) + expect(hook.result.current.triggerItems).toHaveLength(1) + }) + + it('inserts the second command without disturbing the first', () => { + const editor = mountEditor('/work rewrite the composer /cle') + const { hook } = mountTrigger(editor, [item('/clean')]) + + act(() => hook.result.current.refreshTrigger()) + act(() => hook.result.current.replaceTriggerWithChip(item('/clean'))) + + expect(composerPlainText(editor)).toBe('/work rewrite the composer /clean ') + }) }) describe('useComposerTrigger — free-text slash arguments', () => { diff --git a/apps/desktop/src/app/chat/composer/text-utils.ts b/apps/desktop/src/app/chat/composer/text-utils.ts index 3029c7bc601d..bf91f1db6a44 100644 --- a/apps/desktop/src/app/chat/composer/text-utils.ts +++ b/apps/desktop/src/app/chat/composer/text-utils.ts @@ -28,6 +28,11 @@ export interface TriggerState { // there are no args to complete — the trigger is a single token that ends at // the next space, exactly like `@`. // +// Only the FIRST slash can be an invocation, so the inline shape is tested +// first: the command regex's argument tail (`(?:\s+\S*)*`) happily swallows a +// later `/skill` as if it were an argument, which killed completion for every +// slash after a leading command (`/work /cle` → nothing). +// // 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. @@ -124,14 +129,10 @@ export function textBeforeCaret(editor: HTMLDivElement): string | null { } export function detectTrigger(textBefore: string): TriggerState | null { - const command = SLASH_COMMAND_TRIGGER_RE.exec(textBefore) - - if (command) { - return { kind: '/', query: command[2], tokenLength: 1 + command[2].length } - } - // An inline `/skill` is a reference dropped into prose, so it carries no args - // and the whole match is the token the chip replaces. + // and the whole match is the token the chip replaces. Checked before the + // anchored command shape so a second slash isn't mistaken for the first + // command's argument. const inline = SLASH_INLINE_TRIGGER_RE.exec(textBefore) if (inline) { @@ -140,6 +141,12 @@ export function detectTrigger(textBefore: string): TriggerState | null { return { inline: true, kind: '/', query, tokenLength: 1 + query.length } } + const command = SLASH_COMMAND_TRIGGER_RE.exec(textBefore) + + if (command) { + return { kind: '/', query: command[2], tokenLength: 1 + command[2].length } + } + const at = AT_TRIGGER_RE.exec(textBefore) if (at) {