♻️ refactor: improve agent tool approval UI with dedicated settings tab

- Move tool selection from essential settings to dedicated "Pre-approved tools" tab
- Update terminology from "Allowed tools" to "Pre-approved tools" for clarity
- Add new AgentToolSettings component with enhanced card-based layout
- Include warning alert about pre-approved tools bypassing review
- Update all language files with new terminology and translation keys
- Add i18n sync guidance to CLAUDE.md development commands
This commit is contained in:
Vaayne 2025-09-23 10:28:44 +08:00
parent 9c679ede20
commit 7ca9dcd2fb
13 changed files with 269 additions and 137 deletions

View File

@ -24,7 +24,7 @@ This file provides guidance to AI coding assistants when working with code in th
- **Install**: `yarn install`
- **Development**: `yarn dev` - Runs Electron app in development mode
- **Debug**: `yarn debug` - Starts with debugging enabled, use chrome://inspect
- **Build Check**: `yarn build:check` - REQUIRED before commits (lint + test + typecheck)
- **Build Check**: `yarn build:check` - REQUIRED before commits (lint + test + typecheck), if having i18n sort issues, run `yarn i18n:sync` first to sync template.
- **Test**: `yarn test` - Run all tests (Vitest)
- **Single Test**: `yarn test:main` or `yarn test:renderer`
- **Lint**: `yarn lint` - Fix linting issues and run typecheck

View File

@ -46,9 +46,9 @@
},
"allowed_tools": {
"empty": "No tools available for this agent.",
"helper": "Choose which tools are pre-approved. Unselected tools require approval before use.",
"label": "Allowed tools",
"placeholder": "Select allowed tools"
"helper": "Pre-approved tools run without manual approval. Unselected tools require approval before use.",
"label": "Pre-approved tools",
"placeholder": "Select pre-approved tools"
},
"create": {
"error": {
@ -101,7 +101,16 @@
},
"essential": "Essential Settings",
"mcps": "MCP Servers",
"prompt": "Prompt Settings"
"prompt": "Prompt Settings",
"tools": {
"approved": "approved",
"caution": "Pre-approved tools bypass human review. Enable only trusted tools.",
"description": "Choose which tools can run without manual approval.",
"requiresPermission": "Requires permission when not pre-approved.",
"tab": "Pre-approved tools",
"title": "Pre-approved tools",
"toggle": "{{defaultValue}}"
}
},
"type": {
"label": "Agent Type",

View File

@ -47,8 +47,8 @@
"allowed_tools": {
"empty": "当前智能体暂无可用工具。",
"helper": "选择预先授权的工具,未选中的工具在使用时需要手动审批。",
"label": "允许的工具",
"placeholder": "选择允许使用的工具"
"label": "预先授权工具",
"placeholder": "选择预先授权的工具"
},
"create": {
"error": {
@ -101,7 +101,16 @@
},
"essential": "基础设置",
"mcps": "MCP 服务器",
"prompt": "提示词设置"
"prompt": "提示词设置",
"tools": {
"approved": "已授权",
"caution": "预先授权的工具会跳过人工审核,请仅启用可信的工具。",
"description": "选择哪些工具可以在无需人工审批的情况下执行。",
"requiresPermission": "未预先授权时需要人工审批。",
"tab": "预先授权工具",
"title": "预先授权工具",
"toggle": "{{defaultValue}}"
}
},
"type": {
"label": "智能体类型",

View File

@ -47,8 +47,8 @@
"allowed_tools": {
"empty": "目前此代理沒有可用的工具。",
"helper": "選擇預先授權的工具,未選取的工具在使用時需要手動審批。",
"label": "允許的工具",
"placeholder": "選擇允許使用的工具"
"label": "預先授權工具",
"placeholder": "選擇預先授權的工具"
},
"create": {
"error": {
@ -101,7 +101,16 @@
},
"essential": "必要設定",
"mcps": "MCP 伺服器",
"prompt": "提示設定"
"prompt": "提示設定",
"tools": {
"approved": "已授權",
"caution": "預先授權的工具會略過人工審查,請僅啟用可信任的工具。",
"description": "選擇哪些工具可在無需人工核准的情況下執行。",
"requiresPermission": "未預先授權時需要人工核准。",
"tab": "預先授權工具",
"title": "預先授權工具",
"toggle": "{{defaultValue}}"
}
},
"type": {
"label": "代理類型",

View File

@ -47,8 +47,8 @@
"allowed_tools": {
"empty": "Δεν υπάρχουν διαθέσιμα εργαλεία για αυτόν τον agent.",
"helper": "Επιλέξτε ποια εργαλεία είναι προεγκεκριμένα. Τα μη επιλεγμένα θα χρειαστούν έγκριση πριν από τη χρήση.",
"label": "Επιτρεπόμενα εργαλεία",
"placeholder": "Επιλέξτε επιτρεπόμενα εργαλεία"
"label": "[to be translated]:Pre-approved tools",
"placeholder": "[to be translated]:Select pre-approved tools"
},
"create": {
"error": {
@ -101,7 +101,16 @@
},
"essential": "Βασικές Ρυθμίσεις",
"mcps": "[to be translated]:MCP 服务器",
"prompt": "Ρυθμίσεις Προτροπής"
"prompt": "Ρυθμίσεις Προτροπής",
"tools": {
"approved": "[to be translated]:approved",
"caution": "[to be translated]:Pre-approved tools bypass human review. Enable only trusted tools.",
"description": "[to be translated]:Choose which tools can run without manual approval.",
"requiresPermission": "[to be translated]:Requires permission when not pre-approved.",
"tab": "[to be translated]:Pre-approved tools",
"title": "[to be translated]:Pre-approved tools",
"toggle": "{{defaultValue}}"
}
},
"type": {
"label": "Τύπος Πράκτορα",

View File

@ -47,8 +47,8 @@
"allowed_tools": {
"empty": "No hay herramientas disponibles para este agente.",
"helper": "Elige qué herramientas quedan preaprobadas. Las no seleccionadas requerirán aprobación manual antes de usarse.",
"label": "Herramientas permitidas",
"placeholder": "Selecciona las herramientas permitidas"
"label": "[to be translated]:Pre-approved tools",
"placeholder": "[to be translated]:Select pre-approved tools"
},
"create": {
"error": {
@ -101,7 +101,16 @@
},
"essential": "Configuraciones esenciales",
"mcps": "[to be translated]:MCP 服务器",
"prompt": "Configuración de indicaciones"
"prompt": "Configuración de indicaciones",
"tools": {
"approved": "[to be translated]:approved",
"caution": "[to be translated]:Pre-approved tools bypass human review. Enable only trusted tools.",
"description": "[to be translated]:Choose which tools can run without manual approval.",
"requiresPermission": "[to be translated]:Requires permission when not pre-approved.",
"tab": "[to be translated]:Pre-approved tools",
"title": "[to be translated]:Pre-approved tools",
"toggle": "{{defaultValue}}"
}
},
"type": {
"label": "Tipo de Agente",

View File

@ -47,8 +47,8 @@
"allowed_tools": {
"empty": "Aucun outil disponible pour cet agent.",
"helper": "Choisissez les outils préapprouvés. Les outils non sélectionnés nécessiteront une approbation avant utilisation.",
"label": "Outils autorisés",
"placeholder": "Sélectionner les outils autorisés"
"label": "[to be translated]:Pre-approved tools",
"placeholder": "[to be translated]:Select pre-approved tools"
},
"create": {
"error": {
@ -101,7 +101,16 @@
},
"essential": "Paramètres essentiels",
"mcps": "[to be translated]:MCP 服务器",
"prompt": "Paramètres de l'invite"
"prompt": "Paramètres de l'invite",
"tools": {
"approved": "[to be translated]:approved",
"caution": "[to be translated]:Pre-approved tools bypass human review. Enable only trusted tools.",
"description": "[to be translated]:Choose which tools can run without manual approval.",
"requiresPermission": "[to be translated]:Requires permission when not pre-approved.",
"tab": "[to be translated]:Pre-approved tools",
"title": "[to be translated]:Pre-approved tools",
"toggle": "{{defaultValue}}"
}
},
"type": {
"label": "Type d'agent",

View File

@ -47,8 +47,8 @@
"allowed_tools": {
"empty": "このエージェントが利用できるツールはありません。",
"helper": "事前承認済みのツールを選択します。未選択のツールは使用時に承認が必要になります。",
"label": "許可されたツール",
"placeholder": "許可するツールを選択"
"label": "事前承認済みツール",
"placeholder": "事前承認するツールを選択"
},
"create": {
"error": {
@ -101,7 +101,16 @@
},
"essential": "必須設定",
"mcps": "[to be translated]:MCP 服务器",
"prompt": "プロンプト設定"
"prompt": "プロンプト設定",
"tools": {
"approved": "承認済み",
"caution": "事前承認したツールは人によるレビューをスキップします。信頼できるツールのみ有効にしてください。",
"description": "人による承認なしで実行できるツールを選択します。",
"requiresPermission": "事前承認されていない場合は承認が必要です。",
"tab": "事前承認済みツール",
"title": "事前承認済みツール",
"toggle": "{{defaultValue}}"
}
},
"type": {
"label": "エージェントタイプ",

View File

@ -47,8 +47,8 @@
"allowed_tools": {
"empty": "Não há ferramentas disponíveis para este agente.",
"helper": "Escolha quais ferramentas ficam pré-autorizadas. As não selecionadas exigirão aprovação antes do uso.",
"label": "Ferramentas permitidas",
"placeholder": "Selecione as ferramentas permitidas"
"label": "[to be translated]:Pre-approved tools",
"placeholder": "[to be translated]:Select pre-approved tools"
},
"create": {
"error": {
@ -101,7 +101,16 @@
},
"essential": "Configurações Essenciais",
"mcps": "[to be translated]:MCP 服务器",
"prompt": "Configurações de Prompt"
"prompt": "Configurações de Prompt",
"tools": {
"approved": "[to be translated]:approved",
"caution": "[to be translated]:Pre-approved tools bypass human review. Enable only trusted tools.",
"description": "[to be translated]:Choose which tools can run without manual approval.",
"requiresPermission": "[to be translated]:Requires permission when not pre-approved.",
"tab": "[to be translated]:Pre-approved tools",
"title": "[to be translated]:Pre-approved tools",
"toggle": "{{defaultValue}}"
}
},
"type": {
"label": "Tipo de Agente",

View File

@ -47,8 +47,8 @@
"allowed_tools": {
"empty": "Для этого агента нет доступных инструментов.",
"helper": "Выберите инструменты с предварительным допуском. Неотмеченные инструменты потребуют подтверждения перед использованием.",
"label": "Разрешённые инструменты",
"placeholder": "Выберите разрешённые инструменты"
"label": "[to be translated]:Pre-approved tools",
"placeholder": "[to be translated]:Select pre-approved tools"
},
"create": {
"error": {
@ -101,7 +101,16 @@
},
"essential": "Основные настройки",
"mcps": "[to be translated]:MCP 服务器",
"prompt": "Настройки подсказки"
"prompt": "Настройки подсказки",
"tools": {
"approved": "[to be translated]:approved",
"caution": "[to be translated]:Pre-approved tools bypass human review. Enable only trusted tools.",
"description": "[to be translated]:Choose which tools can run without manual approval.",
"requiresPermission": "[to be translated]:Requires permission when not pre-approved.",
"tab": "[to be translated]:Pre-approved tools",
"title": "[to be translated]:Pre-approved tools",
"toggle": "{{defaultValue}}"
}
},
"type": {
"label": "Тип агента",

View File

@ -1,14 +1,13 @@
import { Button, Chip, Select as HeroSelect, SelectedItems, SelectItem, Tooltip } from '@heroui/react'
import { Button, Tooltip } from '@heroui/react'
import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import { ApiModelLabel } from '@renderer/components/ApiModelLabel'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { GetAgentResponse, Tool, UpdateAgentForm } from '@renderer/types'
import { GetAgentResponse, UpdateAgentForm } from '@renderer/types'
import { Input, Select } from 'antd'
import { DefaultOptionType } from 'antd/es/select'
import { Plus } from 'lucide-react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { FC, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AgentLabel, SettingsContainer, SettingsItem, SettingsTitle } from './shared'
@ -24,9 +23,6 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
const { t } = useTranslation()
const [name, setName] = useState<string>((agent?.name ?? '').trim())
const { models } = useApiModels({ providerType: 'anthropic' })
const availableTools = useMemo(() => agent?.tools ?? [], [agent?.tools])
const [allowedToolIds, setAllowedToolIds] = useState<string[]>([])
const selectedToolKeys = useMemo<Selection>(() => new Set<string>(allowedToolIds), [allowedToolIds])
const updateName = (name: string) => {
if (!agent) return
@ -46,14 +42,6 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
[agent, update]
)
const updateAllowedTools = useCallback(
(allowed_tools: UpdateAgentForm['allowed_tools']) => {
if (!agent) return
update({ id: agent.id, allowed_tools })
},
[agent, update]
)
const modelOptions = useMemo(() => {
return models.map((model) => ({
value: model.id,
@ -61,27 +49,6 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
})) satisfies DefaultOptionType[]
}, [models])
useEffect(() => {
if (!agent) {
setAllowedToolIds((prev) => (prev.length === 0 ? prev : []))
return
}
const allowed = agent.allowed_tools ?? []
const filtered = availableTools.length
? allowed.filter((id) => availableTools.some((tool) => tool.id === id))
: allowed
setAllowedToolIds((prev) => {
const prevSet = new Set(prev)
const isSame = filtered.length === prevSet.size && filtered.every((id) => prevSet.has(id))
if (isSame) {
return prev
}
return filtered
})
}, [agent, availableTools])
const addAccessiblePath = useCallback(async () => {
if (!agent) return
@ -116,52 +83,6 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
[agent, t, updateAccessiblePaths]
)
const renderSelectedTools = useCallback((items: SelectedItems<Tool>) => {
if (!items.length) {
return null
}
return (
<div className="flex flex-wrap gap-2">
{items.map((item) => (
<Chip key={item.key} size="sm" variant="flat" className="max-w-[160px] truncate">
{item.data?.name ?? item.textValue ?? item.key}
</Chip>
))}
</div>
)
}, [])
const onAllowedToolsChange = useCallback(
(keys: Selection) => {
if (!agent) return
const nextIds = keys === 'all' ? availableTools.map((tool) => tool.id) : Array.from(keys).map(String)
const filtered = availableTools.length
? nextIds.filter((id) => availableTools.some((tool) => tool.id === id))
: nextIds
setAllowedToolIds((prev) => {
const prevSet = new Set(prev)
const isSame = filtered.length === prevSet.size && filtered.every((id) => prevSet.has(id))
if (isSame) {
return prev
}
return filtered
})
const previous = agent.allowed_tools ?? []
const previousSet = new Set(previous)
const isSameSelection = filtered.length === previousSet.size && filtered.every((id) => previousSet.has(id))
if (isSameSelection) {
return
}
updateAllowedTools(filtered)
},
[agent, availableTools, updateAllowedTools]
)
if (!agent) return null
@ -197,31 +118,6 @@ const AgentEssentialSettings: FC<AgentEssentialSettingsProps> = ({ agent, update
placeholder={t('common.placeholders.select.model')}
/>
</SettingsItem>
<SettingsItem>
<SettingsTitle>{t('agent.session.allowed_tools.label')}</SettingsTitle>
<HeroSelect
aria-label={t('agent.session.allowed_tools.label')}
selectionMode="multiple"
selectedKeys={selectedToolKeys}
onSelectionChange={onAllowedToolsChange}
placeholder={t('agent.session.allowed_tools.placeholder')}
description={
availableTools.length ? t('agent.session.allowed_tools.helper') : t('agent.session.allowed_tools.empty')
}
isDisabled={!availableTools.length}
items={availableTools}
renderValue={renderSelectedTools}
className="max-w-xl">
{(tool) => (
<SelectItem key={tool.id} textValue={tool.name}>
<div className="flex flex-col">
<span className="font-medium text-sm">{tool.name}</span>
{tool.description ? <span className="text-foreground-500 text-xs">{tool.description}</span> : null}
</div>
</SelectItem>
)}
</HeroSelect>
</SettingsItem>
<SettingsItem>
<SettingsTitle
actions={

View File

@ -0,0 +1,149 @@
import { Alert, Card, CardBody, CardHeader, Switch, Tooltip } from '@heroui/react'
import { GetAgentResponse, Tool, UpdateAgentForm } from '@renderer/types'
import { Info } from 'lucide-react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { SettingsContainer, SettingsItem, SettingsTitle } from './shared'
interface AgentToolSettingsProps {
agent: GetAgentResponse | undefined | null
updateAgent: (form: UpdateAgentForm) => Promise<void> | void
}
const isSameSelection = (next: string[], previous: string[]) => {
if (next.length !== previous.length) {
return false
}
const previousSet = new Set(previous)
return next.every((id) => previousSet.has(id))
}
export const AgentToolSettings: FC<AgentToolSettingsProps> = ({ agent, updateAgent }) => {
const { t } = useTranslation()
const [approvedIds, setApprovedIds] = useState<string[]>([])
const availableTools = useMemo<Tool[]>(() => agent?.tools ?? [], [agent?.tools])
useEffect(() => {
if (!agent) {
setApprovedIds((prev) => (prev.length === 0 ? prev : []))
return
}
const allowed = agent.allowed_tools ?? []
const validIds = allowed.filter((id) => availableTools.some((tool) => tool.id === id))
setApprovedIds((prev) => {
if (isSameSelection(prev, validIds)) {
return prev
}
return validIds
})
}, [agent, availableTools])
const handleToggle = useCallback(
(toolId: string, isApproved: boolean) => {
if (!agent) return
setApprovedIds((prev) => {
const exists = prev.includes(toolId)
if (isApproved === exists) {
return prev
}
const next = isApproved ? [...prev, toolId] : prev.filter((id) => id !== toolId)
const previous = agent.allowed_tools ?? []
if (!isSameSelection(next, previous)) {
updateAgent({ id: agent.id, allowed_tools: next })
}
return next
})
},
[agent, updateAgent]
)
const approvedCount = useMemo(() => {
return approvedIds.filter((id) => availableTools.some((tool) => tool.id === id)).length
}, [approvedIds, availableTools])
if (!agent) {
return null
}
return (
<SettingsContainer>
<SettingsItem divider={false} className="flex-1">
<div className="flex h-full flex-col gap-4">
<div className="flex items-center justify-between">
<SettingsTitle>
{t('agent.settings.tools.title', 'Pre-approved tools')}
<Tooltip
placement="right"
content={t(
'agent.settings.tools.description',
'Choose which tools can run without manual approval.'
)}>
<Info size={16} className="text-foreground-400" />
</Tooltip>
</SettingsTitle>
{availableTools.length > 0 ? (
<span className="text-foreground-500 text-xs">
{approvedCount} / {availableTools.length} {t('agent.settings.tools.approved', 'approved')}
</span>
) : null}
</div>
<Alert
color="warning"
title={t(
'agent.settings.tools.caution',
'Pre-approved tools bypass human review. Enable only trusted tools.'
)}
/>
{availableTools.length > 0 ? (
<div className="flex flex-1 flex-col gap-3 overflow-auto pr-1">
{availableTools.map((tool) => {
const isApproved = approvedIds.includes(tool.id)
return (
<Card key={tool.id} shadow="none" className="border border-default-200">
<CardHeader className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate font-medium text-sm">{tool.name}</span>
{tool.description ? (
<span className="line-clamp-2 text-foreground-500 text-xs">{tool.description}</span>
) : null}
</div>
<Switch
aria-label={t('agent.settings.tools.toggle', {
defaultValue: `Toggle ${tool.name}`
})}
isSelected={isApproved}
size="sm"
onValueChange={(value) => handleToggle(tool.id, value)}
/>
</CardHeader>
{tool.requirePermissions ? (
<CardBody className="py-0 pb-3">
<span className="text-foreground-400 text-xs">
{t(
'agent.settings.tools.requiresPermission',
'Requires permission when not pre-approved.'
)}
</span>
</CardBody>
) : null}
</Card>
)
})}
</div>
) : (
<div className="flex flex-1 items-center justify-center rounded-medium border border-default-200 border-dashed px-4 py-10 text-foreground-500 text-sm">
{t('agent.session.allowed_tools.empty')}
</div>
)}
</div>
</SettingsItem>
</SettingsContainer>
)
}
export default AgentToolSettings

View File

@ -11,6 +11,7 @@ import AgentAdvanceSettings from './AgentAdvanceSettings'
import AgentEssentialSettings from './AgentEssentialSettings'
import AgentMCPSettings from './AgentMCPSettings'
import AgentPromptSettings from './AgentPromptSettings'
import AgentToolSettings from './AgentToolSettings'
import { AgentLabel } from './shared'
interface AgentSettingPopupShowParams {
@ -22,7 +23,7 @@ interface AgentSettingPopupParams extends AgentSettingPopupShowParams {
resolve: () => void
}
type AgentSettingPopupTab = 'essential' | 'prompt' | 'mcps' | 'advance' | 'session-mcps'
type AgentSettingPopupTab = 'essential' | 'prompt' | 'tools' | 'mcps' | 'advance' | 'session-mcps'
const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, agentId, resolve }) => {
const [open, setOpen] = useState(true)
@ -54,6 +55,10 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
key: 'prompt',
label: t('agent.settings.prompt')
},
{
key: 'tools',
label: t('agent.settings.tools.tab', 'Pre-approved tools')
},
{
key: 'mcps',
label: t('agent.settings.mcps', 'MCP Servers')
@ -91,6 +96,7 @@ const AgentSettingPopupContainer: React.FC<AgentSettingPopupParams> = ({ tab, ag
<Settings>
{menu === 'essential' && <AgentEssentialSettings agent={agent} update={updateAgent} />}
{menu === 'prompt' && <AgentPromptSettings agent={agent} update={updateAgent} />}
{menu === 'tools' && <AgentToolSettings agent={agent} updateAgent={updateAgent} />}
{menu === 'mcps' && <AgentMCPSettings agent={agent} updateAgent={updateAgent} />}
{menu === 'advance' && <AgentAdvanceSettings agent={agent} updateAgent={updateAgent} />}
</Settings>