feat(desktop): group slash menu commands

This commit is contained in:
RaspberryLee 2026-07-28 17:40:59 +08:00
parent 5e156ec03e
commit 2346427c53
9 changed files with 259 additions and 36 deletions

View File

@ -1475,7 +1475,7 @@ describe('ChatInput file mentions', () => {
selectionStart: 1, selectionStart: 1,
}, },
}) })
expect(await screen.findByText('/mcp')).toBeInTheDocument() expect(await screen.findByText('mcp')).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible') expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden') expect(panel).not.toHaveClass('overflow-hidden')
@ -1672,12 +1672,48 @@ describe('ChatInput file mentions', () => {
await waitFor(() => { await waitFor(() => {
const commandButtons = screen const commandButtons = screen
.getAllByRole('button') .getAllByRole('option')
.filter((button) => button.textContent?.startsWith('/')) .filter((option) => option.textContent?.includes('Creative work planning.'))
expect(commandButtons[0]).toHaveTextContent('/superpowers:brainstorming') 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(<ChatInput />)
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 () => { it('offers active agents as slash entries that insert /agent with the selected type', async () => {
mocks.listAgents.mockResolvedValue({ mocks.listAgents.mockResolvedValue({
activeAgents: [ activeAgents: [
@ -1703,7 +1739,7 @@ describe('ChatInput file mentions', () => {
target: { value: '/debug', selectionStart: 6 }, target: { value: '/debug', selectionStart: 6 },
}) })
const agentOption = await screen.findByText('/agent debugger') const agentOption = await screen.findByText('agent debugger')
fireEvent.click(agentOption) fireEvent.click(agentOption)
expect(input).toHaveValue('/agent debugger ') expect(input).toHaveValue('/agent debugger ')
@ -1737,7 +1773,7 @@ describe('ChatInput file mentions', () => {
target: { value: '/agent', selectionStart: 6 }, target: { value: '/agent', selectionStart: 6 },
}) })
await screen.findByText('/agent debugger') await screen.findByText('agent debugger')
fireEvent.keyDown(input, { key: 'ArrowDown' }) fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' }) fireEvent.keyDown(input, { key: 'Enter' })

View File

@ -1,4 +1,28 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react' 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 { useDismissable } from '@/hooks/useDismissable'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton' import { IconButton } from '@/components/ui/IconButton'
@ -33,6 +57,7 @@ import {
getLocalizedFallbackCommands, getLocalizedFallbackCommands,
filterSlashCommands, filterSlashCommands,
findSlashTrigger, findSlashTrigger,
groupSlashCommands,
mergeSlashCommands, mergeSlashCommands,
replaceSlashToken, replaceSlashToken,
resolveSlashUiAction, resolveSlashUiAction,
@ -61,6 +86,38 @@ type ChatInputProps = {
const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = [] const EMPTY_WORKSPACE_REFERENCES: WorkspaceChatReference[] = []
const SYSTEM_SLASH_COMMAND_ICONS: Record<string, LucideIcon> = {
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 { function workspaceReferenceToAttachment(reference: WorkspaceChatReference): Attachment {
return { return {
id: reference.id, id: reference.id,
@ -121,7 +178,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const plusMenuRef = useRef<HTMLDivElement>(null) const plusMenuRef = useRef<HTMLDivElement>(null)
const slashMenuRef = useRef<HTMLDivElement>(null) const slashMenuRef = useRef<HTMLDivElement>(null)
const fileSearchRef = useRef<FileSearchMenuHandle>(null) const fileSearchRef = useRef<FileSearchMenuHandle>(null)
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]) const slashItemRefs = useRef<(HTMLElement | null)[]>([])
const previousActiveTabIdRef = useRef<string | null>(null) const previousActiveTabIdRef = useRef<string | null>(null)
const inputRef = useRef(input) const inputRef = useRef(input)
const attachmentsRef = useRef(attachments) const attachmentsRef = useRef(attachments)
@ -459,10 +516,12 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
[agentSlashCommands, slashCommands, t], [agentSlashCommands, slashCommands, t],
) )
const filteredCommands = useMemo(() => { const filteredCommandGroups = useMemo(() => {
return filterSlashCommands(allSlashCommands, slashFilter) return groupSlashCommands(filterSlashCommands(allSlashCommands, slashFilter))
}, [allSlashCommands, slashFilter]) }, [allSlashCommands, slashFilter])
const filteredCommands = filteredCommandGroups.ordered
const exactSlashCommand = useMemo(() => { const exactSlashCommand = useMemo(() => {
const normalized = slashFilter.trim().toLowerCase() const normalized = slashFilter.trim().toLowerCase()
if (!normalized) return null 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. // 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)]" 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)]"
> >
<div className="max-h-[300px] overflow-y-auto py-1"> <div
{filteredCommands.map((command, index) => ( role="listbox"
<button aria-label={t('chat.slashCommands')}
key={command.name} className="max-h-[420px] overflow-y-auto p-1.5"
ref={(el) => { slashItemRefs.current[index] = el }} >
onClick={() => selectSlashCommand(command.name)} {filteredCommandGroups.system.map((command, index) => {
onMouseEnter={() => setSlashSelectedIndex(index)} const Icon = getSystemSlashCommandIcon(command.name)
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${ return (
index === slashSelectedIndex <div
? 'bg-[var(--color-surface-hover)]' key={command.name}
: 'hover:bg-[var(--color-surface-hover)]' role="option"
}`} tabIndex={-1}
> aria-selected={index === slashSelectedIndex}
<span className="flex min-w-0 max-w-[52%] shrink-0 items-baseline gap-1.5"> ref={(el) => { slashItemRefs.current[index] = el }}
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]"> onClick={() => selectSlashCommand(command.name)}
/{command.name} onMouseEnter={() => setSlashSelectedIndex(index)}
</span> className={`flex w-full cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors ${
{command.argumentHint ? ( index === slashSelectedIndex
<span className="min-w-0 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]"> ? 'bg-[var(--color-surface-hover)]'
{command.argumentHint} : 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<Icon
aria-hidden="true"
className="h-4 w-4 shrink-0 text-[var(--color-text-secondary)]"
strokeWidth={1.8}
/>
<span className="flex min-w-0 max-w-[52%] shrink-0 items-baseline gap-1.5">
<span className="shrink-0 text-sm font-medium text-[var(--color-text-primary)]">
{command.name}
</span> </span>
) : null} {command.argumentHint ? (
</span> <span className="min-w-0 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]"> {command.argumentHint}
{command.description} </span>
</span> ) : null}
</button> </span>
))} <span className="min-w-0 flex-1 truncate text-right text-xs text-[var(--color-text-tertiary)]">
{command.description}
</span>
</div>
)
})}
{filteredCommandGroups.skills.length > 0 ? (
<div
role="group"
aria-label={t('sidebar.skills')}
className={filteredCommandGroups.system.length > 0
? 'mt-1 border-t border-[var(--color-border-separator)] pt-1'
: ''}
>
<div className="px-2.5 py-1.5 text-xs font-medium text-[var(--color-text-tertiary)]">
{t('sidebar.skills')}
</div>
{filteredCommandGroups.skills.map((command, skillIndex) => {
const index = filteredCommandGroups.system.length + skillIndex
return (
<div
key={command.name}
role="option"
tabIndex={-1}
aria-selected={index === slashSelectedIndex}
ref={(el) => { 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)]'
}`}
>
<Box
aria-hidden="true"
className="h-4 w-4 shrink-0 text-[var(--color-text-secondary)]"
strokeWidth={1.8}
/>
<span className="min-w-0 shrink-0 truncate text-sm font-medium text-[var(--color-text-primary)]">
{command.name}
</span>
<span className="min-w-0 flex-1 truncate text-right text-xs text-[var(--color-text-tertiary)]">
{command.description}
</span>
<span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">
{t('chat.slashSkillPersonal')}
</span>
</div>
)
})}
</div>
) : null}
</div> </div>
{!isMobileComposer ? ( {!isMobileComposer ? (
<div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]"> <div className="flex items-center gap-1.5 border-t border-[var(--color-border)] px-4 py-2 text-xs text-[var(--color-text-tertiary)]">

View File

@ -5,6 +5,7 @@ import {
filterSlashCommands, filterSlashCommands,
findSlashToken, findSlashToken,
getLocalizedFallbackCommands, getLocalizedFallbackCommands,
groupSlashCommands,
insertSlashTrigger, insertSlashTrigger,
mergeSlashCommands, mergeSlashCommands,
replaceSlashCommand, 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', () => { it('resolves hidden settings aliases without displaying duplicate fallback rows', () => {
expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' }) expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' })
expect(resolveSlashUiAction('memory')).toEqual({ type: 'settings', tab: 'memory' }) expect(resolveSlashUiAction('memory')).toEqual({ type: 'settings', tab: 'memory' })

View File

@ -119,6 +119,38 @@ export type SlashCommandOption = {
argumentHint?: string 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<SlashCommandOption>,
): 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 = { export type AgentSlashCommandSource = {
agentType: string agentType: string
description?: string description?: string

View File

@ -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.label': 'Conversation navigation',
'chat.conversationNavigator.attachments': '{count} attachments', 'chat.conversationNavigator.attachments': '{count} attachments',
'chat.slashCommands': 'Slash commands', 'chat.slashCommands': 'Slash commands',
'chat.slashSkillPersonal': 'Personal',
'chat.pendingMessageGuide': 'Guide', 'chat.pendingMessageGuide': 'Guide',
'chat.pendingMessageGuideNow': 'Guide now', 'chat.pendingMessageGuideNow': 'Guide now',
'chat.pendingMessageEdit': 'Edit queued message', 'chat.pendingMessageEdit': 'Edit queued message',

View File

@ -1582,6 +1582,7 @@ export const jp: Record<TranslationKey, string> = {
'chat.conversationNavigator.label': '会話ナビゲーション', 'chat.conversationNavigator.label': '会話ナビゲーション',
'chat.conversationNavigator.attachments': '添付ファイル {count} 件', 'chat.conversationNavigator.attachments': '添付ファイル {count} 件',
'chat.slashCommands': 'スラッシュコマンド', 'chat.slashCommands': 'スラッシュコマンド',
'chat.slashSkillPersonal': '個人',
'chat.pendingMessageGuide': '誘導', 'chat.pendingMessageGuide': '誘導',
'chat.pendingMessageGuideNow': '今すぐ誘導', 'chat.pendingMessageGuideNow': '今すぐ誘導',
'chat.pendingMessageEdit': 'キュー中のメッセージを編集', 'chat.pendingMessageEdit': 'キュー中のメッセージを編集',

View File

@ -1582,6 +1582,7 @@ export const kr: Record<TranslationKey, string> = {
'chat.conversationNavigator.label': '대화 탐색', 'chat.conversationNavigator.label': '대화 탐색',
'chat.conversationNavigator.attachments': '첨부 파일 {count}개', 'chat.conversationNavigator.attachments': '첨부 파일 {count}개',
'chat.slashCommands': '슬래시 명령', 'chat.slashCommands': '슬래시 명령',
'chat.slashSkillPersonal': '개인',
'chat.pendingMessageGuide': '가이드', 'chat.pendingMessageGuide': '가이드',
'chat.pendingMessageGuideNow': '지금 가이드', 'chat.pendingMessageGuideNow': '지금 가이드',
'chat.pendingMessageEdit': '대기 메시지 편집', 'chat.pendingMessageEdit': '대기 메시지 편집',

View File

@ -1581,6 +1581,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.conversationNavigator.label': '對話導覽', 'chat.conversationNavigator.label': '對話導覽',
'chat.conversationNavigator.attachments': '{count} 個附件', 'chat.conversationNavigator.attachments': '{count} 個附件',
'chat.slashCommands': '斜槓命令', 'chat.slashCommands': '斜槓命令',
'chat.slashSkillPersonal': '個人',
'chat.pendingMessageGuide': '引導', 'chat.pendingMessageGuide': '引導',
'chat.pendingMessageGuideNow': '立即引導', 'chat.pendingMessageGuideNow': '立即引導',
'chat.pendingMessageEdit': '編輯排隊訊息', 'chat.pendingMessageEdit': '編輯排隊訊息',

View File

@ -1581,6 +1581,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.conversationNavigator.label': '对话导航', 'chat.conversationNavigator.label': '对话导航',
'chat.conversationNavigator.attachments': '{count} 个附件', 'chat.conversationNavigator.attachments': '{count} 个附件',
'chat.slashCommands': '斜杠命令', 'chat.slashCommands': '斜杠命令',
'chat.slashSkillPersonal': '个人',
'chat.pendingMessageGuide': '引导', 'chat.pendingMessageGuide': '引导',
'chat.pendingMessageGuideNow': '立即引导', 'chat.pendingMessageGuideNow': '立即引导',
'chat.pendingMessageEdit': '编辑排队消息', 'chat.pendingMessageEdit': '编辑排队消息',