From 412a535433b7128444aabe624c809f8fc56552d4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 25 Jul 2026 20:28:29 -0500 Subject: [PATCH] fix(desktop): scope mid-message references to skills and keep them as chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the inline slash reference. Only skills are offered mid-message now. A built-in like `/model` or `/new` acts on the app, so it reads as nothing useful in the middle of a sentence — whereas a skill is exactly the thing you want to point at while describing work. A leading `/` is unchanged and still offers the full command set. A picked skill also stays a pill in the sent message. The composer already inserts one, but the message renderer knew nothing about slash references and flattened it back to raw text on send. It now parses a mid-prose `/skill` into a chip segment and renders it with the same styling the composer uses. The submitted text is untouched — the chip still round-trips to the literal `/clean` the backend expects — so this is presentation only. Path-like tokens (`/usr/local/bin`) are excluded: unlike the caret-anchored composer trigger, this scans finished text, so it also has to reject a token that runs on into a path. --- .../src/app/chat/composer/composer-utils.ts | 3 ++ .../hooks/use-composer-trigger.test.ts | 23 +++++++++- .../composer/hooks/use-composer-trigger.ts | 16 ++++++- .../assistant-ui/directive-text.test.ts | 39 ++++++++++++++++ .../assistant-ui/directive-text.tsx | 44 ++++++++++++++++++- 5 files changed, 120 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/composer-utils.ts b/apps/desktop/src/app/chat/composer/composer-utils.ts index 7939be35b6b..547a210f06f 100644 --- a/apps/desktop/src/app/chat/composer/composer-utils.ts +++ b/apps/desktop/src/app/chat/composer/composer-utils.ts @@ -50,6 +50,9 @@ export function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind return 'command' } +/** True for a skill completion — the only kind offered mid-message. */ +export const isSkillItem = (item: Unstable_TriggerItem) => slashChipKindForItem(item) === 'skill' + /** A `/` query is at its arg stage once it's past the command name. */ export const slashArgStage = (query: string) => query.includes(' ') 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 62a194da20d..c356fae3af8 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 @@ -25,11 +25,11 @@ function mountEditor(text: string) { return editor } -const item = (command: string): Unstable_TriggerItem => ({ +const item = (command: string, group = 'Skills'): Unstable_TriggerItem => ({ id: command, type: 'slash', label: command.slice(1), - metadata: { command, display: command, meta: '', group: 'Skills', action: '', rawText: command } + metadata: { command, display: command, meta: '', group, action: '', rawText: command } }) function mountTrigger(editor: HTMLDivElement, items: Unstable_TriggerItem[]) { @@ -83,6 +83,25 @@ describe('useComposerTrigger — slash anywhere in the prompt', () => { expect(composerPlainText(editor)).toBe('please run /clean ') }) + it('offers only skills mid-message, not app commands', () => { + // `/model` and `/new` act on the app — meaningless as a reference in prose. + const editor = mountEditor('please run /') + const { hook } = mountTrigger(editor, [item('/clean'), item('/model', 'Commands'), item('/new', 'Commands')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.triggerItems.map(i => i.label)).toEqual(['clean']) + }) + + it('still offers the full command set at the start of the prompt', () => { + const editor = mountEditor('/') + const { hook } = mountTrigger(editor, [item('/clean'), item('/model', 'Commands')]) + + act(() => hook.result.current.refreshTrigger()) + + expect(hook.result.current.triggerItems.map(i => i.label)).toEqual(['clean', 'model']) + }) + it('still opens the list for a slash at the start of the prompt', () => { const editor = mountEditor('/cle') const { hook } = mountTrigger(editor, [item('/clean')]) 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 e4ac85bd770..3c22fddb634 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 @@ -4,7 +4,13 @@ import { type MutableRefObject, type RefObject, useCallback, useEffect, useRef, import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text' import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands' -import { COMPLETION_ACTIONS, slashArgStage, slashChipKindForItem, slashCommandToken } from '../composer-utils' +import { + COMPLETION_ACTIONS, + isSkillItem, + slashArgStage, + slashChipKindForItem, + slashCommandToken +} from '../composer-utils' import { composerPlainText, placeCaretEnd, @@ -112,7 +118,13 @@ export function useComposerTrigger({ return } - setTriggerItems(triggerAdapter.search(trigger.query)) + const items = triggerAdapter.search(trigger.query) + + // Mid-message only offers SKILLS. A built-in like `/model` or `/new` acts + // on the app, so it's meaningless as a reference inside prose — only a + // skill reads as "handle this part with X". Filtering here rather than in + // the fetcher keeps one completion source for both shapes. + setTriggerItems(trigger.inline ? items.filter(isSkillItem) : items) }, [trigger, triggerAdapter]) const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false diff --git a/apps/desktop/src/components/assistant-ui/directive-text.test.ts b/apps/desktop/src/components/assistant-ui/directive-text.test.ts index 36ab54d743e..d5f7074e5d1 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.test.ts +++ b/apps/desktop/src/components/assistant-ui/directive-text.test.ts @@ -47,3 +47,42 @@ describe('hermesDirectiveFormatter.parse', () => { ]) }) }) + +describe('inline skill references', () => { + const skills = (text: string) => + [...hermesDirectiveFormatter.parse(text)] + .filter(segment => segment.kind === 'mention' && segment.type === 'skill') + .map(segment => (segment.kind === 'mention' ? segment.id : '')) + + it('keeps a picked skill a chip in the sent message instead of flattening it', () => { + expect(skills('please run /clean on this')).toEqual(['/clean']) + }) + + it('keeps the surrounding prose as text around the chip', () => { + const segments = hermesDirectiveFormatter.parse('tidy this with /clean thanks') + + expect(segments).toEqual([ + { kind: 'text', text: 'tidy this with ' }, + { kind: 'mention', type: 'skill', label: 'clean', id: '/clean' }, + { kind: 'text', text: ' thanks' } + ]) + }) + + it('leaves file paths and fractions alone', () => { + expect(skills('check src/foo/bar')).toEqual([]) + expect(skills('look at /usr/local/bin')).toEqual([]) + expect(skills('roughly 3 /4 of it')).toEqual([]) + }) + + it('does not chip a leading slash — that is a command invocation, not prose', () => { + expect(skills('/clean')).toEqual([]) + }) + + it('parses a skill chip alongside an @ reference', () => { + const mentions = [...hermesDirectiveFormatter.parse('run /clean on @file:`src/a.ts`')].filter( + segment => segment.kind === 'mention' + ) + + expect(mentions.map(segment => (segment.kind === 'mention' ? segment.type : ''))).toEqual(['skill', 'file']) + }) +}) diff --git a/apps/desktop/src/components/assistant-ui/directive-text.tsx b/apps/desktop/src/components/assistant-ui/directive-text.tsx index 6ea2580f16b..c9a45f2d3f7 100644 --- a/apps/desktop/src/components/assistant-ui/directive-text.tsx +++ b/apps/desktop/src/components/assistant-ui/directive-text.tsx @@ -171,6 +171,17 @@ const HERMES_DIRECTIVE_RE = new RegExp( 'g' ) +// A skill referenced mid-prose (`clean this up with /clean`). The composer +// inserts it as a pill, so the sent message renders it as one too rather than +// flattening back to raw text. Only matches after whitespace — a leading `/` +// is a command invocation, which never reaches a rendered message as text. +// +// Unlike the composer's caret-anchored trigger, this scans finished text, so +// it must reject a token that continues into a path: `/usr/local/bin` would +// otherwise chip as `/usr`. `(?![\w-]*\/)` requires the token to end at +// something other than another slash. +const SLASH_SKILL_RE = /(?<=\s)\/([a-zA-Z][\w-]*)(?![\w-]*\/)/g + const TRAILING_PUNCTUATION_RE = /[,.;!?]+$/ function unwrapRefValue(raw: string): string { @@ -269,7 +280,14 @@ function parseDirectiveText(text: string): Unstable_DirectiveSegment[] { label: shortLabel(match[1] as HermesRefType, id), id } - }) + }), + ...Array.from(text.matchAll(SLASH_SKILL_RE)).map(match => ({ + start: match.index ?? 0, + end: (match.index ?? 0) + match[0].length, + type: 'skill', + label: match[1], + id: `/${match[1]}` + })) ] .filter(match => match.id) .sort((a, b) => a.start - b.start) @@ -369,6 +387,8 @@ export function DirectiveContent({ text }: { text: string }) { {segment.text} ) : segment.type === 'image' ? null : segment.type === 'session' ? ( + ) : segment.type === 'skill' ? ( + ) : ( ) @@ -505,6 +525,28 @@ export const SessionRefLink: FC<{ ) } +/** A skill referenced inside a sent message — the rendered twin of the + * composer's slash pill, so a picked skill stays a chip after send. */ +const SlashChip: FC<{ kind: SlashChipKind; label: string; value: string }> = ({ kind, label, value }) => ( + + + {SLASH_ICON_PATHS[kind].map(d => ( + + ))} + + {label} + +) + /** Inert by default; `onClick` promotes the chip to a real button (session * refs, which open the session they name). */ const DirectiveChip: FC<{