feat(desktop): streamline composer capability discovery

Unify skill, plugin and file search across the plus and mention menus.
Prioritize common slash commands and hide withdrawn managed skill packs.
Refresh capability candidates when reopening the composer menu.
This commit is contained in:
程序员阿江(Relakkes)
2026-09-20 01:25:45 +08:00
parent 8ad8a011f4
commit 2d4f48765f
27 changed files with 904 additions and 533 deletions
+15 -13
View File
@@ -147,30 +147,31 @@ describe('Content-only pages render without errors', () => {
setComposerText('/', 1)
expect(await screen.findByText('lark-mail')).toBeInTheDocument()
expect(screen.getByText('mcp')).toBeInTheDocument()
expect(screen.getByText('skills')).toBeInTheDocument()
// Commands with a permanent GUI home stay out of the empty-query listing…
expect(screen.queryByText('help')).not.toBeInTheDocument()
expect(screen.queryByText('plugin')).not.toBeInTheDocument()
expect(screen.queryByText('context')).not.toBeInTheDocument()
expect(screen.queryByText('plugins')).not.toBeInTheDocument()
expect(await screen.findByRole('option', { name: '/init' })).toBeInTheDocument()
expect(screen.getByRole('option', { name: '/context' })).toBeInTheDocument()
expect(screen.queryByText('lark-mail')).not.toBeInTheDocument()
expect(screen.queryByText('internal-only')).not.toBeInTheDocument()
// …but typing the name still matches them.
// Dynamic skills remain searchable before a session has been created.
setComposerText('/lark', 5)
expect(await screen.findByRole('option', { name: 'lark-mail' })).toBeInTheDocument()
setComposerText('/internal', 9)
expect(screen.queryByText('internal-only')).not.toBeInTheDocument()
// Commands with a permanent GUI home remain available through search.
setComposerText('/help', 5)
expect(await screen.findByText('help')).toBeInTheDocument()
expect(await screen.findByRole('option', { name: '/help' })).toBeInTheDocument()
})
it('EmptySession shows /goal as one command with argument hints, not pseudo subcommands', async () => {
it('EmptySession search shows /goal as one command, not pseudo subcommands', async () => {
vi.mocked(skillsApi.list).mockResolvedValueOnce({ skills: [] })
render(<EmptySession />)
setComposerText('/goal', 5)
expect(await screen.findByRole('option', { name: /^goal / })).toBeInTheDocument()
expect(screen.getByText('[<condition> | clear]')).toBeInTheDocument()
expect(await screen.findByRole('option', { name: '/goal' })).toBeInTheDocument()
expect(screen.getAllByRole('option', { name: '/goal' })).toHaveLength(1)
expect(screen.getByText('Set a completion goal')).toBeInTheDocument()
expect(screen.queryByText('/goal status')).not.toBeInTheDocument()
expect(screen.queryByText('/goal --tokens')).not.toBeInTheDocument()
@@ -242,6 +243,7 @@ describe('Content-only pages render without errors', () => {
await Promise.resolve()
})
expect(screen.getByText('Add files or photos')).toBeInTheDocument()
fireEvent.click(screen.getByRole('option', { name: 'More tools' }))
expect(screen.getByText('Slash commands')).toBeInTheDocument()
})
+87 -10
View File
@@ -1862,6 +1862,82 @@ describe('ChatInput file mentions', () => {
})
})
it.each(['@', '/', '+'] as const)('hides withdrawn bundled capabilities in %s while keeping personal skills and other plugins', async (entry) => {
const withdrawnPackage = 'office-frontend-design@haha-connectors'
mocks.listReferences.mockResolvedValue({
plugins: [
{ kind: 'plugin', id: withdrawnPackage, name: 'office-frontend-design', displayName: 'Removed frontend plugin', description: 'Design', source: withdrawnPackage, modelText: 'Use removed plugin' },
{ kind: 'plugin', id: 'design-tools@community', name: 'design-tools', displayName: 'Design tools', description: 'Design', source: 'community', modelText: 'Use design tools' },
],
skills: [
{ kind: 'skill', id: 'office-frontend-design:frontend-design', name: 'frontend-design', displayName: 'Removed frontend skill', description: 'Design', source: withdrawnPackage, modelText: '/office-frontend-design:frontend-design' },
{ kind: 'skill', id: 'frontend-design', name: 'frontend-design', displayName: 'Personal frontend design', description: 'Design', source: 'user', modelText: '/frontend-design' },
],
})
const legacyCommands = [
{ name: 'office-frontend-design:frontend-design', description: 'Removed frontend skill', kind: 'skill' as const, source: 'plugin' as const, userInvocable: true },
{ name: 'frontend-design', description: 'Personal frontend design', kind: 'skill' as const, source: 'user' as const, userInvocable: true },
]
useChatStore.setState({ sessions: { [sessionId]: { ...useChatStore.getState().sessions[sessionId]!, slashCommands: legacyCommands } } })
render(<ChatInput compact />)
if (entry === '+') {
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.change(screen.getByRole('combobox', { name: 'Search skills, plugins, files…' }), { target: { value: 'design' } })
} else setComposerText(`${entry}design`, 7)
expect(await screen.findByRole('option', { name: 'Personal frontend design' })).toBeInTheDocument()
expect(await screen.findByRole('option', { name: 'Design tools' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Removed frontend/ })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: /office-frontend-design/ })).not.toBeInTheDocument()
expect(mocks.wsSend).not.toHaveBeenCalled()
})
it.each(['empty', 'replacement'] as const)('refreshes skills when opening plus and drops stale entries during a pending %s response', async (result) => {
const oldSkill = { kind: 'skill' as const, id: 'old-skill', name: 'old-skill', displayName: 'Old skill', description: 'Previously enabled', source: 'user', modelText: '/old-skill' }
const newSkill = { ...oldSkill, id: 'new-skill', name: 'new-skill', displayName: 'New skill', modelText: '/new-skill' }
mocks.listReferences.mockResolvedValue({ plugins: [], skills: [oldSkill] })
render(<ChatInput compact />)
await act(async () => { await Promise.resolve() })
const initialCalls = mocks.listReferences.mock.calls.length
let resolveRefresh!: (value: { plugins: [], skills: typeof oldSkill[] }) => void
mocks.listReferences.mockImplementation(() => new Promise(resolve => { resolveRefresh = resolve }))
fireEvent.click(screen.getByLabelText('Open composer tools'))
await waitFor(() => expect(mocks.listReferences.mock.calls.length).toBeGreaterThan(initialCalls))
fireEvent.click(await screen.findByRole('option', { name: /^Skills/ }))
expect(screen.queryByRole('option', { name: 'Old skill' })).not.toBeInTheDocument()
await act(async () => resolveRefresh({ plugins: [], skills: result === 'replacement' ? [newSkill] : [] }))
expect(screen.queryByRole('option', { name: 'Old skill' })).not.toBeInTheDocument()
if (result === 'replacement') expect(await screen.findByRole('option', { name: 'New skill' })).toBeInTheDocument()
else expect(screen.queryByRole('option', { name: 'New skill' })).not.toBeInTheDocument()
})
it('inserts a structured project file mention selected through the plus menu search', async () => {
mocks.search.mockResolvedValue({
currentPath: '/repo', parentPath: null, query: 'README',
entries: [{ name: 'README.md', path: '/repo/README.md', relativePath: 'README.md', isDirectory: false }],
})
render(<ChatInput compact />)
setComposerText('Please review ', 14)
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.change(screen.getByRole('combobox', { name: 'Search skills, plugins, files…' }), { target: { value: 'README' } })
fireEvent.click(await screen.findByRole('option', { name: 'README.md' }))
await waitFor(() => {
expect(document.querySelector('.composer-mention')).toHaveAttribute('data-mention-path', '/repo/README.md')
})
expect(document.querySelector('.composer-mention')).toHaveTextContent('@README.md')
expect(getComposerText()).toContain('Please review @README.md')
expect(mocks.search).toHaveBeenCalledWith('README', '/repo')
expect(screen.queryByRole('combobox', { name: 'Search skills, plugins, files…' })).not.toBeInTheDocument()
expect(mocks.wsSend).not.toHaveBeenCalled()
fireEvent.keyDown(getComposerElement(), { key: 'Enter' })
expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, expect.objectContaining({
type: 'user_message', content: 'Please review @"/repo/README.md"',
attachments: [],
}))
})
it('inserts a skill mention badge from the capability menu', async () => {
mocks.listReferences.mockResolvedValue({ plugins: [], skills: [{
kind: 'skill', id: 'design', name: 'design', displayName: 'Design',
@@ -1889,6 +1965,7 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.click(screen.getByRole('option', { name: 'More tools' }))
fireEvent.click(await screen.findByRole('option', { name: /^Agents/ }))
fireEvent.click(await screen.findByRole('option', { name: /debugger/ }))
@@ -1910,12 +1987,12 @@ describe('ChatInput file mentions', () => {
render(<ChatInput compact />)
fireEvent.click(screen.getByLabelText('Open composer tools'))
const row = await screen.findByRole('menuitemcheckbox', { name: /Computer Use/ })
await waitFor(() => expect(row).toHaveAttribute('aria-checked', 'false'))
const row = await screen.findByRole('option', { name: /Computer Use/ })
await waitFor(() => expect(row.querySelector('input[type="checkbox"]')).not.toBeChecked())
fireEvent.click(row.querySelector('input[type="checkbox"]')!)
await waitFor(() => expect(setAuthorizedApps).toHaveBeenCalledWith({ enabled: true }))
await waitFor(() => expect(row).toHaveAttribute('aria-checked', 'true'))
await waitFor(() => expect(row.querySelector('input[type="checkbox"]')).toBeChecked())
expect(getStatus).toHaveBeenCalled()
})
@@ -2356,8 +2433,8 @@ describe('ChatInput file mentions', () => {
const panel = screen.getByTestId('chat-input-panel')
setComposerText('/', 1)
expect(await screen.findByText('mcp')).toBeInTheDocument()
setComposerText('/mcp', 4)
expect(await screen.findByRole('option', { name: '/mcp' })).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
@@ -2748,10 +2825,10 @@ describe('ChatInput file mentions', () => {
render(<ChatInput />)
setComposerText('/', 1)
setComposerText('/a', 2)
const systemCommand = await screen.findByText('mcp')
const futureNativeCommand = screen.getByText('future-native-command')
const systemCommand = await screen.findByRole('option', { name: '/status' })
const futureNativeCommand = screen.getByText('/future-native-command')
const skillsHeading = screen.getByText('Skills')
const projectSkill = screen.getByText('audit')
const pluginSkill = screen.getByText('drawing:render')
@@ -2798,7 +2875,7 @@ describe('ChatInput file mentions', () => {
setComposerText('/debug', 6)
const agentOption = await screen.findByText('agent debugger')
const agentOption = await screen.findByText('/agent debugger')
fireEvent.click(agentOption)
expect(getComposerText()).toBe('/agent debugger ')
@@ -2830,7 +2907,7 @@ describe('ChatInput file mentions', () => {
const input = getComposerElement()
setComposerText('/agent', 6)
await screen.findByText('agent debugger')
await screen.findByText('/agent debugger')
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
+18 -4
View File
@@ -1,3 +1,4 @@
import { isComposerReferenceVisible, isComposerSlashCommandVisible } from '@/lib/composerCapabilityVisibility'
import { useState, useRef, useEffect, useCallback, useMemo, useId } from 'react'
import { useDismissable } from '@/hooks/useDismissable'
import { Button } from '@/components/ui/Button'
@@ -310,18 +311,18 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
const referenceCwd = activeLaunchWorkDir || resolvedWorkDir || ''
const referenceContext = `${activeTabId ?? ''}\0${referenceCwd}`
const referenceCurrent = referenceState?.context === referenceContext ? referenceState : null
const composerReferences = referenceCurrent?.items ?? EMPTY_COMPOSER_REFERENCES
const composerReferences = useMemo(() => (referenceCurrent?.items ?? EMPTY_COMPOSER_REFERENCES).filter(isComposerReferenceVisible), [referenceCurrent?.items])
useEffect(() => {
let active = true
if (isMemberSession) return
setReferenceState(previous => ({ context: referenceContext, items: previous?.context === referenceContext ? previous.items : [], loading: true, error: false }))
setReferenceState({ context: referenceContext, items: [], loading: true, error: false })
void composerReferencesApi.list(referenceCwd || undefined).then(data => {
if (active) setReferenceState({ context: referenceContext, items: [...data.plugins, ...data.skills], loading: false, error: false })
}).catch(() => {
if (active) setReferenceState({ context: referenceContext, items: [], loading: false, error: true })
})
return () => { active = false }
}, [referenceContext, referenceCwd, isMemberSession, slashMenuOpen, fileSearchOpen])
}, [referenceContext, referenceCwd, isMemberSession, slashMenuOpen, fileSearchOpen, plusMenuOpen])
useEffect(() => {
setReferenceDetail(null)
setReferenceOptionId(undefined)
@@ -589,7 +590,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
names.add(name.toLowerCase())
commands.push({ name, description: reference.description, kind: reference.kind })
}
return commands
return commands.filter(isComposerSlashCommandVisible)
}, [agentSlashCommands, slashCommands, composerReferences, t])
const filteredCommandGroups = useMemo(() => {
@@ -1279,6 +1280,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
{isSlashMenuVisible && (
<SlashCommandMenu
isSearching={Boolean(slashFilter.trim())}
ref={slashMenuRef}
id={slashMenuId}
groups={filteredCommandGroups}
@@ -1522,6 +1524,18 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
{plusMenuOpen && (
<ComposerCapabilityMenu
cwd={referenceCwd}
referencesLoading={referenceCurrent?.loading ?? true}
referencesError={referenceCurrent?.error}
onSelectFile={mention => {
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? inputRef.current.length
const inserted = insertMentionIntoText(inputRef.current, mentionsRef.current, cursorPos, cursorPos, mention)
setComposerInput(inserted.text, inserted.mentions)
requestAnimationFrame(() => {
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(inserted.cursorPos)
})
}}
id={capabilityMenuId}
sections={capabilityMenu.sections}
onAction={capabilityMenu.onAction}
@@ -1,9 +1,15 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import { ComposerCapabilityMenu } from './ComposerCapabilityMenu'
import { filesystemApi } from '@/api/filesystem'
import type { CapabilityMenuSection } from './capabilityMenuModel'
vi.mock('@/api/filesystem', () => ({ filesystemApi: { browse: vi.fn(), search: vi.fn() } }))
beforeEach(() => {
vi.mocked(filesystemApi.search).mockResolvedValue({ currentPath: '/work', parentPath: '/', entries: [] })
})
function fixtureSections(): CapabilityMenuSection[] {
return [
{
@@ -87,9 +93,9 @@ function searchInput(): HTMLElement {
describe('ComposerCapabilityMenu', () => {
it('renders section titles and dispatches a leaf action on click', () => {
const { onAction } = renderMenu()
expect(screen.getByText('Add')).toBeInTheDocument()
expect(screen.getByText('Capabilities')).toBeInTheDocument()
expect(screen.getByText('Commands')).toBeInTheDocument()
expect(screen.getByRole('group', { name: 'Add' })).toBeInTheDocument()
expect(screen.getByRole('group', { name: 'Capabilities' })).toBeInTheDocument()
expect(screen.getByRole('group', { name: 'Commands' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('option', { name: /Add files or photos/ }))
expect(onAction).toHaveBeenCalledWith({ type: 'attachment' })
@@ -104,8 +110,8 @@ describe('ComposerCapabilityMenu', () => {
expect(screen.getByRole('option', { name: /Design/ })).toBeInTheDocument()
// Back navigation restores the top-level sections.
fireEvent.click(screen.getByRole('button', { name: /Skills/ }))
expect(screen.getByText('Commands')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Back' }))
expect(screen.getByRole('group', { name: 'Commands' })).toBeInTheDocument()
// Drilling again and picking a leaf fires its action.
fireEvent.click(screen.getByRole('option', { name: /Skills/ }))
@@ -131,21 +137,77 @@ describe('ComposerCapabilityMenu', () => {
it('toggles a switch row without double-firing from the row click', () => {
const { onAction } = renderMenu()
const row = screen.getByRole('menuitemcheckbox')
expect(row).toHaveAttribute('aria-checked', 'false')
const row = screen.getByRole('option', { name: 'Computer Use: Disabled' })
expect(row).toHaveAccessibleName('Computer Use: Disabled')
fireEvent.click(row.querySelector('input[type="checkbox"]')!)
expect(onAction).toHaveBeenCalledTimes(1)
expect(onAction).toHaveBeenCalledWith({ type: 'toggleComputerUse' })
})
it('filters rows through the search box and flattens sub-list matches', () => {
it('filters rows through the search box and flattens sub-list matches', async () => {
renderMenu()
fireEvent.change(searchInput(), { target: { value: 'Design' } })
expect(screen.getByRole('option', { name: /Design/ })).toBeInTheDocument()
expect(screen.queryByText('Commands')).not.toBeInTheDocument()
fireEvent.change(searchInput(), { target: { value: 'no-such-capability' } })
expect(screen.getByText('No matches')).toBeInTheDocument()
expect(await screen.findByText('No matching references')).toBeInTheDocument()
})
})
// Searching a category previously produced an inert parent row.
it('opens a searched category and scopes subsequent searches to its children', () => {
const { onAction } = renderMenu()
fireEvent.change(searchInput(), { target: { value: 'Skills' } })
fireEvent.keyDown(searchInput(), { key: 'Enter' })
expect(searchInput()).toHaveValue('')
expect(screen.getByRole('option', { name: 'Design' })).toBeInTheDocument()
fireEvent.change(searchInput(), { target: { value: 'Design' } })
fireEvent.keyDown(searchInput(), { key: 'Enter' })
expect(onAction).toHaveBeenCalledWith({ type: 'insertSlashText', command: 'design' })
})
it('keeps the root menu concise and exposes descriptions inside a category', () => {
renderMenu()
expect(screen.queryByText('Add a skill to this chat')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('option', { name: 'Skills' }))
expect(screen.getByText('Create interfaces')).toBeInTheDocument()
})
it('uses the shared reference search to insert a plugin without a connector', async () => {
const reference = { kind: 'plugin' as const, id: 'video', name: 'video', displayName: 'Video Studio', description: 'Create videos', source: 'plugin', modelText: 'Use video' }
const sections = fixtureSections()
sections[1]!.items.unshift({ key: 'plugins', label: 'Plugins', icon: { kind: 'slash' }, children: [{ key: 'plugin:video', label: 'Video Studio', icon: { kind: 'slash' }, action: { type: 'insertMention', reference } }] })
const { onAction } = renderMenu({ sections })
fireEvent.change(searchInput(), { target: { value: 'video' } })
const option = await screen.findByRole('option', { name: 'Video Studio' })
expect(searchInput()).toHaveAttribute('aria-activedescendant', option.id)
fireEvent.keyDown(searchInput(), { key: 'Enter' })
expect(onAction).toHaveBeenCalledWith({ type: 'insertMention', reference })
})
it('finds project files through the same search and preserves their structured path', async () => {
vi.mocked(filesystemApi.search).mockResolvedValue({ currentPath: '/work', parentPath: '/', entries: [{ name: 'README.md', path: '/work/README.md', isDirectory: false }] })
const onSelectFile = vi.fn()
const { onClose } = renderMenu({ cwd: '/work', onSelectFile })
fireEvent.change(searchInput(), { target: { value: 'README' } })
fireEvent.click(await screen.findByRole('option', { name: 'README.md' }))
expect(onSelectFile).toHaveBeenCalledWith({ label: 'README.md', path: '/work/README.md', isDirectory: false })
expect(onClose).toHaveBeenCalledTimes(1)
})
it('keeps nested tools accessible and backs up one level per Escape', () => {
const sections = fixtureSections()
const skills = sections[1]!.items[0]!
sections[1]!.items = [{ key: 'more', label: 'More tools', icon: { kind: 'slash' }, children: [skills] }]
const { onClose } = renderMenu({ sections })
fireEvent.click(screen.getByRole('option', { name: 'More tools' }))
fireEvent.click(screen.getByRole('option', { name: 'Skills' }))
expect(screen.getByRole('option', { name: 'Design' })).toBeInTheDocument()
fireEvent.keyDown(searchInput(), { key: 'Escape' })
expect(screen.getByRole('option', { name: 'Skills' })).toBeInTheDocument()
fireEvent.keyDown(searchInput(), { key: 'Escape' })
expect(screen.getByRole('option', { name: 'More tools' })).toBeInTheDocument()
expect(onClose).not.toHaveBeenCalled()
})
@@ -3,28 +3,20 @@ import { ChevronLeft, ChevronRight, Search } from 'lucide-react'
import { useTranslation } from '@/i18n'
import { publicAssetPath } from '@/lib/publicAsset'
import { Switch } from '@/components/ui/Switch'
import {
filterCapabilitySections,
type CapabilityAction,
type CapabilityIcon,
type CapabilityMenuItem,
type CapabilityMenuSection,
} from './capabilityMenuModel'
/**
* The composer's "+" capability menu: one searchable panel that surfaces the
* things a chat can use — skills, connectors, agents, teams, Computer Use,
* workflows — next to attachments and slash commands. Data and actions come
* from `capabilityMenuModel`; this component only renders, navigates and
* dispatches.
*
* Focus stays in the search input the whole time; rows are highlighted via
* aria-activedescendant, the same pattern the @-reference menu uses.
*/
import { IconButton } from '@/components/ui/IconButton'
import { ComposerSuggestionRow } from '@/components/chat/ComposerSuggestionRow'
import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from '@/components/chat/ComposerReferenceMenu'
import type { NewComposerMention } from '@/lib/composerMentions'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import type { CapabilityAction, CapabilityIcon, CapabilityMenuItem, CapabilityMenuSection } from './capabilityMenuModel'
type Props = {
id: string
sections: CapabilityMenuSection[]
cwd?: string
referencesLoading?: boolean
referencesError?: string | boolean | null
onSelectFile?: (mention: NewComposerMention) => void
onAction(action: CapabilityAction): void
onClose(): void
mobile?: boolean
@@ -34,6 +26,10 @@ export function getCapabilityMenuOptionId(id: string, index: number): string {
return `${id}-option-${index}`
}
function descendants(items: CapabilityMenuItem[], path: string[] = []): Array<{ item: CapabilityMenuItem, path: string[] }> {
return items.flatMap(item => [{ item, path }, ...descendants(item.children ?? [], [...path, item.key])])
}
function RowIcon({ icon, iconColor }: { icon: CapabilityIcon, iconColor?: string }) {
if (icon.kind === 'image') {
return <img src={publicAssetPath(icon.src)} alt="" className="h-5 w-5 shrink-0 object-contain" />
@@ -56,193 +52,112 @@ function RowIcon({ icon, iconColor }: { icon: CapabilityIcon, iconColor?: string
)
}
export function ComposerCapabilityMenu({ id, sections, onAction, onClose, mobile = false }: Props) {
/** The + launcher uses the same search, rows and mention selection as @. */
export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoading, referencesError, onSelectFile, onAction, onClose, mobile = false }: Props) {
const t = useTranslation()
const [query, setQuery] = useState('')
const [drillKey, setDrillKey] = useState<string | null>(null)
const [path, setPath] = useState<string[]>([])
const [highlight, setHighlight] = useState(0)
const [referenceOptionId, setReferenceOptionId] = useState<string>()
const listRef = useRef<HTMLDivElement>(null)
const filtered = useMemo(() => filterCapabilitySections(sections, query), [sections, query])
// Drilling only exists without a query: search results are already flat, and
// keeping the drill stack while filtering would resurrect rows the user just
// filtered away.
const drillParent = query.trim()
? null
: filtered.flatMap(section => section.items).find(item => item.key === drillKey && item.children) ?? null
type Row = { item: CapabilityMenuItem, sectionTitle: string | null }
const rows: Row[] = drillParent
? (drillParent.children ?? []).map(item => ({ item, sectionTitle: null }))
: filtered.flatMap(section => section.items.map(item => ({ item, sectionTitle: section.title })))
const activeIndex = rows.length ? Math.min(highlight, rows.length - 1) : -1
const activeOptionId = activeIndex < 0 ? undefined : getCapabilityMenuOptionId(id, activeIndex)
const activate = (row: Row | undefined) => {
if (!row || row.item.disabled) return
if (row.item.children && !query.trim()) {
setDrillKey(row.item.key)
setHighlight(0)
return
}
if (row.item.action) onAction(row.item.action)
const referenceRef = useRef<ComposerReferenceMenuHandle>(null)
const rootItems = useMemo(() => sections.flatMap(section => section.items), [sections])
let drillParent: CapabilityMenuItem | undefined
let items = rootItems
for (const key of path) {
const parent = items.find(item => item.key === key)
if (!parent?.children) break
drillParent = parent
items = parent.children
}
const goBack = () => {
setDrillKey(null)
const browseReferences = drillParent?.key === 'skills' || drillParent?.key === 'plugins'
const showReferences = browseReferences || !!query.trim()
const candidates = useMemo(() => descendants(rootItems), [rootItems])
const scoped = drillParent ? descendants(items, path) : candidates
const references = scoped.flatMap(({ item }) => item.action?.type === 'insertMention' ? [item.action.reference] : [])
.filter((reference, index, all) => all.findIndex(other => other.kind === reference.kind && other.id === reference.id) === index)
const activeIndex = items.length ? Math.min(highlight, items.length - 1) : -1
const listId = showReferences ? `${id}-references` : `${id}-list`
const activeOptionId = showReferences ? referenceOptionId : activeIndex < 0 ? undefined : getCapabilityMenuOptionId(id, activeIndex)
const openCategory = (nextPath: string[]) => {
setPath(nextPath)
setQuery('')
setHighlight(0)
}
const activate = (item: CapabilityMenuItem | undefined) => {
if (!item || item.disabled || item.switch?.disabled) return
if (item.children) openCategory([...path, item.key])
else if (item.action) onAction(item.action)
}
const actions = scoped.filter(({ item }) => item.action?.type !== 'insertMention' && !item.disabled && !item.switch?.disabled)
.map(({ item, path: parentPath }) => ({
key: item.key, label: item.label, description: item.description,
icon: <RowIcon icon={item.icon} iconColor={item.iconColor} />,
onSelect: () => item.children ? openCategory([...parentPath, item.key]) : item.action && onAction(item.action),
}))
const goBack = () => openCategory(path.slice(0, -1))
const handleKeyDown = (event: React.KeyboardEvent) => {
if (event.nativeEvent.isComposing) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
if (event.nativeEvent.isComposing || event.keyCode === 229) return
if (event.key === 'Escape') {
event.preventDefault()
if (!rows.length) return
const next = (Math.max(activeIndex, 0) + (event.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length
setHighlight(next)
const optionId = getCapabilityMenuOptionId(id, next)
listRef.current?.ownerDocument.getElementById(optionId)?.scrollIntoView?.({ block: 'nearest' })
} else if (event.key === 'Enter') {
event.preventDefault()
activate(rows[activeIndex])
} else if (event.key === 'ArrowRight') {
const row = rows[activeIndex]
if (row?.item.children && !query.trim()) {
event.preventDefault()
activate(row)
}
} else if (event.key === 'ArrowLeft' || (event.key === 'Backspace' && !query)) {
if (drillParent) {
event.preventDefault()
goBack()
}
} else if (event.key === 'Escape') {
event.preventDefault()
if (drillParent) goBack()
event.stopPropagation()
if (path.length) goBack()
else onClose()
} else if ((event.key === 'ArrowLeft' || event.key === 'Backspace') && !query && path.length) {
event.preventDefault()
goBack()
} else if (showReferences) {
referenceRef.current?.handleKeyDown(event.nativeEvent)
} else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!items.length) return
const next = (Math.max(activeIndex, 0) + (event.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length
setHighlight(next)
listRef.current?.ownerDocument.getElementById(getCapabilityMenuOptionId(id, next))?.scrollIntoView?.({ block: 'nearest' })
} else if (event.key === 'Enter' || event.key === 'ArrowRight' && items[activeIndex]?.children) {
event.preventDefault()
activate(items[activeIndex])
}
}
let offset = 0
const renderRow = (row: Row, index: number) => {
const { item } = row
const active = index === activeIndex
return (
<div
key={item.key}
id={getCapabilityMenuOptionId(id, index)}
role={item.switch ? 'menuitemcheckbox' : 'option'}
aria-selected={active}
aria-checked={item.switch ? item.switch.checked : undefined}
aria-disabled={item.disabled || undefined}
title={item.disabledReason}
onMouseEnter={() => setHighlight(index)}
onClick={event => {
// The switch toggles itself; the row around it must not double-fire.
if (item.switch && (event.target as Element).closest('[data-capability-switch]')) return
activate(row)
}}
className={`flex min-w-0 items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left focus-visible:outline-none ${
item.disabled
? 'cursor-not-allowed opacity-50'
: `cursor-default ${active ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'}`
}`}
>
<RowIcon icon={item.icon} iconColor={item.iconColor} />
<span className="max-w-[45%] shrink-0 truncate text-sm font-medium text-[var(--color-text-primary)]">
{item.label}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">
{item.description ?? ''}
</span>
{item.switch ? (
<span data-capability-switch className="-my-1 shrink-0" onClick={event => event.stopPropagation()}>
<Switch
size="sm"
checked={item.switch.checked}
disabled={item.switch.disabled}
label={t('chat.capabilities.computerUseToggle')}
labelHidden
onChange={() => item.action && onAction(item.action)}
/>
</span>
) : item.children ? (
<span className="flex shrink-0 items-center gap-1 text-xs text-[var(--color-text-tertiary)]">
{typeof item.count === 'number' ? <span>{item.count}</span> : null}
<ChevronRight aria-hidden="true" className="h-4 w-4" />
</span>
) : typeof item.count === 'number' ? (
<span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{item.count}</span>
) : null}
</div>
)
const selectMention = (mention: NewComposerMention) => {
if (mention.kind === 'skill' || mention.kind === 'plugin') {
const reference: ComposerReferenceCandidate | undefined = references.find(item => item.id === mention.id && item.kind === mention.kind)
if (reference) onAction({ type: 'insertMention', reference })
} else {
onSelectFile?.(mention)
onClose()
}
}
return (
<div
className={`absolute bottom-full left-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)] ${
mobile ? 'w-[min(320px,calc(100vw-32px))]' : 'w-[320px]'
}`}
onMouseDown={event => {
// Keep the composer from losing focus to the panel chrome; the search
// input re-focuses itself on click below.
event.preventDefault()
}}
>
<div className="flex items-center gap-2 border-b border-[var(--color-border-separator)] px-3 py-2">
<Search aria-hidden="true" className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" />
<input
// autoFocus is intentional: the menu opens on an explicit button
// press and moving focus into search is the point.
autoFocus
value={query}
onChange={event => {
setQuery(event.target.value)
setHighlight(0)
}}
onKeyDown={handleKeyDown}
onClick={event => event.currentTarget.focus()}
placeholder={t('chat.capabilities.searchPlaceholder')}
role="combobox"
aria-expanded="true"
aria-controls={`${id}-list`}
aria-activedescendant={activeOptionId}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]"
/>
</div>
<div ref={listRef} id={`${id}-list`} role="listbox" aria-label={t('chat.composerTools')} className="max-h-[360px] overflow-y-auto p-1.5">
{drillParent ? (
<div role="group" aria-label={drillParent.label}>
<div
role="button"
tabIndex={-1}
onClick={goBack}
className="flex cursor-default items-center gap-2 rounded-[var(--radius-md)] px-3 py-2 text-left hover:bg-[var(--color-surface-hover)]"
>
<ChevronLeft aria-hidden="true" className="h-4 w-4 text-[var(--color-text-tertiary)]" />
<span className="text-xs font-medium text-[var(--color-text-secondary)]">{drillParent.label}</span>
</div>
{rows.map((row, index) => renderRow(row, index))}
</div>
) : (
filtered.map(section => {
const start = offset
offset += section.items.length
return (
<div key={section.id} role="group" aria-label={section.title}>
<div className="px-3 pb-1 pt-2 text-xs font-medium text-[var(--color-text-tertiary)]">{section.title}</div>
{section.items.map((item, position) => renderRow({ item, sectionTitle: section.title }, start + position))}
</div>
)
})
)}
{!rows.length ? (
<div className="px-3 py-3 text-xs text-[var(--color-text-tertiary)]">{t('chat.capabilities.empty')}</div>
) : null}
</div>
const renderRow = (item: CapabilityMenuItem, index: number) => <ComposerSuggestionRow
key={item.key} id={getCapabilityMenuOptionId(id, index)} label={item.label}
selected={index === activeIndex} icon={<RowIcon icon={item.icon} iconColor={item.iconColor} />}
aria-label={item.switch ? `${item.label}: ${t(item.switch.checked ? 'settings.plugins.status.enabled' : 'settings.plugins.status.disabled')}` : undefined}
aria-labelledby={item.switch ? undefined : `${getCapabilityMenuOptionId(id, index)}-label`}
aria-disabled={item.disabled || item.switch?.disabled || undefined}
title={item.disabledReason ?? item.description}
onMouseEnter={() => setHighlight(index)} onClick={() => activate(item)}
trailing={item.switch ? <span className="-my-1 shrink-0" onClick={event => event.stopPropagation()}>
<Switch size="sm" checked={item.switch.checked} disabled={item.switch.disabled} label={t('chat.capabilities.computerUseToggle')} labelHidden onChange={() => item.action && onAction(item.action)} />
</span> : item.children ? <ChevronRight aria-hidden="true" className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" /> : item.key === 'slash-commands' ? <kbd>/</kbd> : null}
/>
let offset = 0
return <div className={`absolute bottom-full left-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)] ${mobile ? 'w-[min(360px,calc(100vw-32px))]' : showReferences ? 'w-[min(480px,calc(100vw-32px))]' : 'w-[min(288px,calc(100vw-32px))]'}`} onMouseDown={event => event.preventDefault()}>
<div className="flex items-center gap-2 border-b border-[var(--color-border-separator)] px-3 py-2">
{drillParent ? <IconButton icon={<ChevronLeft className="h-4 w-4" />} label={t('chat.capabilities.back')} size="xs" onClick={goBack} /> : null}
<Search aria-hidden="true" className="h-4 w-4 shrink-0 text-[var(--color-text-tertiary)]" />
<input autoFocus value={query} onChange={event => { setQuery(event.target.value); setHighlight(0) }} onKeyDown={handleKeyDown} onClick={event => event.currentTarget.focus()}
placeholder={drillParent?.label ?? t('chat.capabilities.searchPlaceholder')} aria-label={t('chat.capabilities.searchPlaceholder')}
role="combobox" aria-expanded="true" aria-controls={listId} aria-activedescendant={activeOptionId}
className="min-w-0 flex-1 bg-transparent text-sm text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)]" />
</div>
)
{showReferences ? <ComposerReferenceMenu key={path.join('/')} ref={referenceRef} id={listId} cwd={cwd} filter={query} embedded browseReferences={browseReferences} references={references} actions={actions}
referencesLoading={referencesLoading} referencesError={referencesError} onSelect={selectMention} onActiveChange={setReferenceOptionId} /> :
<div ref={listRef} id={listId} role="listbox" aria-label={t('chat.composerTools')} className="max-h-[min(360px,50vh)] overflow-y-auto p-1.5">
{drillParent ? items.map(renderRow) : sections.map(section => {
const start = offset
offset += section.items.length
return <div key={section.id} role="group" aria-label={section.title} className="border-b border-[var(--color-border-separator)] py-1 last:border-b-0">{section.items.map((item, index) => renderRow(item, start + index))}</div>
})}
</div>}
</div>
}
@@ -4,7 +4,7 @@ import { beforeEach, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import { filesystemApi } from '@/api/filesystem'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from './ComposerReferenceMenu'
import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from '@/components/chat/ComposerReferenceMenu'
vi.mock('@/api/filesystem', () => ({ filesystemApi: { browse: vi.fn(), search: vi.fn() } }))
const directory = { name: 'src', path: '/work/src', isDirectory: true }
@@ -19,33 +19,51 @@ beforeEach(() => {
vi.mocked(filesystemApi.search).mockResolvedValue({ currentPath: '/work', parentPath: '/', entries: [file] })
})
it('unifies plugin, skill and file groups with structured selection and active option ids', async () => {
it('previews capabilities without tabs or filesystem reads and inserts structured references', () => {
const ref = createRef<ComposerReferenceMenuHandle>()
const onSelect = vi.fn()
const onActiveChange = vi.fn()
render(<ComposerReferenceMenu ref={ref} id="references" cwd="/work" references={references} onSelect={onSelect} onActiveChange={onActiveChange} />)
await screen.findByRole('option', { name: 'app.ts' })
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual(['HyperFramesVideo creationPlugin', 'DesignCreate interfacesPersonal', 'src', 'app.ts'])
expect(onActiveChange).toHaveBeenLastCalledWith('references-option-0')
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) })
expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'plugin', id: 'hyperframes', modelText: 'Use HyperFrames', path: '', isDirectory: false }))
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' })) })
expect(screen.getByRole('option', { name: 'Design' })).toHaveAttribute('aria-selected', 'true')
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' })) })
expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ kind: 'skill', id: 'design', path: '/skills/design/SKILL.md' }))
fireEvent.click(screen.getByRole('option', { name: 'app.ts' }))
expect(onSelect).toHaveBeenLastCalledWith({ label: 'app.ts', path: '/work/app.ts', isDirectory: false })
render(<ComposerReferenceMenu ref={ref} id="references" cwd="/work" references={references} onSelect={onSelect} />)
expect(screen.getAllByRole('option').map(row => row.textContent)).toEqual(['DesignCreate interfacesPersonal', 'HyperFramesVideo creationPlugin'])
expect(screen.queryByRole('radiogroup')).not.toBeInTheDocument()
expect(filesystemApi.browse).not.toHaveBeenCalled()
expect(filesystemApi.search).not.toHaveBeenCalled()
expect(screen.getByRole('option', { name: 'HyperFrames' })).toHaveAccessibleDescription('Video creation')
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter', isComposing: true })) })
expect(onSelect).not.toHaveBeenCalled()
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' })) })
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ kind: 'skill', path: '/skills/design/SKILL.md', modelText: 'Use design' }))
})
it('keeps directory selection separate from ArrowRight and pointer navigation and ignores IME Enter', async () => {
it('limits empty previews to three per capability and gives embedded category browsers full access', () => {
const many = Array.from({ length: 12 }, (_, index) => ({ ...references[1]!, id: String(index), displayName: `Design ${index}` }))
const action = { key: 'manage', label: 'Manage', onSelect: vi.fn() }
const view = render(<ComposerReferenceMenu id="large" cwd="/work" references={many} actions={[action]} onSelect={vi.fn()} />)
expect(screen.getAllByRole('option')).toHaveLength(3)
expect(screen.queryByRole('option', { name: 'Manage' })).not.toBeInTheDocument()
view.rerender(<ComposerReferenceMenu embedded browseReferences id="large" cwd="/work" references={many} actions={[action]} onSelect={vi.fn()} />)
expect(screen.getAllByRole('option')).toHaveLength(13)
fireEvent.click(screen.getByRole('option', { name: 'Manage' }))
expect(action.onSelect).toHaveBeenCalledOnce()
expect(filesystemApi.browse).not.toHaveBeenCalled()
})
it('ranks file, capability and action results together with a total limit of eight', async () => {
const many = Array.from({ length: 12 }, (_, index) => ({ ...references[1]!, id: String(index), displayName: `helper ${index}`, description: 'app helper' }))
const action = { key: 'app', label: 'app', onSelect: vi.fn() }
render(<ComposerReferenceMenu id="ranked" cwd="/work" filter="app" references={many} actions={[action]} onSelect={vi.fn()} />)
await screen.findByRole('option', { name: 'app.ts' })
expect(screen.getAllByRole('option')).toHaveLength(8)
expect(screen.getAllByRole('option')[0]).toHaveAccessibleName('app')
expect(screen.queryByRole('group', { name: 'Skills' })).not.toBeInTheDocument()
})
it('keeps directory selection separate from ArrowRight and pointer navigation', async () => {
vi.mocked(filesystemApi.search).mockResolvedValue({ currentPath: '/work', parentPath: '/', entries: [directory] })
const ref = createRef<ComposerReferenceMenuHandle>()
const onSelect = vi.fn()
const onNavigate = vi.fn()
render(<ComposerReferenceMenu ref={ref} id="files" cwd="/work" references={[]} onSelect={onSelect} onNavigate={onNavigate} />)
render(<ComposerReferenceMenu ref={ref} id="files" cwd="/work" filter="src" references={[]} onSelect={onSelect} onNavigate={onNavigate} />)
const option = await screen.findByRole('option', { name: 'src' })
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter', isComposing: true })) })
expect(onSelect).not.toHaveBeenCalled()
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowRight' })) })
expect(onNavigate).toHaveBeenLastCalledWith('src/')
expect(onSelect).not.toHaveBeenCalled()
@@ -55,25 +73,34 @@ it('keeps directory selection separate from ArrowRight and pointer navigation an
expect(onSelect).toHaveBeenCalledWith({ label: 'src/', path: '/work/src', isDirectory: true })
})
it('discards late query and workspace results and never selects stale files while loading', async () => {
it('browses directory queries without filtering out their children', async () => {
const onSelect = vi.fn()
render(<ComposerReferenceMenu id="path" cwd="/work" filter="src/" references={references} onSelect={onSelect} />)
await screen.findByRole('option', { name: 'app.ts' })
expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true })
expect(screen.queryByRole('option', { name: 'Design' })).not.toBeInTheDocument()
})
it('discards late query and workspace results and never selects stale files', async () => {
let oldResolve!: (value: Awaited<ReturnType<typeof filesystemApi.search>>) => void
vi.mocked(filesystemApi.search).mockImplementationOnce(() => new Promise(resolve => { oldResolve = resolve }))
const onSelect = vi.fn()
const ref = createRef<ComposerReferenceMenuHandle>()
const view = render(<ComposerReferenceMenu ref={ref} id="search" cwd="/old" filter="old" references={[]} onSelect={onSelect} />)
view.rerender(<ComposerReferenceMenu ref={ref} id="search" cwd="/work" filter="new" references={[]} onSelect={onSelect} />)
view.rerender(<ComposerReferenceMenu ref={ref} id="search" cwd="/work" filter="app" references={[]} onSelect={onSelect} />)
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) })
expect(onSelect).not.toHaveBeenCalled()
await screen.findByRole('option', { name: 'app.ts' })
await act(async () => { oldResolve({ currentPath: '/old', parentPath: '/', entries: [{ name: 'old.ts', path: '/old/old.ts', isDirectory: false }] }) })
expect(screen.queryByRole('option', { name: 'old.ts' })).not.toBeInTheDocument()
expect(screen.getByRole('option', { name: 'app.ts' })).toBeInTheDocument()
view.rerender(<ComposerReferenceMenu ref={ref} id="search" cwd="/work" references={references} onSelect={onSelect} />)
expect(screen.queryByRole('option', { name: 'app.ts' })).not.toBeInTheDocument()
})
it('keeps plugin matches usable when file loading fails without exposing raw errors or remote icons', async () => {
vi.mocked(filesystemApi.browse).mockRejectedValue(new Error('secret-server-error'))
it('keeps plugin results usable after search failure without exposing raw errors or remote icons', async () => {
vi.mocked(filesystemApi.search).mockRejectedValue(new Error('secret-server-error'))
const onSelect = vi.fn()
render(<ComposerReferenceMenu id="failure" cwd="/work" references={[{ ...references[0]!, icon: 'https://untrusted.test/tracker.svg' }]} referencesError="secret-reference-error" onSelect={onSelect} />)
render(<ComposerReferenceMenu id="failure" cwd="/work" filter="hyper" references={[{ ...references[0]!, icon: 'https://untrusted.test/tracker.svg' }]} referencesError="secret-reference-error" onSelect={onSelect} />)
await waitFor(() => expect(screen.getAllByRole('alert')).toHaveLength(2))
expect(document.body.textContent).not.toContain('secret-')
expect(document.querySelector('img')).toBeNull()
@@ -81,32 +108,41 @@ it('keeps plugin matches usable when file loading fails without exposing raw err
expect(onSelect).toHaveBeenCalled()
})
it('uses the shared fallback vocabulary and labels where each reference came from', async () => {
const { container } = render(<ComposerReferenceMenu id="shared" cwd="/work" references={references} onSelect={vi.fn()} />)
await screen.findByRole('option', { name: 'app.ts' })
// The skill row must use the same outline box the slash menu uses; the
// decorative sparkle it used to render made the two menus disagree.
expect(container.querySelector('.lucide-box')).toBeInTheDocument()
expect(container.querySelector('.lucide-sparkles')).toBeNull()
expect(screen.getByRole('option', { name: 'HyperFrames' })).toHaveTextContent('Plugin')
expect(screen.getByRole('option', { name: 'Design' })).toHaveTextContent('Personal')
expect(screen.getByRole('option', { name: 'src' })).not.toHaveTextContent('Personal')
it('preserves explicitly highlighted results when asynchronous files change their ranking', async () => {
let resolveFiles!: (value: Awaited<ReturnType<typeof filesystemApi.search>>) => void
vi.mocked(filesystemApi.search).mockImplementationOnce(() => new Promise(resolve => { resolveFiles = resolve }))
const ref = createRef<ComposerReferenceMenuHandle>()
const onSelect = vi.fn()
render(<ComposerReferenceMenu ref={ref} id="pending" cwd="/work" filter="hyper" references={references} onSelect={onSelect} />)
fireEvent.mouseEnter(screen.getByRole('option', { name: 'HyperFrames' }))
await act(async () => { resolveFiles({ currentPath: '/work', parentPath: '/', entries: [{ ...file, name: 'hyper', path: '/work/hyper' }] }) })
expect(screen.getAllByRole('option')[0]).toHaveAccessibleName('hyper')
expect(screen.getByRole('option', { name: 'HyperFrames' })).toHaveAttribute('aria-selected', 'true')
act(() => { ref.current!.handleKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) })
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ kind: 'plugin', id: 'hyperframes' }))
})
it('resolves brand icons against the packaged asset base instead of the document root', async () => {
it('keeps explicit capability category searches out of the filesystem', () => {
render(<ComposerReferenceMenu embedded browseReferences id="category" cwd="/work" filter="design" references={references} onSelect={vi.fn()} />)
expect(screen.getByRole('option', { name: 'Design' })).toBeInTheDocument()
expect(filesystemApi.search).not.toHaveBeenCalled()
expect(filesystemApi.browse).not.toHaveBeenCalled()
expect(screen.getByRole('listbox')).toHaveAttribute('aria-busy', 'false')
})
it.each(['src/', 'src\\'])('keeps every child accessible while browsing %s', async filter => {
vi.mocked(filesystemApi.browse).mockResolvedValue({ currentPath: '/work/src', parentPath: '/work', entries: Array.from({ length: 15 }, (_, index) => ({ name: `file-${index}.ts`, path: `/work/src/file-${index}.ts`, isDirectory: false })) })
render(<ComposerReferenceMenu id="directory" cwd="/work" filter={filter} references={[]} onSelect={vi.fn()} />)
await screen.findByRole('option', { name: 'file-14.ts' })
expect(screen.getAllByRole('option')).toHaveLength(15)
expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true })
expect(filesystemApi.search).not.toHaveBeenCalled()
})
it('resolves plugin icons against the packaged asset base', () => {
vi.stubEnv('BASE_URL', './')
try {
render(<ComposerReferenceMenu id="brand" cwd="/work" references={references} onSelect={vi.fn()} />)
const option = await screen.findByRole('option', { name: 'HyperFrames' })
expect(option.querySelector('img')).toHaveAttribute('src', './connectors/hyperframes.svg')
expect(screen.getByRole('option', { name: 'HyperFrames' }).querySelector('img')).toHaveAttribute('src', './connectors/hyperframes.svg')
} finally { vi.unstubAllEnvs() }
})
it('browses explicit path filters and reports no active descendant for empty results', async () => {
vi.mocked(filesystemApi.browse).mockResolvedValue({ currentPath: '/work/src', parentPath: '/work', entries: [] })
const onActiveChange = vi.fn()
render(<ComposerReferenceMenu id="path" cwd="/work" filter="src/" references={references} onSelect={vi.fn()} onActiveChange={onActiveChange} />)
await waitFor(() => expect(filesystemApi.browse).toHaveBeenCalledWith('/work/src', { includeFiles: true }))
expect(screen.queryAllByRole('option')).toHaveLength(0)
expect(onActiveChange).toHaveBeenLastCalledWith(undefined)
})
@@ -1,5 +1,7 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode } from 'react'
import { ChevronRight } from 'lucide-react'
import { ComposerSuggestionRow } from '@/components/chat/ComposerSuggestionRow'
import { rankComposerSuggestions } from '@/components/chat/composerSuggestionSearch'
import { ApiError } from '@/api/client'
import { filesystemApi } from '@/api/filesystem'
import { useTranslation } from '@/i18n'
@@ -9,13 +11,16 @@ import type { ComposerReferenceCandidate } from '@/types/composerReference'
import { referenceFallbackIcon, skillSourceLabelKey } from './referencePresentation'
type FileEntry = { name: string, path: string, isDirectory: boolean, relativePath?: string }
type Row = { key: string, label: string, description: string, source?: string, mention: NewComposerMention, file?: FileEntry }
type Row = { key: string, label: string, description: string, searchTerms?: string[], source?: string, mention?: NewComposerMention, file?: FileEntry, icon?: ReactNode, onSelect?: () => void }
export type ComposerReferenceMenuHandle = { handleKeyDown(event: KeyboardEvent): void }
type Props = {
id: string
cwd: string
filter?: string
compact?: boolean
embedded?: boolean
browseReferences?: boolean
actions?: Array<{ key: string, label: string, description?: string, icon?: ReactNode, onSelect: () => void }>
references: ComposerReferenceCandidate[]
referencesLoading?: boolean
referencesError?: string | boolean | null
@@ -27,7 +32,7 @@ type Props = {
export function getComposerReferenceOptionId(id: string, index: number): string { return `${id}-option-${index}` }
export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Props>(function ComposerReferenceMenu({
id, cwd, filter = '', compact = false, references, referencesLoading = false, referencesError,
id, cwd, filter = '', compact = false, embedded = false, browseReferences = false, actions = [], references, referencesLoading = false, referencesError,
onSelect, onNavigate, onActiveChange,
}, ref) {
const t = useTranslation()
@@ -39,16 +44,18 @@ export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Pro
const listRef = useRef<HTMLDivElement>(null)
const rootRef = useRef({ cwd, path: cwd })
if (rootRef.current.cwd !== cwd) rootRef.current = { cwd, path: cwd }
const currentResult = result?.key === queryKey ? result : null
const loading = currentResult === null
const currentResult = !browseReferences && result?.key === queryKey ? result : null
const directoryQuery = filter.replace(/\\/g, '/').trim()
const browsingDirectory = directoryQuery.endsWith('/')
const isSearching = directoryQuery.length > 0 || override !== undefined
const loading = isSearching && !browseReferences && currentResult === null
useEffect(() => {
if (browseReferences || !filter.trim() && override === undefined) return
let active = true
const base = (cwd || rootRef.current.path).replace(/[\\/]+$/, '')
const directoryQuery = filter.replace(/\\/g, '/').trim()
const browsing = directoryQuery.endsWith('/')
const path = override ?? (browsing && base ? `${base}/${directoryQuery.replace(/\/+$/, '')}` : base)
const search = override || browsing ? '' : directoryQuery
const path = override ?? (browsingDirectory && base ? `${base}/${directoryQuery.replace(/\/+$/, '')}` : base)
const search = override || browsingDirectory ? '' : directoryQuery
const request = search ? filesystemApi.search(search, path) : filesystemApi.browse(path, { includeFiles: true })
void request.then(data => {
if (!active) return
@@ -58,30 +65,36 @@ export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Pro
if (active) setResult({ key: queryKey, entries: [], current: path, root: base, error: error instanceof ApiError && error.status === 403 ? 'denied' : 'failed' })
})
return () => { active = false }
}, [cwd, filter, override, queryKey])
}, [cwd, filter, override, queryKey, browseReferences, directoryQuery, browsingDirectory])
const groups = useMemo(() => {
const query = filter.trim().toLocaleLowerCase()
const matches = references.filter(item => !query.endsWith('/') && query.split(/\s+/).every(word => `${item.displayName} ${item.name} ${item.description}`.toLocaleLowerCase().includes(word)))
const matches = references.filter(() => browseReferences || !browsingDirectory)
const referenceRow = (item: ComposerReferenceCandidate): Row => ({
key: `${item.kind}:${item.id}`, label: item.displayName || item.name, description: item.description, source: item.source,
key: `${item.kind}:${item.id}`, label: item.displayName || item.name, description: item.description, searchTerms: [item.name], source: item.source,
mention: { kind: item.kind, id: item.id, label: item.displayName || item.name, path: item.path ?? '', isDirectory: false, description: item.description, icon: safeMentionIcon(item.icon), modelText: item.modelText },
})
const files: Row[] = (currentResult?.entries ?? []).map(entry => {
const files: Row[] = (browseReferences ? [] : currentResult?.entries ?? []).map(entry => {
const base = (cwd || currentResult?.root || '').replace(/\\/g, '/').replace(/\/+$/, '')
const path = entry.path.replace(/\\/g, '/')
const relative = path.startsWith(`${base}/`) ? path.slice(base.length + 1) : entry.relativePath ?? entry.name
const name = entry.name.split(/[\\/]/).filter(Boolean).at(-1) ?? entry.name
return { key: `file:${entry.path}`, label: name, description: relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) : '', file: { ...entry, relativePath: relative }, mention: { label: entry.isDirectory ? `${name}/` : name, path: entry.path, isDirectory: entry.isDirectory } }
return { key: `file:${entry.path}`, label: name, searchTerms: [relative, path], description: relative.includes('/') ? relative.slice(0, relative.lastIndexOf('/')) : '', file: { ...entry, relativePath: relative }, mention: { label: entry.isDirectory ? `${name}/` : name, path: entry.path, isDirectory: entry.isDirectory } }
})
return [
{ kind: 'plugins', label: t('chat.referencePlugins'), rows: matches.filter(item => item.kind === 'plugin').map(referenceRow) },
{ kind: 'skills', label: t('chat.referenceSkills'), rows: matches.filter(item => item.kind === 'skill').map(referenceRow) },
{ kind: 'plugins', label: t('chat.referencePlugins'), rows: matches.filter(item => item.kind === 'plugin').map(referenceRow) },
{ kind: 'files', label: t('chat.referenceFiles'), rows: files },
{ kind: 'actions', label: t('chat.references'), rows: actions.map(action => ({ ...action, description: action.description ?? '' })) as Row[] },
]
}, [references, filter, currentResult, cwd, t])
const rows = groups.flatMap(group => group.rows)
const foundIndex = selection?.query === queryKey ? rows.findIndex(row => row.key === selection.key) : -1
}, [references, currentResult, cwd, t, actions, browseReferences, browsingDirectory])
const visibleGroups = isSearching
? [{ kind: 'results', label: t('chat.references'), rows: !browseReferences && (override || browsingDirectory)
? groups.find(group => group.kind === 'files')!.rows
: rankComposerSuggestions(groups.flatMap(group => group.rows), filter) }]
: groups.filter(group => group.kind !== 'files' && (browseReferences || group.kind !== 'actions')).map(group => ({ ...group, rows: browseReferences ? group.rows : group.rows.slice(0, 3) }))
const rows = visibleGroups.flatMap(group => group.rows)
const selectionKey = queryKey
const foundIndex = selection?.query === selectionKey ? rows.findIndex(row => row.key === selection.key) : -1
const activeIndex = rows.length ? Math.max(0, foundIndex) : -1
const activeOptionId = activeIndex < 0 ? undefined : getComposerReferenceOptionId(id, activeIndex)
@@ -89,13 +102,17 @@ export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Pro
useEffect(() => {
if (activeOptionId) listRef.current?.ownerDocument.getElementById(activeOptionId)?.scrollIntoView?.({ block: 'nearest' })
}, [activeOptionId])
const highlight = useCallback((row: Row) => setSelection({ query: queryKey, key: row.key }), [queryKey])
const highlight = useCallback((row: Row) => setSelection({ query: selectionKey, key: row.key }), [selectionKey])
const navigate = useCallback((row: Row) => {
if (!row.file?.isDirectory) return
const relative = `${(row.file.relativePath ?? row.file.name).replace(/\/+$/, '')}/`
if (onNavigate) onNavigate(relative)
else setManualPath({ cwd, filter, path: row.file.path })
}, [cwd, filter, onNavigate])
const select = useCallback((row: Row) => {
if (row.onSelect) row.onSelect()
else if (row.mention) onSelect(row.mention)
}, [onSelect])
useImperativeHandle(ref, () => ({ handleKeyDown(event) {
if (event.isComposing || event.keyCode === 229) return
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
@@ -103,39 +120,39 @@ export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Pro
if (rows.length) highlight(rows[(Math.max(activeIndex, 0) + (event.key === 'ArrowDown' ? 1 : rows.length - 1)) % rows.length]!)
} else if (event.key === 'Enter' || event.key === 'Tab') {
event.preventDefault()
if (rows[activeIndex]) onSelect(rows[activeIndex]!.mention)
if (rows[activeIndex]) select(rows[activeIndex]!)
} else if (event.key === 'ArrowRight' && rows[activeIndex]?.file?.isDirectory) {
event.preventDefault()
navigate(rows[activeIndex]!)
}
} }), [activeIndex, rows, highlight, navigate, onSelect])
} }), [activeIndex, rows, highlight, navigate, select])
let offset = 0
return (
<div 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)] ${compact ? 'max-w-[calc(100vw-32px)]' : ''}`} onMouseDown={event => event.preventDefault()}>
<div ref={listRef} id={id} role="listbox" aria-label={t('chat.references')} aria-busy={loading || referencesLoading} className="min-w-0 max-h-[360px] overflow-y-auto p-1.5">
{groups.map(group => {
<div className={embedded ? 'min-w-0' : `absolute bottom-full left-0 w-full 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)] `} onMouseDown={event => event.preventDefault()}>
<div ref={listRef} id={id} role="listbox" aria-label={t('chat.references')} aria-busy={loading || referencesLoading} className="min-w-0 max-h-[min(320px,45vh)] overflow-y-auto p-1.5">
{visibleGroups.map(group => {
const start = offset
offset += group.rows.length
if (!group.rows.length) return null
return <div key={group.kind} role="group" aria-label={group.label}>
<div className="px-3 pb-1 pt-2 text-xs font-medium text-[var(--color-text-tertiary)]">{group.label}</div>
{!isSearching ? <div className="px-3 pb-1 pt-2 text-xs font-medium text-[var(--color-text-tertiary)]">{group.label}</div> : null}
{group.rows.map((row, position) => {
const index = start + position
const Icon = referenceFallbackIcon(row.file ? (row.file.isDirectory ? 'directory' : 'file') : row.mention.kind ?? 'skill')
const Icon = referenceFallbackIcon(row.file ? (row.file.isDirectory ? 'directory' : 'file') : row.mention?.kind ?? 'skill')
const sourceLabel = row.file ? null : skillSourceLabelKey(row.source)
return <div key={row.key} id={getComposerReferenceOptionId(id, index)} role="option" tabIndex={-1} aria-selected={activeIndex === index} aria-labelledby={`${id}-label-${index}`} aria-describedby={`${id}-description-${index}`}
return <ComposerSuggestionRow key={row.key} id={getComposerReferenceOptionId(id, index)}
label={row.label} description={row.description} selected={activeIndex === index}
onMouseEnter={() => highlight(row)} onClick={event => {
if ((event.target as Element).closest('[data-navigate-directory]')) navigate(row)
else onSelect(row.mention)
else select(row)
}}
className={`flex min-w-0 cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${activeIndex === index ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'}`}>
{row.mention.icon ? <img src={publicAssetPath(row.mention.icon)} alt="" className="h-5 w-5 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]" strokeWidth={1.7} />}
<span id={`${id}-label-${index}`} className="max-w-[45%] shrink-0 truncate text-sm font-medium text-[var(--color-text-primary)]">{row.label}</span>
<span id={`${id}-description-${index}`} className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{row.description}</span>
{sourceLabel ? <span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{t(sourceLabel)}</span> : null}
{row.file?.isDirectory ? <span data-navigate-directory title={t('fileSearch.openFolder')} className="-my-2 -mr-2 flex h-8 w-8 shrink-0 items-center justify-center"><ChevronRight aria-hidden="true" className="h-4 w-4 text-[var(--color-text-tertiary)]" /></span> : null}
</div>
icon={row.icon ?? (row.mention?.icon ? <img src={publicAssetPath(row.mention.icon)} alt="" className="h-5 w-5 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]" strokeWidth={1.7} />)}
trailing={<>
{sourceLabel ? <span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{t(sourceLabel)}</span> : null}
{row.file?.isDirectory ? <span data-navigate-directory title={t('fileSearch.openFolder')} className="-my-2 -mr-2 flex h-8 w-8 shrink-0 items-center justify-center"><ChevronRight aria-hidden="true" className="h-4 w-4 text-[var(--color-text-tertiary)]" /></span> : null}
</>}
/>
})}
</div>
})}
@@ -144,7 +161,8 @@ export const ComposerReferenceMenu = forwardRef<ComposerReferenceMenuHandle, Pro
{referencesError ? <div role="alert" className="px-3 py-2 text-xs text-[var(--color-error)]">{t('chat.referencesLoadFailed')}</div> : null}
{!rows.length && !loading && !referencesLoading && !currentResult?.error && !referencesError ? <div className="px-3 py-3 text-xs text-[var(--color-text-tertiary)]">{t('chat.referencesEmpty')}</div> : null}
</div>
{!compact ? <div className="flex items-center gap-2 border-t border-[var(--color-border)] px-4 py-2 text-[10px] text-[var(--color-text-tertiary)]"><kbd>↑↓</kbd><span>{t('fileSearch.navigate')}</span><kbd className="ml-2">Enter / Tab</kbd><span>{t('fileSearch.select')}</span><kbd className="ml-2">→</kbd><span>{t('fileSearch.open')}</span><kbd className="ml-2">Esc</kbd><span>{t('fileSearch.close')}</span></div> : null}
{!isSearching && !embedded ? <div className="border-t border-[var(--color-border)] px-3 py-2 text-xs text-[var(--color-text-tertiary)]">{t('chat.referenceSearchHint')}</div> : null}
{!compact && !embedded ? <div className="flex items-center gap-2 border-t border-[var(--color-border)] px-4 py-2 text-[10px] text-[var(--color-text-tertiary)]"><kbd>↑↓</kbd><span>{t('fileSearch.navigate')}</span><kbd className="ml-2">Enter / Tab</kbd><span>{t('fileSearch.select')}</span><kbd className="ml-2">→</kbd><span>{t('fileSearch.open')}</span><kbd className="ml-2">Esc</kbd><span>{t('fileSearch.close')}</span></div> : null}
</div>
)
})
@@ -0,0 +1,20 @@
import { createRef } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import { ComposerSuggestionRow } from '@/components/chat/ComposerSuggestionRow'
it('separates the accessible name from detail and forwards selection, events and ref', () => {
const ref = createRef<HTMLDivElement>()
const onClick = vi.fn()
const onMouseEnter = vi.fn()
render(<ComposerSuggestionRow ref={ref} id="item" label="Design" description="Build interfaces" selected onClick={onClick} onMouseEnter={onMouseEnter} trailing={<span>Personal</span>} />)
const option = screen.getByRole('option', { name: 'Design' })
expect(option).toHaveAccessibleDescription('Build interfaces')
expect(option).toHaveAttribute('aria-selected', 'true')
expect(ref.current).toBe(option)
fireEvent.mouseEnter(option)
fireEvent.click(option)
expect(onMouseEnter).toHaveBeenCalledOnce()
expect(onClick).toHaveBeenCalledOnce()
})
@@ -0,0 +1,24 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
id: string
label: string
description?: string
icon?: ReactNode
trailing?: ReactNode
selected?: boolean
}
/** Shared dense row for composer capability and reference suggestions. */
export const ComposerSuggestionRow = forwardRef<HTMLDivElement, Props>(function ComposerSuggestionRow({
id, label, description, icon, trailing, selected = false, className = '', ...rest
}, ref) {
return <div ref={ref} id={id} role="option" tabIndex={-1} aria-selected={selected}
aria-labelledby={`${id}-label`} aria-describedby={description ? `${id}-description` : undefined}
className={`flex min-w-0 cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${selected ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'} ${className}`} {...rest}>
{icon}
<span id={`${id}-label`} className={`${description ? 'max-w-[45%] shrink-0' : 'min-w-0 flex-1'} truncate text-sm font-medium text-[var(--color-text-primary)]`}>{label}</span>
{description ? <span id={`${id}-description`} className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{description}</span> : null}
{trailing}
</div>
})
@@ -85,3 +85,43 @@ it('uses the same command-plugin-skill order for option ids and keyboard referen
expect(onSelect).toHaveBeenCalledWith('plugin:video')
expect(itemRefs.current[2]).toBe(options[2])
})
it('explains default discovery and exposes command descriptions separately from option names', () => {
const props = {
id: 'frequent-slash',
groups: { system: [{ name: 'compact', description: 'Compact context' }], skills: [], ordered: [] },
selectedIndex: 0,
itemRefs: { current: [] },
onSelect: vi.fn(),
onHighlight: vi.fn(),
showKeyboardHints: false,
}
const { rerender } = render(<SlashCommandMenu {...props} />)
expect(screen.getByRole('option', { name: '/compact' })).toHaveAccessibleDescription('Compact context')
expect(screen.getByText(translate(useSettingsStore.getState().locale, 'chat.slashSearchHint'))).toBeInTheDocument()
rerender(<SlashCommandMenu {...props} isSearching />)
expect(screen.queryByText(translate(useSettingsStore.getState().locale, 'chat.slashSearchHint'))).not.toBeInTheDocument()
expect(screen.getByText(translate(useSettingsStore.getState().locale, 'chat.slashSearchResults'))).toBeInTheDocument()
})
it('keeps command rows on one line while preserving searchable argument hints and full hover details', () => {
const props = {
id: 'compact-layout',
groups: { system: [{ name: 'goal', description: 'Set a completion goal', argumentHint: '[<condition> | clear]' }], skills: [], ordered: [] },
selectedIndex: 0,
itemRefs: { current: [] },
onSelect: vi.fn(),
onHighlight: vi.fn(),
showKeyboardHints: false,
}
const { container, rerender } = render(<SlashCommandMenu {...props} />)
const option = screen.getByRole('option', { name: '/goal' })
expect(option).toHaveAttribute('title', '/goal — [<condition> | clear] — Set a completion goal')
expect(screen.queryByText('[<condition> | clear]')).not.toBeInTheDocument()
expect(screen.getByText('/goal').parentElement).toBe(screen.getByText('Set a completion goal').parentElement)
expect(container.firstElementChild).toHaveClass('left-0', 'right-0')
expect(container.firstElementChild).not.toHaveClass('max-w-[560px]')
rerender(<SlashCommandMenu {...props} isSearching />)
expect(screen.getByText('[<condition> | clear]')).toHaveAttribute('title', '[<condition> | clear]')
})
@@ -73,6 +73,7 @@ type SlashCommandMenuProps = {
onSelect: (commandName: string) => void
onHighlight: (index: number) => void
showKeyboardHints: boolean
isSearching?: boolean
references?: ComposerReferenceCandidate[]
}
@@ -87,6 +88,7 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
onHighlight,
showKeyboardHints,
references = [],
isSearching = false,
},
ref,
) {
@@ -101,6 +103,9 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
role="option"
tabIndex={-1}
aria-selected={index === selectedIndex}
aria-labelledby={`${id}-label-${index}`}
aria-describedby={`${id}-description-${index}`}
title={[`/${command.name}`, command.argumentHint, command.description].filter(Boolean).join(' — ')}
ref={(element) => { itemRefs.current[index] = element }}
onClick={() => onSelect(command.name)}
onMouseEnter={() => onHighlight(index)}
@@ -108,26 +113,24 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
index === selectedIndex
? 'bg-[var(--color-surface-hover)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]`}
>
<Icon
aria-hidden="true"
className="h-5 w-5 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>
{command.argumentHint ? (
<span className="min-w-0 truncate font-mono text-[11px] text-[var(--color-text-tertiary)]">
{command.argumentHint}
</span>
) : null}
<span id={`${id}-label-${index}`} className="min-w-0 max-w-[60%] flex-none truncate text-sm font-medium text-[var(--color-text-primary)]">
/{command.name}
</span>
<span className="min-w-0 flex-1 truncate text-left text-xs text-[var(--color-text-tertiary)]">
<span id={`${id}-description-${index}`} className="min-w-0 flex-1 truncate text-sm text-[var(--color-text-tertiary)]">
{command.description}
</span>
{isSearching && command.argumentHint ? (
<span className="max-w-[30%] shrink truncate font-mono text-[11px] text-[var(--color-text-tertiary)]" title={command.argumentHint}>
{command.argumentHint}
</span>
) : null}
</div>
)
}
@@ -138,11 +141,14 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
onMouseDown={event => event.preventDefault()}
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="px-4 pb-1 pt-3 text-xs font-medium text-[var(--color-text-tertiary)]">
{t(isSearching ? 'chat.slashSearchResults' : 'chat.slashFrequent')}
</div>
<div
id={id}
role="listbox"
aria-label={t('chat.slashCommands')}
className="max-h-[420px] overflow-y-auto p-1.5"
className="max-h-[min(360px,45vh)] overflow-y-auto p-1.5"
>
{groups.system.map(renderSystemCommand)}
@@ -161,18 +167,20 @@ export const SlashCommandMenu = forwardRef<HTMLDivElement, SlashCommandMenuProps
return <div
id={getSlashCommandOptionId(id, index)} key={command.name} role="option" tabIndex={-1}
aria-selected={index === selectedIndex} aria-labelledby={`${id}-label-${index}`} aria-describedby={`${id}-description-${index}`}
title={[candidate?.displayName || command.name, command.argumentHint, command.description].filter(Boolean).join(' — ')}
ref={element => { itemRefs.current[index] = element }}
onClick={() => onSelect(command.name)} onMouseEnter={() => onHighlight(index)}
className={`flex w-full cursor-default items-center gap-3 rounded-[var(--radius-md)] px-3 py-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${index === selectedIndex ? 'bg-[var(--color-surface-hover)]' : 'hover:bg-[var(--color-surface-hover)]'}`}>
{icon ? <img src={publicAssetPath(icon)} alt="" className="h-5 w-5 shrink-0 object-contain" /> : <Icon aria-hidden="true" className="h-5 w-5 shrink-0 text-[var(--color-text-secondary)]" strokeWidth={1.8} />}
<span id={`${id}-label-${index}`} className="min-w-0 max-w-[45%] shrink-0 truncate text-sm font-medium text-[var(--color-text-primary)]">{candidate?.displayName || command.name}</span>
<span id={`${id}-description-${index}`} className="min-w-0 flex-1 truncate text-xs text-[var(--color-text-tertiary)]">{command.description}</span>
<span id={`${id}-label-${index}`} className="min-w-0 max-w-[60%] flex-none truncate text-sm font-medium text-[var(--color-text-primary)]">{candidate?.displayName || command.name}</span>
<span id={`${id}-description-${index}`} className="min-w-0 flex-1 truncate text-sm text-[var(--color-text-tertiary)]">{command.description}</span>
{sourceLabel ? <span className="shrink-0 text-xs text-[var(--color-text-tertiary)]">{t(sourceLabel)}</span> : null}
</div>
})}
</div>
) : null)}
</div>
{!isSearching ? <div className="px-4 pb-2 text-xs text-[var(--color-text-tertiary)]">{t('chat.slashSearchHint')}</div> : null}
{showKeyboardHints ? (
<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)]">
<kbd className="rounded border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-1.5 py-0.5 font-mono text-[10px]">Up/Down</kbd>
@@ -7,7 +7,6 @@ import type { TeamSummary } from '@/types/team'
import type { WorkflowDefinition } from '@/types/workflow'
import {
buildCapabilitySections,
filterCapabilitySections,
type CapabilityMenuInput,
} from './capabilityMenuModel'
@@ -100,15 +99,17 @@ function buildInput(overrides: Partial<CapabilityMenuInput> = {}): CapabilityMen
function sectionsById(input: CapabilityMenuInput) {
const sections = buildCapabilitySections(input)
return new Map(sections.map(section => [section.id, section]))
const items = sections.flatMap(section => section.items).flatMap(item => item.key === 'more' ? item.children! : [item])
return new Map([['capabilities', { items }]])
}
describe('buildCapabilitySections', () => {
it('leads with attachments, then capabilities, then commands', () => {
it('puts skills and plugins first and groups secondary tools under More', () => {
const sections = buildCapabilitySections(buildInput())
expect(sections.map(section => section.id)).toEqual(['add', 'capabilities', 'commands'])
expect(sections[0]!.items[0]!.action).toEqual({ type: 'attachment' })
expect(sections[2]!.items[0]!.action).toEqual({ type: 'slashTrigger' })
expect(sections[0]!.items.map(item => item.key)).toEqual(['skills', 'plugins'])
expect(sections[1]!.items[0]!.action).toEqual({ type: 'attachment' })
expect(sections[2]!.items.map(item => item.key)).toEqual(['computer-use', 'more'])
expect(sections[2]!.items[1]!.children!.map(item => item.key)).toEqual(['connectors', 'agents', 'teams', 'workflows', 'slash-commands'])
})
it('lists skills as mention insertions with a manage footer', () => {
@@ -185,34 +186,18 @@ describe('buildCapabilitySections', () => {
})
})
describe('filterCapabilitySections', () => {
it('returns sections untouched on an empty query', () => {
const sections = buildCapabilitySections(buildInput())
expect(filterCapabilitySections(sections, ' ')).toBe(sections)
})
it('promotes matching children to the top level with their action intact', () => {
const sections = buildCapabilitySections(buildInput())
const filtered = filterCapabilitySections(sections, 'nightly')
const items = filtered.flatMap(section => section.items)
expect(items).toHaveLength(1)
expect(items[0]).toMatchObject({
key: 'workflow:userSettings:nightly-review',
action: { type: 'insertSlashText', command: 'nightly-review' },
})
})
it('keeps children on a matched parent so it still drills in', () => {
const sections = buildCapabilitySections(buildInput())
const filtered = filterCapabilitySections(sections, sections[1]!.items[0]!.label)
const parent = filtered.flatMap(section => section.items).find(item => item.key === 'skills')
expect(parent?.children?.length).toBeGreaterThan(0)
})
it('drops sections with no matches', () => {
const sections = buildCapabilitySections(buildInput())
const filtered = filterCapabilitySections(sections, 'nightly')
expect(filtered.map(section => section.id)).not.toContain('add')
expect(filtered.map(section => section.id)).not.toContain('commands')
})
it('exposes every mentionable plugin even without a connected connector', () => {
const plugins = buildCapabilitySections(buildInput({ connectors: [] }))[0]!.items[1]!
expect(plugins.children![0]!.action).toEqual({ type: 'insertMention', reference: plugin })
expect(plugins.children!.at(-1)!.action).toEqual({ type: 'settings', tab: 'plugins' })
})
it('keeps withdrawn installed packages out of the secondary connector list', () => {
const hidden = { ...connectedConnector, id: 'frontend-design', pluginId: 'office-frontend-design@haha-connectors' } as ConnectorDto
const sections = buildCapabilitySections(buildInput({ connectors: [hidden, connectedConnector] }))
const more = sections.flatMap(section => section.items).find(item => item.key === 'more')!
const connectors = more.children!.find(item => item.key === 'connectors')!
expect(connectors.count).toBe(1)
expect(connectors.children!.some(item => item.key === 'connector:frontend-design')).toBe(false)
expect(connectors.children!.some(item => item.key === 'connector:feishu')).toBe(true)
})
@@ -2,6 +2,7 @@ import {
Box,
Bot,
MonitorSmartphone,
Ellipsis,
Paperclip,
Plug,
Settings2,
@@ -10,6 +11,7 @@ import {
Workflow,
type LucideIcon,
} from 'lucide-react'
import { isComposerPluginVisible } from '@/lib/composerCapabilityVisibility'
import type { TranslationKey } from '@/i18n'
import type { SettingsTab } from '@/stores/uiStore'
import type { AgentDefinition } from '@/api/agents'
@@ -101,7 +103,7 @@ function referenceIcon(reference: ComposerReferenceCandidate): CapabilityIcon {
}
function connectorStatusCount(connectors: ConnectorDto[]): number {
return connectors.filter(connector => connector.connection === 'connected').length
return connectors.filter(connector => connector.connection === 'connected' && isComposerPluginVisible(connector.pluginId)).length
}
export function buildCapabilitySections(input: CapabilityMenuInput): CapabilityMenuSection[] {
@@ -121,7 +123,21 @@ export function buildCapabilitySections(input: CapabilityMenuInput): CapabilityM
action: { type: 'settings', tab: 'skills' },
})
const connected = input.connectors.filter(connector => connector.connection === 'connected')
const pluginChildren: CapabilityMenuItem[] = input.plugins.map(plugin => ({
key: `plugin:${plugin.id}`,
label: plugin.displayName || plugin.name,
description: plugin.description,
icon: referenceIcon(plugin),
action: { type: 'insertMention', reference: plugin },
}))
pluginChildren.push({
key: 'plugins:manage',
label: t('chat.capabilities.managePlugins'),
icon: { kind: 'lucide', icon: Settings2 },
action: { type: 'settings', tab: 'plugins' },
})
const connected = input.connectors.filter(connector => connector.connection === 'connected' && isComposerPluginVisible(connector.pluginId))
const connectorChildren: CapabilityMenuItem[] = connected.map(connector => {
// A connector backed by an installed plugin can be referenced as a mention;
// anything else only has a management surface, so the row opens the catalog.
@@ -215,7 +231,7 @@ export function buildCapabilitySections(input: CapabilityMenuInput): CapabilityM
action: { type: 'saveWorkflowPanel' },
})
return [
const sections: CapabilityMenuSection[] = [
{
id: 'add',
title: t('chat.capabilities.sectionAdd'),
@@ -284,38 +300,26 @@ export function buildCapabilitySections(input: CapabilityMenuInput): CapabilityM
}],
},
]
}
function itemMatches(item: CapabilityMenuItem, words: string[]): boolean {
const haystack = `${item.label} ${item.description ?? ''}`.toLocaleLowerCase()
return words.every(word => haystack.includes(word))
}
/**
* Filter sections by a search query. Matches against both top-level rows and
* sub-list rows; matched children are promoted to the top level (flattened,
* with their action intact) so the result list stays a single linear menu.
*/
export function filterCapabilitySections(
sections: CapabilityMenuSection[],
query: string,
): CapabilityMenuSection[] {
const words = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (!words.length) return sections
const filtered: CapabilityMenuSection[] = []
for (const section of sections) {
const items: CapabilityMenuItem[] = []
for (const item of section.items) {
if (itemMatches(item, words)) {
// Keep children: a matched parent still drills into its sub-list.
items.push(item)
continue
}
const matchingChildren = item.children?.filter(child => itemMatches(child, words)) ?? []
items.push(...matchingChildren.map(child => ({ ...child, children: undefined })))
}
if (items.length) filtered.push({ ...section, items })
}
return filtered
const capabilities = sections[1]!.items
const primary = capabilities.filter(item => item.key === 'skills')
primary.push({
key: 'plugins',
label: t('chat.referencePlugins'),
icon: { kind: 'lucide', icon: Plug },
count: input.plugins.length,
children: pluginChildren,
})
return [
{ ...sections[1]!, items: primary },
sections[0]!,
{
...sections[2]!,
items: [computerUseItem, {
key: 'more',
label: t('chat.capabilities.moreTools'),
icon: { kind: 'lucide', icon: Ellipsis },
children: [...capabilities.filter(item => item.key !== 'skills' && item.key !== 'computer-use'), ...sections[2]!.items],
}],
},
]
}
@@ -0,0 +1,20 @@
import { expect, it } from 'vitest'
import { rankComposerSuggestions } from '@/components/chat/composerSuggestionSearch'
it('ranks exact names, prefixes, name matches, then description matches in stable order', () => {
const items = [
{ label: 'Other', description: 'Design interfaces' },
{ label: 'My design' },
{ label: 'Design tools' },
{ label: 'Design' },
{ label: 'Design systems' },
]
expect(rankComposerSuggestions(items, 'design').map(item => item.label)).toEqual(['Design', 'Design tools', 'Design systems', 'My design', 'Other'])
})
it('matches aliases and normalized paths and limits cross-source results', () => {
const file = { label: 'index.ts', searchTerms: ['src/index.ts'] }
expect(rankComposerSuggestions([file], 'src\\index')).toEqual([file])
expect(rankComposerSuggestions(Array.from({ length: 12 }, (_, i) => ({ label: `item ${i}` })), 'item')).toHaveLength(8)
expect(rankComposerSuggestions([{ label: 'no match' }], 'absent')).toEqual([])
})
@@ -0,0 +1,19 @@
type SearchableSuggestion = { label: string, searchTerms?: string[], description?: string }
/** Rank names ahead of descriptive matches while keeping ties in source order. */
export function rankComposerSuggestions<T extends SearchableSuggestion>(items: T[], query: string, limit = 8): T[] {
const normalized = query.trim().toLocaleLowerCase().replace(/\\/g, '/')
if (!normalized) return items.slice(0, limit)
const words = normalized.split(/\s+/)
return items.map((item, index) => {
const names = [item.label, ...item.searchTerms ?? []].map(value => value.toLocaleLowerCase().replace(/\\/g, '/'))
const description = item.description?.toLocaleLowerCase() ?? ''
const score = names.some(name => name === normalized) ? 4
: names.some(name => name.startsWith(normalized)) ? 3
: names.some(name => name.includes(normalized)) ? 2
: words.every(word => [...names, description].some(value => value.includes(word))) ? 1 : 0
return { item, index, score }
}).filter(result => result.score > 0)
.sort((a, b) => b.score - a.score || a.index - b.index)
.slice(0, limit).map(result => result.item)
}
@@ -159,7 +159,7 @@ describe('composerUtils', () => {
expect(replaceSlashCommand('/goal sta', 9, 'goal status')).toBeNull()
})
it('ranks slash command name matches before broad description matches', () => {
it('keeps name matches free of broad description-only matches', () => {
expect(
filterSlashCommands([
{ name: 'lark-calendar', description: 'Includes shortcuts and suggestion helpers' },
@@ -170,57 +170,36 @@ describe('composerUtils', () => {
).toEqual([
'superpowers:brainstorming',
'superpowers:systematic-debugging',
'lark-calendar',
'agent-team-orchestrator',
])
})
it('opens on the commands the desktop owns instead of the CLI registration order', () => {
// The CLI lists its bundled skills first, so an unprioritised menu opens on
// `update-config` / `debug` / `batch`. Desktop-owned commands lead instead,
// and everything else keeps the order its source gave it. `help` has a
// permanent GUI home, so it stays out of the empty-query listing.
const commands = [
it('opens on a bounded set of frequent commands regardless of CLI registration order', () => {
const commands = mergeSlashCommands([
{ name: 'update-config', description: 'Configure' },
{ name: 'debug', description: 'Debug' },
{ name: 'compact', description: 'Compact conversation context' },
{ name: 'help', description: 'Show available commands' },
{ name: 'model', description: 'Switch AI model' },
]
expect(filterSlashCommands(commands, '').map((command) => command.name)).toEqual([
'model',
'update-config',
'debug',
'compact',
{ name: 'heapdump', description: 'Heap dump' },
{ name: 'video', description: 'Make videos', kind: 'skill' },
{ name: 'draw', description: 'Draw diagrams', kind: 'plugin' },
])
})
it('hides commands with a permanent GUI home from the empty-query listing only', () => {
const all = mergeSlashCommands([])
const emptyQueryNames = filterSlashCommands(all, '').map((command) => command.name)
for (const hidden of ['status', 'cost', 'context', 'config', 'doctor', 'memory', 'plugin', 'help', 'plugins', 'settings']) {
expect(emptyQueryNames).not.toContain(hidden)
expect(filterSlashCommands(commands, '').map(command => command.name)).toEqual([
'compact', 'context', 'status', 'init', 'review', 'model',
])
expect(filterSlashCommands(commands, ' ')).toEqual(filterSlashCommands(commands, ''))
for (const name of ['update-config', 'debug', 'heapdump', 'video', 'draw', 'config', 'help']) {
expect(filterSlashCommands(commands, name).map(command => command.name)).toContain(name)
}
// Everything the desktop still surfaces in the menu is untouched.
for (const visible of ['mcp', 'skills', 'save-workflow', 'model', 'compact']) {
expect(emptyQueryNames).toContain(visible)
}
// Typing the name still matches — and still resolves to the same UI action.
expect(filterSlashCommands(all, 'status').map((command) => command.name)).toContain('status')
expect(filterSlashCommands(all, 'conf').map((command) => command.name)).toContain('config')
})
it('hides CLI-reported commands by name too, regardless of source', () => {
const cliReported = [{ name: 'status', description: 'CLI status', kind: 'command' as const }]
const emptyQueryNames = filterSlashCommands(mergeSlashCommands(cliReported), '').map(
(command) => command.name,
)
expect(emptyQueryNames).not.toContain('status')
it('does not surface a skill merely because it shares a frequent command name', () => {
expect(filterSlashCommands([{ name: 'review', description: 'Custom review', kind: 'skill' }], '')).toEqual([])
})
it('leaves match ranking alone once a query is typed', () => {
it('keeps CLI-reported frequent commands available in the default list', () => {
const commands = mergeSlashCommands([{ name: 'status', description: 'CLI status', kind: 'command' }])
expect(filterSlashCommands(commands, '').map(command => command.name)).toContain('status')
})
it('keeps the named command instead of description-only matches', () => {
const commands = [
{ name: 'help', description: 'Show available commands' },
{ name: 'compact', description: 'Compact conversation context' },
@@ -228,7 +207,26 @@ describe('composerUtils', () => {
]
expect(filterSlashCommands(commands, 'comp').map((command) => command.name)).toEqual([
'compact',
'update-config',
])
})
it('falls back to descriptions and arguments when no command name matches', () => {
const commands = [
{ name: 'compact', description: 'Reduce conversation size' },
{ name: 'run', description: 'Run a task', argumentHint: '<conversation>' },
]
expect(filterSlashCommands(commands, 'conversation').map(command => command.name)).toEqual(['compact', 'run'])
})
it('ranks exact names, prefixes and name segments before substrings', () => {
const commands = [
{ name: 'decompact', description: '' },
{ name: 'workspace:compact', description: '' },
{ name: 'compactor', description: '' },
{ name: 'compact', description: '' },
]
expect(filterSlashCommands(commands, 'compact').map(command => command.name)).toEqual([
'compact', 'compactor', 'workspace:compact', 'decompact',
])
})
+18 -69
View File
@@ -63,33 +63,11 @@ export const SLASH_COMMAND_ALIASES = [
{ name: 'settings', target: 'config' },
] as const
/**
* Desktop-owned commands that duplicate a permanent GUI surface and so stay
* out of the slash menu's *default* (empty-query) listing: status/cost/context
* live on the toolbar's ContextUsageIndicator, config/doctor/memory/plugin are
* Settings tabs, and help is superseded by the composer's capability menu.
* They are only hidden from the empty-query view — typing the name still
* matches and executes them exactly as before, and the commands remain
* registered for `getSlashCommandNameConflict`. Aliases of hidden commands
* are hidden too so `plugins`/`settings` don't leak their targets back in.
*/
export const DEFAULT_HIDDEN_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set([
'status',
'cost',
'context',
'config',
'doctor',
'memory',
'plugin',
'help',
...SLASH_COMMAND_ALIASES.map(alias => alias.name),
])
/** A short, predictable entry point; typing searches the complete registry. */
export const FREQUENT_SLASH_COMMAND_NAMES = [
'compact', 'context', 'status', 'init', 'review', 'model',
] as const
/**
* Commands the desktop owns, in the order the slash menu should lead with them.
* The order is the one the panel and settings tables declare, so the first
* screen stays the same no matter how the CLI happened to register its list.
*/
const DESKTOP_SLASH_COMMAND_NAMES: readonly string[] = [
...PANEL_SLASH_COMMANDS.map(command => command.name),
...SETTINGS_SLASH_COMMANDS.map(command => command.name),
@@ -97,32 +75,6 @@ const DESKTOP_SLASH_COMMAND_NAMES: readonly string[] = [
'model',
]
/**
* A session's command list is stitched together from the CLI's own registration
* (its bundled skills first) and the desktop fallback, which leaves entries such
* as `update-config`, `debug` and `batch` above the fold while the commands a
* user reaches for sit below it. Desktop-owned commands are unconditional — the
* client runs them itself — so they lead, and everything else keeps the order
* its source gave it.
*/
const PREFERRED_SLASH_COMMAND_RANKS = new Map(
DESKTOP_SLASH_COMMAND_NAMES.map((name, index) => [name.toLowerCase(), index] as const),
)
function prioritizeSlashCommands(commands: SlashCommandOption[]): SlashCommandOption[] {
const rankOf = (command: SlashCommandOption): number | undefined =>
PREFERRED_SLASH_COMMAND_RANKS.get(command.name.trim().toLowerCase())
const preferred = commands
.map((command, index) => ({ command, index, rank: rankOf(command) }))
.filter((entry): entry is { command: SlashCommandOption, index: number, rank: number } => entry.rank !== undefined)
.sort((a, b) => a.rank - b.rank || a.index - b.index)
if (!preferred.length || preferred.length === commands.length) return commands
return [
...preferred.map(entry => entry.command),
...commands.filter(command => rankOf(command) === undefined),
]
}
/** Commands the desktop reserves for itself; new workflows must not claim them. */
const DESKTOP_RESERVED_SLASH_COMMAND_NAMES = new Set(DESKTOP_SLASH_COMMAND_NAMES.map(name => name.toLowerCase()))
@@ -376,26 +328,23 @@ export function filterSlashCommands(
commands: ReadonlyArray<SlashCommandOption>,
filter: string,
): SlashCommandOption[] {
const normalized = filter.toLowerCase()
// No query yet: this is the order the menu opens on, so lead with the
// commands the desktop owns instead of whatever the CLI registered first.
// Commands with a permanent GUI home (status/cost/context/…) stay hidden
// until the user types — see DEFAULT_HIDDEN_SLASH_COMMAND_NAMES.
const normalized = filter.trim().toLowerCase()
if (!normalized.trim()) {
return prioritizeSlashCommands(
[...commands].filter(
command => !DEFAULT_HIDDEN_SLASH_COMMAND_NAMES.has(command.name.trim().toLowerCase()),
),
)
return FREQUENT_SLASH_COMMAND_NAMES.flatMap(name => commands.filter(command =>
command.name.trim().toLowerCase() === name && (!command.kind || command.kind === 'command'),
))
}
return commands
.map((command, index) => ({
command,
index,
rank: getSlashCommandMatchRank(command, normalized),
}))
.filter((item) => Number.isFinite(item.rank))
const matches = commands.map((command, index) => ({
command,
index,
rank: getSlashCommandMatchRank(command, normalized),
})).filter(item => Number.isFinite(item.rank))
// Broad descriptions are useful as a fallback, but must not crowd out a
// command the user is naming — especially after grouping by command kind.
const hasNameMatch = matches.some(item => item.rank < 4)
return matches
.filter(item => !hasNameMatch || item.rank < 4)
.sort((a, b) => a.rank - b.rank || a.index - b.index)
.map((item) => item.command)
}
@@ -22,10 +22,11 @@ import {
* both composers (ChatInput and EmptySession) so the two stay identical by
* construction rather than by a parity test.
*
* Everything except skills/plugins (already in memory for the @-menu) is
* lazy-loaded the first time the menu opens, and re-fetched on each open —
* these lists change in Settings while a session is running, and the APIs are
* cheap local reads. Fetches are dropped when the menu closes mid-flight.
* Parents refresh the shared skill/plugin references on each +, @ or slash
* menu opening and clear stale entries while loading. This hook lazy-loads
* the remaining capabilities and re-fetches them on each opening because
* Settings can change while a session is running. Fetches are dropped when
* the menu closes mid-flight.
*/
export type CapabilityMenuComposerHandlers = {
+7 -1
View File
@@ -463,6 +463,12 @@ export const en = {
'attachments.selectionNoteTitle': 'Requested changes',
'attachments.selectionBatch': '{count} selected page changes',
'attachments.selectionItem': 'Selected element {number}: {name}',
'chat.capabilities.managePlugins': 'Manage plugins',
'chat.capabilities.moreTools': 'More tools',
'chat.referenceSearchHint': 'Type to find more skills, plugins and project files',
'chat.slashFrequent': 'Frequent commands',
'chat.slashSearchHint': 'Type a name to find other commands',
'chat.slashSearchResults': 'Search results',
'chat.pendingMessageQueuedLabel': 'Queued',
'repoLaunch.worktreeIsolatedDesc': 'Create an isolated worktree for this session; experiment safely without touching {branch} until merge.',
'repoLaunch.worktreeCurrentDesc': 'Work directly in the project directory; changes land on {branch} immediately.',
@@ -2413,7 +2419,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'chat.capabilities.sectionAdd': 'Add',
'chat.capabilities.sectionCapabilities': 'Capabilities',
'chat.capabilities.sectionCommands': 'Commands',
'chat.capabilities.searchPlaceholder': 'Search skills, connectors, agents…',
'chat.capabilities.searchPlaceholder': 'Search skills, plugins, files…',
'chat.capabilities.empty': 'No matches',
'chat.capabilities.back': 'Back',
'chat.capabilities.skills': 'Skills',
+7 -1
View File
@@ -465,6 +465,12 @@ export const jp: Record<TranslationKey, string> = {
'attachments.selectionNoteTitle': '修正内容',
'attachments.selectionBatch': '{count} 件のページ修正',
'attachments.selectionItem': '選択要素 {number}:{name}',
'chat.capabilities.managePlugins': 'プラグインを管理',
'chat.capabilities.moreTools': 'その他のツール',
'chat.referenceSearchHint': '名前を入力してスキル・プラグイン・プロジェクトファイルを検索',
'chat.slashFrequent': 'よく使うコマンド',
'chat.slashSearchHint': '名前を入力して他のコマンドを検索',
'chat.slashSearchResults': '検索結果',
'chat.pendingMessageQueuedLabel': 'キュー済み',
'repoLaunch.worktreeIsolatedDesc': 'このセッション用に分離された worktree を作成し、マージまで {branch} に影響しません。',
'repoLaunch.worktreeCurrentDesc': 'プロジェクトディレクトリで直接作業し、変更はすぐに {branch} に反映されます。',
@@ -2415,7 +2421,7 @@ export const jp: Record<TranslationKey, string> = {
'chat.capabilities.sectionAdd': '追加',
'chat.capabilities.sectionCapabilities': '機能',
'chat.capabilities.sectionCommands': 'コマンド',
'chat.capabilities.searchPlaceholder': 'スキル・コネクタ・Agent を検索…',
'chat.capabilities.searchPlaceholder': 'スキル・プラグイン・ファイルを検索…',
'chat.capabilities.empty': '一致する項目がありません',
'chat.capabilities.back': '戻る',
'chat.capabilities.skills': 'スキル',
+7 -1
View File
@@ -465,6 +465,12 @@ export const kr: Record<TranslationKey, string> = {
'attachments.selectionNoteTitle': '수정 내용',
'attachments.selectionBatch': '페이지 수정 {count}개',
'attachments.selectionItem': '선택한 요소 {number}: {name}',
'chat.capabilities.managePlugins': '플러그인 관리',
'chat.capabilities.moreTools': '더 많은 도구',
'chat.referenceSearchHint': '이름을 입력하여 스킬, 플러그인 및 프로젝트 파일 검색',
'chat.slashFrequent': '자주 쓰는 명령',
'chat.slashSearchHint': '이름을 입력하여 다른 명령 검색',
'chat.slashSearchResults': '검색 결과',
'chat.pendingMessageQueuedLabel': '대기 중',
'repoLaunch.worktreeIsolatedDesc': '이 세션용 격리 worktree를 만들어 병합 전까지 {branch}에 영향을 주지 않습니다.',
'repoLaunch.worktreeCurrentDesc': '프로젝트 디렉터리에서 직접 작업하며 변경 사항이 즉시 {branch}에 반영됩니다.',
@@ -2415,7 +2421,7 @@ export const kr: Record<TranslationKey, string> = {
'chat.capabilities.sectionAdd': '추가',
'chat.capabilities.sectionCapabilities': '기능',
'chat.capabilities.sectionCommands': '명령',
'chat.capabilities.searchPlaceholder': '스킬, 커넥터, Agent 검색…',
'chat.capabilities.searchPlaceholder': '스킬, 플러그인, 파일 검색…',
'chat.capabilities.empty': '일치하는 항목 없음',
'chat.capabilities.back': '뒤로',
'chat.capabilities.skills': '스킬',
+7 -1
View File
@@ -465,6 +465,12 @@ export const zh: Record<TranslationKey, string> = {
'attachments.selectionNoteTitle': '修改內容',
'attachments.selectionBatch': '{count} 個頁面修改',
'attachments.selectionItem': '所選元素 {number}:{name}',
'chat.capabilities.managePlugins': '管理外掛',
'chat.capabilities.moreTools': '更多工具',
'chat.referenceSearchHint': '輸入名稱,尋找更多技能、外掛和專案檔案',
'chat.slashFrequent': '常用指令',
'chat.slashSearchHint': '輸入名稱尋找其他指令',
'chat.slashSearchResults': '搜尋結果',
'chat.pendingMessageQueuedLabel': '已排隊',
'repoLaunch.worktreeIsolatedDesc': '為本會話建立隔離 worktree,安全實驗,合併前不影響 {branch}。',
'repoLaunch.worktreeCurrentDesc': '直接在專案目錄中工作,改動立即出現在 {branch} 上。',
@@ -2414,7 +2420,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.capabilities.sectionAdd': '新增',
'chat.capabilities.sectionCapabilities': '能力',
'chat.capabilities.sectionCommands': '指令',
'chat.capabilities.searchPlaceholder': '搜尋技能、連接器、Agent…',
'chat.capabilities.searchPlaceholder': '搜尋技能、外掛、檔案…',
'chat.capabilities.empty': '沒有相符項目',
'chat.capabilities.back': '返回',
'chat.capabilities.skills': '技能',
+7 -1
View File
@@ -464,6 +464,12 @@ export const zh: Record<TranslationKey, string> = {
'attachments.selectionNoteTitle': '修改内容',
'attachments.selectionBatch': '{count} 个页面修改',
'attachments.selectionItem': '所选元素 {number}:{name}',
'chat.capabilities.managePlugins': '管理插件',
'chat.capabilities.moreTools': '更多工具',
'chat.referenceSearchHint': '输入名称,查找更多技能、插件和项目文件',
'chat.slashFrequent': '常用命令',
'chat.slashSearchHint': '输入名称查找其他命令',
'chat.slashSearchResults': '搜索结果',
'chat.pendingMessageQueuedLabel': '已排队',
'repoLaunch.worktreeIsolatedDesc': '为本会话创建隔离 worktree,安全实验,合并前不影响 {branch}。',
'repoLaunch.worktreeCurrentDesc': '直接在项目目录中工作,改动立即出现在 {branch} 上。',
@@ -2413,7 +2419,7 @@ export const zh: Record<TranslationKey, string> = {
'chat.capabilities.sectionAdd': '添加',
'chat.capabilities.sectionCapabilities': '能力',
'chat.capabilities.sectionCommands': '命令',
'chat.capabilities.searchPlaceholder': '搜索技能、连接器、Agent…',
'chat.capabilities.searchPlaceholder': '搜索技能、插件、文件…',
'chat.capabilities.empty': '没有匹配项',
'chat.capabilities.back': '返回',
'chat.capabilities.skills': '技能',
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { isComposerPluginVisible, isComposerReferenceVisible, isComposerSlashCommandVisible } from './composerCapabilityVisibility'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
const reference = (kind: 'skill' | 'plugin', id: string, source: string): ComposerReferenceCandidate => ({ kind, id, source, name: id, displayName: id, description: '', modelText: '' })
describe('withdrawn managed packages in composer discovery', () => {
it.each(['frontend-design', 'canvas-design', 'algorithmic-art', 'webapp-testing', 'mcp-builder'])('hides %s through references, plugin entries and CLI slash namespaces', name => {
const id = `office-${name}@haha-connectors`
expect(isComposerPluginVisible(id)).toBe(false)
expect(isComposerReferenceVisible(reference('plugin', id, id))).toBe(false)
expect(isComposerReferenceVisible(reference('skill', `office-${name}:${name}`, id))).toBe(false)
expect(isComposerSlashCommandVisible({ name: id, description: '', kind: 'plugin' })).toBe(false)
expect(isComposerSlashCommandVisible({ name: `office-${name}:${name}`, description: '', kind: 'skill', source: 'plugin' })).toBe(false)
expect(isComposerSlashCommandVisible({ name: `office-${name}:${name}`, description: '' })).toBe(false)
expect(isComposerReferenceVisible(reference('skill', name, 'user'))).toBe(true)
expect(isComposerReferenceVisible(reference('skill', name, 'project'))).toBe(true)
expect(isComposerSlashCommandVisible({ name, description: '', kind: 'skill', source: 'user' })).toBe(true)
})
it('does not hide unrelated packages, similar namespaces or personal namespaced skills', () => {
for (const id of ['office-hyperframes@haha-connectors', 'office-drawio@haha-connectors', 'office-obsidian@haha-connectors', 'office-frontend-design@another-market']) {
expect(isComposerReferenceVisible(reference('plugin', id, id))).toBe(true)
}
for (const name of ['frontend-design', 'office-frontend-design-extra:design', 'office-frontend-design']) {
expect(isComposerSlashCommandVisible({ name, description: '' })).toBe(true)
}
expect(isComposerSlashCommandVisible({ name: 'office-frontend-design:design', source: 'project', description: '' })).toBe(true)
})
})
@@ -0,0 +1,31 @@
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import type { SlashCommandOption } from '@/types/slashCommand'
// These managed packages were withdrawn from the skill market. Keep them out
// of composer discovery too, without disabling installed packages or hiding
// independently installed personal/project skills with the same short name.
const withdrawnPluginNames = new Set([
'office-frontend-design',
'office-canvas-design',
'office-algorithmic-art',
'office-webapp-testing',
'office-mcp-builder',
])
export function isComposerPluginVisible(pluginId: string): boolean {
const [name, marketplace, ...rest] = pluginId.split('@')
return marketplace !== 'haha-connectors' || rest.length > 0 || !withdrawnPluginNames.has(name!)
}
export function isComposerReferenceVisible(reference: ComposerReferenceCandidate): boolean {
return isComposerPluginVisible(reference.kind === 'plugin' ? reference.id : reference.source)
}
export function isComposerSlashCommandVisible(command: SlashCommandOption): boolean {
if (command.source === 'user' || command.source === 'project' || command.kind === 'agent') return true
if (!isComposerPluginVisible(command.name)) return false
// CLI slash snapshots carry the plugin namespace, but not its marketplace ID.
// Match the exact managed namespace, never an unqualified skill name.
const separator = command.name.indexOf(':')
return separator < 0 || !withdrawnPluginNames.has(command.name.slice(0, separator))
}
+81 -8
View File
@@ -339,6 +339,79 @@ describe('EmptySession', () => {
expect(screen.getByTestId('empty-session-composer-panel')).toHaveClass('rounded-[var(--radius-2xl)]')
})
it.each(['@', '/', '+'] as const)('hides withdrawn bundled capabilities in %s while keeping personal skills and other plugins', async (entry) => {
const withdrawnPackage = 'office-frontend-design@haha-connectors'
mocks.listReferences.mockResolvedValue({
plugins: [
{ kind: 'plugin', id: withdrawnPackage, name: 'office-frontend-design', displayName: 'Removed frontend plugin', description: 'Design', source: withdrawnPackage, modelText: 'Use removed plugin' },
{ kind: 'plugin', id: 'design-tools@community', name: 'design-tools', displayName: 'Design tools', description: 'Design', source: 'community', modelText: 'Use design tools' },
],
skills: [
{ kind: 'skill', id: 'office-frontend-design:frontend-design', name: 'frontend-design', displayName: 'Removed frontend skill', description: 'Design', source: withdrawnPackage, modelText: '/office-frontend-design:frontend-design' },
{ kind: 'skill', id: 'frontend-design', name: 'frontend-design', displayName: 'Personal frontend design', description: 'Design', source: 'user', modelText: '/frontend-design' },
],
})
const legacyCommands = [
{ name: 'office-frontend-design:frontend-design', description: 'Removed frontend skill', kind: 'skill' as const, source: 'plugin' as const, userInvocable: true },
{ name: 'frontend-design', description: 'Personal frontend design', kind: 'skill' as const, source: 'user' as const, userInvocable: true },
]
mocks.listSkills.mockResolvedValue({ skills: legacyCommands })
render(<EmptySession />)
if (entry === '+') {
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.change(screen.getByRole('combobox', { name: 'Search skills, plugins, files…' }), { target: { value: 'design' } })
} else setComposerText(`${entry}design`, 7)
expect(await screen.findByRole('option', { name: 'Personal frontend design' })).toBeInTheDocument()
expect(await screen.findByRole('option', { name: 'Design tools' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Removed frontend/ })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: /office-frontend-design/ })).not.toBeInTheDocument()
expect(mocks.wsSend).not.toHaveBeenCalled()
})
it.each(['empty', 'replacement'] as const)('refreshes skills when opening plus and drops stale entries during a pending %s response', async (result) => {
const oldSkill = { kind: 'skill' as const, id: 'old-skill', name: 'old-skill', displayName: 'Old skill', description: 'Previously enabled', source: 'user', modelText: '/old-skill' }
const newSkill = { ...oldSkill, id: 'new-skill', name: 'new-skill', displayName: 'New skill', modelText: '/new-skill' }
mocks.listReferences.mockResolvedValue({ plugins: [], skills: [oldSkill] })
render(<EmptySession />)
await act(async () => { await Promise.resolve() })
const initialCalls = mocks.listReferences.mock.calls.length
let resolveRefresh!: (value: { plugins: [], skills: typeof oldSkill[] }) => void
mocks.listReferences.mockImplementation(() => new Promise(resolve => { resolveRefresh = resolve }))
fireEvent.click(screen.getByLabelText('Open composer tools'))
await waitFor(() => expect(mocks.listReferences.mock.calls.length).toBeGreaterThan(initialCalls))
fireEvent.click(await screen.findByRole('option', { name: /^Skills/ }))
expect(screen.queryByRole('option', { name: 'Old skill' })).not.toBeInTheDocument()
await act(async () => resolveRefresh({ plugins: [], skills: result === 'replacement' ? [newSkill] : [] }))
expect(screen.queryByRole('option', { name: 'Old skill' })).not.toBeInTheDocument()
if (result === 'replacement') expect(await screen.findByRole('option', { name: 'New skill' })).toBeInTheDocument()
else expect(screen.queryByRole('option', { name: 'New skill' })).not.toBeInTheDocument()
})
it('inserts a structured project file mention selected through the plus menu search', async () => {
mocks.search.mockResolvedValue({
currentPath: '/workspace/project', parentPath: null, query: 'README',
entries: [{ name: 'README.md', path: '/workspace/project/README.md', relativePath: 'README.md', isDirectory: false }],
})
render(<EmptySession />)
await pickProject()
setComposerText('Please review ', 14)
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.change(screen.getByRole('combobox', { name: 'Search skills, plugins, files…' }), { target: { value: 'README' } })
fireEvent.click(await screen.findByRole('option', { name: 'README.md' }))
await waitFor(() => {
expect(document.querySelector('.composer-mention')).toHaveAttribute('data-mention-path', '/workspace/project/README.md')
})
expect(document.querySelector('.composer-mention')).toHaveTextContent('@README.md')
expect(getComposerText()).toContain('Please review @README.md')
expect(mocks.search).toHaveBeenCalledWith('README', '/workspace/project')
expect(screen.queryByRole('combobox', { name: 'Search skills, plugins, files…' })).not.toBeInTheDocument()
expect(mocks.wsSend).not.toHaveBeenCalled()
expect(mocks.createSession).not.toHaveBeenCalled()
})
it('keeps user-only skills as slash text when no mention capability is available', async () => {
mocks.listSkills.mockResolvedValue({ skills: [{ name: 'manual-only', description: 'User invocation only', userInvocable: true, disableModelInvocation: true }] })
render(<EmptySession />)
@@ -355,10 +428,10 @@ describe('EmptySession', () => {
? new Promise(resolve => { resolveProject = resolve })
: Promise.resolve({ skills: [{ name: 'old-directory-skill', description: 'Old scope', userInvocable: true }] }))
render(<EmptySession />)
setComposerText('/', 1)
setComposerText('/directory', 10)
expect(await screen.findByText('old-directory-skill')).toBeInTheDocument()
await pickProject()
setComposerText('/', 1)
setComposerText('/directory', 10)
expect(screen.queryByText('old-directory-skill')).not.toBeInTheDocument()
await act(async () => resolveProject({ skills: [{ name: 'new-directory-skill', description: 'New scope', userInvocable: true }] }))
expect(await screen.findByText('new-directory-skill')).toBeInTheDocument()
@@ -484,11 +557,11 @@ describe('EmptySession', () => {
expect(mocks.listSkills).toHaveBeenCalledTimes(1)
})
setComposerText('/', 1)
setComposerText('/a', 2)
const listbox = await screen.findByRole('listbox', { name: 'Slash commands' })
const combobox = screen.getByRole('combobox')
const systemCommand = screen.getByText('mcp')
const systemCommand = screen.getByText('/status')
const skillsHeading = screen.getByText('Skills')
const projectSkill = screen.getByText('project-audit')
const pluginSkill = screen.getByText('drawing:render')
@@ -530,7 +603,7 @@ describe('EmptySession', () => {
setComposerText('/debug', 6)
const agentOption = await screen.findByText('agent debugger')
const agentOption = await screen.findByText('/agent debugger')
fireEvent.click(agentOption)
expect(getComposerText()).toBe('/agent debugger ')
@@ -607,7 +680,7 @@ describe('EmptySession', () => {
const input = getComposerElement()
setComposerText('/agent', 6)
await screen.findByText('agent debugger')
await screen.findByText('/agent debugger')
fireEvent.keyDown(input, { key: 'ArrowDown' })
fireEvent.keyDown(input, { key: 'Enter' })
@@ -1102,8 +1175,8 @@ describe('EmptySession', () => {
const panel = screen.getByTestId('empty-session-composer-panel')
setComposerText('/', 1)
expect(await screen.findByText('mcp')).toBeInTheDocument()
setComposerText('/mcp', 4)
expect(await screen.findByRole('option', { name: '/mcp' })).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
+19 -4
View File
@@ -1,3 +1,4 @@
import { isComposerReferenceVisible, isComposerSlashCommandVisible } from '@/lib/composerCapabilityVisibility'
import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'
import { useDismissable } from '@/hooks/useDismissable'
import { BrandSeal } from '@/components/composite/BrandSeal'
@@ -165,17 +166,17 @@ export function EmptySession() {
const [draftPermissionMode, setDraftPermissionMode] = useState<PermissionMode>(defaultPermissionMode)
const lastPluginReloadSummary = usePluginStore((state) => state.lastReloadSummary)
const referenceCurrent = referenceState?.cwd === workDir ? referenceState : null
const composerReferences = referenceCurrent?.items ?? EMPTY_COMPOSER_REFERENCES
const composerReferences = useMemo(() => (referenceCurrent?.items ?? EMPTY_COMPOSER_REFERENCES).filter(isComposerReferenceVisible), [referenceCurrent?.items])
useEffect(() => {
let active = true
setReferenceState(previous => ({ cwd: workDir, items: previous?.cwd === workDir ? previous.items : [], loading: true, error: false }))
setReferenceState({ cwd: workDir, items: [], loading: true, error: false })
void composerReferencesApi.list(workDir || undefined).then(data => {
if (active) setReferenceState({ cwd: workDir, items: [...data.plugins, ...data.skills], loading: false, error: false })
}).catch(() => {
if (active) setReferenceState({ cwd: workDir, items: [], loading: false, error: true })
})
return () => { active = false }
}, [workDir, lastPluginReloadSummary, slashMenuOpen, fileSearchOpen])
}, [workDir, lastPluginReloadSummary, slashMenuOpen, fileSearchOpen, plusMenuOpen])
useEffect(() => {
setReferenceDetail(null)
setReferenceOptionId(undefined)
@@ -287,7 +288,7 @@ export function EmptySession() {
names.add(name.toLowerCase())
commands.push({ name, description: reference.description, kind: reference.kind })
}
return commands
return commands.filter(isComposerSlashCommandVisible)
}, [agentSlashCommands, slashCommands, slashCommandsCwd, workDir, composerReferences, t])
const handleWorkDirChange = (newWorkDir: string) => {
@@ -822,6 +823,7 @@ export function EmptySession() {
ref={slashMenuRef}
id={slashMenuId}
groups={filteredCommandGroups}
isSearching={Boolean(slashFilter.trim())}
references={composerReferences}
selectedIndex={slashSelectedIndex}
itemRefs={slashItemRefs}
@@ -883,6 +885,19 @@ export function EmptySession() {
{plusMenuOpen && (
<ComposerCapabilityMenu
cwd={workDir}
referencesLoading={referenceCurrent?.loading ?? true}
referencesError={referenceCurrent?.error}
onSelectFile={mention => {
const cursorPos = composerRef.current?.getSelectionOffsets().start ?? input.length
const inserted = insertMentionIntoText(input, mentions, cursorPos, cursorPos, mention)
setInput(inserted.text)
setMentions(inserted.mentions)
requestAnimationFrame(() => {
composerRef.current?.focus()
composerRef.current?.setSelectionOffsets(inserted.cursorPos)
})
}}
id={capabilityMenuId}
sections={capabilityMenu.sections}
onAction={capabilityMenu.onAction}