feat: add tool selection functionality for agents and sessions

Add comprehensive tool management UI allowing users to select which tools are pre-approved for agents and sessions. Includes multi-select dropdowns with tool descriptions, proper validation, and internationalization support across 10+ languages.

- Add tool selection UI to AgentModal, SessionModal, and AgentEssentialSettings
- Extend BaseAgentForm and related types with allowed_tools field
- Implement tool validation and filtering logic
- Add i18n support for tool selection labels and descriptions
- Include visual chip-based display for selected tools
This commit is contained in:
Vaayne 2025-09-23 00:21:04 +08:00
parent b4a92cecc8
commit 14638b7470
13 changed files with 389 additions and 16 deletions

View File

@ -1,5 +1,6 @@
import {
Button,
Chip,
cn,
Form,
Input,
@ -10,17 +11,19 @@ import {
ModalHeader,
Select,
SelectedItemProps,
SelectedItems,
SelectItem,
Textarea,
useDisclosure
} from '@heroui/react'
import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import ClaudeIcon from '@renderer/assets/images/models/claude.png'
import { getModelLogo } from '@renderer/config/models'
import { useAgents } from '@renderer/hooks/agents/useAgents'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent'
import { AddAgentForm, AgentEntity, AgentType, BaseAgentForm, isAgentType, UpdateAgentForm } from '@renderer/types'
import { AddAgentForm, AgentEntity, AgentType, BaseAgentForm, isAgentType, Tool, UpdateAgentForm } from '@renderer/types'
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -37,17 +40,20 @@ interface AgentTypeOption extends BaseOption {
type Option = AgentTypeOption | ModelOption
const buildAgentForm = (existing?: AgentEntity): BaseAgentForm => ({
type AgentWithTools = AgentEntity & { tools?: Tool[] }
const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({
type: existing?.type ?? 'claude-code',
name: existing?.name ?? 'Claude Code',
description: existing?.description,
instructions: existing?.instructions,
model: existing?.model ?? 'claude-4-sonnet',
accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : []
accessible_paths: existing?.accessible_paths ? [...existing.accessible_paths] : [],
allowed_tools: existing?.allowed_tools ? [...existing.allowed_tools] : []
})
interface BaseProps {
agent?: AgentEntity
agent?: AgentWithTools
}
interface TriggerProps extends BaseProps {
@ -83,7 +89,7 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
const updateAgent = useUpdateAgent()
// hard-coded. We only support anthropic for now.
const { models } = useApiModels({ providerType: 'anthropic' })
const isEditing = (agent?: AgentEntity) => agent !== undefined
const isEditing = (agent?: AgentWithTools) => agent !== undefined
const [form, setForm] = useState<BaseAgentForm>(() => buildAgentForm(agent))
@ -93,6 +99,26 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
}
}, [agent, isOpen])
const availableTools = useMemo(() => agent?.tools ?? [], [agent?.tools])
const selectedToolKeys = useMemo(() => new Set(form.allowed_tools), [form.allowed_tools])
useEffect(() => {
if (!availableTools.length) {
return
}
setForm((prev) => {
const validTools = prev.allowed_tools.filter((id) => availableTools.some((tool) => tool.id === id))
if (validTools.length === prev.allowed_tools.length) {
return prev
}
return {
...prev,
allowed_tools: validTools
}
})
}, [availableTools])
// add supported agents type here.
const agentConfig = useMemo(
() =>
@ -160,6 +186,48 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
}))
}, [])
const onAllowedToolsChange = useCallback(
(keys: Selection) => {
setForm((prev) => {
if (keys === 'all') {
return {
...prev,
allowed_tools: availableTools.map((tool) => tool.id)
}
}
const next = Array.from(keys).map(String)
const filtered = availableTools.length
? next.filter((id) => availableTools.some((tool) => tool.id === id))
: next
return {
...prev,
allowed_tools: filtered
}
})
},
[availableTools]
)
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 addAccessiblePath = useCallback(async () => {
try {
const selected = await window.api.file.selectFolder()
@ -246,7 +314,8 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths]
accessible_paths: [...form.accessible_paths],
allowed_tools: [...form.allowed_tools]
} satisfies UpdateAgentForm
updateAgent(updatePayload)
@ -258,7 +327,8 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths]
accessible_paths: [...form.accessible_paths],
allowed_tools: [...form.allowed_tools]
} satisfies AddAgentForm
addAgent(newAgent)
logger.debug('Added agent', newAgent)
@ -276,6 +346,7 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
form.description,
form.instructions,
form.accessible_paths,
form.allowed_tools,
agent,
onClose,
t,
@ -347,6 +418,31 @@ export const AgentModal: React.FC<Props> = ({ agent, trigger, isOpen: _isOpen, o
value={form.description ?? ''}
onValueChange={onDescChange}
/>
<Select
selectionMode="multiple"
selectedKeys={selectedToolKeys}
onSelectionChange={onAllowedToolsChange}
label={t('agent.session.allowed_tools.label')}
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}>
{(tool) => (
<SelectItem key={tool.id} textValue={tool.name}>
<div className="flex flex-col">
<span className="text-sm font-medium">{tool.name}</span>
{tool.description ? (
<span className="text-xs text-foreground-500">{tool.description}</span>
) : null}
</div>
</SelectItem>
)}
</Select>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="font-medium text-foreground text-sm">

View File

@ -1,5 +1,6 @@
import {
Button,
Chip,
cn,
Form,
Input,
@ -16,12 +17,20 @@ import {
useDisclosure
} from '@heroui/react'
import { loggerService } from '@logger'
import type { Selection } from '@react-types/shared'
import { getModelLogo } from '@renderer/config/models'
import { useAgent } from '@renderer/hooks/agents/useAgent'
import { useApiModels } from '@renderer/hooks/agents/useModels'
import { useSessions } from '@renderer/hooks/agents/useSessions'
import { useUpdateSession } from '@renderer/hooks/agents/useUpdateSession'
import { AgentEntity, AgentSessionEntity, BaseSessionForm, CreateSessionForm, UpdateSessionForm } from '@renderer/types'
import {
AgentEntity,
AgentSessionEntity,
BaseSessionForm,
CreateSessionForm,
Tool,
UpdateSessionForm
} from '@renderer/types'
import { ChangeEvent, FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -32,7 +41,10 @@ const logger = loggerService.withContext('SessionAgentPopup')
type Option = ModelOption
const buildSessionForm = (existing?: AgentSessionEntity, agent?: AgentEntity): BaseSessionForm => ({
type AgentWithTools = AgentEntity & { tools?: Tool[] }
type SessionWithTools = AgentSessionEntity & { tools?: Tool[] }
const buildSessionForm = (existing?: SessionWithTools, agent?: AgentWithTools): BaseSessionForm => ({
name: existing?.name ?? agent?.name ?? 'Claude Code',
description: existing?.description ?? agent?.description,
instructions: existing?.instructions ?? agent?.instructions,
@ -41,12 +53,17 @@ const buildSessionForm = (existing?: AgentSessionEntity, agent?: AgentEntity): B
? [...existing.accessible_paths]
: agent?.accessible_paths
? [...agent.accessible_paths]
: [],
allowed_tools: existing?.allowed_tools
? [...existing.allowed_tools]
: agent?.allowed_tools
? [...agent.allowed_tools]
: []
})
interface BaseProps {
agentId: string
session?: AgentSessionEntity
session?: SessionWithTools
onSessionCreated?: (session: AgentSessionEntity) => void
}
@ -102,6 +119,27 @@ export const SessionModal: React.FC<Props> = ({
}
}, [session, agent, isOpen])
const availableTools = useMemo(() => session?.tools ?? agent?.tools ?? [], [agent?.tools, session?.tools])
const selectedToolKeys = useMemo(() => new Set(form.allowed_tools ?? []), [form.allowed_tools])
useEffect(() => {
if (!availableTools.length) {
return
}
setForm((prev) => {
const allowed = prev.allowed_tools ?? []
const validTools = allowed.filter((id) => availableTools.some((tool) => tool.id === id))
if (validTools.length === allowed.length) {
return prev
}
return {
...prev,
allowed_tools: validTools
}
})
}, [availableTools])
const Item = useCallback(({ item }: { item: SelectedItemProps<BaseOption> }) => <Option option={item.data} />, [])
const renderOption = useCallback(
@ -130,6 +168,53 @@ export const SessionModal: React.FC<Props> = ({
}))
}, [])
const onAllowedToolsChange = useCallback(
(keys: Selection) => {
setForm((prev) => {
const existing = prev.allowed_tools ?? []
if (keys === 'all') {
return {
...prev,
allowed_tools: availableTools.map((tool) => tool.id)
}
}
const next = Array.from(keys).map(String)
const filtered = availableTools.length
? next.filter((id) => availableTools.some((tool) => tool.id === id))
: next
if (existing.length === filtered.length && existing.every((id) => filtered.includes(id))) {
return prev
}
return {
...prev,
allowed_tools: filtered
}
})
},
[availableTools]
)
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 modelOptions = useMemo(() => {
// mocked data. not final version
return (models ?? []).map((model) => ({
@ -183,7 +268,8 @@ export const SessionModal: React.FC<Props> = ({
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths]
accessible_paths: [...form.accessible_paths],
allowed_tools: [...(form.allowed_tools ?? [])]
} satisfies UpdateSessionForm
updateSession(updatePayload)
@ -194,7 +280,8 @@ export const SessionModal: React.FC<Props> = ({
description: form.description,
instructions: form.instructions,
model: form.model,
accessible_paths: [...form.accessible_paths]
accessible_paths: [...form.accessible_paths],
allowed_tools: [...(form.allowed_tools ?? [])]
} satisfies CreateSessionForm
const createdSession = await createSession(newSession)
if (createdSession) {
@ -215,6 +302,7 @@ export const SessionModal: React.FC<Props> = ({
form.description,
form.instructions,
form.accessible_paths,
form.allowed_tools,
session,
onClose,
onSessionCreated,
@ -274,6 +362,31 @@ export const SessionModal: React.FC<Props> = ({
value={form.description ?? ''}
onValueChange={onDescChange}
/>
<Select
selectionMode="multiple"
selectedKeys={selectedToolKeys}
onSelectionChange={onAllowedToolsChange}
label={t('agent.session.allowed_tools.label')}
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}>
{(tool) => (
<SelectItem key={tool.id} textValue={tool.name}>
<div className="flex flex-col">
<span className="text-sm font-medium">{tool.name}</span>
{tool.description ? (
<span className="text-xs text-foreground-500">{tool.description}</span>
) : null}
</div>
</SelectItem>
)}
</Select>
<Textarea label={t('common.prompt')} value={form.instructions ?? ''} onValueChange={onInstChange} />
</ModalBody>
<ModalFooter className="w-full">

View File

@ -44,6 +44,12 @@
"add": {
"title": "Add a session"
},
"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"
},
"create": {
"error": {
"failed": "Failed to add a session"

View File

@ -44,6 +44,12 @@
"add": {
"title": "添加会话"
},
"allowed_tools": {
"empty": "当前智能体暂无可用工具。",
"helper": "选择预先授权的工具,未选中的工具在使用时需要手动审批。",
"label": "允许的工具",
"placeholder": "选择允许使用的工具"
},
"create": {
"error": {
"failed": "添加会话失败"

View File

@ -44,6 +44,12 @@
"add": {
"title": "新增會議"
},
"allowed_tools": {
"empty": "目前此代理沒有可用的工具。",
"helper": "選擇預先授權的工具,未選取的工具在使用時需要手動審批。",
"label": "允許的工具",
"placeholder": "選擇允許使用的工具"
},
"create": {
"error": {
"failed": "無法新增工作階段"

View File

@ -44,6 +44,12 @@
"add": {
"title": "Προσθήκη συνεδρίας"
},
"allowed_tools": {
"empty": "Δεν υπάρχουν διαθέσιμα εργαλεία για αυτόν τον agent.",
"helper": "Επιλέξτε ποια εργαλεία είναι προεγκεκριμένα. Τα μη επιλεγμένα θα χρειαστούν έγκριση πριν από τη χρήση.",
"label": "Επιτρεπόμενα εργαλεία",
"placeholder": "Επιλέξτε επιτρεπόμενα εργαλεία"
},
"create": {
"error": {
"failed": "Αποτυχία προσθήκης συνεδρίας"

View File

@ -44,6 +44,12 @@
"add": {
"title": "Agregar una sesión"
},
"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"
},
"create": {
"error": {
"failed": "Error al añadir una sesión"

View File

@ -44,6 +44,12 @@
"add": {
"title": "Ajouter une session"
},
"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"
},
"create": {
"error": {
"failed": "Échec de l'ajout d'une session"

View File

@ -44,6 +44,12 @@
"add": {
"title": "セッションを追加"
},
"allowed_tools": {
"empty": "このエージェントが利用できるツールはありません。",
"helper": "事前承認済みのツールを選択します。未選択のツールは使用時に承認が必要になります。",
"label": "許可されたツール",
"placeholder": "許可するツールを選択"
},
"create": {
"error": {
"failed": "セッションの追加に失敗しました"

View File

@ -44,6 +44,12 @@
"add": {
"title": "Adicionar uma sessão"
},
"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"
},
"create": {
"error": {
"failed": "Falha ao adicionar uma sessão"

View File

@ -44,6 +44,12 @@
"add": {
"title": "Добавить сеанс"
},
"allowed_tools": {
"empty": "Для этого агента нет доступных инструментов.",
"helper": "Выберите инструменты с предварительным допуском. Неотмеченные инструменты потребуют подтверждения перед использованием.",
"label": "Разрешённые инструменты",
"placeholder": "Выберите разрешённые инструменты"
},
"create": {
"error": {
"failed": "Не удалось добавить сеанс"

View File

@ -1,13 +1,14 @@
import { Button, Tooltip } from '@heroui/react'
import { Button, Chip, Select as HeroSelect, SelectedItems, SelectItem, 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 { AgentEntity, UpdateAgentForm } from '@renderer/types'
import { GetAgentResponse, Tool, UpdateAgentForm } from '@renderer/types'
import { Input, Select } from 'antd'
import { DefaultOptionType } from 'antd/es/select'
import { Plus } from 'lucide-react'
import { FC, useCallback, useMemo, useState } from 'react'
import { FC, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AgentLabel, SettingsContainer, SettingsItem, SettingsTitle } from './shared'
@ -15,7 +16,7 @@ import { AgentLabel, SettingsContainer, SettingsItem, SettingsTitle } from './sh
const logger = loggerService.withContext('AgentEssentialSettings')
interface AgentEssentialSettingsProps {
agent: AgentEntity | undefined | null
agent: GetAgentResponse | undefined | null
update: ReturnType<typeof useUpdateAgent>
}
@ -23,6 +24,9 @@ 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
@ -42,6 +46,14 @@ 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,
@ -49,6 +61,27 @@ 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
@ -83,6 +116,53 @@ 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
return (
@ -117,6 +197,35 @@ 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="text-sm font-medium">{tool.name}</span>
{tool.description ? (
<span className="text-xs text-foreground-500">{tool.description}</span>
) : null}
</div>
</SelectItem>
)}
</HeroSelect>
</SettingsItem>
<SettingsItem>
<SettingsTitle
actions={

View File

@ -169,6 +169,7 @@ export type BaseAgentForm = {
instructions?: string
model: string
accessible_paths: string[]
allowed_tools: string[]
}
export type AddAgentForm = Omit<BaseAgentForm, 'id'> & { id?: never }