From 05db171a84cfa3628e15dd6234eb6ad37007ce3d 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: Tue, 6 Oct 2026 05:03:05 +0800 Subject: [PATCH] feat(desktop): reorganize the composer + menu around skills, connectors and run modes - Group the root into "for this task" (skills, connectors, files) and "how it runs" (Agent Teams and Computer Use switches); the rest moves under More tools. - Open categories in a side panel on desktop; narrow windows and the phone sheet keep in-place navigation. - Connectors list connected services, ones that need sign-in, and three fixed suggestions, each opening its detail in the extension market. - Drop the Plugins entry: plugin skills show under Skills, MCP plugins under Connectors, the rest stay searchable. - Skills show recently sent ones, all skills, and two curated market skills that install in place. - Agent Teams arms the next message with a team instruction and a removable chip; the bubble and restored history show only the typed text. - Turning Computer Use on from the menu goes through the same consent dialog and grants as Settings. --- desktop/src/components/chat/AgentTeamChip.tsx | 27 + .../src/components/chat/ChatInput.test.tsx | 120 ++++- desktop/src/components/chat/ChatInput.tsx | 34 +- .../chat/ComposerCapabilityMenu.test.tsx | 275 ++++++---- .../chat/ComposerCapabilityMenu.tsx | 252 +++++++-- .../chat/capabilityMenuModel.test.ts | 378 ++++++++----- .../components/chat/capabilityMenuModel.ts | 502 ++++++++++++------ .../chat/useCapabilityMenu.test.tsx | 153 ++++++ .../src/components/chat/useCapabilityMenu.ts | 198 +++++-- desktop/src/i18n/locales/en.ts | 34 +- desktop/src/i18n/locales/jp.ts | 34 +- desktop/src/i18n/locales/kr.ts | 34 +- desktop/src/i18n/locales/zh-TW.ts | 34 +- desktop/src/i18n/locales/zh.ts | 34 +- desktop/src/lib/agentTeamRequest.test.ts | 34 ++ desktop/src/lib/agentTeamRequest.ts | 29 + desktop/src/lib/computerUseEnable.ts | 21 + desktop/src/lib/recentSkills.test.ts | 60 +++ desktop/src/lib/recentSkills.ts | 56 ++ desktop/src/pages/ComputerUseSettings.tsx | 18 +- desktop/src/pages/Connectors.test.tsx | 7 + desktop/src/pages/Connectors.tsx | 10 +- desktop/src/pages/EmptySession.test.tsx | 7 +- desktop/src/pages/EmptySession.tsx | 33 +- desktop/src/pages/ExtensionMarket.test.tsx | 22 +- desktop/src/pages/ExtensionMarket.tsx | 16 +- desktop/src/stores/chatStore.ts | 6 + desktop/src/stores/uiStore.ts | 11 + 28 files changed, 1832 insertions(+), 607 deletions(-) create mode 100644 desktop/src/components/chat/AgentTeamChip.tsx create mode 100644 desktop/src/components/chat/useCapabilityMenu.test.tsx create mode 100644 desktop/src/lib/agentTeamRequest.test.ts create mode 100644 desktop/src/lib/agentTeamRequest.ts create mode 100644 desktop/src/lib/computerUseEnable.ts create mode 100644 desktop/src/lib/recentSkills.test.ts create mode 100644 desktop/src/lib/recentSkills.ts diff --git a/desktop/src/components/chat/AgentTeamChip.tsx b/desktop/src/components/chat/AgentTeamChip.tsx new file mode 100644 index 00000000..a520c044 --- /dev/null +++ b/desktop/src/components/chat/AgentTeamChip.tsx @@ -0,0 +1,27 @@ +import { Users, X } from 'lucide-react' +import { useTranslation } from '@/i18n' + +/** + * Shown in the composer toolbar while Agent Team is armed for the next + * message, so the mode is visible — and removable — before sending. + */ +export function AgentTeamChip({ onRemove, touch = false }: { onRemove(): void, touch?: boolean }) { + const t = useTranslation() + return ( + + + ) +} diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index f583c57a..d8232b9b 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -7,6 +7,9 @@ import { useSideChatStore } from '@/stores/sideChatStore' import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import '@testing-library/jest-dom' +import { withAgentTeamRequest } from '@/lib/agentTeamRequest' +import { COMPUTER_USE_ENABLE_REQUEST } from '@/lib/computerUseEnable' +import { readRecentSkills, RECENT_SKILLS_STORAGE_KEY } from '@/lib/recentSkills' import { act, StrictMode } from 'react' const viewportMocks = vi.hoisted(() => ({ @@ -248,6 +251,7 @@ describe('ChatInput file mentions', () => { beforeEach(() => { vi.clearAllMocks() + localStorage.removeItem(RECENT_SKILLS_STORAGE_KEY) useSideChatStore.setState({ entries: {} }) useTeamPlanStore.setState({ bySession: {} }) useTeamStore.setState({ workbenchesBySession: {} }) @@ -2176,7 +2180,7 @@ describe('ChatInput file mentions', () => { render() if (entry === '+') { fireEvent.click(screen.getByLabelText('Open composer tools')) - fireEvent.change(screen.getByRole('combobox', { name: 'Search skills, plugins, files…' }), { target: { value: 'design' } }) + fireEvent.change(screen.getByRole('combobox', { name: 'Search skills, connectors, files…' }), { target: { value: 'design' } }) } else if (entry === '/empty') setComposerText('/', 1) else setComposerText(`${entry}design`, 7) expect(await screen.findByRole('option', { name: 'Personal frontend design' })).toBeInTheDocument() @@ -2211,6 +2215,7 @@ describe('ChatInput file mentions', () => { 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/ })) + fireEvent.click(await screen.findByRole('option', { name: /^All skills/ })) expect(screen.queryByRole('option', { name: 'Old skill' })).not.toBeInTheDocument() await act(async () => resolveRefresh({ plugins: [], skills: result === 'replacement' ? [newSkill] : [] })) @@ -2227,7 +2232,7 @@ describe('ChatInput file mentions', () => { 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.change(screen.getByRole('combobox', { name: 'Search skills, connectors, files…' }), { target: { value: 'README' } }) fireEvent.click(await screen.findByRole('option', { name: 'README.md' })) await waitFor(() => { @@ -2236,7 +2241,7 @@ describe('ChatInput file mentions', () => { expect(document.querySelector('.composer-mention')).toHaveTextContent('@README.md') expect(getComposerText()).toContain('Please review @README.md') expect(mocks.search).toHaveBeenCalledWith('README', '/repo', { signal: expect.any(AbortSignal) }) - expect(screen.queryByRole('combobox', { name: 'Search skills, plugins, files…' })).not.toBeInTheDocument() + expect(screen.queryByRole('combobox', { name: 'Search skills, connectors, files…' })).not.toBeInTheDocument() expect(mocks.wsSend).not.toHaveBeenCalled() fireEvent.keyDown(getComposerElement(), { key: 'Enter' }) expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, expect.objectContaining({ @@ -2254,6 +2259,7 @@ describe('ChatInput file mentions', () => { fireEvent.click(screen.getByLabelText('Open composer tools')) fireEvent.click(await screen.findByRole('option', { name: /^Skills/ })) + fireEvent.click(await screen.findByRole('option', { name: /^All skills/ })) fireEvent.click(await screen.findByRole('option', { name: /Design/ })) await waitFor(() => expect(document.querySelector('[data-mention-kind="skill"]')).toBeInTheDocument()) @@ -2280,9 +2286,12 @@ describe('ChatInput file mentions', () => { expect(mocks.wsSend).not.toHaveBeenCalled() }) - it('toggles Computer Use from the capability menu with a rollback on failure', async () => { - const getStatus = vi.spyOn(computerUseApi, 'getStatus').mockResolvedValue({ + it('turns Computer Use on from the capability menu only after the consent dialog', async () => { + vi.spyOn(computerUseApi, 'getStatus').mockResolvedValue({ supported: true, + platform: 'darwin', + engine: 'macos-native', + permissions: { accessibility: true, screenRecording: true }, } as Awaited>) vi.spyOn(computerUseApi, 'getAuthorizedApps').mockResolvedValue({ enabled: false, @@ -2291,16 +2300,113 @@ describe('ChatInput file mentions', () => { pythonPath: null, }) const setAuthorizedApps = vi.spyOn(computerUseApi, 'setAuthorizedApps').mockResolvedValue({ ok: true }) + const openPermissionCard = vi.spyOn(computerUseApi, 'openPermissionCard').mockResolvedValue({ ok: true }) render() fireEvent.click(screen.getByLabelText('Open composer tools')) const row = await screen.findByRole('option', { name: /Computer use/ }) await waitFor(() => expect(row.querySelector('input[type="checkbox"]')).not.toBeChecked()) + // The switch asks first; dismissing the dialog changes nothing. fireEvent.click(row.querySelector('input[type="checkbox"]')!) - await waitFor(() => expect(setAuthorizedApps).toHaveBeenCalledWith({ enabled: true })) + const dialog = await screen.findByRole('dialog') + expect(setAuthorizedApps).not.toHaveBeenCalled() + fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' })) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + expect(setAuthorizedApps).not.toHaveBeenCalled() + + fireEvent.click(screen.getByLabelText('Open composer tools')) + fireEvent.click((await screen.findByRole('option', { name: /Computer use/ })).querySelector('input[type="checkbox"]')!) + fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Enable Computer Use' })) + await waitFor(() => expect(setAuthorizedApps).toHaveBeenCalledWith(COMPUTER_USE_ENABLE_REQUEST)) + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()) + // Permissions already granted: no macOS permission card. + expect(openPermissionCard).not.toHaveBeenCalled() + }) + + it('turns Computer Use off from the capability menu without asking', async () => { + vi.spyOn(computerUseApi, 'getStatus').mockResolvedValue({ supported: true, platform: 'darwin' } as Awaited>) + vi.spyOn(computerUseApi, 'getAuthorizedApps').mockResolvedValue({ + enabled: true, + authorizedApps: [], + grantFlags: { clipboardRead: true, clipboardWrite: true, systemKeyCombos: true }, + pythonPath: null, + }) + const setAuthorizedApps = vi.spyOn(computerUseApi, 'setAuthorizedApps').mockRejectedValue(new Error('disk full')) + render() + + fireEvent.click(screen.getByLabelText('Open composer tools')) + const row = await screen.findByRole('option', { name: /Computer use/ }) await waitFor(() => expect(row.querySelector('input[type="checkbox"]')).toBeChecked()) - expect(getStatus).toHaveBeenCalled() + fireEvent.click(row.querySelector('input[type="checkbox"]')!) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(setAuthorizedApps).toHaveBeenCalledWith({ enabled: false }) + // The write failed: the switch rolls back. + await waitFor(() => expect(row.querySelector('input[type="checkbox"]')).toBeChecked()) + }) + + it('sends one message as an Agent Team request while the bubble keeps the typed text', async () => { + render() + setComposerText('Ship the release checklist', 26) + + fireEvent.click(screen.getByLabelText('Open composer tools')) + fireEvent.click((await screen.findByRole('option', { name: /Agent Teams/ })).querySelector('input[type="checkbox"]')!) + const chip = await screen.findByTestId('agent-team-chip') + expect(chip).toHaveTextContent('Agent Teams') + + fireEvent.keyDown(getComposerElement(), { key: 'Enter' }) + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { + type: 'user_message', + content: withAgentTeamRequest('Ship the release checklist'), + attachments: [], + }) + const messages = useChatStore.getState().sessions[sessionId]?.messages ?? [] + expect(messages[messages.length - 1]).toMatchObject({ type: 'user_text', content: 'Ship the release checklist' }) + // Armed for one message only. + expect(screen.queryByTestId('agent-team-chip')).not.toBeInTheDocument() + + // The first turn finished; the next message goes out as a plain one. + act(() => { + useChatStore.setState(state => ({ sessions: { ...state.sessions, [sessionId]: { ...state.sessions[sessionId]!, chatState: 'idle' } } })) + }) + setComposerText('Follow-up', 9) + fireEvent.keyDown(getComposerElement(), { key: 'Enter' }) + expect(mocks.wsSend).toHaveBeenLastCalledWith(sessionId, { type: 'user_message', content: 'Follow-up', attachments: [] }) + }) + + it('disarms Agent Team from the chip before sending', async () => { + render() + fireEvent.click(screen.getByLabelText('Open composer tools')) + fireEvent.click((await screen.findByRole('option', { name: /Agent Teams/ })).querySelector('input[type="checkbox"]')!) + fireEvent.click(within(await screen.findByTestId('agent-team-chip')).getByRole('button', { name: 'Remove Agent Teams' })) + expect(screen.queryByTestId('agent-team-chip')).not.toBeInTheDocument() + + setComposerText('Just me', 7) + fireEvent.keyDown(getComposerElement(), { key: 'Enter' }) + expect(mocks.wsSend).toHaveBeenCalledWith(sessionId, { type: 'user_message', content: 'Just me', attachments: [] }) + }) + + it('lists a skill under Recently used after it was sent', async () => { + const design = { + kind: 'skill' as const, id: 'design', name: 'design', displayName: 'Design', + description: 'Create interfaces', source: 'user', modelText: 'Use the Skill tool with skill: "design" for this request.', + } + mocks.listReferences.mockResolvedValue({ plugins: [], skills: [design] }) + render() + + fireEvent.click(screen.getByLabelText('Open composer tools')) + fireEvent.click(await screen.findByRole('option', { name: /^Skills/ })) + expect(screen.queryByText('Recently used')).not.toBeInTheDocument() + fireEvent.click(await screen.findByRole('option', { name: /^All skills/ })) + fireEvent.click(await screen.findByRole('option', { name: /Design/ })) + await waitFor(() => expect(document.querySelector('[data-mention-kind="skill"]')).toBeInTheDocument()) + fireEvent.keyDown(getComposerElement(), { key: 'Enter' }) + expect(readRecentSkills()).toEqual(['design']) + + fireEvent.click(screen.getByLabelText('Open composer tools')) + fireEvent.click(await screen.findByRole('option', { name: /^Skills/ })) + expect(await screen.findByText('Recently used')).toBeInTheDocument() + expect(screen.getByRole('option', { name: /Design/ })).toBeInTheDocument() }) it('inserts a selected @ file as an inline mention pill and sends its absolute path', async () => { diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index efc54b25..58987082 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -45,6 +45,10 @@ import { ComposerCapabilityMenu } from './ComposerCapabilityMenu' import { MobileComposerSheet, type MobileComposerSetting } from './MobileComposerSheet' import { MobileApprovalDock } from './MobileApprovalDock' import { useCapabilityMenu } from './useCapabilityMenu' +import { AgentTeamChip } from './AgentTeamChip' +import { ComputerUseEnableDialog } from '@/components/computer-use/ComputerUseEnableDialog' +import { withAgentTeamRequest } from '@/lib/agentTeamRequest' +import { recordRecentSkills } from '@/lib/recentSkills' import { composerReferencesApi, mentionProviderId } from '@/api/composerReferences' import type { ComposerReferenceCandidate } from '@/types/composerReference' import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel' @@ -163,6 +167,8 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis const [shellRef, shellWidth] = useElementWidth() const [input, setInput] = useState('') const [mentions, setMentions] = useState([]) + // Bumped when the + menu installs a skill, so the mention list reloads. + const [referencesVersion, setReferencesVersion] = useState(0) const [referenceDetail, setReferenceDetail] = useState(null) const [referenceOptionId, setReferenceOptionId] = useState() const [referenceState, setReferenceState] = useState<{ context: string, items: ComposerReferenceCandidate[], loading: boolean, error: boolean } | null>(null) @@ -375,7 +381,7 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis if (active) setReferenceState({ context: referenceContext, items: [], loading: false, error: true }) }) return () => { active = false } - }, [referenceContext, referenceCwd, referenceProviderId, isMemberSession, slashMenuOpen, fileSearchOpen, plusMenuOpen]) + }, [referenceContext, referenceCwd, referenceProviderId, isMemberSession, slashMenuOpen, fileSearchOpen, plusMenuOpen, referencesVersion]) useEffect(() => { setReferenceDetail(null) setReferenceOptionId(undefined) @@ -922,7 +928,11 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis // `@label` is a pill and which is literal text the user typed. const sessionReferences = getSessionReferences(input, mentions) const serializedText = (composerRef.current?.getModelContent() ?? input).trim() - const contentForModel = [workspaceReferencePrompt, serializedText].filter(Boolean).join('\n\n') + const joinedContent = [workspaceReferencePrompt, serializedText].filter(Boolean).join('\n\n') + // Agent Team armed in the + menu: the model gets the team instruction, the + // bubble keeps what the user typed. + const sendAsTeam = capabilityMenu.agentTeamArmed && !isMemberSession + const contentForModel = sendAsTeam ? withAgentTeamRequest(joinedContent) : joinedContent const displayContent = text || ( workspaceReferences.length > 0 ? t('chat.contextReferencesOnly', { count: workspaceReferences.length }) @@ -1020,6 +1030,8 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis }) } invalidatePendingPastes() + recordRecentSkills(mentions.flatMap(mention => mention.kind === 'skill' && mention.id ? [mention.id] : [])) + if (sendAsTeam) capabilityMenu.disarmAgentTeam() setComposerInput('', []) setComposerAttachments([]) const chatStore = useChatStore.getState() @@ -1307,23 +1319,18 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis composerRef.current?.setSelectionOffsets(replacement.cursorPos) }) }, - onInsertPromptSeed: (text) => { - const next = inputRef.current.trim() ? `${inputRef.current}\n${text}` : text - setComposerInput(next) - requestAnimationFrame(() => { - composerRef.current?.focus() - composerRef.current?.setSelectionOffsets(next.length) - }) - }, onAttachment: openAttachmentPicker, onSlashTrigger: insertSlashCommand, onSaveWorkflow: () => setLocalSlashPanel('save-workflow'), + onReferencesChanged: () => setReferencesVersion(version => version + 1), onClose: () => setPlusMenuOpen(false), }, }) const composerPlaceholder = - isHeroComposer + capabilityMenu.agentTeamArmed && !isWorkspaceMissing && !questionPending && !isMemberSession + ? t('chat.capabilities.teamPlaceholder') + : isHeroComposer ? t('empty.placeholder') : isWorkspaceMissing ? workspaceState === 'worktree_removed' @@ -1719,8 +1726,13 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis onAction={capabilityMenu.onAction} /> )} + + {capabilityMenu.agentTeamArmed && !isMemberSession ? ( + + ) : null} +
({ sessionCollaborationApi: { list: vi.fn() } })) vi.mock('@/api/filesystem', () => ({ filesystemApi: { browse: vi.fn(), search: vi.fn() } })) +const initialWidth = window.innerWidth beforeEach(() => { vi.mocked(sessionCollaborationApi.list).mockClear().mockResolvedValue({ sessions: [] }) vi.mocked(filesystemApi.search).mockResolvedValue({ currentPath: '/work', parentPath: '/', entries: [] }) }) +afterEach(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: initialWidth }) +}) + +const designSkill: ComposerReferenceCandidate = { kind: 'skill', id: 'design', name: 'design', displayName: 'Design', description: 'Create interfaces', source: 'user', modelText: 'Use design' } +const videoPlugin: ComposerReferenceCandidate = { kind: 'plugin', id: 'video', name: 'video', displayName: 'Video Studio', description: 'Create videos', source: 'plugin', modelText: 'Use video' } function fixtureSections(): CapabilityMenuSection[] { return [ { - id: 'add', - title: 'Add', - items: [{ - key: 'add-files', - label: 'Add files or photos', - icon: { kind: 'slash' }, - action: { type: 'attachment' }, - }], - }, - { - id: 'capabilities', - title: 'Capabilities', + id: 'use', + title: 'For this task', + showTitle: true, items: [ { key: 'skills', @@ -41,36 +40,89 @@ function fixtureSections(): CapabilityMenuSection[] { label: 'Design', description: 'Create interfaces', icon: { kind: 'slash' }, - action: { type: 'insertSlashText', command: 'design' }, + group: 'Recently used', + action: { type: 'insertMention', reference: designSkill }, }, { - key: 'skills:manage', - label: 'Manage skills', + key: 'skills:all', + label: 'All skills', icon: { kind: 'slash' }, - action: { type: 'settings', tab: 'skills' }, + count: 1, + children: [{ + key: 'skill:design', + label: 'Design', + icon: { kind: 'slash' }, + action: { type: 'insertMention', reference: designSkill }, + }], + }, + { + key: 'market-skill:pptx', + label: 'pptx', + description: 'Build slides', + icon: { kind: 'slash' }, + group: 'Popular', + action: { type: 'installSkill', id: 'pptx', name: 'pptx' }, + button: { label: 'Install', action: { type: 'installSkill', id: 'pptx', name: 'pptx' } }, + }, + { + key: 'skills:browse', + label: 'Browse the skill market', + icon: { kind: 'slash' }, + action: { type: 'market', section: 'skills' }, }, ], }, { - key: 'computer-use', - label: 'Computer Use', - description: 'Let Claude operate apps', + key: 'connectors', + label: 'Connectors', icon: { kind: 'slash' }, - switch: { checked: false, disabled: false }, - action: { type: 'toggleComputerUse' }, + children: [{ + key: 'connector:github', + label: 'GitHub', + icon: { kind: 'slash' }, + status: 'ok', + group: 'Connected', + action: { type: 'market', section: 'plugins', connectorId: 'github' }, + }], + }, + { + key: 'add-files', + label: 'Add files or photos', + icon: { kind: 'slash' }, + action: { type: 'attachment' }, }, ], }, { - id: 'commands', - title: 'Commands', + id: 'run', + title: 'How it runs', + showTitle: true, items: [{ - key: 'slash-commands', - label: 'Slash commands', + key: 'computer-use', + label: 'Computer Use', + description: 'Let Claude operate apps', icon: { kind: 'slash' }, - action: { type: 'slashTrigger' }, + switch: { checked: false, disabled: false }, + action: { type: 'toggleComputerUse' }, }], }, + { + id: 'more', + title: 'More tools', + showTitle: false, + items: [{ + key: 'more', + label: 'More tools', + icon: { kind: 'slash' }, + children: [{ + key: 'slash-commands', + label: 'Slash commands', + icon: { kind: 'slash' }, + action: { type: 'slashTrigger' }, + }], + }], + searchOnly: [{ key: 'plugin:video', label: 'Video Studio', icon: { kind: 'slash' }, action: { type: 'insertMention', reference: videoPlugin } }], + }, ] } @@ -93,66 +145,115 @@ function searchInput(): HTMLElement { return screen.getByRole('combobox') } +function rootList(): HTMLElement { + return screen.getByRole('listbox', { name: 'Open composer tools' }) +} + describe('ComposerCapabilityMenu', () => { - it('renders section titles and dispatches a leaf action on click', () => { + it('titles the two groups and dispatches a leaf action on click', () => { const { onAction } = renderMenu() - expect(screen.getByRole('group', { name: 'Add' })).toBeInTheDocument() - expect(screen.getByRole('group', { name: 'Capabilities' })).toBeInTheDocument() - expect(screen.getByRole('group', { name: 'Commands' })).toBeInTheDocument() + expect(within(rootList()).getByText('For this task')).toBeInTheDocument() + expect(within(rootList()).getByText('How it runs')).toBeInTheDocument() + // More is one row; its group needs no visible title. + expect(within(rootList()).queryByText('More tools', { selector: '[role="presentation"]' })).not.toBeInTheDocument() fireEvent.click(screen.getByRole('option', { name: /Add files or photos/ })) expect(onAction).toHaveBeenCalledWith({ type: 'attachment' }) }) - it('drills into a sub-list and returns with the back row', () => { + it('opens a category beside the root on hover and keeps the root in view', () => { const { onAction } = renderMenu() + fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ })) + const flyout = screen.getByTestId('capability-flyout') + expect(within(flyout).getByText('Skills')).toBeInTheDocument() + expect(within(flyout).getByText('Recently used')).toBeInTheDocument() + expect(within(flyout).getByText('Create interfaces')).toBeInTheDocument() + // The root list is still there, its open category marked. + expect(within(rootList()).getByRole('option', { name: /Skills/ })).toHaveAttribute('aria-selected', 'true') + + // Hovering a sibling category swaps the panel; a leaf closes it. + fireEvent.mouseEnter(screen.getByRole('option', { name: /Connectors/ })) + expect(within(screen.getByTestId('capability-flyout')).getByRole('option', { name: 'GitHub' })).toBeInTheDocument() + fireEvent.mouseEnter(screen.getByRole('option', { name: /Add files or photos/ })) + expect(screen.queryByTestId('capability-flyout')).not.toBeInTheDocument() - // Parent rows open their sub-list instead of firing an action. fireEvent.click(screen.getByRole('option', { name: /Skills/ })) - expect(onAction).not.toHaveBeenCalled() - expect(screen.getByRole('option', { name: /Design/ })).toBeInTheDocument() - - // Back navigation restores the top-level sections. - 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/ })) - fireEvent.click(screen.getByRole('option', { name: /Design/ })) - expect(onAction).toHaveBeenCalledWith({ type: 'insertSlashText', command: 'design' }) + fireEvent.click(within(screen.getByTestId('capability-flyout')).getByRole('option', { name: /Design/ })) + expect(onAction).toHaveBeenCalledWith({ type: 'insertMention', reference: designSkill }) }) - it('navigates with the keyboard from the search input', () => { - const { onClose } = renderMenu() + it('walks into a category with the keyboard and steps back one panel per Escape', () => { + const { onAction, onClose } = renderMenu() const input = searchInput() - // Order: Add files → Skills → Computer Use → Slash commands. + // Root order: Skills → Connectors → Add files → Computer Use → More. + fireEvent.keyDown(input, { key: 'ArrowRight' }) + const flyout = screen.getByTestId('capability-flyout') + expect(input).toHaveAttribute('aria-controls', 'cap-sub-list') + expect(input).not.toHaveAttribute('aria-activedescendant') + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + const first = within(flyout).getByRole('option', { name: /Design/ }) + expect(input).toHaveAttribute('aria-activedescendant', first.id) + fireEvent.keyDown(input, { key: 'ArrowLeft' }) + expect(screen.queryByTestId('capability-flyout')).not.toBeInTheDocument() + + fireEvent.keyDown(input, { key: 'Enter' }) fireEvent.keyDown(input, { key: 'ArrowDown' }) fireEvent.keyDown(input, { key: 'Enter' }) - expect(screen.getByRole('option', { name: /Design/ })).toBeInTheDocument() + expect(onAction).toHaveBeenCalledWith({ type: 'insertMention', reference: designSkill }) fireEvent.keyDown(input, { key: 'Escape' }) - // Esc inside a sub-list steps back first, then closes. + expect(screen.queryByTestId('capability-flyout')).not.toBeInTheDocument() expect(onClose).not.toHaveBeenCalled() fireEvent.keyDown(input, { key: 'Escape' }) expect(onClose).toHaveBeenCalledTimes(1) }) + it('runs a row button once without also running the row', () => { + const { onAction } = renderMenu() + fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ })) + const flyout = screen.getByTestId('capability-flyout') + expect(within(flyout).getByText('Popular')).toBeInTheDocument() + fireEvent.click(within(flyout).getByRole('button', { name: 'Install' })) + expect(onAction).toHaveBeenCalledTimes(1) + expect(onAction).toHaveBeenCalledWith({ type: 'installSkill', id: 'pptx', name: 'pptx' }) + }) + + it('browses the full skill list in the side panel and returns with its back button', () => { + renderMenu() + fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ })) + fireEvent.click(within(screen.getByTestId('capability-flyout')).getByRole('option', { name: /All skills/ })) + const flyout = screen.getByTestId('capability-flyout') + expect(within(flyout).getByText('All skills')).toBeInTheDocument() + expect(within(flyout).getByRole('listbox', { name: 'References' })).toBeInTheDocument() + expect(within(flyout).getByRole('option', { name: 'Design' })).toBeInTheDocument() + + fireEvent.click(within(flyout).getByRole('button', { name: 'Back' })) + expect(within(screen.getByTestId('capability-flyout')).getByText('Recently used')).toBeInTheDocument() + }) + it('toggles a switch row without double-firing from the row click', () => { const { onAction } = renderMenu() 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', async () => { - renderMenu() + it('searches everything from the root, closing an open category', async () => { + const { onAction } = renderMenu() + fireEvent.mouseEnter(screen.getByRole('option', { name: /Connectors/ })) fireEvent.change(searchInput(), { target: { value: 'Design' } }) - expect(screen.getByRole('option', { name: /Design/ })).toBeInTheDocument() - expect(screen.queryByText('Commands')).not.toBeInTheDocument() + expect(screen.queryByTestId('capability-flyout')).not.toBeInTheDocument() + expect(await screen.findByRole('option', { name: 'Design' })).toBeInTheDocument() + + // Plugins that no sub-list shows are still found and mentioned. + 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: videoPlugin }) fireEvent.change(searchInput(), { target: { value: 'no-such-capability' } }) expect(await screen.findByText('No matching references')).toBeInTheDocument() @@ -161,35 +262,19 @@ describe('ComposerCapabilityMenu', () => { }) }) -// 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' } }) +it('opens a searched category in the side panel', () => { + renderMenu() + fireEvent.change(searchInput(), { target: { value: 'Connectors' } }) 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' }) + expect(within(screen.getByTestId('capability-flyout')).getByRole('option', { name: 'GitHub' })).toBeInTheDocument() }) -it('keeps the root menu concise and exposes descriptions inside a category', () => { +it('keeps the root a list of names and leaves descriptions to the categories', () => { 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 }) + fireEvent.mouseEnter(screen.getByRole('option', { name: 'Skills' })) + expect(screen.getByText('Build slides')).toBeInTheDocument() }) it('finds project files through the same search and preserves their structured path', async () => { @@ -202,17 +287,25 @@ it('finds project files through the same search and preserves their structured p 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() +it('opens categories in place when the window has no room for a side panel', () => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 480 }) + const { onClose } = renderMenu() + fireEvent.mouseEnter(screen.getByRole('option', { name: /Skills/ })) + expect(screen.queryByTestId('capability-flyout')).not.toBeInTheDocument() + fireEvent.click(screen.getByRole('option', { name: /Skills/ })) + expect(screen.queryByRole('listbox', { name: 'Open composer tools' })).not.toBeInTheDocument() + expect(screen.getByRole('option', { name: /Design/ })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Back' })) + expect(rootList()).toBeInTheDocument() + expect(onClose).not.toHaveBeenCalled() +}) + +it('drills in place inside the phone sheet and backs up one level per Escape', () => { + const { onClose } = renderMenu({ presentation: 'sheet' }) + fireEvent.click(screen.getByRole('option', { name: /More tools/ })) + expect(screen.queryByTestId('capability-flyout')).not.toBeInTheDocument() + expect(screen.getByRole('option', { name: /Slash commands/ })).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 9e16b24a..a228969e 100644 --- a/desktop/src/components/chat/ComposerCapabilityMenu.tsx +++ b/desktop/src/components/chat/ComposerCapabilityMenu.tsx @@ -1,7 +1,8 @@ -import { useMemo, useRef, useState } from 'react' +import { Fragment, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ChevronLeft, ChevronRight, Search } from 'lucide-react' import { useTranslation } from '@/i18n' import { publicAssetPath } from '@/lib/publicAsset' +import { Button } from '@/components/ui/Button' import { Switch } from '@/components/ui/Switch' import { IconButton } from '@/components/ui/IconButton' import { ComposerSuggestionRow } from '@/components/chat/ComposerSuggestionRow' @@ -9,7 +10,7 @@ import { ComposerReferenceMenu, type ComposerReferenceMenuHandle } from '@/compo import type { NewComposerMention } from '@/lib/composerMentions' import type { ComposerReferenceCandidate } from '@/types/composerReference' import type { CapabilityAction, CapabilityIcon, CapabilityMenuItem, CapabilityMenuSection } from './capabilityMenuModel' -import { COMPOSER_KBD } from './composerMenuStyles' +import { COMPOSER_KBD, COMPOSER_MENU_SECTION, COMPOSER_MENU_SEPARATOR } from './composerMenuStyles' type Props = { id: string @@ -21,18 +22,31 @@ type Props = { onAction(action: CapabilityAction): void onClose(): void /** - * `popover` floats above the composer's + button. `sheet` lays the same menu - * out inside the phone's bottom sheet: in the flow, 44px rows, and no - * autofocused search, since focusing a field on a phone throws the keyboard - * up over the very list that was just opened. + * `popover` floats above the composer's + button; a category opens its + * sub-list in a second panel beside it, like a desktop menu. `sheet` lays the + * same menu out inside the phone's bottom sheet: in the flow, 44px rows, a + * category replaces the list in place, and no autofocused search, since + * focusing a field on a phone throws the keyboard up over the very list that + * was just opened. */ presentation?: 'popover' | 'sheet' } +const ROOT_WIDTH = 288 +const FLYOUT_WIDTH = 320 +const PANEL_GAP = 4 +/** The full skill list reuses the @ menu's reference browser. */ +const BROWSE_REFERENCES_KEY = 'skills:all' +const PANEL = 'overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)]' + export function getCapabilityMenuOptionId(id: string, index: number): string { return `${id}-option-${index}` } +export function getCapabilitySubMenuOptionId(id: string, index: number): string { + return `${id}-sub-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])]) } @@ -65,11 +79,25 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi const t = useTranslation() const [query, setQuery] = useState('') const [path, setPath] = useState([]) - const [highlight, setHighlight] = useState(0) + /** Keyboard cursor in the open sub-list; -1 until the keyboard moves it. */ + const [highlight, setHighlight] = useState(-1) + const [rootHighlight, setRootHighlight] = useState(0) const [referenceOptionId, setReferenceOptionId] = useState() - const listRef = useRef(null) + // Too little room to the right of the popover for a second panel: open + // categories in place, as the sheet does. + const [narrow, setNarrow] = useState(false) + const containerRef = useRef(null) const referenceRef = useRef(null) + useLayoutEffect(() => { + if (sheet) return + const left = containerRef.current?.getBoundingClientRect().left ?? 0 + const view = containerRef.current?.ownerDocument.defaultView + if (view) setNarrow(left + ROOT_WIDTH + PANEL_GAP + FLYOUT_WIDTH > view.innerWidth) + }, [sheet]) + const inPlace = sheet || narrow + const rootItems = useMemo(() => sections.flatMap(section => section.items), [sections]) + const searchOnly = useMemo(() => sections.flatMap(section => section.searchOnly ?? []), [sections]) let drillParent: CapabilityMenuItem | undefined let items = rootItems for (const key of path) { @@ -78,23 +106,34 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi drillParent = parent items = parent.children } - const browseReferences = drillParent?.key === 'skills' || drillParent?.key === 'plugins' - const showReferences = browseReferences || !!query.trim() - const candidates = useMemo(() => descendants(rootItems), [rootItems]) + const searching = !!query.trim() + const browseReferences = drillParent?.key === BROWSE_REFERENCES_KEY + const showReferences = browseReferences || searching + const flyoutOpen = !inPlace && !!drillParent && !searching + const candidates = useMemo(() => descendants([...rootItems, ...searchOnly]), [rootItems, searchOnly]) 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 subIndex = items.length ? Math.min(highlight, items.length - 1) : -1 + const rootIndex = Math.min(rootHighlight, rootItems.length - 1) + const listId = `${id}-list` + const subListId = `${id}-sub-list` + const referencesId = `${id}-references` + const activeOptionId = showReferences + ? referenceOptionId + : drillParent + ? subIndex < 0 ? undefined : getCapabilitySubMenuOptionId(id, subIndex) + : rootIndex < 0 ? undefined : getCapabilityMenuOptionId(id, rootIndex) + const controlsId = showReferences ? referencesId : drillParent ? subListId : listId + const openCategory = (nextPath: string[]) => { setPath(nextPath) setQuery('') - setHighlight(0) + setHighlight(-1) } - const activate = (item: CapabilityMenuItem | undefined) => { + const activate = (item: CapabilityMenuItem | undefined, parentPath: string[]) => { if (!item || item.disabled || item.switch?.disabled) return - if (item.children) openCategory([...path, item.key]) + if (item.children) openCategory([...parentPath, item.key]) else if (item.action) onAction(item.action) } const actions = scoped.filter(({ item }) => item.action?.type !== 'insertMention' && !item.disabled && !item.switch?.disabled) @@ -103,7 +142,14 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi icon: , onSelect: () => item.children ? openCategory([...parentPath, item.key]) : item.action && onAction(item.action), })) - const goBack = () => openCategory(path.slice(0, -1)) + const goBack = () => { + const parentKey = path[0] + openCategory(path.slice(0, -1)) + if (path.length === 1 && parentKey) setRootHighlight(Math.max(0, rootItems.findIndex(item => item.key === parentKey))) + } + const scrollToOption = (optionId: string) => { + containerRef.current?.ownerDocument.getElementById(optionId)?.scrollIntoView?.({ block: 'nearest' }) + } const handleKeyDown = (event: React.KeyboardEvent) => { if (event.nativeEvent.isComposing || event.keyCode === 229) return if (event.key === 'Escape') { @@ -118,13 +164,23 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi 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) { + const down = event.key === 'ArrowDown' + if (drillParent) { + if (!items.length) return + const next = subIndex < 0 ? (down ? 0 : items.length - 1) : (subIndex + (down ? 1 : items.length - 1)) % items.length + setHighlight(next) + scrollToOption(getCapabilitySubMenuOptionId(id, next)) + } else { + if (!rootItems.length) return + const next = (Math.max(rootIndex, 0) + (down ? 1 : rootItems.length - 1)) % rootItems.length + setRootHighlight(next) + scrollToOption(getCapabilityMenuOptionId(id, next)) + } + } else if (event.key === 'Enter' || event.key === 'ArrowRight') { + const current = drillParent ? items[subIndex] : rootItems[rootIndex] + if (event.key === 'ArrowRight' && !current?.children) return event.preventDefault() - activate(items[activeIndex]) + activate(current, drillParent ? path : []) } } const selectMention = (mention: NewComposerMention) => { @@ -136,40 +192,122 @@ export function ComposerCapabilityMenu({ id, sections, cwd = '', referencesLoadi onClose() } } - 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} - touch={sheet} - onMouseEnter={() => setHighlight(index)} onClick={() => activate(item)} - trailing={item.switch ? event.stopPropagation()}> - item.action && onAction(item.action)} /> - : item.children ?