From 15b2b655ea92b0e630d2df4d264559308a170b2c Mon Sep 17 00:00:00 2001 From: Diego Serrano <129707357+diegoserranobst@users.noreply.github.com> Date: Tue, 14 Apr 2026 07:37:27 -0400 Subject: [PATCH] feat(chat): show project and user skills as slash command suggestions --- .../team/messages/MessageComposer.tsx | 25 ++++--- .../components/ui/MentionSuggestionList.tsx | 19 ++++- src/renderer/types/mention.ts | 6 +- src/renderer/utils/mentionSuggestions.ts | 4 +- src/renderer/utils/skillCommandSuggestions.ts | 48 +++++++++++++ src/shared/utils/slashCommands.ts | 5 ++ .../utils/skillCommandSuggestions.test.ts | 72 +++++++++++++++++++ test/shared/utils/slashCommands.test.ts | 8 +++ 8 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 src/renderer/utils/skillCommandSuggestions.ts create mode 100644 test/renderer/utils/skillCommandSuggestions.test.ts diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index 7f79114c..7f5003ea 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -19,6 +19,7 @@ import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { nameColorSet } from '@renderer/utils/projectColor'; +import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions'; import { extractTaskRefsFromText, stripEncodedTaskReferenceMetadata, @@ -208,17 +209,21 @@ export const MessageComposer = ({ const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName); const { suggestions: taskSuggestions } = useTaskSuggestions(teamName); + // Project skills as slash command suggestions + const projectSkills = useStore( + useShallow((s) => (projectPath ? (s.skillsProjectCatalogByProjectPath[projectPath] ?? []) : [])) + ); + const userSkills = useStore(useShallow((s) => s.skillsUserCatalog)); + const fetchSkillsCatalog = useStore((s) => s.fetchSkillsCatalog); + + // Fetch skills catalog for the team's project on mount / project change + useEffect(() => { + void fetchSkillsCatalog(projectPath ?? undefined); + }, [fetchSkillsCatalog, projectPath]); + const slashCommandSuggestions = useMemo( - () => - KNOWN_SLASH_COMMANDS.map((command) => ({ - id: `command:${command.name}`, - name: command.name, - command: command.command, - description: command.description, - subtitle: command.description, - type: 'command', - })), - [] + () => buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, projectSkills, userSkills), + [projectSkills, userSkills] ); const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim(); diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index 141c9460..e64d6341 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -83,11 +83,12 @@ export const MentionSuggestionList = ({ } // Categorize suggestions (folders are grouped with files) - type Section = 'member' | 'team' | 'task' | 'file' | 'command'; + type Section = 'member' | 'team' | 'task' | 'file' | 'command' | 'skill'; const getSuggestionSection = (s: MentionSuggestion): Section => { if (s.type === 'file' || s.type === 'folder') return 'file'; if (s.type === 'task') return 'task'; if (s.type === 'command') return 'command'; + if (s.type === 'skill') return 'skill'; if (s.type === 'team') return 'team'; return 'member'; }; @@ -98,6 +99,7 @@ export const MentionSuggestionList = ({ task: 'Tasks', file: 'Files', command: 'Commands', + skill: 'Skills', }; // Determine which sections are present @@ -117,6 +119,7 @@ export const MentionSuggestionList = ({ const isTeam = section === 'team'; const isTask = section === 'task'; const isCommand = section === 'command'; + const isSkill = section === 'skill'; const taskTeamColorSet = isTask && s.color ? getTeamColorSet(s.color) @@ -165,6 +168,8 @@ export const MentionSuggestionList = ({ ) : isCommand ? ( + ) : isSkill ? ( + ) : isTeam ? ( @@ -218,7 +231,7 @@ export const MentionSuggestionList = ({ {isTask && s.subtitle ? (
{s.subtitle}
) : null} - {isCommand && s.description ? ( + {(isCommand || isSkill) && s.description ? (
{s.description}
diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts index a708abc8..f5150e30 100644 --- a/src/renderer/types/mention.ts +++ b/src/renderer/types/mention.ts @@ -9,8 +9,8 @@ export interface MentionSuggestion { description?: string; /** Color name from TeamColorSet palette */ color?: string; - /** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', or 'command' */ - type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command'; + /** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', 'command', or 'skill' */ + type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command' | 'skill'; /** Whether the team is currently online (team suggestions only) */ isOnline?: boolean; /** Absolute file/folder path (file/folder suggestions only) */ @@ -21,7 +21,7 @@ export interface MentionSuggestion { insertText?: string; /** Optional extra searchable text (subject, team name, path, etc.) */ searchText?: string; - /** Optional slash command string including leading slash (command suggestions only) */ + /** Optional slash command string including leading slash (command and skill suggestions only) */ command?: `/${string}`; /** Canonical task id (task suggestions only) */ taskId?: string; diff --git a/src/renderer/utils/mentionSuggestions.ts b/src/renderer/utils/mentionSuggestions.ts index a83e1bed..cc979085 100644 --- a/src/renderer/utils/mentionSuggestions.ts +++ b/src/renderer/utils/mentionSuggestions.ts @@ -2,12 +2,12 @@ import type { MentionSuggestion } from '@renderer/types/mention'; export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' | '/' { if (suggestion.type === 'task') return '#'; - if (suggestion.type === 'command') return '/'; + if (suggestion.type === 'command' || suggestion.type === 'skill') return '/'; return '@'; } export function getSuggestionInsertionText(suggestion: MentionSuggestion): string { - if (suggestion.type === 'command') { + if (suggestion.type === 'command' || suggestion.type === 'skill') { return suggestion.command?.slice(1) ?? suggestion.insertText ?? suggestion.name; } return suggestion.insertText ?? suggestion.name; diff --git a/src/renderer/utils/skillCommandSuggestions.ts b/src/renderer/utils/skillCommandSuggestions.ts new file mode 100644 index 00000000..201cffea --- /dev/null +++ b/src/renderer/utils/skillCommandSuggestions.ts @@ -0,0 +1,48 @@ +import { getKnownSlashCommand, isSupportedSlashCommandName } from '@shared/utils/slashCommands'; + +import type { MentionSuggestion } from '@renderer/types/mention'; +import type { SkillCatalogItem } from '@shared/types/extensions'; +import type { KnownSlashCommandDefinition } from '@shared/utils/slashCommands'; + +export function buildSlashCommandSuggestions( + builtIns: readonly KnownSlashCommandDefinition[], + projectSkills: readonly SkillCatalogItem[], + userSkills: readonly SkillCatalogItem[] +): MentionSuggestion[] { + const builtInSuggestions: MentionSuggestion[] = builtIns.map((command) => ({ + id: `command:${command.name}`, + name: command.name, + command: command.command, + description: command.description, + subtitle: command.description, + type: 'command', + })); + + const seenSkillNames = new Set(); + const skillSuggestions: MentionSuggestion[] = []; + for (const skill of [...projectSkills, ...userSkills]) { + const normalizedFolderName = skill.folderName.trim().toLowerCase(); + if ( + !skill.isValid || + !normalizedFolderName || + !isSupportedSlashCommandName(normalizedFolderName) || + getKnownSlashCommand(normalizedFolderName) !== null || + seenSkillNames.has(normalizedFolderName) + ) { + continue; + } + + seenSkillNames.add(normalizedFolderName); + skillSuggestions.push({ + id: `skill:${skill.id}`, + name: skill.folderName, + command: `/${normalizedFolderName}`, + description: skill.description, + subtitle: skill.scope === 'project' ? 'Project skill' : 'Personal skill', + searchText: `${skill.name} ${skill.folderName}`, + type: 'skill', + }); + } + + return [...builtInSuggestions, ...skillSuggestions]; +} diff --git a/src/shared/utils/slashCommands.ts b/src/shared/utils/slashCommands.ts index 35e4f19b..e06dd174 100644 --- a/src/shared/utils/slashCommands.ts +++ b/src/shared/utils/slashCommands.ts @@ -15,6 +15,7 @@ export interface ParsedStandaloneSlashCommand { endIndex: number; } +const SLASH_COMMAND_NAME_PATTERN = /^[a-z][a-z0-9:-]{0,63}$/i; const STANDALONE_SLASH_COMMAND_PATTERN = /^\/([a-z][a-z0-9:-]{0,63})(?:\s+([\s\S]*\S))?$/i; export const KNOWN_SLASH_COMMANDS: readonly KnownSlashCommandDefinition[] = [ @@ -78,6 +79,10 @@ export function getKnownSlashCommand(name: string): KnownSlashCommandDefinition return KNOWN_SLASH_COMMANDS_BY_NAME.get(name.trim().toLowerCase()) ?? null; } +export function isSupportedSlashCommandName(name: string): boolean { + return SLASH_COMMAND_NAME_PATTERN.test(name.trim()); +} + export function buildSlashCommandMeta( name: string, args?: string, diff --git a/test/renderer/utils/skillCommandSuggestions.test.ts b/test/renderer/utils/skillCommandSuggestions.test.ts new file mode 100644 index 00000000..e7e2a1d2 --- /dev/null +++ b/test/renderer/utils/skillCommandSuggestions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; + +import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions'; +import { KNOWN_SLASH_COMMANDS } from '@shared/utils/slashCommands'; + +import type { SkillCatalogItem } from '@shared/types/extensions'; + +function createSkill(overrides: Partial): SkillCatalogItem { + return { + id: overrides.id ?? 'skill-id', + sourceType: 'filesystem', + name: overrides.name ?? 'Skill Name', + description: overrides.description ?? 'Skill description', + folderName: overrides.folderName ?? 'skill-name', + scope: overrides.scope ?? 'project', + rootKind: overrides.rootKind ?? 'claude', + projectRoot: overrides.projectRoot ?? '/tmp/project', + discoveryRoot: overrides.discoveryRoot ?? '/tmp/project/.claude/skills', + skillDir: overrides.skillDir ?? '/tmp/project/.claude/skills/skill-name', + skillFile: overrides.skillFile ?? '/tmp/project/.claude/skills/skill-name/SKILL.md', + metadata: overrides.metadata ?? {}, + invocationMode: overrides.invocationMode ?? 'manual-only', + flags: overrides.flags ?? { hasScripts: false, hasReferences: false, hasAssets: false }, + isValid: overrides.isValid ?? true, + issues: overrides.issues ?? [], + modifiedAt: overrides.modifiedAt ?? 0, + }; +} + +describe('buildSlashCommandSuggestions', () => { + it('keeps built-ins and adds valid skills in a separate suggestion type', () => { + const suggestions = buildSlashCommandSuggestions(KNOWN_SLASH_COMMANDS, [ + createSkill({ id: 'project-skill', folderName: 'review-skill', scope: 'project' }), + ], []); + + expect(suggestions[0]?.type).toBe('command'); + expect(suggestions.some((suggestion) => suggestion.type === 'skill')).toBe(true); + expect(suggestions.find((suggestion) => suggestion.id === 'skill:project-skill')).toMatchObject({ + name: 'review-skill', + command: '/review-skill', + subtitle: 'Project skill', + type: 'skill', + }); + }); + + it('filters slash-unsafe names and built-in collisions', () => { + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [ + createSkill({ id: 'unsafe', folderName: 'bad skill' }), + createSkill({ id: 'collision', folderName: 'plan' }), + ], + [] + ); + + expect(suggestions.find((suggestion) => suggestion.id === 'skill:unsafe')).toBeUndefined(); + expect(suggestions.find((suggestion) => suggestion.id === 'skill:collision')).toBeUndefined(); + }); + + it('prefers project skills when user and project skills share the same slash name', () => { + const suggestions = buildSlashCommandSuggestions( + KNOWN_SLASH_COMMANDS, + [createSkill({ id: 'project', folderName: 'shared-skill', scope: 'project' })], + [createSkill({ id: 'user', folderName: 'shared-skill', scope: 'user' })] + ); + + expect(suggestions.filter((suggestion) => suggestion.command === '/shared-skill')).toHaveLength(1); + expect(suggestions.find((suggestion) => suggestion.command === '/shared-skill')?.id).toBe( + 'skill:project' + ); + }); +}); diff --git a/test/shared/utils/slashCommands.test.ts b/test/shared/utils/slashCommands.test.ts index 29a48857..e30492a9 100644 --- a/test/shared/utils/slashCommands.test.ts +++ b/test/shared/utils/slashCommands.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { getKnownSlashCommand, + isSupportedSlashCommandName, KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand, } from '@shared/utils/slashCommands'; @@ -53,4 +54,11 @@ describe('slashCommands', () => { expect(getKnownSlashCommand('MODEL')?.description).toContain('Claude model'); expect(getKnownSlashCommand('foo')).toBeNull(); }); + + it('validates slash-compatible command names', () => { + expect(isSupportedSlashCommandName('review')).toBe(true); + expect(isSupportedSlashCommandName('skill:name')).toBe(true); + expect(isSupportedSlashCommandName('my_skill')).toBe(false); + expect(isSupportedSlashCommandName('my skill')).toBe(false); + }); });