From 2d4f48765f9a2a406e42e3d8bd06b0cfac414320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sun, 20 Sep 2026 01:25:45 +0800 Subject: [PATCH] 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. --- desktop/src/__tests__/pages.test.tsx | 28 +- .../src/components/chat/ChatInput.test.tsx | 97 +++++- desktop/src/components/chat/ChatInput.tsx | 22 +- .../chat/ComposerCapabilityMenu.test.tsx | 82 ++++- .../chat/ComposerCapabilityMenu.tsx | 301 +++++++----------- .../chat/ComposerReferenceMenu.test.tsx | 130 +++++--- .../components/chat/ComposerReferenceMenu.tsx | 92 +++--- .../chat/ComposerSuggestionRow.test.tsx | 20 ++ .../components/chat/ComposerSuggestionRow.tsx | 24 ++ .../components/chat/SlashCommandMenu.test.tsx | 40 +++ .../src/components/chat/SlashCommandMenu.tsx | 36 ++- .../chat/capabilityMenuModel.test.ts | 57 ++-- .../components/chat/capabilityMenuModel.ts | 78 ++--- .../chat/composerSuggestionSearch.test.ts | 20 ++ .../chat/composerSuggestionSearch.ts | 19 ++ .../src/components/chat/composerUtils.test.ts | 82 +++-- desktop/src/components/chat/composerUtils.ts | 87 ++--- .../src/components/chat/useCapabilityMenu.ts | 9 +- desktop/src/i18n/locales/en.ts | 8 +- desktop/src/i18n/locales/jp.ts | 8 +- desktop/src/i18n/locales/kr.ts | 8 +- desktop/src/i18n/locales/zh-TW.ts | 8 +- desktop/src/i18n/locales/zh.ts | 8 +- .../lib/composerCapabilityVisibility.test.ts | 30 ++ .../src/lib/composerCapabilityVisibility.ts | 31 ++ desktop/src/pages/EmptySession.test.tsx | 89 +++++- desktop/src/pages/EmptySession.tsx | 23 +- 27 files changed, 904 insertions(+), 533 deletions(-) create mode 100644 desktop/src/components/chat/ComposerSuggestionRow.test.tsx create mode 100644 desktop/src/components/chat/ComposerSuggestionRow.tsx create mode 100644 desktop/src/components/chat/composerSuggestionSearch.test.ts create mode 100644 desktop/src/components/chat/composerSuggestionSearch.ts create mode 100644 desktop/src/lib/composerCapabilityVisibility.test.ts create mode 100644 desktop/src/lib/composerCapabilityVisibility.ts diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 4aaf940e..eee5a09b 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -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() setComposerText('/goal', 5) - expect(await screen.findByRole('option', { name: /^goal / })).toBeInTheDocument() - expect(screen.getByText('[ | 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() }) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index b51faa5e..e259c90f 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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() + 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() + 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() + 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() 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() 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() - 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' }) diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 04403514..4eda8b46 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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 && ( { + 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} diff --git a/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx b/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx index cda4cca2..d4ee495a 100644 --- a/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx +++ b/desktop/src/components/chat/ComposerCapabilityMenu.test.tsx @@ -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() +}) diff --git a/desktop/src/components/chat/ComposerCapabilityMenu.tsx b/desktop/src/components/chat/ComposerCapabilityMenu.tsx index 52f231b3..5ff2bc17 100644 --- a/desktop/src/components/chat/ComposerCapabilityMenu.tsx +++ b/desktop/src/components/chat/ComposerCapabilityMenu.tsx @@ -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 @@ -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(null) + const [path, setPath] = useState([]) const [highlight, setHighlight] = useState(0) + const [referenceOptionId, setReferenceOptionId] = useState() const listRef = useRef(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(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: , + 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 ( -
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)]'}` - }`} - > - - - {item.label} - - - {item.description ?? ''} - - {item.switch ? ( - event.stopPropagation()}> - item.action && onAction(item.action)} - /> - - ) : item.children ? ( - - {typeof item.count === 'number' ? {item.count} : null} - - ) : typeof item.count === 'number' ? ( - {item.count} - ) : null} -
- ) + 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 ( -
{ - // Keep the composer from losing focus to the panel chrome; the search - // input re-focuses itself on click below. - event.preventDefault() - }} - > -
-
- -
- {drillParent ? ( -
-
-
- {rows.map((row, index) => renderRow(row, index))} -
- ) : ( - filtered.map(section => { - const start = offset - offset += section.items.length - return ( -
-
{section.title}
- {section.items.map((item, position) => renderRow({ item, sectionTitle: section.title }, start + position))} -
- ) - }) - )} - {!rows.length ? ( -
{t('chat.capabilities.empty')}
- ) : null} -
+ const renderRow = (item: CapabilityMenuItem, index: number) => } + 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 ? event.stopPropagation()}> + item.action && onAction(item.action)} /> + : item.children ?
) } @@ -138,11 +141,14 @@ export const SlashCommandMenu = forwardRef 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)]" > +
+ {t(isSearching ? 'chat.slashSearchResults' : 'chat.slashFrequent')} +
{groups.system.map(renderSystemCommand)} @@ -161,18 +167,20 @@ export const SlashCommandMenu = forwardRef { 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 ? :
})} ) : null)} + {!isSearching ?
{t('chat.slashSearchHint')}
: null} {showKeyboardHints ? (
Up/Down diff --git a/desktop/src/components/chat/capabilityMenuModel.test.ts b/desktop/src/components/chat/capabilityMenuModel.test.ts index b4a1844e..d00c6def 100644 --- a/desktop/src/components/chat/capabilityMenuModel.test.ts +++ b/desktop/src/components/chat/capabilityMenuModel.test.ts @@ -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 = {}): 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) }) diff --git a/desktop/src/components/chat/capabilityMenuModel.ts b/desktop/src/components/chat/capabilityMenuModel.ts index 543481ec..4bd7dc52 100644 --- a/desktop/src/components/chat/capabilityMenuModel.ts +++ b/desktop/src/components/chat/capabilityMenuModel.ts @@ -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], + }], + }, + ] } diff --git a/desktop/src/components/chat/composerSuggestionSearch.test.ts b/desktop/src/components/chat/composerSuggestionSearch.test.ts new file mode 100644 index 00000000..2d2b97be --- /dev/null +++ b/desktop/src/components/chat/composerSuggestionSearch.test.ts @@ -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([]) +}) diff --git a/desktop/src/components/chat/composerSuggestionSearch.ts b/desktop/src/components/chat/composerSuggestionSearch.ts new file mode 100644 index 00000000..c6baee9c --- /dev/null +++ b/desktop/src/components/chat/composerSuggestionSearch.ts @@ -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(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) +} diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 94aaa596..973d3a7d 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -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: '' }, + ] + 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', ]) }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index eb161e0f..1471aa74 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -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 = 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, 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) } diff --git a/desktop/src/components/chat/useCapabilityMenu.ts b/desktop/src/components/chat/useCapabilityMenu.ts index 52ba4f8a..d0d554bb 100644 --- a/desktop/src/components/chat/useCapabilityMenu.ts +++ b/desktop/src/components/chat/useCapabilityMenu.ts @@ -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 = { diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 1d5430cc..40811bf8 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 33b3d83c..7ea70584 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -465,6 +465,12 @@ export const jp: Record = { '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 = { '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': 'スキル', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 89e0bdb1..b7680c96 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -465,6 +465,12 @@ export const kr: Record = { '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 = { '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': '스킬', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 2778e922..81848bc4 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -465,6 +465,12 @@ export const zh: Record = { '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 = { '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': '技能', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 99678d7a..26e953e8 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -464,6 +464,12 @@ export const zh: Record = { '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 = { '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': '技能', diff --git a/desktop/src/lib/composerCapabilityVisibility.test.ts b/desktop/src/lib/composerCapabilityVisibility.test.ts new file mode 100644 index 00000000..48855626 --- /dev/null +++ b/desktop/src/lib/composerCapabilityVisibility.test.ts @@ -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) + }) +}) diff --git a/desktop/src/lib/composerCapabilityVisibility.ts b/desktop/src/lib/composerCapabilityVisibility.ts new file mode 100644 index 00000000..8d731948 --- /dev/null +++ b/desktop/src/lib/composerCapabilityVisibility.ts @@ -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)) +} diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index eec55603..69cb5792 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -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() + 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() + 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() + 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() @@ -355,10 +428,10 @@ describe('EmptySession', () => { ? new Promise(resolve => { resolveProject = resolve }) : Promise.resolve({ skills: [{ name: 'old-directory-skill', description: 'Old scope', userInvocable: true }] })) render() - 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') diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 4f873785..86e784cd 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -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(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 && ( { + 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}