mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
feat(desktop): group slash menu commands
This commit is contained in:
parent
5e156ec03e
commit
2346427c53
@ -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(<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 () => {
|
||||
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' })
|
||||
|
||||
|
||||
@ -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<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 {
|
||||
return {
|
||||
id: reference.id,
|
||||
@ -121,7 +178,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
|
||||
const plusMenuRef = useRef<HTMLDivElement>(null)
|
||||
const slashMenuRef = useRef<HTMLDivElement>(null)
|
||||
const fileSearchRef = useRef<FileSearchMenuHandle>(null)
|
||||
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const slashItemRefs = useRef<(HTMLElement | null)[]>([])
|
||||
const previousActiveTabIdRef = useRef<string | null>(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)]"
|
||||
>
|
||||
<div className="max-h-[300px] overflow-y-auto py-1">
|
||||
{filteredCommands.map((command, index) => (
|
||||
<button
|
||||
key={command.name}
|
||||
ref={(el) => { slashItemRefs.current[index] = el }}
|
||||
onClick={() => selectSlashCommand(command.name)}
|
||||
onMouseEnter={() => setSlashSelectedIndex(index)}
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors ${
|
||||
index === slashSelectedIndex
|
||||
? 'bg-[var(--color-surface-hover)]'
|
||||
: 'hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 max-w-[52%] shrink-0 items-baseline gap-1.5">
|
||||
<span className="shrink-0 text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
/{command.name}
|
||||
</span>
|
||||
{command.argumentHint ? (
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{command.argumentHint}
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t('chat.slashCommands')}
|
||||
className="max-h-[420px] overflow-y-auto p-1.5"
|
||||
>
|
||||
{filteredCommandGroups.system.map((command, index) => {
|
||||
const Icon = getSystemSlashCommandIcon(command.name)
|
||||
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)]'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">
|
||||
{command.description}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{command.argumentHint ? (
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
|
||||
{command.argumentHint}
|
||||
</span>
|
||||
) : null}
|
||||
</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>
|
||||
{!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)]">
|
||||
|
||||
@ -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' })
|
||||
|
||||
@ -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<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 = {
|
||||
agentType: string
|
||||
description?: string
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1582,6 +1582,7 @@ export const jp: Record<TranslationKey, string> = {
|
||||
'chat.conversationNavigator.label': '会話ナビゲーション',
|
||||
'chat.conversationNavigator.attachments': '添付ファイル {count} 件',
|
||||
'chat.slashCommands': 'スラッシュコマンド',
|
||||
'chat.slashSkillPersonal': '個人',
|
||||
'chat.pendingMessageGuide': '誘導',
|
||||
'chat.pendingMessageGuideNow': '今すぐ誘導',
|
||||
'chat.pendingMessageEdit': 'キュー中のメッセージを編集',
|
||||
|
||||
@ -1582,6 +1582,7 @@ export const kr: Record<TranslationKey, string> = {
|
||||
'chat.conversationNavigator.label': '대화 탐색',
|
||||
'chat.conversationNavigator.attachments': '첨부 파일 {count}개',
|
||||
'chat.slashCommands': '슬래시 명령',
|
||||
'chat.slashSkillPersonal': '개인',
|
||||
'chat.pendingMessageGuide': '가이드',
|
||||
'chat.pendingMessageGuideNow': '지금 가이드',
|
||||
'chat.pendingMessageEdit': '대기 메시지 편집',
|
||||
|
||||
@ -1581,6 +1581,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.conversationNavigator.label': '對話導覽',
|
||||
'chat.conversationNavigator.attachments': '{count} 個附件',
|
||||
'chat.slashCommands': '斜槓命令',
|
||||
'chat.slashSkillPersonal': '個人',
|
||||
'chat.pendingMessageGuide': '引導',
|
||||
'chat.pendingMessageGuideNow': '立即引導',
|
||||
'chat.pendingMessageEdit': '編輯排隊訊息',
|
||||
|
||||
@ -1581,6 +1581,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'chat.conversationNavigator.label': '对话导航',
|
||||
'chat.conversationNavigator.attachments': '{count} 个附件',
|
||||
'chat.slashCommands': '斜杠命令',
|
||||
'chat.slashSkillPersonal': '个人',
|
||||
'chat.pendingMessageGuide': '引导',
|
||||
'chat.pendingMessageGuideNow': '立即引导',
|
||||
'chat.pendingMessageEdit': '编辑排队消息',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user