mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
feat(desktop): reorganize the composer + menu around skills, connectors and run modes
- Group the root into "for this task" (skills, connectors, files) and "how it runs" (Agent Teams and Computer Use switches); the rest moves under More tools. - Open categories in a side panel on desktop; narrow windows and the phone sheet keep in-place navigation. - Connectors list connected services, ones that need sign-in, and three fixed suggestions, each opening its detail in the extension market. - Drop the Plugins entry: plugin skills show under Skills, MCP plugins under Connectors, the rest stay searchable. - Skills show recently sent ones, all skills, and two curated market skills that install in place. - Agent Teams arms the next message with a team instruction and a removable chip; the bubble and restored history show only the typed text. - Turning Computer Use on from the menu goes through the same consent dialog and grants as Settings.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} 件追加しました',
|
||||
|
||||
@@ -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}개를 추가했습니다',
|
||||
|
||||
@@ -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} 個工作區引用',
|
||||
|
||||
@@ -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} 个工作区引用',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 */ }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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 || []
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user