From 2346427c53ec83a21cf4e1287479cba57c3e6522 Mon Sep 17 00:00:00 2001 From: RaspberryLee <35322749+RaspberryLee@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:40:59 +0800 Subject: [PATCH] feat(desktop): group slash menu commands --- .../src/components/chat/ChatInput.test.tsx | 48 ++++- desktop/src/components/chat/ChatInput.tsx | 182 +++++++++++++++--- .../src/components/chat/composerUtils.test.ts | 28 +++ desktop/src/components/chat/composerUtils.ts | 32 +++ desktop/src/i18n/locales/en.ts | 1 + desktop/src/i18n/locales/jp.ts | 1 + desktop/src/i18n/locales/kr.ts | 1 + desktop/src/i18n/locales/zh-TW.ts | 1 + desktop/src/i18n/locales/zh.ts | 1 + 9 files changed, 259 insertions(+), 36 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index 48b81d2d..439bf6af 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -1475,7 +1475,7 @@ describe('ChatInput file mentions', () => { selectionStart: 1, }, }) - expect(await screen.findByText('/mcp')).toBeInTheDocument() + expect(await screen.findByText('mcp')).toBeInTheDocument() expect(panel).toHaveClass('overflow-visible') expect(panel).not.toHaveClass('overflow-hidden') @@ -1672,12 +1672,48 @@ describe('ChatInput file mentions', () => { await waitFor(() => { const commandButtons = screen - .getAllByRole('button') - .filter((button) => button.textContent?.startsWith('/')) - expect(commandButtons[0]).toHaveTextContent('/superpowers:brainstorming') + .getAllByRole('option') + .filter((option) => option.textContent?.includes('Creative work planning.')) + expect(commandButtons[0]).toHaveTextContent('superpowers:brainstorming') + expect(commandButtons[0]).toHaveTextContent('Personal') }) }) + it('shows app commands before the personal skills section', async () => { + useChatStore.setState({ + sessions: { + [sessionId]: { + ...useChatStore.getState().sessions[sessionId]!, + slashCommands: [ + { + name: 'audit', + description: 'Audit product UX.', + }, + ], + }, + }, + }) + + render() + + const input = screen.getByRole('textbox') as HTMLTextAreaElement + fireEvent.change(input, { + target: { value: '/', selectionStart: 1 }, + }) + + const systemCommand = await screen.findByText('mcp') + const skillsHeading = screen.getByText('Skills') + const personalSkill = screen.getByText('audit') + + expect(systemCommand.compareDocumentPosition(skillsHeading)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ) + expect(skillsHeading.compareDocumentPosition(personalSkill)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ) + expect(personalSkill.closest('[role="option"]')).toHaveTextContent('Personal') + }) + it('offers active agents as slash entries that insert /agent with the selected type', async () => { mocks.listAgents.mockResolvedValue({ activeAgents: [ @@ -1703,7 +1739,7 @@ describe('ChatInput file mentions', () => { target: { value: '/debug', selectionStart: 6 }, }) - const agentOption = await screen.findByText('/agent debugger') + const agentOption = await screen.findByText('agent debugger') fireEvent.click(agentOption) expect(input).toHaveValue('/agent debugger ') @@ -1737,7 +1773,7 @@ describe('ChatInput file mentions', () => { target: { value: '/agent', selectionStart: 6 }, }) - await screen.findByText('/agent debugger') + await screen.findByText('agent debugger') fireEvent.keyDown(input, { key: 'ArrowDown' }) fireEvent.keyDown(input, { key: 'Enter' }) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 75b07884..c7fde20b 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -1,4 +1,28 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import { + Bot, + Box, + Bug, + CircleDollarSign, + CircleGauge, + Command as CommandIcon, + Eraser, + GitCommitHorizontal, + GitPullRequest, + HelpCircle, + LogIn, + LogOut, + Package, + PanelTop, + Settings, + ShieldCheck, + Sparkles, + Target, + Terminal, + Wrench, + Zap, + type LucideIcon, +} from 'lucide-react' import { useDismissable } from '@/hooks/useDismissable' import { Button } from '@/components/ui/Button' import { IconButton } from '@/components/ui/IconButton' @@ -33,6 +57,7 @@ import { getLocalizedFallbackCommands, filterSlashCommands, findSlashTrigger, + groupSlashCommands, mergeSlashCommands, replaceSlashToken, resolveSlashUiAction, @@ -61,6 +86,38 @@ type ChatInputProps = { const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = [] +const SYSTEM_SLASH_COMMAND_ICONS: Record = { + agent: Bot, + mcp: Wrench, + skills: Package, + help: HelpCircle, + status: CircleGauge, + cost: CircleDollarSign, + context: PanelTop, + plugin: Package, + memory: Sparkles, + doctor: Wrench, + compact: Zap, + clear: Eraser, + goal: Target, + review: ShieldCheck, + commit: GitCommitHorizontal, + pr: GitPullRequest, + bug: Bug, + config: Settings, + login: LogIn, + logout: LogOut, + model: Bot, + permissions: ShieldCheck, + 'terminal-setup': Terminal, + vim: CommandIcon, +} + +function getSystemSlashCommandIcon(commandName: string): LucideIcon { + const rootCommand = commandName.trim().split(/\s+/, 1)[0] ?? '' + return SYSTEM_SLASH_COMMAND_ICONS[rootCommand] ?? CommandIcon +} + function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Attachment { return { id: reference.id, @@ -121,7 +178,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro const plusMenuRef = useRef(null) const slashMenuRef = useRef(null) const fileSearchRef = useRef(null) - const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]) + const slashItemRefs = useRef<(HTMLElement | null)[]>([]) const previousActiveTabIdRef = useRef(null) const inputRef = useRef(input) const attachmentsRef = useRef(attachments) @@ -459,10 +516,12 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro [agentSlashCommands, slashCommands, t], ) - const filteredCommands = useMemo(() => { - return filterSlashCommands(allSlashCommands, slashFilter) + const filteredCommandGroups = useMemo(() => { + return groupSlashCommands(filterSlashCommands(allSlashCommands, slashFilter)) }, [allSlashCommands, slashFilter]) + const filteredCommands = filteredCommandGroups.ordered + const exactSlashCommand = useMemo(() => { const normalized = slashFilter.trim().toLowerCase() if (!normalized) return null @@ -1065,34 +1124,97 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro // effort, branch and worktree panels above this row already use. className="absolute bottom-full left-0 right-0 z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-overlay)]" > -
- {filteredCommands.map((command, index) => ( - - ))} + {command.argumentHint ? ( + + {command.argumentHint} + + ) : null} + + + {command.description} + +
+ ) + })} + + {filteredCommandGroups.skills.length > 0 ? ( +
0 + ? 'mt-1 border-t border-[var(--color-border-separator)] pt-1' + : ''} + > +
+ {t('sidebar.skills')} +
+ {filteredCommandGroups.skills.map((command, skillIndex) => { + const index = filteredCommandGroups.system.length + skillIndex + return ( +
{ slashItemRefs.current[index] = el }} + onClick={() => selectSlashCommand(command.name)} + onMouseEnter={() => setSlashSelectedIndex(index)} + className={`flex w-full cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors ${ + index === slashSelectedIndex + ? 'bg-[var(--color-surface-hover)]' + : 'hover:bg-[var(--color-surface-hover)]' + }`} + > +
+ ) + })} +
+ ) : null} {!isMobileComposer ? (
diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index f713caeb..837d6f60 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -5,6 +5,7 @@ import { filterSlashCommands, findSlashToken, getLocalizedFallbackCommands, + groupSlashCommands, insertSlashTrigger, mergeSlashCommands, replaceSlashCommand, @@ -161,6 +162,33 @@ describe('composerUtils', () => { ]) }) + it('groups built-in app commands before personal skills without changing their relative order', () => { + const groups = groupSlashCommands([ + { name: 'amazon-review-scraper', description: 'Collect Amazon reviews' }, + { name: 'status', description: 'Show session status' }, + { name: 'agent debugger', description: 'Run the debugger agent' }, + { name: 'audit', description: 'Audit product UX' }, + { name: 'model', description: 'Switch model' }, + ]) + + expect(groups.system.map((command) => command.name)).toEqual([ + 'status', + 'agent debugger', + 'model', + ]) + expect(groups.skills.map((command) => command.name)).toEqual([ + 'amazon-review-scraper', + 'audit', + ]) + expect(groups.ordered.map((command) => command.name)).toEqual([ + 'status', + 'agent debugger', + 'model', + 'amazon-review-scraper', + 'audit', + ]) + }) + it('resolves hidden settings aliases without displaying duplicate fallback rows', () => { expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' }) expect(resolveSlashUiAction('memory')).toEqual({ type: 'settings', tab: 'memory' }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 3663d38b..c240b1cc 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -119,6 +119,38 @@ export type SlashCommandOption = { argumentHint?: string } +export type SlashCommandGroups = { + system: SlashCommandOption[] + skills: SlashCommandOption[] + ordered: SlashCommandOption[] +} + +function isSystemSlashCommand(command: SlashCommandOption): boolean { + const rootCommand = command.name.trim().split(/\s+/, 1)[0] + return Boolean(rootCommand && BUILT_IN_COMMAND_NAMES.has(rootCommand)) +} + +export function groupSlashCommands( + commands: ReadonlyArray, +): SlashCommandGroups { + const system: SlashCommandOption[] = [] + const skills: SlashCommandOption[] = [] + + for (const command of commands) { + if (isSystemSlashCommand(command)) { + system.push(command) + } else { + skills.push(command) + } + } + + return { + system, + skills, + ordered: [...system, ...skills], + } +} + export type AgentSlashCommandSource = { agentType: string description?: string diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ca5f5102..84b84933 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1580,6 +1580,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'chat.conversationNavigator.label': 'Conversation navigation', 'chat.conversationNavigator.attachments': '{count} attachments', 'chat.slashCommands': 'Slash commands', + 'chat.slashSkillPersonal': 'Personal', 'chat.pendingMessageGuide': 'Guide', 'chat.pendingMessageGuideNow': 'Guide now', 'chat.pendingMessageEdit': 'Edit queued message', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 7f1776ea..025282d3 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -1582,6 +1582,7 @@ export const jp: Record = { 'chat.conversationNavigator.label': '会話ナビゲーション', 'chat.conversationNavigator.attachments': '添付ファイル {count} 件', 'chat.slashCommands': 'スラッシュコマンド', + 'chat.slashSkillPersonal': '個人', 'chat.pendingMessageGuide': '誘導', 'chat.pendingMessageGuideNow': '今すぐ誘導', 'chat.pendingMessageEdit': 'キュー中のメッセージを編集', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 36951047..fcfe4770 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -1582,6 +1582,7 @@ export const kr: Record = { 'chat.conversationNavigator.label': '대화 탐색', 'chat.conversationNavigator.attachments': '첨부 파일 {count}개', 'chat.slashCommands': '슬래시 명령', + 'chat.slashSkillPersonal': '개인', 'chat.pendingMessageGuide': '가이드', 'chat.pendingMessageGuideNow': '지금 가이드', 'chat.pendingMessageEdit': '대기 메시지 편집', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index d262e0d2..60e650b5 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -1581,6 +1581,7 @@ export const zh: Record = { 'chat.conversationNavigator.label': '對話導覽', 'chat.conversationNavigator.attachments': '{count} 個附件', 'chat.slashCommands': '斜槓命令', + 'chat.slashSkillPersonal': '個人', 'chat.pendingMessageGuide': '引導', 'chat.pendingMessageGuideNow': '立即引導', 'chat.pendingMessageEdit': '編輯排隊訊息', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 41e5416c..43c23d10 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1581,6 +1581,7 @@ export const zh: Record = { 'chat.conversationNavigator.label': '对话导航', 'chat.conversationNavigator.attachments': '{count} 个附件', 'chat.slashCommands': '斜杠命令', + 'chat.slashSkillPersonal': '个人', 'chat.pendingMessageGuide': '引导', 'chat.pendingMessageGuideNow': '立即引导', 'chat.pendingMessageEdit': '编辑排队消息',