Skip to content

Commit 9fd74c6

Browse files
committed
feat(desktop): localize Agent Teams and warn before enabling it
Rename the feature to 智能体团队 / 智慧體團隊 / エージェントチーム / 에이전트 팀 across the five locales, and show a shared notice (token cost, slower, not for every task) before it is turned on from Settings or armed from the composer + menu. The composer notice has a per-device 'don't remind me again' option.
1 parent 9988877 commit 9fd74c6

21 files changed

Lines changed: 428 additions & 56 deletions

‎desktop/src/__tests__/generalSettings.test.tsx‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1361,7 +1361,25 @@ describe('Settings > General tab', () => {
13611361
await act(async () => { fireEvent.click(toggle) })
13621362
expect(toggle).not.toBeChecked()
13631363
expect(updateUser).toHaveBeenLastCalledWith({ agentTeamsEnabled: false })
1364-
await act(async () => { fireEvent.click(toggle) })
1364+
updateUser.mockClear()
1365+
1366+
// Turning it back on first explains the cost; nothing is saved until
1367+
// the user confirms, and cancelling leaves it off.
1368+
fireEvent.click(toggle)
1369+
const dialog = screen.getByRole('dialog')
1370+
expect(dialog).toHaveTextContent('Uses a lot of tokens')
1371+
expect(dialog).toHaveTextContent('Slower to finish')
1372+
expect(dialog).toHaveTextContent('Not suited to every task')
1373+
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument()
1374+
fireEvent.click(within(dialog).getByRole('button', { name: 'Cancel' }))
1375+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
1376+
expect(toggle).not.toBeChecked()
1377+
expect(updateUser).not.toHaveBeenCalled()
1378+
1379+
fireEvent.click(toggle)
1380+
await act(async () => {
1381+
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Turn on anyway' }))
1382+
})
13651383
expect(toggle).toBeChecked()
13661384
expect(updateUser).toHaveBeenLastCalledWith({ agentTeamsEnabled: true })
13671385
} finally {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import '@testing-library/jest-dom'
4+
import { useSettingsStore } from '@/stores/settingsStore'
5+
import { AgentTeamsCostDialog } from './AgentTeamsCostDialog'
6+
7+
beforeEach(() => {
8+
useSettingsStore.setState({ locale: 'en' })
9+
})
10+
11+
afterEach(() => {
12+
cleanup()
13+
})
14+
15+
describe('AgentTeamsCostDialog', () => {
16+
it('states the token cost, the slowdown and the limited fit', () => {
17+
render(<AgentTeamsCostDialog open onClose={() => {}} onConfirm={() => {}} />)
18+
19+
const dialog = screen.getByRole('dialog')
20+
expect(dialog).toHaveTextContent('Uses a lot of tokens')
21+
expect(dialog).toHaveTextContent('Slower to finish')
22+
expect(dialog).toHaveTextContent('Not suited to every task')
23+
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
24+
})
25+
26+
it('confirms without a reminder preference when dismissal is not offered', () => {
27+
const onConfirm = vi.fn()
28+
render(<AgentTeamsCostDialog open onClose={() => {}} onConfirm={onConfirm} />)
29+
30+
fireEvent.click(screen.getByRole('button', { name: 'Turn on anyway' }))
31+
expect(onConfirm).toHaveBeenCalledWith({ dontRemind: false })
32+
})
33+
34+
it('reports the reminder checkbox, and resets it each time it opens', () => {
35+
const onConfirm = vi.fn()
36+
const { rerender } = render(<AgentTeamsCostDialog open allowDismiss onClose={() => {}} onConfirm={onConfirm} />)
37+
38+
fireEvent.click(screen.getByRole('checkbox', { name: "Don't remind me again" }))
39+
fireEvent.click(screen.getByRole('button', { name: 'Turn on anyway' }))
40+
expect(onConfirm).toHaveBeenLastCalledWith({ dontRemind: true })
41+
42+
rerender(<AgentTeamsCostDialog open={false} allowDismiss onClose={() => {}} onConfirm={onConfirm} />)
43+
rerender(<AgentTeamsCostDialog open allowDismiss onClose={() => {}} onConfirm={onConfirm} />)
44+
expect(screen.getByRole('checkbox', { name: "Don't remind me again" })).not.toBeChecked()
45+
})
46+
47+
it('closes on cancel without confirming', () => {
48+
const onClose = vi.fn()
49+
const onConfirm = vi.fn()
50+
render(<AgentTeamsCostDialog open onClose={onClose} onConfirm={onConfirm} />)
51+
52+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
53+
expect(onClose).toHaveBeenCalledTimes(1)
54+
expect(onConfirm).not.toHaveBeenCalled()
55+
})
56+
})
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useEffect, useState } from 'react'
2+
import { TriangleAlert } from 'lucide-react'
3+
import { useTranslation } from '@/i18n'
4+
import { ActionDialog } from '@/components/ui/ActionDialog'
5+
import { Checkbox } from '@/components/ui/Checkbox'
6+
7+
type Props = {
8+
open: boolean
9+
/**
10+
* Offer the "don't remind me again" checkbox. Only the composer's per-message
11+
* switch passes this: it is armed again for every message, while the Settings
12+
* switch is a rare, deliberate change that should always be confirmed.
13+
*/
14+
allowDismiss?: boolean
15+
onClose: () => void
16+
onConfirm: (options: { dontRemind: boolean }) => void | Promise<void>
17+
}
18+
19+
/**
20+
* Shown before Agent Teams is turned on, from Settings and from the composer.
21+
* One component so both entries say exactly the same thing.
22+
*/
23+
export function AgentTeamsCostDialog({ open, allowDismiss = false, onClose, onConfirm }: Props) {
24+
const t = useTranslation()
25+
const [dontRemind, setDontRemind] = useState(false)
26+
27+
useEffect(() => {
28+
if (open) setDontRemind(false)
29+
}, [open])
30+
31+
return (
32+
<ActionDialog
33+
open={open}
34+
onClose={onClose}
35+
title={t('agentTeams.notice.title')}
36+
width={480}
37+
body={(
38+
<div className="space-y-4 text-[13px] leading-[1.6] text-[var(--color-text-secondary)]">
39+
<div className="flex items-start gap-2.5 rounded-[var(--radius-md)] bg-[var(--color-warning-container)] px-3 py-2.5 text-[var(--color-on-warning-container)]">
40+
<TriangleAlert size={16} strokeWidth={1.75} aria-hidden="true" className="mt-0.5 shrink-0 text-[var(--color-warning)]" />
41+
<p className="font-medium">{t('agentTeams.notice.intro')}</p>
42+
</div>
43+
<ul className="list-disc space-y-1.5 pl-5">
44+
<li>{t('agentTeams.notice.tokens')}</li>
45+
<li>{t('agentTeams.notice.slow')}</li>
46+
<li>{t('agentTeams.notice.scope')}</li>
47+
</ul>
48+
{allowDismiss && (
49+
<Checkbox
50+
size="sm"
51+
label={t('agentTeams.notice.dontRemind')}
52+
checked={dontRemind}
53+
onChange={(event) => setDontRemind(event.target.checked)}
54+
/>
55+
)}
56+
</div>
57+
)}
58+
actions={[
59+
{
60+
label: t('common.cancel'),
61+
onClick: onClose,
62+
variant: 'secondary',
63+
},
64+
{
65+
label: t('agentTeams.notice.confirm'),
66+
onClick: () => onConfirm({ dontRemind: allowDismiss && dontRemind }),
67+
variant: 'primary',
68+
},
69+
]}
70+
/>
71+
)
72+
}

‎desktop/src/components/agentTeams/AgentTeamsWorkbench.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ export function AgentTeamsWorkbench({ sessionId }: { sessionId: string }) {
328328
<div className="flex flex-wrap items-center gap-x-5 gap-y-2.5 px-[18px] py-2.5">
329329
<div className="min-w-0 max-w-full">
330330
<div className="text-[11px] font-semibold text-[var(--color-text-tertiary)]">
331-
Agent Teams · {t('agentTeams.sharedTaskList')}
331+
{t('agentTeams.name')} · {t('agentTeams.sharedTaskList')}
332332
</div>
333333
<div className="mt-0.5 flex min-w-0 items-center gap-2">
334334
<span

‎desktop/src/components/chat/ChatInput.test.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
99
import '@testing-library/jest-dom'
1010
import { withAgentTeamRequest } from '@/lib/agentTeamRequest'
1111
import { COMPUTER_USE_ENABLE_REQUEST } from '@/lib/computerUseEnable'
12+
import { AGENT_TEAMS_NOTICE_STORAGE_KEY, dismissAgentTeamsNotice } from '@/lib/agentTeamsNotice'
1213
import { readRecentSkills, RECENT_SKILLS_STORAGE_KEY } from '@/lib/recentSkills'
1314
import { act, StrictMode } from 'react'
1415

@@ -252,6 +253,7 @@ describe('ChatInput file mentions', () => {
252253
beforeEach(() => {
253254
vi.clearAllMocks()
254255
localStorage.removeItem(RECENT_SKILLS_STORAGE_KEY)
256+
localStorage.removeItem(AGENT_TEAMS_NOTICE_STORAGE_KEY)
255257
useSideChatStore.setState({ entries: {} })
256258
useTeamPlanStore.setState({ bySession: {} })
257259
useTeamStore.setState({ workbenchesBySession: {} })
@@ -2351,6 +2353,17 @@ describe('ChatInput file mentions', () => {
23512353

23522354
fireEvent.click(screen.getByLabelText('Open composer tools'))
23532355
fireEvent.click((await screen.findByRole('option', { name: /Agent Teams/ })).querySelector('input[type="checkbox"]')!)
2356+
2357+
// Arming costs tokens and time, so the first attempt explains that. Cancel
2358+
// leaves the composer plain; confirming arms it.
2359+
const notice = await screen.findByRole('dialog')
2360+
expect(notice).toHaveTextContent('Uses a lot of tokens')
2361+
fireEvent.click(within(notice).getByRole('button', { name: 'Cancel' }))
2362+
expect(screen.queryByTestId('agent-team-chip')).not.toBeInTheDocument()
2363+
2364+
fireEvent.click(screen.getByLabelText('Open composer tools'))
2365+
fireEvent.click((await screen.findByRole('option', { name: /Agent Teams/ })).querySelector('input[type="checkbox"]')!)
2366+
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Turn on anyway' }))
23542367
const chip = await screen.findByTestId('agent-team-chip')
23552368
expect(chip).toHaveTextContent('Agent Teams')
23562369

@@ -2375,6 +2388,7 @@ describe('ChatInput file mentions', () => {
23752388
})
23762389

23772390
it('disarms Agent Team from the chip before sending', async () => {
2391+
dismissAgentTeamsNotice()
23782392
render(<ChatInput compact />)
23792393
fireEvent.click(screen.getByLabelText('Open composer tools'))
23802394
fireEvent.click((await screen.findByRole('option', { name: /Agent Teams/ })).querySelector('input[type="checkbox"]')!)

‎desktop/src/components/chat/ChatInput.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { MobileApprovalDock } from './MobileApprovalDock'
4646
import { useCapabilityMenu } from './useCapabilityMenu'
4747
import { AgentTeamChip } from './AgentTeamChip'
4848
import { ComputerUseEnableDialog } from '@/components/computer-use/ComputerUseEnableDialog'
49+
import { AgentTeamsCostDialog } from '@/components/agentTeams/AgentTeamsCostDialog'
4950
import { withAgentTeamRequest } from '@/lib/agentTeamRequest'
5051
import { recordRecentSkills } from '@/lib/recentSkills'
5152
import { composerReferencesApi, mentionProviderId } from '@/api/composerReferences'
@@ -1718,6 +1719,7 @@ export function ChatInput({ variant = 'default', compact = false, sessionId, vis
17181719
/>
17191720
)}
17201721
<ComputerUseEnableDialog {...capabilityMenu.computerUseConsent} />
1722+
<AgentTeamsCostDialog allowDismiss {...capabilityMenu.agentTeamsNotice} />
17211723
</div>
17221724

17231725
{capabilityMenu.agentTeamArmed && !isMemberSession ? (

‎desktop/src/components/chat/MessageList.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { SETTINGS_TAB_ID, useTabStore } from '../../stores/tabStore'
1313
import { teamTaskWindowsForSnapshot, useTeamStore } from '../../stores/teamStore'
1414
import { useUIStore } from '../../stores/uiStore'
1515
import { useChatAppearanceStore } from '../../stores/chatAppearanceStore'
16-
import { useTranslation } from '../../i18n'
16+
import { t as translateNow, useTranslation } from '../../i18n'
1717
import type { TranslationKey } from '../../i18n/locales/en'
1818
import { UserMessage } from './UserMessage'
1919
import { AssistantMessage } from './AssistantMessage'
@@ -834,7 +834,7 @@ function teamNameFromCreate(toolCall: ToolCall, result: ToolResult | undefined):
834834
for (const value of [nestedStringField(result?.content, 'team_name'), input?.team_name]) {
835835
if (typeof value === 'string' && value.trim()) return value.trim()
836836
}
837-
return 'Agent Teams'
837+
return translateNow('agentTeams.name')
838838
}
839839

840840
function isExplicitTeamAgent(

‎desktop/src/components/chat/useCapabilityMenu.test.tsx‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { act, renderHook, waitFor } from '@testing-library/react'
22
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33
import type { NormalizedSkill } from '@/types/market'
4+
import { AGENT_TEAMS_NOTICE_STORAGE_KEY, dismissAgentTeamsNotice, isAgentTeamsNoticeDismissed } from '@/lib/agentTeamsNotice'
45
import { useMarketStore } from '@/stores/marketStore'
56
import { useSettingsStore } from '@/stores/settingsStore'
67
import { MARKET_TAB_ID, useTabStore } from '@/stores/tabStore'
@@ -57,6 +58,7 @@ function skillChildKeys(result: { current: ReturnType<typeof useCapabilityMenu>
5758
}
5859

5960
beforeEach(() => {
61+
localStorage.removeItem(AGENT_TEAMS_NOTICE_STORAGE_KEY)
6062
resetPopularSkillsCache()
6163
mocks.marketList.mockReset().mockResolvedValue({ items: [marketSkill('excalidraw'), marketSkill('pptx')], nextCursor: null, sources: {} })
6264
mocks.marketInstall.mockReset()
@@ -137,7 +139,44 @@ describe('useCapabilityMenu', () => {
137139
expect(skillChildKeys(result)).toContain('market-skill:excalidraw')
138140
})
139141

142+
it('asks before arming Agent Team, and arms only after the user confirms', () => {
143+
const { result, composer } = renderMenu()
144+
act(() => result.current.onAction({ type: 'toggleAgentTeam' }))
145+
expect(result.current.agentTeamArmed).toBe(false)
146+
expect(result.current.agentTeamsNotice.open).toBe(true)
147+
expect(composer.onClose).toHaveBeenCalled()
148+
149+
act(() => result.current.agentTeamsNotice.onClose())
150+
expect(result.current.agentTeamsNotice.open).toBe(false)
151+
expect(result.current.agentTeamArmed).toBe(false)
152+
153+
act(() => result.current.onAction({ type: 'toggleAgentTeam' }))
154+
act(() => result.current.agentTeamsNotice.onConfirm({ dontRemind: false }))
155+
expect(result.current.agentTeamsNotice.open).toBe(false)
156+
expect(result.current.agentTeamArmed).toBe(true)
157+
expect(isAgentTeamsNoticeDismissed()).toBe(false)
158+
159+
// Turning it off never asks.
160+
act(() => result.current.onAction({ type: 'toggleAgentTeam' }))
161+
expect(result.current.agentTeamArmed).toBe(false)
162+
expect(result.current.agentTeamsNotice.open).toBe(false)
163+
})
164+
165+
it('stops asking once the user chose not to be reminded', () => {
166+
const first = renderMenu()
167+
act(() => first.result.current.onAction({ type: 'toggleAgentTeam' }))
168+
act(() => first.result.current.agentTeamsNotice.onConfirm({ dontRemind: true }))
169+
expect(first.result.current.agentTeamArmed).toBe(true)
170+
first.unmount()
171+
172+
const second = renderMenu()
173+
act(() => second.result.current.onAction({ type: 'toggleAgentTeam' }))
174+
expect(second.result.current.agentTeamsNotice.open).toBe(false)
175+
expect(second.result.current.agentTeamArmed).toBe(true)
176+
})
177+
140178
it('arms Agent Team until disarmed, and never while Settings turns it off', () => {
179+
dismissAgentTeamsNotice()
141180
const { result, rerender } = renderMenu()
142181
act(() => result.current.onAction({ type: 'toggleAgentTeam' }))
143182
expect(result.current.agentTeamArmed).toBe(true)

‎desktop/src/components/chat/useCapabilityMenu.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { marketApi } from '@/api/market'
66
import { workflowsApi } from '@/api/workflows'
77
import { useTranslation } from '@/i18n'
88
import { COMPUTER_USE_ENABLE_REQUEST, needsComputerUsePermissionCard } from '@/lib/computerUseEnable'
9+
import { dismissAgentTeamsNotice, isAgentTeamsNoticeDismissed } from '@/lib/agentTeamsNotice'
910
import { readRecentSkills } from '@/lib/recentSkills'
1011
import { useMarketStore } from '@/stores/marketStore'
1112
import { useSettingsStore } from '@/stores/settingsStore'
@@ -60,6 +61,13 @@ export type ComputerUseConsent = {
6061
onConfirm(): Promise<void>
6162
}
6263

64+
/** Cost notice shown before the composer arms Agent Teams. */
65+
export type AgentTeamsNotice = {
66+
open: boolean
67+
onClose(): void
68+
onConfirm(options: { dontRemind: boolean }): void
69+
}
70+
6371
type CapabilityMenuData = {
6472
agents: AgentDefinition[]
6573
connectors: ConnectorDto[]
@@ -117,6 +125,7 @@ export function useCapabilityMenu(options: {
117125
/** Agent Team is armed for the next message. */
118126
agentTeamArmed: boolean
119127
disarmAgentTeam(): void
128+
agentTeamsNotice: AgentTeamsNotice
120129
computerUseConsent: ComputerUseConsent
121130
} {
122131
const { open, cwd, references, handlers } = options
@@ -126,6 +135,7 @@ export function useCapabilityMenu(options: {
126135
const [recentSkillIds, setRecentSkillIds] = useState<string[]>([])
127136
const [armed, setArmed] = useState(false)
128137
const [consent, setConsent] = useState<{ open: boolean, loading: boolean }>({ open: false, loading: false })
138+
const [agentTeamsNoticeOpen, setAgentTeamsNoticeOpen] = useState(false)
129139
const agentTeamsEnabled = useSettingsStore(state => state.agentTeamsEnabled)
130140
const installingSkillIds = useMarketStore(state => state.installingIds)
131141
const agentTeamArmed = armed && agentTeamsEnabled
@@ -247,6 +257,13 @@ export function useCapabilityMenu(options: {
247257
void installSkill(action.id, action.name)
248258
return
249259
case 'toggleAgentTeam':
260+
if (!armed && !isAgentTeamsNoticeDismissed()) {
261+
// Arming costs real tokens and time: say so first. Turning it off
262+
// never asks.
263+
setAgentTeamsNoticeOpen(true)
264+
handlers.onClose()
265+
return
266+
}
250267
setArmed(previous => !previous)
251268
return
252269
case 'toggleComputerUse': {
@@ -272,6 +289,16 @@ export function useCapabilityMenu(options: {
272289
}
273290
}
274291

292+
const agentTeamsNotice: AgentTeamsNotice = {
293+
open: agentTeamsNoticeOpen,
294+
onClose: () => setAgentTeamsNoticeOpen(false),
295+
onConfirm: ({ dontRemind }) => {
296+
if (dontRemind) dismissAgentTeamsNotice()
297+
setAgentTeamsNoticeOpen(false)
298+
setArmed(true)
299+
},
300+
}
301+
275302
const computerUseConsent: ComputerUseConsent = {
276303
open: consent.open,
277304
loading: consent.loading,
@@ -304,6 +331,7 @@ export function useCapabilityMenu(options: {
304331
onAction,
305332
agentTeamArmed,
306333
disarmAgentTeam: () => setArmed(false),
334+
agentTeamsNotice,
307335
computerUseConsent,
308336
}
309337
}

‎desktop/src/i18n/locales/en.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2455,6 +2455,14 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
24552455
'settings.general.agentTeamsTitle': 'Agent Teams',
24562456
'settings.general.agentTeamsDescription': 'Let multiple agents divide tasks and collaborate as a team.',
24572457
'settings.general.agentTeamsEnabled': 'Enable Agent Teams',
2458+
'agentTeams.name': 'Agent Teams',
2459+
'agentTeams.notice.title': 'Before you turn on Agent Teams',
2460+
'agentTeams.notice.intro': 'Agent Teams has multiple agents work on a task at the same time, so:',
2461+
'agentTeams.notice.tokens': 'Uses a lot of tokens: members run in parallel, so usage is far higher than a single agent.',
2462+
'agentTeams.notice.slow': 'Slower to finish: members have to coordinate and hand work over, so the task usually takes longer overall.',
2463+
'agentTeams.notice.scope': 'Not suited to every task: it fits complex work that splits into fairly independent parts. For simple tasks, or steps that depend on each other, a single agent is faster and cheaper.',
2464+
'agentTeams.notice.confirm': 'Turn on anyway',
2465+
'agentTeams.notice.dontRemind': 'Don\'t remind me again',
24582466
'settings.general.subagentConcurrencyTitle': 'Ordinary SubAgent concurrency limit',
24592467
'settings.general.subagentConcurrencyDescription': 'Limits ordinary foreground and background SubAgents in the same session. New starts are rejected at the limit; start them after existing tasks finish.',
24602468
'settings.general.subagentConcurrencyHint': 'Unlimited by default. Clear the field and save to restore unlimited. The main Agent, Agent Teams, and Workflow do not count. Applies to running sessions without a restart; this is not a global quota across sessions.',

0 commit comments

Comments
 (0)