feat(desktop): reorganize the composer + menu around skills, connectors and run modes (#1462)

- 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.
This commit is contained in:
程序员阿江-Relakkes
2026-10-06 05:05:04 +08:00
committed by GitHub
parent 50f784114b
commit b99bd1fb38
28 changed files with 1832 additions and 607 deletions
@@ -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 (
<span
data-testid="agent-team-chip"
className={`inline-flex shrink-0 items-center gap-1 rounded-[var(--radius-sm)] bg-[var(--color-brand-soft)] pl-2 text-xs font-medium text-[var(--color-on-brand-soft)] ${touch ? 'h-9' : 'h-6'}`}
>
<Users size={12} strokeWidth={1.75} aria-hidden="true" />
{t('chat.capabilities.teams')}
<button
type="button"
onClick={onRemove}
aria-label={t('chat.capabilities.teamRemove')}
className={`inline-flex items-center justify-center rounded-[var(--radius-xs)] hover:bg-[var(--color-brand-soft-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] ${touch ? 'h-9 w-9' : 'h-6 w-6'}`}
>
<X size={12} strokeWidth={1.75} aria-hidden="true" />
</button>
</span>
)
}
+113 -7
View File
@@ -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(<ChatInput compact />)
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(<ChatInput compact />)
setComposerText('Please review ', 14)
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.change(screen.getByRole('combobox', { name: 'Search skills, plugins, files…' }), { target: { value: 'README' } })
fireEvent.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<ReturnType<typeof computerUseApi.getStatus>>)
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(<ChatInput compact />)
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<ReturnType<typeof computerUseApi.getStatus>>)
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(<ChatInput compact />)
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(<ChatInput compact />)
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(<ChatInput compact />)
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(<ChatInput compact />)
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 () => {
+23 -11
View File
@@ -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<HTMLDivElement>()
const [input, setInput] = useState('')
const [mentions, setMentions] = useState<ComposerMention[]>([])
// Bumped when the + menu installs a skill, so the mention list reloads.
const [referencesVersion, setReferencesVersion] = useState(0)
const [referenceDetail, setReferenceDetail] = useState<ComposerMention | null>(null)
const [referenceOptionId, setReferenceOptionId] = useState<string | undefined>()
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}
/>
)}
<ComputerUseEnableDialog {...capabilityMenu.computerUseConsent} />
</div>
{capabilityMenu.agentTeamArmed && !isMemberSession ? (
<AgentTeamChip touch={isMobileComposer} onRemove={capabilityMenu.disarmAgentTeam} />
) : null}
<div className="shrink-0">
<PermissionModeSelector
ref={permissionSelectorRef}
@@ -1,33 +1,32 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import '@testing-library/jest-dom'
import { ComposerCapabilityMenu } from './ComposerCapabilityMenu'
import { sessionCollaborationApi } from '@/api/sessionCollaboration'
import { filesystemApi } from '@/api/filesystem'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import type { CapabilityMenuSection } from './capabilityMenuModel'
vi.mock('@/api/sessionCollaboration', () => ({ 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()
})
@@ -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<string[]>([])
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<string>()
const listRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null)
const referenceRef = useRef<ComposerReferenceMenuHandle>(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: <RowIcon icon={item.icon} iconColor={item.iconColor} />,
onSelect: () => item.children ? openCategory([...parentPath, item.key]) : item.action && onAction(item.action),
}))
const goBack = () => openCategory(path.slice(0, -1))
const 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) => <ComposerSuggestionRow
key={item.key} id={getCapabilityMenuOptionId(id, index)} label={item.label}
// A finger has no keyboard cursor to show; the sheet keeps rows plain.
selected={!sheet && index === activeIndex} icon={<RowIcon icon={item.icon} iconColor={item.iconColor} />}
aria-label={item.switch ? `${item.label}: ${t(item.switch.checked ? 'settings.plugins.status.enabled' : 'settings.plugins.status.disabled')}` : undefined}
aria-labelledby={item.switch ? undefined : `${getCapabilityMenuOptionId(id, index)}-label`}
aria-disabled={item.disabled || item.switch?.disabled || undefined}
title={item.disabledReason ?? item.description}
touch={sheet}
onMouseEnter={() => setHighlight(index)} onClick={() => activate(item)}
trailing={item.switch ? <span className="-my-1 shrink-0" onClick={event => event.stopPropagation()}>
<Switch size="sm" checked={item.switch.checked} disabled={item.switch.disabled} label={t('chat.capabilities.computerUseToggle')} labelHidden onChange={() => item.action && onAction(item.action)} />
</span> : item.children ? <ChevronRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-[var(--color-text-tertiary)]" /> : item.key === 'slash-commands' ? <kbd className={COMPOSER_KBD}>/</kbd> : null}
/>
const renderRow = (item: CapabilityMenuItem, index: number, level: 'root' | 'sub') => {
const optionId = level === 'root' ? getCapabilityMenuOptionId(id, index) : getCapabilitySubMenuOptionId(id, index)
const open = level === 'root' && path[0] === item.key && !inPlace
const selected = !sheet && (open || (level === 'root' ? !drillParent && index === rootIndex : index === subIndex))
const status = item.status
? <span aria-hidden="true" className={`h-1.5 w-1.5 shrink-0 rounded-full ${item.status === 'ok' ? 'bg-[var(--color-success)]' : 'bg-[var(--color-warning)]'}`} />
: null
const trailing = item.switch
? <span className="-my-1 shrink-0" onClick={event => event.stopPropagation()}>
<Switch size="sm" checked={item.switch.checked} disabled={item.switch.disabled} label={item.label} labelHidden onChange={() => item.action && onAction(item.action)} />
</span>
: item.button
? <>{status}<Button size="sm" variant="secondary" loading={item.button.busy} className="-my-1 shrink-0 px-2"
onClick={event => { event.stopPropagation(); if (!item.button?.busy) onAction(item.button!.action) }}>{item.button.label}</Button></>
: item.children
? <>
{item.count ? <span className="shrink-0 text-xs tabular-nums text-[var(--color-text-tertiary)]">{item.count}</span> : null}
<ChevronRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 text-[var(--color-text-tertiary)]" />
</>
: item.key === 'slash-commands' ? <kbd className={COMPOSER_KBD}>/</kbd> : status
return <ComposerSuggestionRow
key={item.key} id={optionId} label={item.label}
// The root stays a short list of names; sub-lists say what each row is.
description={level === 'sub' ? item.description : undefined}
// A finger has no keyboard cursor to show; the sheet keeps rows plain.
selected={selected} icon={<RowIcon icon={item.icon} iconColor={item.iconColor} />}
aria-label={item.switch ? `${item.label}: ${t(item.switch.checked ? 'settings.plugins.status.enabled' : 'settings.plugins.status.disabled')}` : undefined}
aria-labelledby={item.switch ? undefined : `${optionId}-label`}
aria-disabled={item.disabled || item.switch?.disabled || undefined}
title={item.disabledReason ?? item.description}
touch={sheet}
onMouseEnter={() => {
if (level === 'sub') {
setHighlight(index)
return
}
setRootHighlight(index)
// Desktop menus open a category on hover and close it on a sibling.
if (!inPlace) {
const nextPath = item.children ? [item.key] : []
if (nextPath.join('/') !== path.join('/') && !query) openCategory(nextPath)
}
}}
onClick={() => activate(item, level === 'root' ? [] : path)}
trailing={trailing}
/>
}
const renderSubList = (listItems: CapabilityMenuItem[]) => <div id={subListId} role="listbox" aria-label={drillParent?.label}
className={sheet ? 'p-2' : 'max-h-[min(360px,50vh)] overflow-y-auto p-1'}>
{listItems.map((item, index) => {
const previous = listItems[index - 1]
const heading = item.group && item.group !== previous?.group
// Footer rows (browse, manage) under a titled group get a hairline.
const separator = !item.group && !!previous?.group
return <Fragment key={item.key}>
{heading ? <div role="presentation" className={COMPOSER_MENU_SECTION}>{item.group}</div> : null}
{separator ? <div role="presentation" className={COMPOSER_MENU_SEPARATOR} /> : null}
{renderRow(item, index, 'sub')}
</Fragment>
})}
</div>
const referenceMenu = <ComposerReferenceMenu key={path.join('/')} ref={referenceRef} id={referencesId} cwd={cwd} filter={query} embedded browseReferences={browseReferences && !searching} references={references} actions={actions}
referencesLoading={referencesLoading} referencesError={referencesError} onSelect={selectMention} onActiveChange={setReferenceOptionId} />
let offset = 0
return <div className={sheet
? 'flex min-w-0 flex-col'
: `absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2 overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] shadow-[var(--shadow-dropdown)] ${showReferences ? 'w-[min(480px,calc(100vw-32px))]' : 'w-[min(288px,calc(100vw-32px))]'}`} onMouseDown={sheet ? undefined : event => event.preventDefault()}>
<div className={`flex items-center gap-2 border-b border-[var(--color-border)] px-3 ${sheet ? 'h-12' : 'h-10'}`}>
{drillParent ? <IconButton icon={<ChevronLeft size={14} strokeWidth={1.75} />} label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null}
<Search aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<input autoFocus={!sheet} value={query} onChange={event => { setQuery(event.target.value); setHighlight(0) }} onKeyDown={handleKeyDown} onClick={event => event.currentTarget.focus()}
placeholder={drillParent?.label ?? t('chat.capabilities.searchPlaceholder')} aria-label={t('chat.capabilities.searchPlaceholder')}
role="combobox" aria-expanded="true" aria-controls={listId} aria-activedescendant={activeOptionId}
className={`min-w-0 flex-1 bg-transparent text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] ${sheet ? 'text-[15px]' : 'text-[13px]'}`} />
const rootList = <div id={listId} role="listbox" aria-label={t('chat.composerTools')} className={sheet ? 'p-2' : 'max-h-[min(420px,60vh)] overflow-y-auto p-1'}>
{sections.map(section => {
const start = offset
offset += section.items.length
return <div key={section.id} role="group" aria-label={section.title} className="border-b border-[var(--color-border)] py-1 first:pt-0 last:border-b-0 last:pb-0">
{section.showTitle ? <div role="presentation" className={COMPOSER_MENU_SECTION}>{section.title}</div> : null}
{section.items.map((item, index) => renderRow(item, start + index, 'root'))}
</div>
})}
</div>
const searchRow = <div className={`flex items-center gap-2 border-b border-[var(--color-border)] px-3 ${sheet ? 'h-12' : 'h-10'}`}>
{inPlace && drillParent ? <IconButton icon={<ChevronLeft size={14} strokeWidth={1.75} />} label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null}
<Search aria-hidden="true" size={14} strokeWidth={1.75} className="shrink-0 text-[var(--color-text-tertiary)]" />
<input autoFocus={!sheet} value={query}
onChange={event => {
setQuery(event.target.value)
setRootHighlight(0)
setHighlight(-1)
// In the popover, typing searches everything: the side panel closes.
if (!inPlace && path.length && event.target.value.trim()) setPath([])
}}
onKeyDown={handleKeyDown} onClick={event => event.currentTarget.focus()}
placeholder={inPlace && drillParent ? drillParent.label : t('chat.capabilities.searchPlaceholder')} aria-label={t('chat.capabilities.searchPlaceholder')}
role="combobox" aria-expanded="true" aria-controls={controlsId} aria-activedescendant={activeOptionId}
className={`min-w-0 flex-1 bg-transparent text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] ${sheet ? 'text-[15px]' : 'text-[13px]'}`} />
</div>
if (inPlace) {
return <div ref={containerRef} className={sheet
? 'flex min-w-0 flex-col'
: `absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2 ${PANEL} ${showReferences ? 'w-[min(480px,calc(100vw-32px))]' : 'w-[min(288px,calc(100vw-32px))]'}`} onMouseDown={sheet ? undefined : event => event.preventDefault()}>
{searchRow}
{showReferences ? referenceMenu : drillParent ? renderSubList(items) : rootList}
</div>
{showReferences ? <ComposerReferenceMenu key={path.join('/')} ref={referenceRef} id={listId} cwd={cwd} filter={query} embedded browseReferences={browseReferences} references={references} actions={actions}
referencesLoading={referencesLoading} referencesError={referencesError} onSelect={selectMention} onActiveChange={setReferenceOptionId} /> :
<div ref={listRef} id={listId} role="listbox" aria-label={t('chat.composerTools')} className={sheet ? 'p-2' : 'max-h-[min(360px,50vh)] overflow-y-auto p-1'}>
{drillParent ? items.map(renderRow) : sections.map(section => {
const start = offset
offset += section.items.length
return <div key={section.id} role="group" aria-label={section.title} className="border-b border-[var(--color-border)] py-1 first:pt-0 last:border-b-0 last:pb-0">{section.items.map((item, index) => renderRow(item, start + index))}</div>
})}
</div>}
}
return <div ref={containerRef} className="absolute bottom-full left-0 z-[var(--z-dropdown)] mb-2 flex items-end" style={{ gap: PANEL_GAP }} onMouseDown={event => event.preventDefault()}>
<div className={`${PANEL} shrink-0 ${searching ? 'w-[min(480px,calc(100vw-32px))]' : ''}`} style={searching ? undefined : { width: ROOT_WIDTH }}>
{searchRow}
{searching ? referenceMenu : rootList}
</div>
{flyoutOpen ? <div data-testid="capability-flyout" className={`${PANEL} shrink-0`} style={{ width: FLYOUT_WIDTH }}>
<div className="flex h-10 items-center gap-1.5 border-b border-[var(--color-border)] px-2">
{path.length > 1 ? <IconButton icon={<ChevronLeft size={14} strokeWidth={1.75} />} label={t('chat.capabilities.back')} size="xs" tone="muted" onClick={goBack} /> : null}
<span className="min-w-0 flex-1 truncate px-1 text-[13px] font-semibold text-[var(--color-text-primary)]">{drillParent!.label}</span>
</div>
{browseReferences ? referenceMenu : renderSubList(items)}
</div> : null}
</div>
}
@@ -3,27 +3,24 @@ import { translate, type TranslationKey } from '@/i18n'
import type { AgentDefinition } from '@/api/agents'
import type { ConnectorDto } from '@/types/connector'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import type { TeamSummary } from '@/types/team'
import type { NormalizedSkill } from '@/types/market'
import type { WorkflowDefinition } from '@/types/workflow'
import {
buildCapabilitySections,
type CapabilityMenuInput,
type CapabilityMenuItem,
} from './capabilityMenuModel'
const t = (key: TranslationKey, params?: Record<string, string | number>) =>
params?.count !== undefined ? `${key}#${params.count}` : key
const skill: ComposerReferenceCandidate = {
kind: 'skill',
id: 'design',
name: 'design',
displayName: 'Design',
description: 'Create interfaces',
source: 'user',
modelText: 'Use design',
function skill(id: string): ComposerReferenceCandidate {
return { kind: 'skill', id, name: id, displayName: id, description: `${id} skill`, source: 'user', modelText: `Use ${id}` }
}
const plugin: ComposerReferenceCandidate = {
const design = skill('design')
const feishuPlugin: ComposerReferenceCandidate = {
kind: 'plugin',
id: 'feishu-plugin',
name: 'feishu',
@@ -33,184 +30,301 @@ const plugin: ComposerReferenceCandidate = {
modelText: 'Use Feishu',
}
const agent: AgentDefinition = {
agentType: 'debugger',
description: 'Debug failures',
source: 'userSettings',
isActive: true,
const mcpPlugin: ComposerReferenceCandidate = {
kind: 'plugin',
id: 'sentry-tools@team',
name: 'sentry-tools',
displayName: 'Sentry tools',
description: 'Bring Sentry issues',
source: 'plugin',
modelText: 'Use Sentry tools',
mcpServerNames: ['sentry'],
}
const inactiveAgent: AgentDefinition = {
agentType: 'retired',
source: 'userSettings',
isActive: false,
const docsPlugin: ComposerReferenceCandidate = {
kind: 'plugin',
id: 'document-skills@anthropic',
name: 'document-skills',
displayName: 'Document skills',
description: 'PDF and Word',
source: 'plugin',
modelText: 'Use document skills',
skillNames: ['pdf', 'docx'],
}
const connectedConnector = {
id: 'feishu',
displayName: 'Feishu',
pluginId: 'feishu-plugin',
connection: 'connected',
enabled: true,
installed: true,
supported: true,
status: 'ready',
} as unknown as ConnectorDto
const agent: AgentDefinition = { agentType: 'debugger', description: 'Debug failures', source: 'userSettings', isActive: true }
const inactiveAgent: AgentDefinition = { agentType: 'retired', source: 'userSettings', isActive: false }
const plainConnector = {
id: 'dingtalk',
displayName: 'DingTalk',
pluginId: 'dingtalk-plugin',
connection: 'connected',
enabled: true,
installed: true,
supported: true,
status: 'ready',
} as unknown as ConnectorDto
const disconnectedConnector = {
...plainConnector,
id: 'wecom',
connection: 'disconnected',
} as unknown as ConnectorDto
const team: TeamSummary = { name: 'review-team', memberCount: 3 }
const workflow: WorkflowDefinition = {
name: 'nightly-review',
description: 'Review the day',
source: 'userSettings',
function connector(id: string, overrides: Partial<ConnectorDto> = {}): ConnectorDto {
return {
id,
displayName: id,
description: `${id} service`,
pluginId: `${id}@haha-connectors`,
connection: 'disconnected',
enabled: false,
installed: false,
supported: true,
status: 'not-installed',
transport: 'mcp',
...overrides,
} as unknown as ConnectorDto
}
const feishu = connector('feishu', { pluginId: 'feishu-plugin', connection: 'connected', installed: true, enabled: true, status: 'ready' })
const dingtalk = connector('dingtalk', { connection: 'connected', installed: true, enabled: true, status: 'ready' })
const popular: NormalizedSkill = {
id: 'clawhub:excalidraw',
source: 'clawhub',
slug: 'excalidraw',
name: 'excalidraw',
summary: 'Hand-drawn diagrams',
author: { handle: 'someone' },
stats: { downloads: 10 },
tags: [],
securityStatus: 'verified',
installState: 'installable',
}
const workflow: WorkflowDefinition = { name: 'nightly-review', description: 'Review the day', source: 'userSettings' }
function buildInput(overrides: Partial<CapabilityMenuInput> = {}): CapabilityMenuInput {
return {
skills: [skill],
plugins: [plugin],
skills: [design],
plugins: [feishuPlugin],
recentSkillIds: [],
popularSkills: [],
installingSkillIds: new Set(),
agents: [agent, inactiveAgent],
connectors: [connectedConnector, plainConnector, disconnectedConnector],
teams: [team],
connectors: [feishu, dingtalk],
workflows: [workflow],
agentTeam: { enabled: true, armed: false },
computerUse: { supported: true, enabled: false },
teamCreatePrompt: 'Create a team: ',
t,
...overrides,
}
}
function sectionsById(input: CapabilityMenuInput) {
const sections = buildCapabilitySections(input)
const items = sections.flatMap(section => section.items).flatMap(item => item.key === 'more' ? item.children! : [item])
return new Map([['capabilities', { items }]])
function rootItem(input: CapabilityMenuInput, key: string): CapabilityMenuItem {
const item = buildCapabilitySections(input).flatMap(section => section.items).find(candidate => candidate.key === key)
if (!item) throw new Error(`missing root item ${key}`)
return item
}
function moreItem(input: CapabilityMenuInput, key: string): CapabilityMenuItem {
const item = rootItem(input, 'more').children!.find(candidate => candidate.key === key)
if (!item) throw new Error(`missing More item ${key}`)
return item
}
const keys = (items: CapabilityMenuItem[]) => items.map(item => item.key)
describe('buildCapabilitySections', () => {
it('puts skills and plugins first and groups secondary tools under More', () => {
it('splits the root into things for this task, how it runs, and More', () => {
const sections = buildCapabilitySections(buildInput())
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', () => {
const capabilities = sectionsById(buildInput()).get('capabilities')!
const skills = capabilities.items.find(item => item.key === 'skills')!
expect(skills.count).toBe(1)
expect(skills.children!.map(child => child.key)).toEqual(['skill:design', 'skills:manage'])
expect(skills.children![0]!.action).toEqual({ type: 'insertMention', reference: skill })
expect(skills.children![1]!.action).toEqual({ type: 'settings', tab: 'skills' })
})
it('lists only connected connectors, preferring a plugin mention when one exists', () => {
const capabilities = sectionsById(buildInput()).get('capabilities')!
const connectors = capabilities.items.find(item => item.key === 'connectors')!
expect(connectors.count).toBe(2)
expect(connectors.children!.map(child => child.key)).toEqual([
'connector:feishu',
'connector:dingtalk',
'connectors:manage',
expect(sections.map(section => [section.id, section.title, section.showTitle])).toEqual([
['use', 'chat.capabilities.sectionUse', true],
['run', 'chat.capabilities.sectionRun', true],
['more', 'chat.capabilities.moreTools', false],
])
// Feishu has an installed plugin candidate → mention; DingTalk does not → catalog.
expect(connectors.children![0]!.action).toEqual({ type: 'insertMention', reference: plugin })
expect(connectors.children![1]!.action).toEqual({ type: 'connectorsTab' })
expect(connectors.children![2]!.action).toEqual({ type: 'connectorsTab' })
expect(keys(sections[0]!.items)).toEqual(['skills', 'connectors', 'add-files'])
expect(sections[0]!.items[2]!.action).toEqual({ type: 'attachment' })
expect(keys(sections[1]!.items)).toEqual(['agent-team', 'computer-use'])
expect(keys(sections[2]!.items[0]!.children!)).toEqual(['agents', 'workflows', 'slash-commands'])
})
it('lists active agents as /agent text insertions and skips inactive ones', () => {
const capabilities = sectionsById(buildInput()).get('capabilities')!
const agents = capabilities.items.find(item => item.key === 'agents')!
expect(agents.count).toBe(1)
expect(agents.children![0]).toMatchObject({
key: 'agent:debugger',
action: { type: 'insertSlashText', command: 'agent debugger' },
it('leads Skills with recent ones, then all skills, market picks and the market', () => {
const skills = [design, skill('pdf'), skill('git'), skill('lint'), skill('deploy'), skill('notes')]
const item = rootItem(buildInput({
skills,
// Newest first; an id that is no longer installed is skipped.
recentSkillIds: ['git', 'uninstalled', 'design', 'pdf', 'lint', 'deploy', 'notes'],
popularSkills: [popular, { ...popular, id: 'clawhub:pptx', name: 'pptx' }, { ...popular, id: 'clawhub:third', name: 'third' }],
}), 'skills')
expect(item.count).toBe(6)
expect(keys(item.children!)).toEqual([
'skill:git', 'skill:design', 'skill:pdf', 'skill:lint',
'skills:all',
'market-skill:clawhub:excalidraw', 'market-skill:clawhub:pptx',
'skills:browse',
])
expect(item.children!.slice(0, 4).every(child => child.group === 'chat.capabilities.skillsRecent')).toBe(true)
expect(item.children![0]!.action).toEqual({ type: 'insertMention', reference: skills[2] })
const all = item.children![4]!
expect(all.count).toBe(6)
expect(keys(all.children!)).toEqual([...skills.map(candidate => `skill:${candidate.id}`), 'skills:manage'])
expect(all.children!.at(-1)!.action).toEqual({ type: 'settings', tab: 'skills' })
const install = { type: 'installSkill', id: 'clawhub:excalidraw', name: 'excalidraw' }
expect(item.children![5]).toMatchObject({
group: 'chat.capabilities.skillsPopular',
action: install,
button: { label: 'chat.capabilities.skillInstall', action: install, busy: false },
})
expect(agents.children!.some(child => child.key === 'agent:retired')).toBe(false)
expect(agents.children!.at(-1)!.action).toEqual({ type: 'settings', tab: 'agents' })
expect(item.children!.at(-1)!.action).toEqual({ type: 'market', section: 'skills' })
})
it('leads the teams sub-list with a prompt-seed creation row, then existing teams', () => {
const capabilities = sectionsById(buildInput()).get('capabilities')!
const teams = capabilities.items.find(item => item.key === 'teams')!
expect(teams.count).toBe(1)
expect(teams.children![0]!.action).toEqual({ type: 'insertPromptSeed', text: 'Create a team: ' })
expect(teams.children![1]).toMatchObject({
key: 'team:review-team',
description: 'chat.capabilities.teamMembers#3',
action: { type: 'openTeam', teamName: 'review-team' },
it('opens Skills on the full list when nothing was used yet, and marks an install in flight', () => {
const item = rootItem(buildInput({ popularSkills: [popular], installingSkillIds: new Set([popular.id]) }), 'skills')
expect(keys(item.children!)).toEqual(['skills:all', 'market-skill:clawhub:excalidraw', 'skills:browse'])
expect(item.children![1]).toMatchObject({
disabled: true,
button: { label: 'chat.capabilities.skillInstalling', busy: true },
})
})
it('groups connectors into connected, needing attention and three suggestions', () => {
const item = rootItem(buildInput({
plugins: [feishuPlugin, mcpPlugin, docsPlugin],
connectors: [
feishu,
dingtalk,
connector('notion', { installed: true, connection: 'needs-auth', status: 'needs-auth' }),
connector('linear', { installed: true, status: 'error' }),
// Mid-operation rows are left to the market page.
connector('miro', { installed: true, connection: 'needs-auth', status: 'needs-auth', operation: { id: 'op', kind: 'authenticate', phase: 'waiting', startedAt: '' } }),
connector('github'),
connector('context7'),
connector('figma'),
connector('tencent-docs'),
// Skill bundles and unsupported services are never suggested.
connector('remotion', { collection: 'tools', transport: 'skills' }),
connector('wecom', { supported: false }),
],
}), 'connectors')
expect(keys(item.children!)).toEqual([
'connector:feishu', 'connector:dingtalk', 'connector-plugin:sentry-tools@team',
'connector:notion', 'connector:linear',
'connector:github', 'connector:context7', 'connector:figma',
'connectors:browse',
])
expect(item.count).toBe(3)
// Feishu has an installed plugin candidate → mention; DingTalk does not → its detail.
expect(item.children![0]).toMatchObject({ status: 'ok', group: 'chat.capabilities.connectorsConnected', action: { type: 'insertMention', reference: feishuPlugin } })
expect(item.children![1]!.action).toEqual({ type: 'market', section: 'plugins', connectorId: 'dingtalk' })
expect(item.children![2]!.action).toEqual({ type: 'insertMention', reference: mcpPlugin })
const authorize = { type: 'market', section: 'plugins', connectorId: 'notion' }
expect(item.children![3]).toMatchObject({
status: 'attention',
group: 'chat.capabilities.connectorsNeedsAction',
description: 'chat.capabilities.connectorNeedsAuth',
button: { label: 'chat.capabilities.connectorAuthorize', action: authorize },
})
expect(item.children![4]).toMatchObject({ description: 'chat.capabilities.connectorFailed', button: { label: 'chat.capabilities.connectorView' } })
expect(item.children![5]).toMatchObject({
group: 'chat.capabilities.connectorsSuggested',
button: { label: 'chat.capabilities.connectorConnect', action: { type: 'market', section: 'plugins', connectorId: 'github' } },
})
expect(item.children!.at(-1)).toMatchObject({
label: 'chat.capabilities.connectorsBrowseCount#9',
action: { type: 'market', section: 'plugins' },
})
})
it('suggests the next connectors in line once the first ones are installed', () => {
const item = rootItem(buildInput({
connectors: [
connector('github', { installed: true, connection: 'connected', status: 'ready' }),
connector('feishu', { installed: true, connection: 'connected', status: 'ready' }),
connector('amap'),
connector('notion'),
connector('context7'),
connector('figma'),
],
}), 'connectors')
expect(item.children!.filter(child => child.group === 'chat.capabilities.connectorsSuggested').map(child => child.key))
.toEqual(['connector:context7', 'connector:notion', 'connector:figma'])
})
it('still offers to browse connectors before the catalog has loaded', () => {
const item = rootItem(buildInput({ connectors: [], plugins: [] }), 'connectors')
expect(item.count).toBe(0)
expect(item.children!.map(child => [child.key, child.label])).toEqual([['connectors:browse', 'chat.capabilities.connectorsBrowse']])
})
it('shows plugins through their skills and connectors, keeping the rest searchable', () => {
const sections = buildCapabilitySections(buildInput({ plugins: [feishuPlugin, mcpPlugin, docsPlugin] }))
const allKeys = (items: CapabilityMenuItem[]): string[] => items.flatMap(item => [item.key, ...allKeys(item.children ?? [])])
expect(allKeys(sections.flatMap(section => section.items)).some(key => key === 'plugins' || key.startsWith('plugin:'))).toBe(false)
const searchOnly = sections.flatMap(section => section.searchOnly ?? [])
expect(searchOnly.map(item => item.action)).toEqual([{ type: 'insertMention', reference: docsPlugin }])
})
it('makes Agent Team a switch, or a way to Settings when it is turned off there', () => {
const armed = rootItem(buildInput({ agentTeam: { enabled: true, armed: true } }), 'agent-team')
expect(armed.switch).toEqual({ checked: true, disabled: false })
expect(armed.action).toEqual({ type: 'toggleAgentTeam' })
const off = rootItem(buildInput({ agentTeam: { enabled: false, armed: true } }), 'agent-team')
expect(off.switch).toBeUndefined()
expect(off.description).toBe('chat.capabilities.teamsDisabled')
expect(off.action).toEqual({ type: 'settings', tab: 'general' })
})
it('renders Computer Use as a switch only when the platform supports it', () => {
const supported = sectionsById(buildInput()).get('capabilities')!
const switchRow = supported.items.find(item => item.key === 'computer-use')!
const switchRow = rootItem(buildInput(), 'computer-use')
expect(switchRow.switch).toEqual({ checked: false, disabled: false })
expect(switchRow.action).toEqual({ type: 'toggleComputerUse' })
const unsupported = sectionsById(buildInput({ computerUse: { supported: false, enabled: false } })).get('capabilities')!
const navRow = unsupported.items.find(item => item.key === 'computer-use')!
const navRow = rootItem(buildInput({ computerUse: { supported: false, enabled: false } }), 'computer-use')
expect(navRow.switch).toBeUndefined()
expect(navRow.action).toEqual({ type: 'settings', tab: 'computerUse' })
// Status not loaded yet: same navigation fallback, never a dead switch.
const unknown = sectionsById(buildInput({ computerUse: null })).get('capabilities')!
expect(unknown.items.find(item => item.key === 'computer-use')!.action)
expect(rootItem(buildInput({ computerUse: null }), 'computer-use').action)
.toEqual({ type: 'settings', tab: 'computerUse' })
})
it('lists active agents as /agent text insertions and skips inactive ones', () => {
const agents = moreItem(buildInput(), 'agents')
expect(agents.count).toBe(1)
expect(agents.children![0]).toMatchObject({ key: 'agent:debugger', action: { type: 'insertSlashText', command: 'agent debugger' } })
expect(agents.children!.some(child => child.key === 'agent:retired')).toBe(false)
expect(agents.children!.at(-1)!.action).toEqual({ type: 'settings', tab: 'agents' })
})
it('lists workflows as /name text insertions with a save footer', () => {
const capabilities = sectionsById(buildInput()).get('capabilities')!
const workflows = capabilities.items.find(item => item.key === 'workflows')!
const workflows = moreItem(buildInput(), 'workflows')
expect(workflows.count).toBe(1)
expect(workflows.children![0]!.action).toEqual({ type: 'insertSlashText', command: 'nightly-review' })
expect(workflows.children!.at(-1)!.action).toEqual({ type: 'saveWorkflowPanel' })
})
})
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')!
it('keeps withdrawn installed packages out of the connector list', () => {
const hidden = { ...feishu, id: 'frontend-design', pluginId: 'office-frontend-design@haha-connectors' } as ConnectorDto
const connectors = rootItem(buildInput({ connectors: [hidden, feishu] }), '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)
})
it.each([
['zh', '操作电脑'],
['zh-TW', '操作電腦'],
['en', 'Computer use'],
['jp', 'コンピューター操作'],
['kr', '컴퓨터 조작'],
] as const)('localizes the computer-use menu entry in %s', (locale, label) => {
['zh', '操作电脑', '给这次任务用', '运行方式'],
['zh-TW', '操作電腦', '給這次任務用', '執行方式'],
['en', 'Computer use', 'For this task', 'How it runs'],
['jp', 'コンピューター操作', 'このタスクで使う', '実行方法'],
['kr', '컴퓨터 조작', '이번 작업에 사용', '실행 방식'],
] as const)('localizes the menu groups and the computer-use entry in %s', (locale, label, useTitle, runTitle) => {
const sections = buildCapabilitySections(buildInput({ t: (key, params) => translate(locale, key, params) }))
expect(sections.slice(0, 2).map(section => section.title)).toEqual([useTitle, runTitle])
const entry = sections.flatMap(section => section.items).find(item => item.key === 'computer-use')!
expect(entry.label).toBe(label)
expect(entry.switch).toEqual({ checked: false, disabled: false })
})
it('uses the localized catalog name and description of a connector', () => {
const sections = buildCapabilitySections(buildInput({
connectors: [connector('feishu', { displayName: 'feishu-cli', description: 'raw' })],
t: (key, params) => translate('zh', key, params),
}))
const suggestion = sections[0]!.items[1]!.children!.find(item => item.key === 'connector:feishu')!
expect(suggestion.label).toBe(translate('zh', 'connectors.feishu.name'))
expect(suggestion.description).toBe(translate('zh', 'connectors.feishu.description'))
})
+326 -176
View File
@@ -1,12 +1,14 @@
import {
Box,
Bot,
Compass,
MonitorSmartphone,
Ellipsis,
List,
Paperclip,
Plug,
Settings2,
UserPlus,
Store,
Users,
Workflow,
type LucideIcon,
@@ -17,7 +19,7 @@ import type { SettingsTab } from '@/stores/uiStore'
import type { AgentDefinition } from '@/api/agents'
import type { ConnectorDto } from '@/types/connector'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import type { TeamSummary } from '@/types/team'
import type { NormalizedSkill } from '@/types/market'
import type { WorkflowDefinition } from '@/types/workflow'
/**
@@ -26,8 +28,12 @@ import type { WorkflowDefinition } from '@/types/workflow'
* The menu component (`ComposerCapabilityMenu`) is deliberately dumb: this
* module decides which sections and rows exist and what each row does, so the
* whole structure is testable without rendering. Actions are interpreted by
* the two composers (ChatInput / EmptySession), which own the actual composer
* state, stores and panels.
* `useCapabilityMenu`, shared by both composers.
*
* The root has two titled groups: things the user hands to this task (skills,
* connectors, files — picking one inserts it into the composer) and how the
* task runs (Agent Team, Computer Use — switches that stay on). Everything
* else sits under More.
*/
export type CapabilityAction =
@@ -37,16 +43,16 @@ export type CapabilityAction =
| { type: 'insertMention', reference: ComposerReferenceCandidate }
/** Insert `/command ` as plain text (agents and workflows run this way). */
| { type: 'insertSlashText', command: string }
/** Fill the composer with a prompt template the user edits before sending. */
| { type: 'insertPromptSeed', text: string }
/** Open an existing team's workbench tab. */
| { type: 'openTeam', teamName: string }
| { type: 'settings', tab: SettingsTab }
/** Open the market tab that hosts the connector catalog. */
| { type: 'connectorsTab' }
/** Open the extension market; a connector id opens that connector's detail. */
| { type: 'market', section: 'plugins' | 'skills', connectorId?: string }
/** Install a curated market skill from the menu. */
| { type: 'installSkill', id: string, name: string }
/** Open the save-workflow panel above the composer. */
| { type: 'saveWorkflowPanel' }
/** Flip the global Computer Use switch. */
/** Arm or disarm Agent Team for the next message. */
| { type: 'toggleAgentTeam' }
/** Flip the global Computer Use switch; turning it on asks for consent first. */
| { type: 'toggleComputerUse' }
export type CapabilityIcon =
@@ -64,8 +70,14 @@ export type CapabilityMenuItem = {
/** Rows that open a sub-list show a chevron; leaf rows execute `action`. */
children?: CapabilityMenuItem[]
action?: CapabilityAction
/** Computer Use row: an inline switch instead of a chevron. */
/** Mode rows (Agent Team, Computer Use): an inline switch instead of a chevron. */
switch?: { checked: boolean, disabled: boolean }
/** A small button at the end of the row (Connect, Authorize, Install). */
button?: { label: string, action: CapabilityAction, busy?: boolean }
/** Connection state dot for connector rows. */
status?: 'ok' | 'attention'
/** Sub-list heading this row sits under; consecutive rows share one heading. */
group?: string
disabled?: boolean
disabledReason?: string
/** Agent rows can tint their icon with the definition's color. */
@@ -73,9 +85,13 @@ export type CapabilityMenuItem = {
}
export type CapabilityMenuSection = {
id: 'add' | 'capabilities' | 'commands'
id: 'use' | 'run' | 'more'
title: string
/** Show the title above the rows (the More group is a single row). */
showTitle: boolean
items: CapabilityMenuItem[]
/** Rows reachable only through search (plugins that no sub-list shows). */
searchOnly?: CapabilityMenuItem[]
}
export type CapabilityMenuComputerUse = {
@@ -88,110 +104,251 @@ export type CapabilityMenuInput = {
skills: ComposerReferenceCandidate[]
/** Plugin mention candidates (kind 'plugin'), already loaded for the composer. */
plugins: ComposerReferenceCandidate[]
/** Skill ids the user sent most recently, newest first. */
recentSkillIds: string[]
/** Curated market skills not installed yet, best first. */
popularSkills: NormalizedSkill[]
installingSkillIds: ReadonlySet<string>
agents: AgentDefinition[]
connectors: ConnectorDto[]
teams: TeamSummary[]
workflows: WorkflowDefinition[]
agentTeam: { enabled: boolean, armed: boolean }
computerUse: CapabilityMenuComputerUse
/** Translated seed text for the "create a team" prompt row. */
teamCreatePrompt: string
t: (key: TranslationKey, params?: Record<string, string | number>) => string
}
/** How many installed skills the Skills sub-list lists before "All skills". */
export const RECENT_SKILL_LIMIT = 4
/** How many market skills the Skills sub-list suggests. */
export const POPULAR_SKILL_LIMIT = 2
/** How many uninstalled connectors the Connectors sub-list suggests. */
export const SUGGESTED_CONNECTOR_LIMIT = 3
/**
* Connectors worth suggesting to someone who has not added them, in order. A
* fixed list on purpose: suggestions do not follow the task, they show what
* can be connected at all. Installed ones are skipped, so the next in line
* moves up.
*/
export const SUGGESTED_CONNECTOR_IDS = [
'github',
'feishu',
'context7',
'notion',
'figma',
'tencent-docs',
'linear',
'dingtalk',
'wecom',
'amap',
]
function referenceIcon(reference: ComposerReferenceCandidate): CapabilityIcon {
return reference.icon ? { kind: 'image', src: reference.icon } : { kind: 'lucide', icon: reference.kind === 'plugin' ? Plug : Box }
}
function connectorStatusCount(connectors: ConnectorDto[]): number {
return connectors.filter(connector => connector.connection === 'connected' && isComposerPluginVisible(connector.pluginId)).length
function mentionItem(reference: ComposerReferenceCandidate, group?: string): CapabilityMenuItem {
return {
key: `${reference.kind}:${reference.id}`,
label: reference.displayName || reference.name,
description: reference.description,
icon: referenceIcon(reference),
action: { type: 'insertMention', reference },
...(group ? { group } : {}),
}
}
/** Skill bundles live in the catalog too; they are not services to connect. */
function isServiceConnector(connector: ConnectorDto): boolean {
return connector.collection !== 'tools' && connector.transport !== 'skills'
}
function isVisibleConnector(connector: ConnectorDto): boolean {
return connector.supported && isServiceConnector(connector) && isComposerPluginVisible(connector.pluginId)
}
/** Catalog names and descriptions are localized under `connectors.<id>.*`. */
function connectorText(connector: ConnectorDto, field: 'name' | 'description', t: CapabilityMenuInput['t']): string {
const key = `connectors.${connector.id}.${field}` as TranslationKey
const translated = t(key)
if (translated !== key) return translated
return field === 'name' ? connector.displayName || connector.id : connector.description ?? ''
}
function connectorNeedsAttention(connector: ConnectorDto): boolean {
return connector.installed && !connector.operation && connector.connection !== 'connected'
&& (connector.connection === 'needs-auth' || connector.status === 'needs-auth' || connector.status === 'error')
}
function buildSkillChildren(input: CapabilityMenuInput): CapabilityMenuItem[] {
const { t } = input
const byId = new Map(input.skills.map(skill => [skill.id, skill]))
const recent = input.recentSkillIds
.map(id => byId.get(id))
.filter((skill): skill is ComposerReferenceCandidate => !!skill)
.slice(0, RECENT_SKILL_LIMIT)
const recentGroup = t('chat.capabilities.skillsRecent')
const popularGroup = t('chat.capabilities.skillsPopular')
const children: CapabilityMenuItem[] = recent.map(skill => mentionItem(skill, recentGroup))
children.push({
key: 'skills:all',
label: t('chat.capabilities.skillsAll'),
icon: { kind: 'lucide', icon: List },
count: input.skills.length,
children: [
...input.skills.map(skill => mentionItem(skill)),
{
key: 'skills:manage',
label: t('chat.capabilities.manageSkills'),
icon: { kind: 'lucide', icon: Settings2 },
action: { type: 'settings', tab: 'skills' },
},
],
})
for (const skill of input.popularSkills.slice(0, POPULAR_SKILL_LIMIT)) {
const busy = input.installingSkillIds.has(skill.id)
const install: CapabilityAction = { type: 'installSkill', id: skill.id, name: skill.name }
children.push({
key: `market-skill:${skill.id}`,
label: skill.name,
description: skill.summary,
icon: skill.iconUrl ? { kind: 'image', src: skill.iconUrl } : { kind: 'lucide', icon: Box },
group: popularGroup,
action: install,
disabled: busy,
button: { label: t(busy ? 'chat.capabilities.skillInstalling' : 'chat.capabilities.skillInstall'), action: install, busy },
})
}
children.push({
key: 'skills:browse',
label: t('chat.capabilities.skillsBrowse'),
icon: { kind: 'lucide', icon: Store },
action: { type: 'market', section: 'skills' },
})
return children
}
function buildConnectorChildren(input: CapabilityMenuInput): CapabilityMenuItem[] {
const { t } = input
const visible = input.connectors.filter(isVisibleConnector)
const connectedGroup = t('chat.capabilities.connectorsConnected')
const attentionGroup = t('chat.capabilities.connectorsNeedsAction')
const suggestedGroup = t('chat.capabilities.connectorsSuggested')
const pluginFor = (connector: ConnectorDto) => input.plugins.find(candidate => candidate.id === connector.pluginId)
const connected: CapabilityMenuItem[] = visible
.filter(connector => connector.connection === '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 its detail.
const plugin = pluginFor(connector)
return {
key: `connector:${connector.id}`,
label: connectorText(connector, 'name', t),
description: plugin?.description ?? connectorText(connector, 'description', t),
icon: plugin ? referenceIcon(plugin) : { kind: 'lucide', icon: Plug },
status: 'ok',
group: connectedGroup,
action: plugin
? { type: 'insertMention', reference: plugin }
: { type: 'market', section: 'plugins', connectorId: connector.id },
}
})
// Plugins that bring their own MCP servers are connectors to the user, even
// though no catalog entry describes them.
const connectorPluginIds = new Set(input.connectors.map(connector => connector.pluginId))
for (const plugin of input.plugins) {
if (connectorPluginIds.has(plugin.id) || !plugin.mcpServerNames?.length) continue
connected.push({ ...mentionItem(plugin, connectedGroup), key: `connector-plugin:${plugin.id}`, status: 'ok' })
}
const attention: CapabilityMenuItem[] = visible.filter(connectorNeedsAttention).map(connector => {
const open: CapabilityAction = { type: 'market', section: 'plugins', connectorId: connector.id }
const needsAuth = connector.status !== 'error'
return {
key: `connector:${connector.id}`,
label: connectorText(connector, 'name', t),
description: t(needsAuth ? 'chat.capabilities.connectorNeedsAuth' : 'chat.capabilities.connectorFailed'),
icon: { kind: 'lucide', icon: Plug },
status: 'attention',
group: attentionGroup,
action: open,
button: { label: t(needsAuth ? 'chat.capabilities.connectorAuthorize' : 'chat.capabilities.connectorView'), action: open },
}
})
const byId = new Map(visible.map(connector => [connector.id, connector]))
const suggested: CapabilityMenuItem[] = SUGGESTED_CONNECTOR_IDS
.map(id => byId.get(id))
.filter((connector): connector is ConnectorDto => !!connector && !connector.installed && !connector.operation)
.slice(0, SUGGESTED_CONNECTOR_LIMIT)
.map(connector => {
const open: CapabilityAction = { type: 'market', section: 'plugins', connectorId: connector.id }
return {
key: `connector:${connector.id}`,
label: connectorText(connector, 'name', t),
description: connectorText(connector, 'description', t),
icon: { kind: 'lucide', icon: Plug },
group: suggestedGroup,
action: open,
button: { label: t('chat.capabilities.connectorConnect'), action: open },
}
})
return [
...connected,
...attention,
...suggested,
{
key: 'connectors:browse',
label: visible.length
? t('chat.capabilities.connectorsBrowseCount', { count: visible.length })
: t('chat.capabilities.connectorsBrowse'),
icon: { kind: 'lucide', icon: Compass },
action: { type: 'market', section: 'plugins' },
},
]
}
export function buildCapabilitySections(input: CapabilityMenuInput): CapabilityMenuSection[] {
const { t } = input
const skillChildren: CapabilityMenuItem[] = input.skills.map(skill => ({
key: `skill:${skill.id}`,
label: skill.displayName || skill.name,
description: skill.description,
icon: referenceIcon(skill),
action: { type: 'insertMention', reference: skill },
}))
skillChildren.push({
key: 'skills:manage',
label: t('chat.capabilities.manageSkills'),
icon: { kind: 'lucide', icon: Settings2 },
action: { type: 'settings', tab: 'skills' },
})
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.
const plugin = input.plugins.find(candidate => candidate.id === connector.pluginId)
return {
key: `connector:${connector.id}`,
label: connector.displayName || connector.id,
description: plugin?.description ?? connector.description,
icon: plugin ? referenceIcon(plugin) : { kind: 'lucide', icon: Plug },
action: plugin
? { type: 'insertMention', reference: plugin }
: { type: 'connectorsTab' },
}
})
connectorChildren.push({
key: 'connectors:manage',
label: t('chat.capabilities.manageConnectors'),
icon: { kind: 'lucide', icon: Settings2 },
action: { type: 'connectorsTab' },
})
const agentChildren: CapabilityMenuItem[] = input.agents
.filter(agent => agent.isActive)
.map(agent => ({
key: `agent:${agent.agentType}`,
label: agent.agentType,
description: agent.description,
icon: { kind: 'lucide', icon: Bot },
iconColor: agent.color,
action: { type: 'insertSlashText', command: `agent ${agent.agentType}` },
}))
agentChildren.push({
key: 'agents:manage',
label: t('chat.capabilities.manageAgents'),
icon: { kind: 'lucide', icon: Settings2 },
action: { type: 'settings', tab: 'agents' },
})
const teamChildren: CapabilityMenuItem[] = [{
key: 'teams:create',
label: t('chat.capabilities.teamCreate'),
description: t('chat.capabilities.teamCreateDescription'),
icon: { kind: 'lucide', icon: UserPlus },
action: { type: 'insertPromptSeed', text: input.teamCreatePrompt },
}]
for (const team of input.teams) {
teamChildren.push({
key: `team:${team.name}`,
label: team.name,
description: t('chat.capabilities.teamMembers', { count: team.memberCount }),
icon: { kind: 'lucide', icon: Users },
action: { type: 'openTeam', teamName: team.name },
})
const connectorChildren = buildConnectorChildren(input)
const skills: CapabilityMenuItem = {
key: 'skills',
label: t('chat.capabilities.skills'),
description: t('chat.capabilities.skillsDescription'),
icon: { kind: 'lucide', icon: Box },
count: input.skills.length,
children: buildSkillChildren(input),
}
const connectors: CapabilityMenuItem = {
key: 'connectors',
label: t('chat.capabilities.connectors'),
description: t('chat.capabilities.connectorsDescription'),
icon: { kind: 'lucide', icon: Plug },
count: connectorChildren.filter(item => item.status === 'ok').length,
children: connectorChildren,
}
const agentTeamItem: CapabilityMenuItem = input.agentTeam.enabled
? {
key: 'agent-team',
label: t('chat.capabilities.teams'),
description: t('chat.capabilities.teamsDescription'),
icon: { kind: 'lucide', icon: Users },
switch: { checked: input.agentTeam.armed, disabled: false },
action: { type: 'toggleAgentTeam' },
}
: {
// Turned off in Settings: the row leads there instead of arming a
// mode the session would not honour.
key: 'agent-team',
label: t('chat.capabilities.teams'),
description: t('chat.capabilities.teamsDisabled'),
icon: { kind: 'lucide', icon: Users },
action: { type: 'settings', tab: 'general' },
}
const computerUseItem: CapabilityMenuItem = input.computerUse?.supported
? {
@@ -214,6 +371,23 @@ export function buildCapabilitySections(input: CapabilityMenuInput): CapabilityM
action: { type: 'settings', tab: 'computerUse' },
}
const agentChildren: CapabilityMenuItem[] = input.agents
.filter(agent => agent.isActive)
.map(agent => ({
key: `agent:${agent.agentType}`,
label: agent.agentType,
description: agent.description,
icon: { kind: 'lucide', icon: Bot },
iconColor: agent.color,
action: { type: 'insertSlashText', command: `agent ${agent.agentType}` },
}))
agentChildren.push({
key: 'agents:manage',
label: t('chat.capabilities.manageAgents'),
icon: { kind: 'lucide', icon: Settings2 },
action: { type: 'settings', tab: 'agents' },
})
// Workflows are read from the same on-disk location the CLI loads at session
// start, so inserting `/name` is safe even before the session reports its
// command list — the CLI picks the file up when the session launches.
@@ -231,95 +405,71 @@ export function buildCapabilitySections(input: CapabilityMenuInput): CapabilityM
action: { type: 'saveWorkflowPanel' },
})
const sections: CapabilityMenuSection[] = [
// Plugins are packaging: their skills are listed under Skills and their MCP
// servers under Connectors. A plugin with neither stays reachable through
// search, so it can still be mentioned as a whole.
const listedPluginIds = new Set(connectorChildren.flatMap(item =>
item.action?.type === 'insertMention' ? [item.action.reference.id] : []))
const searchOnly = input.plugins
.filter(plugin => !listedPluginIds.has(plugin.id))
.map(plugin => mentionItem(plugin))
return [
{
id: 'add',
title: t('chat.capabilities.sectionAdd'),
items: [{
key: 'add-files',
label: t('chat.addFiles'),
icon: { kind: 'lucide', icon: Paperclip },
action: { type: 'attachment' },
}],
},
{
id: 'capabilities',
title: t('chat.capabilities.sectionCapabilities'),
id: 'use',
title: t('chat.capabilities.sectionUse'),
showTitle: true,
items: [
skills,
connectors,
{
key: 'skills',
label: t('chat.capabilities.skills'),
description: t('chat.capabilities.skillsDescription'),
icon: { kind: 'lucide', icon: Box },
count: input.skills.length,
children: skillChildren,
},
{
key: 'connectors',
label: t('chat.capabilities.connectors'),
description: t('chat.capabilities.connectorsDescription'),
icon: { kind: 'lucide', icon: Plug },
count: connectorStatusCount(input.connectors),
children: connectorChildren,
},
{
key: 'agents',
label: t('chat.capabilities.agents'),
description: t('chat.capabilities.agentsDescription'),
icon: { kind: 'lucide', icon: Bot },
count: agentChildren.length - 1,
children: agentChildren,
},
{
key: 'teams',
label: t('chat.capabilities.teams'),
description: t('chat.capabilities.teamsDescription'),
icon: { kind: 'lucide', icon: Users },
count: input.teams.length,
children: teamChildren,
},
computerUseItem,
{
key: 'workflows',
label: t('chat.capabilities.workflows'),
description: t('chat.capabilities.workflowsDescription'),
icon: { kind: 'lucide', icon: Workflow },
count: input.workflows.length,
children: workflowChildren,
key: 'add-files',
label: t('chat.addFiles'),
icon: { kind: 'lucide', icon: Paperclip },
action: { type: 'attachment' },
},
],
},
{
id: 'commands',
title: t('chat.capabilities.sectionCommands'),
items: [{
key: 'slash-commands',
label: t('chat.slashCommands'),
icon: { kind: 'slash' },
action: { type: 'slashTrigger' },
}],
id: 'run',
title: t('chat.capabilities.sectionRun'),
showTitle: true,
items: [agentTeamItem, computerUseItem],
},
]
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, {
id: 'more',
title: t('chat.capabilities.moreTools'),
showTitle: false,
items: [{
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],
children: [
{
key: 'agents',
label: t('chat.capabilities.agents'),
description: t('chat.capabilities.agentsDescription'),
icon: { kind: 'lucide', icon: Bot },
count: agentChildren.length - 1,
children: agentChildren,
},
{
key: 'workflows',
label: t('chat.capabilities.workflows'),
description: t('chat.capabilities.workflowsDescription'),
icon: { kind: 'lucide', icon: Workflow },
count: input.workflows.length,
children: workflowChildren,
},
{
key: 'slash-commands',
label: t('chat.slashCommands'),
icon: { kind: 'slash' },
action: { type: 'slashTrigger' },
},
],
}],
searchOnly,
},
]
}
@@ -0,0 +1,153 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NormalizedSkill } from '@/types/market'
import { useMarketStore } from '@/stores/marketStore'
import { useSettingsStore } from '@/stores/settingsStore'
import { MARKET_TAB_ID, useTabStore } from '@/stores/tabStore'
import { useUIStore } from '@/stores/uiStore'
import { pickPopularSkills, resetPopularSkillsCache, useCapabilityMenu, type CapabilityMenuComposerHandlers } from './useCapabilityMenu'
const mocks = vi.hoisted(() => ({
marketList: vi.fn(),
marketInstall: vi.fn(),
}))
vi.mock('@/api/agents', () => ({ agentsApi: { list: vi.fn().mockResolvedValue({ activeAgents: [], allAgents: [] }) } }))
vi.mock('@/api/connectors', () => ({ connectorsApi: { list: vi.fn().mockResolvedValue({ items: [] }) } }))
vi.mock('@/api/workflows', () => ({ workflowsApi: { list: vi.fn().mockResolvedValue({ workflows: [] }) } }))
vi.mock('@/api/computerUse', () => ({ computerUseApi: { getStatus: vi.fn().mockRejectedValue(new Error('off')), getAuthorizedApps: vi.fn() } }))
vi.mock('@/api/market', () => ({ marketApi: { list: mocks.marketList, install: mocks.marketInstall } }))
vi.mock('@/stores/skillStore', () => ({ useSkillStore: { getState: () => ({ fetchSkills: vi.fn().mockResolvedValue(undefined) }) } }))
function marketSkill(id: string, overrides: Partial<NormalizedSkill> = {}): NormalizedSkill {
return {
id,
source: 'clawhub',
slug: id,
name: id,
summary: `${id} summary`,
author: { handle: 'someone' },
stats: { downloads: 1 },
tags: [],
securityStatus: 'verified',
installState: 'installable',
...overrides,
}
}
function handlers(): CapabilityMenuComposerHandlers {
return {
onInsertMention: vi.fn(),
onInsertSlashText: vi.fn(),
onAttachment: vi.fn(),
onSlashTrigger: vi.fn(),
onSaveWorkflow: vi.fn(),
onReferencesChanged: vi.fn(),
onClose: vi.fn(),
}
}
function renderMenu(composer = handlers()) {
const view = renderHook(() => useCapabilityMenu({ open: true, cwd: '/repo', references: [], handlers: composer }))
return { ...view, composer }
}
function skillChildKeys(result: { current: ReturnType<typeof useCapabilityMenu> }) {
return result.current.sections[0]!.items.find(item => item.key === 'skills')!.children!.map(item => item.key)
}
beforeEach(() => {
resetPopularSkillsCache()
mocks.marketList.mockReset().mockResolvedValue({ items: [marketSkill('excalidraw'), marketSkill('pptx')], nextCursor: null, sources: {} })
mocks.marketInstall.mockReset()
useMarketStore.setState({ installingIds: new Set(), installError: null })
useSettingsStore.setState({ agentTeamsEnabled: true, locale: 'en' })
useUIStore.setState({ pendingMarketTarget: null, toasts: [] })
useTabStore.setState({ tabs: [], activeTabId: null })
})
afterEach(() => {
vi.clearAllMocks()
})
describe('pickPopularSkills', () => {
it('keeps installable, cleanly scanned skills that need no key, editor picks first', () => {
const picked = pickPopularSkills([
marketSkill('plain'),
marketSkill('installed', { installState: 'installed' }),
marketSkill('flagged', { securityStatus: 'flagged' }),
marketSkill('unknown', { securityStatus: 'unknown' }),
marketSkill('benign', { securityStatus: 'benign' }),
marketSkill('needs-key', { requiresApiKey: true }),
marketSkill('pick', { featured: true }),
])
expect(picked.map(skill => skill.id)).toEqual(['pick', 'plain', 'benign'])
})
})
describe('useCapabilityMenu', () => {
it('opens the market at a connector and closes the menu', () => {
const { result, composer } = renderMenu()
act(() => result.current.onAction({ type: 'market', section: 'plugins', connectorId: 'github' }))
expect(useUIStore.getState().pendingMarketTarget).toEqual({ section: 'plugins', connectorId: 'github' })
expect(useTabStore.getState().activeTabId).toBe(MARKET_TAB_ID)
expect(composer.onClose).toHaveBeenCalled()
})
it('reads the curated catalog once for every composer', async () => {
const first = renderMenu()
await waitFor(() => expect(skillChildKeys(first.result)).toContain('market-skill:excalidraw'))
const second = renderMenu()
await waitFor(() => expect(skillChildKeys(second.result)).toContain('market-skill:excalidraw'))
expect(mocks.marketList).toHaveBeenCalledTimes(1)
expect(mocks.marketList).toHaveBeenCalledWith({ scope: 'catalog', installed: 'installable', limit: 30 })
})
it('installs a suggested skill in place and reloads the mention list', async () => {
mocks.marketInstall.mockResolvedValue({ ok: true, installedPath: '/skills/excalidraw', skill: marketSkill('excalidraw', { installState: 'installed' }) })
const { result, composer } = renderMenu()
await waitFor(() => expect(skillChildKeys(result)).toContain('market-skill:excalidraw'))
await act(async () => {
result.current.onAction({ type: 'installSkill', id: 'excalidraw', name: 'excalidraw' })
})
await waitFor(() => expect(composer.onReferencesChanged).toHaveBeenCalledTimes(1))
expect(mocks.marketInstall).toHaveBeenCalledWith('excalidraw', undefined)
expect(composer.onClose).not.toHaveBeenCalled()
expect(skillChildKeys(result)).not.toContain('market-skill:excalidraw')
expect(skillChildKeys(result)).toContain('market-skill:pptx')
expect(useUIStore.getState().toasts.at(-1)).toMatchObject({ type: 'success' })
// Another composer opened later does not offer the installed skill again.
const later = renderMenu()
await waitFor(() => expect(skillChildKeys(later.result)).toContain('market-skill:pptx'))
expect(skillChildKeys(later.result)).not.toContain('market-skill:excalidraw')
})
it('keeps the suggestion and reports why when an install fails', async () => {
mocks.marketInstall.mockRejectedValue(new Error('network down'))
const { result, composer } = renderMenu()
await waitFor(() => expect(skillChildKeys(result)).toContain('market-skill:excalidraw'))
await act(async () => {
result.current.onAction({ type: 'installSkill', id: 'excalidraw', name: 'excalidraw' })
})
await waitFor(() => expect(useUIStore.getState().toasts.at(-1)).toMatchObject({ type: 'error' }))
expect(composer.onReferencesChanged).not.toHaveBeenCalled()
expect(skillChildKeys(result)).toContain('market-skill:excalidraw')
})
it('arms Agent Team until disarmed, and never while Settings turns it off', () => {
const { result, rerender } = renderMenu()
act(() => result.current.onAction({ type: 'toggleAgentTeam' }))
expect(result.current.agentTeamArmed).toBe(true)
act(() => result.current.disarmAgentTeam())
expect(result.current.agentTeamArmed).toBe(false)
act(() => result.current.onAction({ type: 'toggleAgentTeam' }))
act(() => useSettingsStore.setState({ agentTeamsEnabled: false }))
rerender()
expect(result.current.agentTeamArmed).toBe(false)
expect(result.current.sections[1]!.items[0]!.action).toEqual({ type: 'settings', tab: 'general' })
})
})
+160 -38
View File
@@ -1,15 +1,20 @@
import { useEffect, useMemo, useState } from 'react'
import { agentsApi, type AgentDefinition } from '@/api/agents'
import { computerUseApi } from '@/api/computerUse'
import { computerUseApi, type ComputerUseStatus } from '@/api/computerUse'
import { connectorsApi } from '@/api/connectors'
import { teamsApi } from '@/api/teams'
import { marketApi } from '@/api/market'
import { workflowsApi } from '@/api/workflows'
import { useTranslation } from '@/i18n'
import { COMPUTER_USE_ENABLE_REQUEST, needsComputerUsePermissionCard } from '@/lib/computerUseEnable'
import { readRecentSkills } from '@/lib/recentSkills'
import { useMarketStore } from '@/stores/marketStore'
import { useSettingsStore } from '@/stores/settingsStore'
import { useSkillStore } from '@/stores/skillStore'
import { MARKET_TAB_ID, SETTINGS_TAB_ID, useTabStore } from '@/stores/tabStore'
import { useUIStore } from '@/stores/uiStore'
import type { ConnectorDto } from '@/types/connector'
import type { ComposerReferenceCandidate } from '@/types/composerReference'
import type { TeamSummary } from '@/types/team'
import type { NormalizedSkill } from '@/types/market'
import type { WorkflowDefinition } from '@/types/workflow'
import {
buildCapabilitySections,
@@ -27,6 +32,9 @@ import {
* 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.
*
* It also owns the composer's Agent Team switch (armed for the next message)
* and the Computer Use consent dialog the menu's switch opens.
*/
export type CapabilityMenuComposerHandlers = {
@@ -34,46 +42,99 @@ export type CapabilityMenuComposerHandlers = {
onInsertMention(reference: ComposerReferenceCandidate): void
/** Insert `/command ` as plain text (agent runs, workflow runs). */
onInsertSlashText(command: string): void
/** Fill the composer with a prompt template; the user edits before sending. */
onInsertPromptSeed(text: string): void
onAttachment(): void
/** Insert the bare `/` trigger and open the slash menu. */
onSlashTrigger(): void
/** Open the save-workflow panel above the composer. */
onSaveWorkflow(): void
/** A skill was installed from the menu: reload the mention candidates. */
onReferencesChanged(): void
onClose(): void
}
export type ComputerUseConsent = {
open: boolean
loading: boolean
platform: 'darwin' | 'win32'
onClose(): void
onConfirm(): Promise<void>
}
type CapabilityMenuData = {
agents: AgentDefinition[]
connectors: ConnectorDto[]
teams: TeamSummary[]
workflows: WorkflowDefinition[]
computerUse: { supported: boolean, enabled: boolean } | null
computerUse: { supported: boolean, enabled: boolean, status: ComputerUseStatus | null } | null
}
const EMPTY_DATA: CapabilityMenuData = {
agents: [],
connectors: [],
teams: [],
workflows: [],
computerUse: null,
}
const SAFE_SECURITY = new Set(['verified', 'benign'])
/**
* Curated skills to suggest: installable, cleanly scanned, usable right after
* the one-click install (no API key to set up), editor picks first.
*/
export function pickPopularSkills(items: NormalizedSkill[]): NormalizedSkill[] {
return items
.filter(skill => skill.installState === 'installable' && SAFE_SECURITY.has(skill.securityStatus) && !skill.requiresApiKey)
.map((skill, index) => ({ skill, index }))
.sort((a, b) => Number(!!b.skill.featured) - Number(!!a.skill.featured) || a.index - b.index)
.map(({ skill }) => skill)
}
// The curated catalog ships with the app and barely changes during a run, so
// one read serves every composer; an install drops its entry locally.
let popularSkillsRequest: Promise<NormalizedSkill[]> | null = null
function loadPopularSkills(): Promise<NormalizedSkill[]> {
popularSkillsRequest ??= marketApi.list({ scope: 'catalog', installed: 'installable', limit: 30 })
.then(response => pickPopularSkills(response.items))
.catch((error: unknown) => {
popularSkillsRequest = null
throw error
})
return popularSkillsRequest
}
/** Tests only: forget the shared catalog read. */
export function resetPopularSkillsCache(): void {
popularSkillsRequest = null
}
export function useCapabilityMenu(options: {
open: boolean
cwd: string
references: ComposerReferenceCandidate[]
handlers: CapabilityMenuComposerHandlers
}): { sections: CapabilityMenuSection[], onAction: (action: CapabilityAction) => void } {
}): {
sections: CapabilityMenuSection[]
onAction: (action: CapabilityAction) => void
/** Agent Team is armed for the next message. */
agentTeamArmed: boolean
disarmAgentTeam(): void
computerUseConsent: ComputerUseConsent
} {
const { open, cwd, references, handlers } = options
const t = useTranslation()
const [data, setData] = useState<CapabilityMenuData>(EMPTY_DATA)
const [popularSkills, setPopularSkills] = useState<NormalizedSkill[]>([])
const [recentSkillIds, setRecentSkillIds] = useState<string[]>([])
const [armed, setArmed] = useState(false)
const [consent, setConsent] = useState<{ open: boolean, loading: boolean }>({ open: false, loading: false })
const agentTeamsEnabled = useSettingsStore(state => state.agentTeamsEnabled)
const installingSkillIds = useMarketStore(state => state.installingIds)
const agentTeamArmed = armed && agentTeamsEnabled
useEffect(() => {
if (!open) return
let active = true
const cwdArg = cwd || undefined
setRecentSkillIds(readRecentSkills())
// Each source is independent: a failing one (e.g. computer-use off macOS)
// must not blank the others.
@@ -85,8 +146,10 @@ export function useCapabilityMenu(options: {
load(agentsApi.list(cwdArg), value => ({ agents: value.activeAgents }))
load(connectorsApi.list(), value => ({ connectors: value.items }))
load(teamsApi.list(), value => ({ teams: value.teams }))
load(workflowsApi.list(cwdArg), value => ({ workflows: value.workflows }))
void loadPopularSkills().then(items => {
if (active) setPopularSkills(items)
}).catch(() => {})
void (async () => {
try {
const status = await computerUseApi.getStatus()
@@ -94,12 +157,12 @@ export function useCapabilityMenu(options: {
if (active) {
setData(previous => ({
...previous,
computerUse: { supported: status.supported, enabled: config.enabled },
computerUse: { supported: status.supported, enabled: config.enabled, status },
}))
}
} catch {
if (active) {
setData(previous => ({ ...previous, computerUse: { supported: false, enabled: false } }))
setData(previous => ({ ...previous, computerUse: { supported: false, enabled: false, status: null } }))
}
}
})()
@@ -110,14 +173,43 @@ export function useCapabilityMenu(options: {
const sections = useMemo(() => buildCapabilitySections({
skills: references.filter(reference => reference.kind === 'skill'),
plugins: references.filter(reference => reference.kind === 'plugin'),
recentSkillIds,
popularSkills,
installingSkillIds,
agents: data.agents,
connectors: data.connectors,
teams: data.teams,
workflows: data.workflows,
agentTeam: { enabled: agentTeamsEnabled, armed: agentTeamArmed },
computerUse: data.computerUse,
teamCreatePrompt: t('chat.capabilities.teamCreatePrompt'),
t,
}), [references, data, t])
}), [references, recentSkillIds, popularSkills, installingSkillIds, data, agentTeamsEnabled, agentTeamArmed, t])
const setComputerUseEnabled = (enabled: boolean) => setData(previous => previous.computerUse
? { ...previous, computerUse: { ...previous.computerUse, enabled } }
: previous)
const installSkill = async (id: string, name: string) => {
const market = useMarketStore.getState()
const ok = await market.install(id)
if (ok) {
useUIStore.getState().addToast({ type: 'success', message: t('market.installSuccess', { name }) })
popularSkillsRequest = popularSkillsRequest?.then(items => items.filter(skill => skill.id !== id)) ?? null
setPopularSkills(previous => previous.filter(skill => skill.id !== id))
handlers.onReferencesChanged()
// Keep the Settings → Skills browser in sync.
void useSkillStore.getState().fetchSkills()
return
}
const error = useMarketStore.getState().installError
if (error) {
useUIStore.getState().addToast({
type: 'error',
message: error.kind === 'generic'
? t('market.installError.generic', { message: error.message })
: t(`market.installError.${error.kind}`),
})
}
}
const onAction = (action: CapabilityAction) => {
switch (action.type) {
@@ -135,10 +227,6 @@ export function useCapabilityMenu(options: {
handlers.onInsertSlashText(action.command)
handlers.onClose()
return
case 'insertPromptSeed':
handlers.onInsertPromptSeed(action.text)
handlers.onClose()
return
case 'saveWorkflowPanel':
handlers.onSaveWorkflow()
handlers.onClose()
@@ -148,31 +236,32 @@ export function useCapabilityMenu(options: {
useTabStore.getState().openTab(SETTINGS_TAB_ID, 'Settings', 'settings')
handlers.onClose()
return
case 'connectorsTab':
case 'market':
useUIStore.getState().setPendingMarketTarget({ section: action.section, connectorId: action.connectorId })
useTabStore.getState().openTab(MARKET_TAB_ID, t('sidebar.extensions'), 'market')
handlers.onClose()
return
case 'openTeam':
// TeamSummary has no lead session id; the detail lookup is one cheap
// local read and the workbench cannot open without it.
void teamsApi.get(action.teamName).then(detail => {
if (detail.leadSessionId) {
useTabStore.getState().openTeamWorkbenchTab(detail.leadSessionId, action.teamName)
handlers.onClose()
}
}).catch(() => {})
case 'installSkill':
// The menu stays open: the row turns into "Installing…" and then
// leaves the suggestions once the skill shows up under All skills.
void installSkill(action.id, action.name)
return
case 'toggleAgentTeam':
setArmed(previous => !previous)
return
case 'toggleComputerUse': {
// Turning it on goes through the same consent dialog as Settings —
// that confirmation is what authorizes every app.
if (!data.computerUse?.enabled) {
setConsent({ open: true, loading: false })
handlers.onClose()
return
}
// Global (not per-session) switch: optimistic flip, roll back on
// failure. The menu stays open so the user sees the result.
const next = !data.computerUse?.enabled
setData(previous => previous.computerUse
? { ...previous, computerUse: { ...previous.computerUse, enabled: next } }
: previous)
void computerUseApi.setAuthorizedApps({ enabled: next }).catch(error => {
setData(previous => previous.computerUse
? { ...previous, computerUse: { ...previous.computerUse, enabled: !next } }
: previous)
setComputerUseEnabled(false)
void computerUseApi.setAuthorizedApps({ enabled: false }).catch(error => {
setComputerUseEnabled(true)
useUIStore.getState().addToast({
type: 'error',
message: error instanceof Error ? error.message : String(error),
@@ -183,5 +272,38 @@ export function useCapabilityMenu(options: {
}
}
return { sections, onAction }
const computerUseConsent: ComputerUseConsent = {
open: consent.open,
loading: consent.loading,
platform: data.computerUse?.status?.platform === 'win32' ? 'win32' : 'darwin',
onClose: () => setConsent({ open: false, loading: false }),
onConfirm: async () => {
setConsent({ open: true, loading: true })
try {
await computerUseApi.setAuthorizedApps(COMPUTER_USE_ENABLE_REQUEST)
} catch {
setConsent({ open: true, loading: false })
useUIStore.getState().addToast({ type: 'error', message: t('settings.computerUse.configSaveFailed') })
return
}
setComputerUseEnabled(true)
setConsent({ open: false, loading: false })
if (needsComputerUsePermissionCard(data.computerUse?.status)) {
try {
const result = await computerUseApi.openPermissionCard()
if (!result.ok) throw new Error(result.reason ?? 'permission card failed')
} catch {
useUIStore.getState().addToast({ type: 'warning', message: t('settings.computerUse.openCardFailed') })
}
}
},
}
return {
sections,
onAction,
agentTeamArmed,
disarmAgentTeam: () => setArmed(false),
computerUseConsent,
}
}
+22 -12
View File
@@ -577,7 +577,6 @@ 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 sessions, skills, plugins and project files",
'chat.slashFrequent': 'Frequent commands',
@@ -2637,34 +2636,45 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'chat.placeholderQuestionPending': 'Answer the question above first — Claude is waiting on it.',
'chat.addFiles': 'Add files or photos',
'chat.composerTools': 'Open composer tools',
'chat.capabilities.sectionAdd': 'Add',
'chat.capabilities.sectionCapabilities': 'Capabilities',
'chat.capabilities.sectionCommands': 'Commands',
'chat.capabilities.searchPlaceholder': 'Search skills, plugins, files…',
'chat.capabilities.empty': 'No matches',
'chat.capabilities.searchPlaceholder': 'Search skills, connectors, files…',
'chat.capabilities.back': 'Back',
'chat.capabilities.skills': 'Skills',
'chat.capabilities.skillsDescription': 'Add a skill to this chat',
'chat.capabilities.manageSkills': 'Manage skills',
'chat.capabilities.connectors': 'Connectors',
'chat.capabilities.connectorsDescription': 'Use a connected service',
'chat.capabilities.manageConnectors': 'Manage connectors',
'chat.capabilities.agents': 'Agents',
'chat.capabilities.agentsDescription': 'Run a task with a chosen agent',
'chat.capabilities.manageAgents': 'Manage agents',
'chat.capabilities.teams': 'Agent Teams',
'chat.capabilities.teamsDescription': 'Coordinate multiple agents on one goal',
'chat.capabilities.teamCreate': 'Create a team via chat',
'chat.capabilities.teamCreateDescription': 'Describe the goal, agents do the rest',
'chat.capabilities.teamCreatePrompt': 'Create an agent team to work on the following goal. Split the work across teammates and coordinate them: ',
'chat.capabilities.teamMembers': '{count} members',
'chat.capabilities.computerUse': 'Computer use',
'chat.capabilities.computerUseDescription': 'Let Claude operate apps on this computer',
'chat.capabilities.computerUseUnsupported': 'Not available on this platform — open settings',
'chat.capabilities.computerUseToggle': 'Toggle computer use',
'chat.capabilities.workflows': 'Workflows',
'chat.capabilities.workflowsDescription': 'Run a saved workflow',
'chat.capabilities.saveWorkflow': 'Save this session as a workflow',
'chat.capabilities.sectionUse': 'For this task',
'chat.capabilities.sectionRun': 'How it runs',
'chat.capabilities.skillsRecent': 'Recently used',
'chat.capabilities.skillsAll': 'All skills',
'chat.capabilities.skillsPopular': 'Popular in the market',
'chat.capabilities.skillInstall': 'Install',
'chat.capabilities.skillInstalling': 'Installing…',
'chat.capabilities.skillsBrowse': 'Browse the skill market',
'chat.capabilities.connectorsConnected': 'Connected',
'chat.capabilities.connectorsNeedsAction': 'Needs attention',
'chat.capabilities.connectorsSuggested': 'Suggested',
'chat.capabilities.connectorsBrowse': 'Browse all connectors',
'chat.capabilities.connectorsBrowseCount': 'Browse all {count} connectors',
'chat.capabilities.connectorNeedsAuth': 'Sign-in required',
'chat.capabilities.connectorFailed': 'Connection failed',
'chat.capabilities.connectorAuthorize': 'Authorize',
'chat.capabilities.connectorView': 'View',
'chat.capabilities.connectorConnect': 'Connect',
'chat.capabilities.teamsDisabled': 'Turned off in Settings — open Settings',
'chat.capabilities.teamRemove': 'Remove Agent Teams',
'chat.capabilities.teamPlaceholder': 'Describe the goal; agents will split the work and team up…',
'chat.dropFilesTitle': 'Drop files here',
'chat.dropFilesHint': 'Attached as file paths.',
'chat.workspaceReferencesOnly': 'Added {count} workspace references',
+22 -12
View File
@@ -578,7 +578,6 @@ export const jp: Record<TranslationKey, string> = {
'attachments.selectionNoteTitle': '修正内容',
'attachments.selectionBatch': '{count} 件のページ修正',
'attachments.selectionItem': '選択要素 {number}:{name}',
'chat.capabilities.managePlugins': 'プラグインを管理',
'chat.capabilities.moreTools': 'その他のツール',
'chat.referenceSearchHint': "入力してセッション、スキル、プラグイン、プロジェクトファイルを検索",
'chat.slashFrequent': 'よく使うコマンド',
@@ -2638,34 +2637,45 @@ export const jp: Record<TranslationKey, string> = {
'chat.placeholderQuestionPending': '先に上の質問に回答してください。Claude が回答を待っています。',
'chat.addFiles': 'ファイルまたは写真を追加',
'chat.composerTools': '入力ツールを開く',
'chat.capabilities.sectionAdd': '追加',
'chat.capabilities.sectionCapabilities': '機能',
'chat.capabilities.sectionCommands': 'コマンド',
'chat.capabilities.searchPlaceholder': 'スキル・プラグイン・ファイルを検索…',
'chat.capabilities.empty': '一致する項目がありません',
'chat.capabilities.searchPlaceholder': 'スキル・コネクタ・ファイルを検索…',
'chat.capabilities.back': '戻る',
'chat.capabilities.skills': 'スキル',
'chat.capabilities.skillsDescription': 'スキルをこのチャットに追加',
'chat.capabilities.manageSkills': 'スキルを管理',
'chat.capabilities.connectors': 'コネクタ',
'chat.capabilities.connectorsDescription': '接続済みのサービスを使う',
'chat.capabilities.manageConnectors': 'コネクタを管理',
'chat.capabilities.agents': 'Agent',
'chat.capabilities.agentsDescription': '指定した Agent でタスクを実行',
'chat.capabilities.manageAgents': 'Agent を管理',
'chat.capabilities.teams': 'Agent Teams',
'chat.capabilities.teamsDescription': '複数の Agent でひとつの目標に協働',
'chat.capabilities.teamCreate': 'チャットでチームを作成',
'chat.capabilities.teamCreateDescription': '目標を伝えれば Agent が分担',
'chat.capabilities.teamCreatePrompt': '次の目標に取り組む Agent チームを作成し、分担と連携をしてください:',
'chat.capabilities.teamMembers': 'メンバー {count} 人',
'chat.capabilities.computerUse': 'コンピューター操作',
'chat.capabilities.computerUseDescription': 'このコンピュータ上のアプリを操作',
'chat.capabilities.computerUseUnsupported': 'このプラットフォームでは利用不可 — 設定を開く',
'chat.capabilities.computerUseToggle': 'コンピューター操作を切り替え',
'chat.capabilities.workflows': 'ワークフロー',
'chat.capabilities.workflowsDescription': '保存済みのワークフローを実行',
'chat.capabilities.saveWorkflow': 'このセッションをワークフローとして保存',
'chat.capabilities.sectionUse': 'このタスクで使う',
'chat.capabilities.sectionRun': '実行方法',
'chat.capabilities.skillsRecent': '最近使ったもの',
'chat.capabilities.skillsAll': 'すべてのスキル',
'chat.capabilities.skillsPopular': 'マーケットの人気',
'chat.capabilities.skillInstall': 'インストール',
'chat.capabilities.skillInstalling': 'インストール中…',
'chat.capabilities.skillsBrowse': 'スキルマーケットを見る',
'chat.capabilities.connectorsConnected': '接続済み',
'chat.capabilities.connectorsNeedsAction': '対応が必要',
'chat.capabilities.connectorsSuggested': 'おすすめ',
'chat.capabilities.connectorsBrowse': 'すべてのコネクタを見る',
'chat.capabilities.connectorsBrowseCount': '{count} 件のコネクタをすべて見る',
'chat.capabilities.connectorNeedsAuth': 'サインインが必要',
'chat.capabilities.connectorFailed': '接続エラー',
'chat.capabilities.connectorAuthorize': '認証',
'chat.capabilities.connectorView': '表示',
'chat.capabilities.connectorConnect': '接続',
'chat.capabilities.teamsDisabled': '設定でオフ — 設定を開く',
'chat.capabilities.teamRemove': 'Agent Teams を解除',
'chat.capabilities.teamPlaceholder': '目標を書くと、Agent が作業を分担してチームで進めます…',
'chat.dropFilesTitle': 'ここにファイルをドロップ',
'chat.dropFilesHint': 'ファイルパスとして添付されます。',
'chat.workspaceReferencesOnly': 'ワークスペース参照を {count} 件追加しました',
+22 -12
View File
@@ -580,7 +580,6 @@ export const kr: Record<TranslationKey, string> = {
'attachments.selectionNoteTitle': '수정 내용',
'attachments.selectionBatch': '페이지 수정 {count}개',
'attachments.selectionItem': '선택한 요소 {number}: {name}',
'chat.capabilities.managePlugins': '플러그인 관리',
'chat.capabilities.moreTools': '더 많은 도구',
'chat.referenceSearchHint': "입력하여 세션, 스킬, 플러그인 및 프로젝트 파일 검색",
'chat.slashFrequent': '자주 쓰는 명령',
@@ -2640,34 +2639,45 @@ export const kr: Record<TranslationKey, string> = {
'chat.placeholderQuestionPending': '위 질문에 먼저 답해 주세요. Claude가 기다리고 있습니다.',
'chat.addFiles': '파일 또는 사진 추가',
'chat.composerTools': '입력 도구 열기',
'chat.capabilities.sectionAdd': '추가',
'chat.capabilities.sectionCapabilities': '기능',
'chat.capabilities.sectionCommands': '명령',
'chat.capabilities.searchPlaceholder': '스킬, 플러그인, 파일 검색…',
'chat.capabilities.empty': '일치하는 항목 없음',
'chat.capabilities.searchPlaceholder': '스킬, 커넥터, 파일 검색…',
'chat.capabilities.back': '뒤로',
'chat.capabilities.skills': '스킬',
'chat.capabilities.skillsDescription': '이 채팅에 스킬 추가',
'chat.capabilities.manageSkills': '스킬 관리',
'chat.capabilities.connectors': '커넥터',
'chat.capabilities.connectorsDescription': '연결된 서비스 사용',
'chat.capabilities.manageConnectors': '커넥터 관리',
'chat.capabilities.agents': 'Agent',
'chat.capabilities.agentsDescription': '지정한 Agent로 작업 실행',
'chat.capabilities.manageAgents': 'Agent 관리',
'chat.capabilities.teams': 'Agent Teams',
'chat.capabilities.teamsDescription': '여러 Agent가 하나의 목표에 협업',
'chat.capabilities.teamCreate': '채팅으로 팀 만들기',
'chat.capabilities.teamCreateDescription': '목표를 말하면 Agent가 분업',
'chat.capabilities.teamCreatePrompt': '다음 목표를 수행할 Agent 팀을 만들고, 역할을 나누어 협업해 주세요: ',
'chat.capabilities.teamMembers': '멤버 {count}명',
'chat.capabilities.computerUse': '컴퓨터 조작',
'chat.capabilities.computerUseDescription': '이 컴퓨터의 앱을 Claude가 조작',
'chat.capabilities.computerUseUnsupported': '이 플랫폼에서는 사용 불가 — 설정 열기',
'chat.capabilities.computerUseToggle': '컴퓨터 조작 전환',
'chat.capabilities.workflows': '워크플로',
'chat.capabilities.workflowsDescription': '저장된 워크플로 실행',
'chat.capabilities.saveWorkflow': '이 세션을 워크플로로 저장',
'chat.capabilities.sectionUse': '이번 작업에 사용',
'chat.capabilities.sectionRun': '실행 방식',
'chat.capabilities.skillsRecent': '최근 사용',
'chat.capabilities.skillsAll': '모든 스킬',
'chat.capabilities.skillsPopular': '마켓 인기',
'chat.capabilities.skillInstall': '설치',
'chat.capabilities.skillInstalling': '설치 중…',
'chat.capabilities.skillsBrowse': '스킬 마켓 둘러보기',
'chat.capabilities.connectorsConnected': '연결됨',
'chat.capabilities.connectorsNeedsAction': '확인 필요',
'chat.capabilities.connectorsSuggested': '추천',
'chat.capabilities.connectorsBrowse': '모든 커넥터 보기',
'chat.capabilities.connectorsBrowseCount': '커넥터 {count}개 모두 보기',
'chat.capabilities.connectorNeedsAuth': '로그인 필요',
'chat.capabilities.connectorFailed': '연결 오류',
'chat.capabilities.connectorAuthorize': '인증',
'chat.capabilities.connectorView': '보기',
'chat.capabilities.connectorConnect': '연결',
'chat.capabilities.teamsDisabled': '설정에서 꺼짐 — 설정 열기',
'chat.capabilities.teamRemove': 'Agent Teams 해제',
'chat.capabilities.teamPlaceholder': '목표를 적으면 Agent가 작업을 나눠 팀으로 진행합니다…',
'chat.dropFilesTitle': '여기에 파일을 드롭하세요',
'chat.dropFilesHint': '파일 경로로 첨부됩니다.',
'chat.workspaceReferencesOnly': '작업 공간 참조 {count}개를 추가했습니다',
+22 -12
View File
@@ -578,7 +578,6 @@ export const zh: Record<TranslationKey, string> = {
'attachments.selectionNoteTitle': '修改內容',
'attachments.selectionBatch': '{count} 個頁面修改',
'attachments.selectionItem': '所選元素 {number}:{name}',
'chat.capabilities.managePlugins': '管理外掛',
'chat.capabilities.moreTools': '更多工具',
'chat.referenceSearchHint': "輸入以尋找對話、技能、外掛程式及專案檔案",
'chat.slashFrequent': '常用指令',
@@ -2637,34 +2636,45 @@ export const zh: Record<TranslationKey, string> = {
'chat.placeholderQuestionPending': '請先回答上面的問題,Claude 正在等你的回覆。',
'chat.addFiles': '新增檔案或圖片',
'chat.composerTools': '開啟輸入框工具',
'chat.capabilities.sectionAdd': '新增',
'chat.capabilities.sectionCapabilities': '能力',
'chat.capabilities.sectionCommands': '指令',
'chat.capabilities.searchPlaceholder': '搜尋技能、外掛、檔案…',
'chat.capabilities.empty': '沒有相符項目',
'chat.capabilities.searchPlaceholder': '搜尋技能、連接器、檔案…',
'chat.capabilities.back': '返回',
'chat.capabilities.skills': '技能',
'chat.capabilities.skillsDescription': '將技能加入目前對話',
'chat.capabilities.manageSkills': '管理技能',
'chat.capabilities.connectors': '連接器',
'chat.capabilities.connectorsDescription': '使用已連接的服務',
'chat.capabilities.manageConnectors': '管理連接器',
'chat.capabilities.agents': 'Agent',
'chat.capabilities.agentsDescription': '用指定的 Agent 執行任務',
'chat.capabilities.manageAgents': '管理 Agent',
'chat.capabilities.teams': 'Agent Teams',
'chat.capabilities.teamsDescription': '讓多個 Agent 協作完成一個目標',
'chat.capabilities.teamCreate': '透過對話建立團隊',
'chat.capabilities.teamCreateDescription': '描述目標,Agent 自行分工協作',
'chat.capabilities.teamCreatePrompt': '幫我建立一個 Agent 團隊來完成下面的目標,請合理分工並協調各個成員:',
'chat.capabilities.teamMembers': '{count} 位成員',
'chat.capabilities.computerUse': '操作電腦',
'chat.capabilities.computerUseDescription': '讓 Claude 操作本機應用',
'chat.capabilities.computerUseUnsupported': '目前平台不可用 — 開啟設定',
'chat.capabilities.computerUseToggle': '切換操作電腦功能',
'chat.capabilities.workflows': '工作流程',
'chat.capabilities.workflowsDescription': '執行已儲存的工作流程',
'chat.capabilities.saveWorkflow': '將目前對話儲存為工作流程',
'chat.capabilities.sectionUse': '給這次任務用',
'chat.capabilities.sectionRun': '執行方式',
'chat.capabilities.skillsRecent': '最近用過',
'chat.capabilities.skillsAll': '全部技能',
'chat.capabilities.skillsPopular': '市集熱門',
'chat.capabilities.skillInstall': '安裝',
'chat.capabilities.skillInstalling': '安裝中…',
'chat.capabilities.skillsBrowse': '瀏覽技能市集',
'chat.capabilities.connectorsConnected': '已連接',
'chat.capabilities.connectorsNeedsAction': '需要處理',
'chat.capabilities.connectorsSuggested': '推薦',
'chat.capabilities.connectorsBrowse': '瀏覽全部連接器',
'chat.capabilities.connectorsBrowseCount': '瀏覽全部 {count} 個連接器',
'chat.capabilities.connectorNeedsAuth': '需要登入授權',
'chat.capabilities.connectorFailed': '連接出錯',
'chat.capabilities.connectorAuthorize': '授權',
'chat.capabilities.connectorView': '查看',
'chat.capabilities.connectorConnect': '連接',
'chat.capabilities.teamsDisabled': '已在設定中關閉 — 開啟設定',
'chat.capabilities.teamRemove': '取消 Agent Teams',
'chat.capabilities.teamPlaceholder': '描述目標,Agent 會拆分任務、組隊協作…',
'chat.dropFilesTitle': '鬆手新增檔案',
'chat.dropFilesHint': '會以檔案路徑附加到當前輸入。',
'chat.workspaceReferencesOnly': '已新增 {count} 個工作區引用',
+22 -12
View File
@@ -577,7 +577,6 @@ export const zh: Record<TranslationKey, string> = {
'attachments.selectionNoteTitle': '修改内容',
'attachments.selectionBatch': '{count} 个页面修改',
'attachments.selectionItem': '所选元素 {number}:{name}',
'chat.capabilities.managePlugins': '管理插件',
'chat.capabilities.moreTools': '更多工具',
'chat.referenceSearchHint': "输入以查找会话、技能、插件和项目文件",
'chat.slashFrequent': '常用命令',
@@ -2636,34 +2635,45 @@ export const zh: Record<TranslationKey, string> = {
'chat.placeholderQuestionPending': '请先回答上面的问题,Claude 正在等你的答复。',
'chat.addFiles': '添加文件或图片',
'chat.composerTools': '打开输入框工具',
'chat.capabilities.sectionAdd': '添加',
'chat.capabilities.sectionCapabilities': '能力',
'chat.capabilities.sectionCommands': '命令',
'chat.capabilities.searchPlaceholder': '搜索技能、插件、文件…',
'chat.capabilities.empty': '没有匹配项',
'chat.capabilities.searchPlaceholder': '搜索技能、连接器、文件…',
'chat.capabilities.back': '返回',
'chat.capabilities.skills': '技能',
'chat.capabilities.skillsDescription': '把技能加入当前对话',
'chat.capabilities.manageSkills': '管理技能',
'chat.capabilities.connectors': '连接器',
'chat.capabilities.connectorsDescription': '使用已连接的服务',
'chat.capabilities.manageConnectors': '管理连接器',
'chat.capabilities.agents': 'Agent',
'chat.capabilities.agentsDescription': '用指定的 Agent 执行任务',
'chat.capabilities.manageAgents': '管理 Agent',
'chat.capabilities.teams': 'Agent Teams',
'chat.capabilities.teamsDescription': '让多个 Agent 协作完成一个目标',
'chat.capabilities.teamCreate': '通过对话创建团队',
'chat.capabilities.teamCreateDescription': '描述目标,Agent 自行分工协作',
'chat.capabilities.teamCreatePrompt': '帮我创建一个 Agent 团队来完成下面的目标,请合理分工并协调各个成员:',
'chat.capabilities.teamMembers': '{count} 个成员',
'chat.capabilities.computerUse': '操作电脑',
'chat.capabilities.computerUseDescription': '让 Claude 操作本机应用',
'chat.capabilities.computerUseUnsupported': '当前平台不可用 — 打开设置',
'chat.capabilities.computerUseToggle': '开关操作电脑功能',
'chat.capabilities.workflows': '工作流',
'chat.capabilities.workflowsDescription': '运行已保存的工作流',
'chat.capabilities.saveWorkflow': '把当前会话保存为工作流',
'chat.capabilities.sectionUse': '给这次任务用',
'chat.capabilities.sectionRun': '运行方式',
'chat.capabilities.skillsRecent': '最近用过',
'chat.capabilities.skillsAll': '全部技能',
'chat.capabilities.skillsPopular': '市场热门',
'chat.capabilities.skillInstall': '安装',
'chat.capabilities.skillInstalling': '安装中…',
'chat.capabilities.skillsBrowse': '浏览技能市场',
'chat.capabilities.connectorsConnected': '已连接',
'chat.capabilities.connectorsNeedsAction': '需要处理',
'chat.capabilities.connectorsSuggested': '推荐',
'chat.capabilities.connectorsBrowse': '浏览全部连接器',
'chat.capabilities.connectorsBrowseCount': '浏览全部 {count} 个连接器',
'chat.capabilities.connectorNeedsAuth': '需要登录授权',
'chat.capabilities.connectorFailed': '连接出错',
'chat.capabilities.connectorAuthorize': '授权',
'chat.capabilities.connectorView': '查看',
'chat.capabilities.connectorConnect': '连接',
'chat.capabilities.teamsDisabled': '已在设置中关闭 — 打开设置',
'chat.capabilities.teamRemove': '取消 Agent Teams',
'chat.capabilities.teamPlaceholder': '描述目标,Agent 会拆分任务、组队协作…',
'chat.dropFilesTitle': '松手添加文件',
'chat.dropFilesHint': '会以文件路径附加到当前输入。',
'chat.workspaceReferencesOnly': '已添加 {count} 个工作区引用',
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { extractRestoredUserDisplay } from '@/stores/chatStore'
import { AGENT_TEAM_REQUEST_INSTRUCTION, stripAgentTeamRequest, withAgentTeamRequest } from './agentTeamRequest'
describe('agent team request', () => {
it('puts the team instruction in front of the message and takes it off again', () => {
const sent = withAgentTeamRequest('Ship the release checklist')
expect(sent).toContain(AGENT_TEAM_REQUEST_INSTRUCTION)
expect(sent.endsWith('\n\nShip the release checklist')).toBe(true)
expect(stripAgentTeamRequest(sent)).toEqual({ content: 'Ship the release checklist', requested: true })
})
it('leaves ordinary messages, and a user quoting the tag, untouched', () => {
expect(stripAgentTeamRequest('plain text')).toEqual({ content: 'plain text', requested: false })
const quoted = 'Why does <agent-team-request> show up in logs?'
expect(stripAgentTeamRequest(quoted)).toEqual({ content: quoted, requested: false })
})
it('shows only the typed text when a team request is restored from history', () => {
const sent = withAgentTeamRequest('Ship the release checklist')
expect(extractRestoredUserDisplay(sent)).toMatchObject({
content: 'Ship the release checklist',
modelContent: sent,
})
})
it('keeps file references after the team block when restoring', () => {
const sent = withAgentTeamRequest('@"/repo/README.md" summarize this')
const restored = extractRestoredUserDisplay(sent)
expect(restored.content).not.toContain('agent-team-request')
expect(restored.content).not.toContain(AGENT_TEAM_REQUEST_INSTRUCTION)
expect(restored.modelContent).toBe(sent)
})
})
+29
View File
@@ -0,0 +1,29 @@
/**
* The message wrapper behind the composer's Agent Team switch.
*
* Armed, the next message goes to the model with a fixed instruction block in
* front of it, while the bubble shows only what the user typed. The block has
* a tag of its own so a reloaded transcript can drop it again; its text is
* English on purpose, since it talks to the model, not the user.
*/
const OPEN_TAG = '<agent-team-request>'
const CLOSE_TAG = '</agent-team-request>'
export const AGENT_TEAM_REQUEST_INSTRUCTION =
'Use an Agent Team for this request: create a team, split the goal below into tasks with clear owners, '
+ 'and coordinate the teammates until the goal is done.'
const REQUEST_BLOCK = new RegExp(`${OPEN_TAG}[\\s\\S]*?${CLOSE_TAG}\\n*`)
export function withAgentTeamRequest(content: string): string {
const block = `${OPEN_TAG}\n${AGENT_TEAM_REQUEST_INSTRUCTION}\n${CLOSE_TAG}`
return content ? `${block}\n\n${content}` : block
}
/** The user-facing text of a message, with the Agent Team block removed. */
export function stripAgentTeamRequest(text: string): { content: string, requested: boolean } {
if (!text.includes(OPEN_TAG)) return { content: text, requested: false }
const content = text.replace(REQUEST_BLOCK, '')
return content === text ? { content: text, requested: false } : { content, requested: true }
}
+21
View File
@@ -0,0 +1,21 @@
import type { ComputerUseStatus } from '@/api/computerUse'
/**
* What turning Computer Use on writes, wherever the user does it. The consent
* dialog in front of it is the one authorization boundary: confirming covers
* every app, so the grants it implies are switched on together.
*/
export const COMPUTER_USE_ENABLE_REQUEST = {
enabled: true,
grantFlags: {
clipboardRead: true,
clipboardWrite: true,
systemKeyCombos: true,
},
} as const
/** The native macOS engine still needs the OS permission card after consent. */
export function needsComputerUsePermissionCard(status: ComputerUseStatus | null | undefined): boolean {
return status?.engine === 'macos-native'
&& (status.permissions.accessibility === false || status.permissions.screenRecording === false)
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { parseRecentSkills, readRecentSkills, recordRecentSkills, RECENT_SKILLS_MAX, RECENT_SKILLS_STORAGE_KEY } from './recentSkills'
function memoryStorage(initial: Record<string, string> = {}) {
const values = new Map(Object.entries(initial))
return {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => { values.set(key, value) },
raw: (key: string) => values.get(key),
}
}
describe('recent skills', () => {
it('moves sent skills to the front, newest first, without duplicates', () => {
const storage = memoryStorage()
recordRecentSkills(['design'], storage)
recordRecentSkills(['lint', 'design', 'lint'], storage)
expect(readRecentSkills(storage)).toEqual(['lint', 'design'])
expect(JSON.parse(storage.raw(RECENT_SKILLS_STORAGE_KEY)!)).toEqual({ version: 1, ids: ['lint', 'design'] })
})
it('keeps only the most recent entries', () => {
const storage = memoryStorage()
for (let index = 0; index < RECENT_SKILLS_MAX + 3; index++) recordRecentSkills([`skill-${index}`], storage)
const ids = readRecentSkills(storage)
expect(ids).toHaveLength(RECENT_SKILLS_MAX)
expect(ids[0]).toBe(`skill-${RECENT_SKILLS_MAX + 2}`)
})
it('leaves storage alone when no skill was sent', () => {
const storage = memoryStorage()
recordRecentSkills([], storage)
expect(storage.raw(RECENT_SKILLS_STORAGE_KEY)).toBeUndefined()
})
it.each([
['nothing stored', null],
['corrupt JSON', '{"ids":'],
['a bare array', '["design"]'],
['ids that are not a list', '{"version":1,"ids":"design"}'],
])('reads %s as an empty list', (_label, raw) => {
expect(parseRecentSkills(raw)).toEqual([])
})
it('drops foreign entries from a stored list and still records over it', () => {
const storage = memoryStorage({ [RECENT_SKILLS_STORAGE_KEY]: JSON.stringify({ version: 1, ids: ['design', 3, '', null, 'design', 'lint'] }) })
expect(readRecentSkills(storage)).toEqual(['design', 'lint'])
recordRecentSkills(['pdf'], storage)
expect(readRecentSkills(storage)).toEqual(['pdf', 'design', 'lint'])
})
it('survives storage that throws', () => {
const storage = {
getItem: () => { throw new Error('denied') },
setItem: () => { throw new Error('denied') },
}
expect(readRecentSkills(storage)).toEqual([])
expect(() => recordRecentSkills(['design'], storage)).not.toThrow()
})
})
+56
View File
@@ -0,0 +1,56 @@
/**
* Skills the user sent most recently, newest first, so the + menu can put
* them within one click. A per-device convenience: losing it only empties the
* "Recent" group, so every read tolerates a missing or foreign value.
*
* Stored shape (version 1): `{ "version": 1, "ids": ["skill-id", ...] }`.
*/
export const RECENT_SKILLS_STORAGE_KEY = 'cc-haha-recent-skills'
export const RECENT_SKILLS_MAX = 8
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
function defaultStorage(): StorageLike | null {
try {
return globalThis.localStorage ?? null
} catch {
return null
}
}
/** Normalize anything found under the key into a list of skill ids. */
export function parseRecentSkills(raw: string | null): string[] {
if (!raw) return []
try {
const parsed: unknown = JSON.parse(raw)
const ids = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as { ids?: unknown }).ids
: null
if (!Array.isArray(ids)) return []
return ids
.filter((id): id is string => typeof id === 'string' && id.length > 0)
.filter((id, index, all) => all.indexOf(id) === index)
.slice(0, RECENT_SKILLS_MAX)
} catch {
return []
}
}
export function readRecentSkills(storage: StorageLike | null = defaultStorage()): string[] {
try {
return parseRecentSkills(storage?.getItem(RECENT_SKILLS_STORAGE_KEY) ?? null)
} catch {
return []
}
}
/** Move the sent skills to the front, in the order they appear in the message. */
export function recordRecentSkills(skillIds: string[], storage: StorageLike | null = defaultStorage()): void {
if (!storage || skillIds.length === 0) return
const sent = skillIds.filter((id, index, all) => id && all.indexOf(id) === index)
const ids = [...sent, ...readRecentSkills(storage).filter(id => !sent.includes(id))].slice(0, RECENT_SKILLS_MAX)
try {
storage.setItem(RECENT_SKILLS_STORAGE_KEY, JSON.stringify({ version: 1, ids }))
} catch { /* storage full or unavailable */ }
}
+3 -15
View File
@@ -16,6 +16,7 @@ import {
import { computerUseApi, type ComputerUseStatus, type SetupResult } from '../api/computerUse'
import { useTranslation } from '../i18n'
import { ComputerUseEnableDialog } from '@/components/computer-use/ComputerUseEnableDialog'
import { COMPUTER_USE_ENABLE_REQUEST, needsComputerUsePermissionCard } from '@/lib/computerUseEnable'
import {
SettingsBlock,
SettingsGroup,
@@ -192,16 +193,7 @@ export function ComputerUseSettings() {
setConfigError(null)
setComputerUseEnabled(value)
try {
await computerUseApi.setAuthorizedApps(value
? {
enabled: true,
grantFlags: {
clipboardRead: true,
clipboardWrite: true,
systemKeyCombos: true,
},
}
: { enabled: false })
await computerUseApi.setAuthorizedApps(value ? COMPUTER_USE_ENABLE_REQUEST : { enabled: false })
if (requestSeq !== configMutationSeqRef.current) return true
return true
} catch {
@@ -247,11 +239,7 @@ export function ComputerUseSettings() {
setEnableSaving(false)
if (!saved) return
setEnableConfirmOpen(false)
if (
status?.engine === 'macos-native'
&& (status.permissions.accessibility === false
|| status.permissions.screenRecording === false)
) {
if (needsComputerUsePermissionCard(status)) {
await openPermissionCard()
}
}
+7
View File
@@ -21,6 +21,13 @@ const perform = vi.fn(async () => {})
beforeEach(() => { vi.clearAllMocks(); mocks.translations = null; useConnectorStore.setState({ items: [item], loading: false, error: null, pending: {}, refresh, act: perform }) })
afterEach(() => { cleanup(); vi.useRealTimers() })
describe('Connectors', () => {
it('opens the detail of the connector the composer menu pointed at', () => {
const handled = vi.fn()
render(<Connectors focusConnectorId="feishu" onFocusHandled={handled} />)
expect(screen.getByRole('dialog')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'connectors.action.authenticate' })).toBeInTheDocument()
expect(handled).toHaveBeenCalledTimes(1)
})
it('explains shared credentials and requires confirmation before account connection', () => {
render(<Connectors />)
fireEvent.click(screen.getByRole('button', { name: 'connectors.details' }))
+9 -1
View File
@@ -38,7 +38,7 @@ const PHASE_KEYS: Record<string, TranslationKey> = {
const TOOL_PLUGIN_IDS = new Set(['hyperframes', 'obsidian', 'drawio', 'remotion'])
export function Connectors({ mode = 'plugins', embedded = false, externalQuery, installedFilter = 'all', management = false }: { mode?: 'plugins' | 'skills', embedded?: boolean, externalQuery?: string, installedFilter?: 'all' | 'installed' | 'installable', management?: boolean } = {}) {
export function Connectors({ mode = 'plugins', embedded = false, externalQuery, installedFilter = 'all', management = false, focusConnectorId, onFocusHandled }: { mode?: 'plugins' | 'skills', embedded?: boolean, externalQuery?: string, installedFilter?: 'all' | 'installed' | 'installable', management?: boolean, focusConnectorId?: string, onFocusHandled?: () => void } = {}) {
const t = useTranslation()
const { items, loading, error, pending, refresh, act } = useConnectorStore()
const [query, setQuery] = useState('')
@@ -111,6 +111,14 @@ export function Connectors({ mode = 'plugins', embedded = false, externalQuery,
setSelected(item.id)
}
const closeDetail = () => { setSelected(null); setConfiguration({}); setInvalidFields([]); setLocalError(null) }
// Opened from the composer at one connector: its detail shows as soon as the
// catalog has it (the modal reads `items`, so an early id simply waits).
useEffect(() => {
if (!focusConnectorId) return
setConfiguration({}); setInvalidFields([]); setLocalError(null)
setSelected(focusConnectorId)
onFocusHandled?.()
}, [focusConnectorId, onFocusHandled])
const requestAction = (item: ConnectorDto, action: ConnectorAction, fromDetail = false) => {
if (!(management && action === 'remove' && !fromDetail)) showDetail(item)
const fields = item.setupFields || []
+4 -3
View File
@@ -459,7 +459,7 @@ describe('EmptySession', () => {
render(<EmptySession />)
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()
@@ -494,6 +494,7 @@ describe('EmptySession', () => {
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] : [] }))
@@ -511,7 +512,7 @@ describe('EmptySession', () => {
await pickProject()
setComposerText('Please review ', 14)
fireEvent.click(screen.getByLabelText('Open composer tools'))
fireEvent.change(screen.getByRole('combobox', { name: 'Search skills, plugins, files…' }), { target: { value: 'README' } })
fireEvent.change(screen.getByRole('combobox', { name: 'Search skills, connectors, files…' }), { target: { value: 'README' } })
fireEvent.click(await screen.findByRole('option', { name: 'README.md' }))
await waitFor(() => {
@@ -520,7 +521,7 @@ describe('EmptySession', () => {
expect(document.querySelector('.composer-mention')).toHaveTextContent('@README.md')
expect(getComposerText()).toContain('Please review @README.md')
expect(mocks.search).toHaveBeenCalledWith('README', '/workspace/project', { 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()
expect(mocks.createSession).not.toHaveBeenCalled()
})
+21 -12
View File
@@ -77,6 +77,10 @@ import {
import { ComposerCapabilityMenu } from '@/components/chat/ComposerCapabilityMenu'
import { MobileComposerSheet, type MobileComposerSetting } from '@/components/chat/MobileComposerSheet'
import { useCapabilityMenu } from '@/components/chat/useCapabilityMenu'
import { AgentTeamChip } from '@/components/chat/AgentTeamChip'
import { ComputerUseEnableDialog } from '@/components/computer-use/ComputerUseEnableDialog'
import { withAgentTeamRequest } from '@/lib/agentTeamRequest'
import { recordRecentSkills } from '@/lib/recentSkills'
import type { AttachmentRef } from '../types/chat'
import type { PermissionMode } from '../types/settings'
import type { SlashCommandOption } from '../components/chat/composerUtils'
@@ -146,6 +150,8 @@ export function EmptySession({ initialWorkDir = '' }: EmptySessionProps = {}) {
const [repositoryLaunchReady, setRepositoryLaunchReady] = useState(!initialWorkDir)
const [attachments, setAttachments] = useState<Attachment[]>([])
const [plusMenuOpen, setPlusMenuOpen] = useState(false)
// Bumped when the + menu installs a skill, so the mention list reloads.
const [referencesVersion, setReferencesVersion] = useState(0)
const [slashMenuOpen, setSlashMenuOpen] = useState(false)
const [fileSearchOpen, setFileSearchOpen] = useState(false)
const [localSlashPanel, setLocalSlashPanel] = useState<LocalSlashCommandName | null>(null)
@@ -197,7 +203,7 @@ export function EmptySession({ initialWorkDir = '' }: EmptySessionProps = {}) {
if (active) setReferenceState({ context: referenceContext, items: [], loading: false, error: true })
})
return () => { active = false }
}, [referenceContext, workDir, referenceProviderId, lastPluginReloadSummary, slashMenuOpen, fileSearchOpen, plusMenuOpen])
}, [referenceContext, workDir, referenceProviderId, lastPluginReloadSummary, slashMenuOpen, fileSearchOpen, plusMenuOpen, referencesVersion])
useEffect(() => {
setReferenceDetail(null)
setReferenceOptionId(undefined)
@@ -473,9 +479,14 @@ export function EmptySession({ initialWorkDir = '' }: EmptySessionProps = {}) {
// Inline @-mentions go out as the `@"absolute path"` text the CLI parses,
// serialized from the live document; the bubble keeps the pill text.
const serializedText = (composerRef.current?.getModelContent() ?? input).trim()
// Agent Team armed in the + menu: the model gets the team instruction,
// the bubble keeps what the user typed.
const sendAsTeam = capabilityMenu.agentTeamArmed
if (serializedText || attachmentPayload.length > 0) {
sendMessage(sessionId, serializedText, attachmentPayload, { displayContent: text })
sendMessage(sessionId, sendAsTeam ? withAgentTeamRequest(serializedText) : serializedText, attachmentPayload, { displayContent: text })
}
recordRecentSkills(mentions.flatMap(mention => mention.kind === 'skill' && mention.id ? [mention.id] : []))
if (sendAsTeam) capabilityMenu.disarmAgentTeam()
setInput('')
setMentions([])
setAttachments([])
@@ -775,23 +786,16 @@ export function EmptySession({ initialWorkDir = '' }: EmptySessionProps = {}) {
composerRef.current?.setSelectionOffsets(replacement.cursorPos)
})
},
onInsertPromptSeed: (text) => {
const next = input.trim() ? `${input}\n${text}` : text
setInput(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 heroProject = workDir ? (resolveProjectDisplayName(workDir) ?? projectTitle(workDir)) : null
// Same merge as the "+" menu's prompt seeds: a draft already typed is kept.
// A draft already typed is kept: the suggestion goes on a new line.
const insertSuggestion = (text: string) => {
const next = input.trim() ? `${input}\n${text}` : text
setInput(next)
@@ -956,7 +960,7 @@ export function EmptySession({ initialWorkDir = '' }: EmptySessionProps = {}) {
onPaste={handleComposerPaste}
onCompositionStart={dictation.compositionHandlers.onCompositionStart}
onCompositionEnd={dictation.compositionHandlers.onCompositionEnd}
placeholder={t('empty.placeholder')}
placeholder={capabilityMenu.agentTeamArmed ? t('chat.capabilities.teamPlaceholder') : t('empty.placeholder')}
// `min-w-0`: see ChatInput — an unbreakable long run (URL,
// hash) otherwise grows this flex item past the panel.
className="flex-1 min-w-0"
@@ -1024,8 +1028,13 @@ export function EmptySession({ initialWorkDir = '' }: EmptySessionProps = {}) {
onAction={capabilityMenu.onAction}
/>
)}
<ComputerUseEnableDialog {...capabilityMenu.computerUseConsent} />
</div>
{capabilityMenu.agentTeamArmed ? (
<AgentTeamChip touch={isMobileComposer} onRemove={capabilityMenu.disarmAgentTeam} />
) : null}
<PermissionModeSelector
ref={permissionSelectorRef}
workDir={workDir}
+20 -2
View File
@@ -1,9 +1,10 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { it, expect, vi } from 'vitest'
import { ExtensionMarket } from './ExtensionMarket'
import { useUIStore } from '@/stores/uiStore'
vi.mock('@/i18n', () => ({ useTranslation: () => (key: string) => key }))
vi.mock('@/pages/Connectors', () => ({ Connectors: ({ mode, embedded, externalQuery, installedFilter, management }: { mode?: string, management?: boolean, embedded?: boolean, externalQuery?: string, installedFilter?: string }) => <div data-testid={`catalog-${mode || "plugins"}`} data-management={management} data-embedded={embedded} data-query={externalQuery} data-filter={installedFilter} /> }))
vi.mock('@/pages/Connectors', () => ({ Connectors: ({ mode, embedded, externalQuery, installedFilter, management, focusConnectorId }: { mode?: string, management?: boolean, embedded?: boolean, externalQuery?: string, installedFilter?: string, focusConnectorId?: string }) => <div data-testid={`catalog-${mode || "plugins"}`} data-management={management} data-embedded={embedded} data-query={externalQuery} data-filter={installedFilter} data-focus={focusConnectorId} /> }))
vi.mock('@/pages/InstalledSkills', () => ({ InstalledSkills: () => <div data-testid="installed-skills" /> }))
vi.mock('@/pages/Market', () => ({ Market: ({ featured }: { featured: React.ReactNode }) => <div data-testid="existing-skill-market">{featured}</div> }))
it('defaults to plugins and shows only the skill market on the skills tab', () => {
@@ -37,3 +38,20 @@ it('opens installed management from the current section without adding another n
fireEvent.click(screen.getByRole('button', { name: 'extensions.mySkills' }))
expect(screen.getByTestId('installed-skills')).toBeInTheDocument()
})
it('opens at the section and connector the composer menu asked for, once', () => {
useUIStore.setState({ pendingMarketTarget: null })
render(<ExtensionMarket />)
fireEvent.click(screen.getByRole('radio', { name: 'extensions.skills' }))
fireEvent.click(screen.getByRole('button', { name: 'extensions.mySkills' }))
expect(screen.getByTestId('installed-skills')).toBeInTheDocument()
// Already open in a tab: the request still lands, in the catalog view.
act(() => useUIStore.getState().setPendingMarketTarget({ section: 'plugins', connectorId: 'github' }))
expect(screen.getByTestId('catalog-plugins')).toHaveAttribute('data-focus', 'github')
expect(screen.getByTestId('catalog-plugins')).not.toHaveAttribute('data-management', 'true')
expect(useUIStore.getState().pendingMarketTarget).toBeNull()
act(() => useUIStore.getState().setPendingMarketTarget({ section: 'skills' }))
expect(screen.getByTestId('existing-skill-market')).toBeInTheDocument()
})
+14 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { ArrowLeft, PackageCheck } from 'lucide-react'
import { useTranslation } from '@/i18n'
import { useUIStore } from '@/stores/uiStore'
import { cx } from '@/lib/cx'
import { Button } from '@/components/ui/Button'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
@@ -13,6 +14,17 @@ export function ExtensionMarket() {
const t = useTranslation()
const [section, setSection] = useState<'plugins' | 'skills'>('plugins')
const [managing, setManaging] = useState(false)
const [focusConnectorId, setFocusConnectorId] = useState<string>()
const pendingTarget = useUIStore(state => state.pendingMarketTarget)
// The composer's + menu opens the market at a section, or straight at one
// connector's detail. Consumed once, so reopening the tab later starts fresh.
useEffect(() => {
if (!pendingTarget) return
setSection(pendingTarget.section)
setManaging(false)
setFocusConnectorId(pendingTarget.connectorId)
useUIStore.getState().setPendingMarketTarget(null)
}, [pendingTarget])
const myLabel = t(section === 'plugins' ? 'extensions.myPlugins' : 'extensions.mySkills')
const description = section === 'plugins'
? t('extensions.pluginsSubtitle')
@@ -40,7 +52,7 @@ export function ExtensionMarket() {
previous featured row is a change to the skills branch below. */}
{managing
? section === 'plugins' ? <Connectors key="installed" management /> : <InstalledSkills />
: section === 'plugins' ? <Connectors key="catalog" mode="plugins" /> : <Market />}
: section === 'plugins' ? <Connectors key="catalog" mode="plugins" focusConnectorId={focusConnectorId} onFocusHandled={() => setFocusConnectorId(undefined)} /> : <Market />}
</div>
</section>
}
+6
View File
@@ -33,6 +33,7 @@ import { AGENT_LIFECYCLE_TYPES } from '../types/team'
import type { ComposerAttachment } from '../lib/composerAttachments'
import type { ComposerMention } from '../lib/composerMentions'
import type { MessageEntry } from '../types/session'
import { stripAgentTeamRequest } from '../lib/agentTeamRequest'
import type { PermissionMode } from '../types/settings'
import type { RuntimeSelection } from '../types/runtime'
import type {
@@ -7419,6 +7420,11 @@ type RestoredUserDisplay = {
}
export function extractRestoredUserDisplay(text: string): RestoredUserDisplay {
// The Agent Team switch prefixes an instruction block the user never typed.
const teamRequest = stripAgentTeamRequest(text)
if (teamRequest.requested) {
return { ...extractRestoredUserDisplay(teamRequest.content), modelContent: text }
}
const referenceContext = splitSessionReferenceContext(text)
if (referenceContext.sessionReferences.length) {
return { ...extractRestoredUserDisplay(referenceContext.content), sessionReferences: referenceContext.sessionReferences, modelContent: text }
+11
View File
@@ -233,6 +233,12 @@ export type Toast = {
duration?: number
}
export type MarketTarget = {
section: 'plugins' | 'skills'
/** Open this connector's detail once the catalog loads. */
connectorId?: string
}
export type SettingsTab =
| 'providers'
| 'activity'
@@ -269,6 +275,8 @@ type UIStore = {
activeSettingsTab: SettingsTab
pendingSettingsTab: SettingsTab | null
pendingMemoryPath: string | null
/** Where the extension market should open next (set by the composer + menu). */
pendingMarketTarget: MarketTarget | null
activeModal: string | null
toasts: Toast[]
@@ -282,6 +290,7 @@ type UIStore = {
setActiveSettingsTab: (tab: SettingsTab) => void
setPendingSettingsTab: (tab: SettingsTab | null) => void
setPendingMemoryPath: (path: string | null) => void
setPendingMarketTarget: (target: MarketTarget | null) => void
openModal: (id: string) => void
closeModal: () => void
addToast: (toast: Omit<Toast, 'id'>) => void
@@ -301,6 +310,7 @@ export const useUIStore = create<UIStore>((set) => ({
activeSettingsTab: getStoredSettingsTab(),
pendingSettingsTab: null,
pendingMemoryPath: null,
pendingMarketTarget: null,
activeModal: null,
toasts: [],
@@ -374,6 +384,7 @@ export const useUIStore = create<UIStore>((set) => ({
},
setPendingSettingsTab: (tab) => set({ pendingSettingsTab: tab }),
setPendingMemoryPath: (path) => set({ pendingMemoryPath: path }),
setPendingMarketTarget: (target) => set({ pendingMarketTarget: target }),
openModal: (id) => set({ activeModal: id }),
closeModal: () => set({ activeModal: null }),