diff --git a/packages/ui/src/components/CreateAgentModal.tsx b/packages/ui/src/components/CreateAgentModal.tsx index af9d7927..da75da2c 100644 --- a/packages/ui/src/components/CreateAgentModal.tsx +++ b/packages/ui/src/components/CreateAgentModal.tsx @@ -6,9 +6,10 @@ import { useState, useEffect, useMemo } from 'react'; import { LoadingSpinner } from './LoadingSpinner'; -import { agentsApi, modelsApi, toolsApi } from '../api'; +import { GroupedToolSelector } from './GroupedToolSelector'; +import { agentsApi, modelsApi } from '../api'; import { useModalClose } from '../hooks'; -import type { Agent, Tool, ModelInfo } from '../types'; +import type { Agent, ModelInfo } from '../types'; export interface CreateAgentModalProps { onClose: () => void; @@ -22,7 +23,6 @@ export function CreateAgentModal({ onClose, onCreated }: CreateAgentModalProps) const [selectedModel, setSelectedModel] = useState(null); const [selectedTools, setSelectedTools] = useState([]); const [models, setModels] = useState([]); - const [tools, setTools] = useState([]); const [configuredProviders, setConfiguredProviders] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); @@ -30,7 +30,7 @@ export function CreateAgentModal({ onClose, onCreated }: CreateAgentModalProps) const [step, setStep] = useState<'info' | 'model' | 'tools'>('info'); useEffect(() => { - Promise.all([fetchModels(), fetchTools()]).finally(() => setIsLoading(false)); + fetchModels().finally(() => setIsLoading(false)); }, []); const fetchModels = async () => { @@ -46,15 +46,6 @@ export function CreateAgentModal({ onClose, onCreated }: CreateAgentModalProps) } }; - const fetchTools = async () => { - try { - const data = await toolsApi.list(); - setTools(data); - } catch { - // API client handles error reporting - } - }; - const handleSubmit = async () => { if (!name.trim() || !selectedModel) return; @@ -78,12 +69,6 @@ export function CreateAgentModal({ onClose, onCreated }: CreateAgentModalProps) } }; - const toggleTool = (toolName: string) => { - setSelectedTools((prev) => - prev.includes(toolName) ? prev.filter((t) => t !== toolName) : [...prev, toolName] - ); - }; - // Group models by provider const modelsByProvider = useMemo( () => @@ -216,42 +201,10 @@ export function CreateAgentModal({ onClose, onCreated }: CreateAgentModalProps) )} ) : ( -
-

- Select tools this agent can use: -

- {tools.length === 0 ? ( -

- No tools available. -

- ) : ( -
- {tools.map((tool) => ( - - ))} -
- )} -
+ )} {error &&

{error}

} diff --git a/packages/ui/src/components/EditAgentModal.tsx b/packages/ui/src/components/EditAgentModal.tsx index 00c75f04..d6f8b8cb 100644 --- a/packages/ui/src/components/EditAgentModal.tsx +++ b/packages/ui/src/components/EditAgentModal.tsx @@ -6,9 +6,10 @@ import { useState, useEffect, useMemo } from 'react'; import { LoadingSpinner } from './LoadingSpinner'; -import { agentsApi, modelsApi, toolsApi } from '../api'; +import { GroupedToolSelector } from './GroupedToolSelector'; +import { agentsApi, modelsApi } from '../api'; import { useModalClose } from '../hooks'; -import type { Agent, Tool, ModelInfo, AgentDetail } from '../types'; +import type { Agent, ModelInfo, AgentDetail } from '../types'; export interface EditAgentModalProps { agentId: string; @@ -28,7 +29,6 @@ export function EditAgentModal({ agentId, onClose, onUpdated }: EditAgentModalPr const [maxTurns, setMaxTurns] = useState(50); const [maxToolCalls, setMaxToolCalls] = useState(200); const [models, setModels] = useState([]); - const [tools, setTools] = useState([]); const [configuredProviders, setConfiguredProviders] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSubmitting, setIsSubmitting] = useState(false); @@ -36,7 +36,7 @@ export function EditAgentModal({ agentId, onClose, onUpdated }: EditAgentModalPr const [step, setStep] = useState<'info' | 'model' | 'tools' | 'config'>('info'); useEffect(() => { - Promise.all([fetchAgentDetail(), fetchModels(), fetchTools()]).finally(() => + Promise.all([fetchAgentDetail(), fetchModels()]).finally(() => setIsLoading(false) ); }, [agentId]); @@ -67,15 +67,6 @@ export function EditAgentModal({ agentId, onClose, onUpdated }: EditAgentModalPr } }; - const fetchTools = async () => { - try { - const data = await toolsApi.list(); - setTools(data); - } catch { - // API client handles error reporting - } - }; - // Set selected model once both agent detail and models are loaded useEffect(() => { if (agentDetail && models.length > 0) { @@ -115,12 +106,6 @@ export function EditAgentModal({ agentId, onClose, onUpdated }: EditAgentModalPr } }; - const toggleTool = (toolName: string) => { - setSelectedTools((prev) => - prev.includes(toolName) ? prev.filter((t) => t !== toolName) : [...prev, toolName] - ); - }; - // Group models by provider const modelsByProvider = useMemo( () => @@ -256,42 +241,10 @@ export function EditAgentModal({ agentId, onClose, onUpdated }: EditAgentModalPr )} ) : step === 'tools' ? ( -
-

- Select tools this agent can use: -

- {tools.length === 0 ? ( -

- No tools available. -

- ) : ( -
- {tools.map((tool) => ( - - ))} -
- )} -
+ ) : (
diff --git a/packages/ui/src/components/GroupedToolSelector.tsx b/packages/ui/src/components/GroupedToolSelector.tsx new file mode 100644 index 00000000..22dac256 --- /dev/null +++ b/packages/ui/src/components/GroupedToolSelector.tsx @@ -0,0 +1,293 @@ +/** + * Grouped Tool Selector + * + * Displays tools organized by category with group-level select/deselect. + * Users can select an entire category at once, then deselect individual tools. + */ + +import { useState, useEffect, useMemo } from 'react'; +import { toolsApi } from '../api'; +import { LoadingSpinner } from './LoadingSpinner'; +import type { Tool } from '../types'; + +interface CategoryData { + info: { icon: string; description: string }; + tools: Tool[]; +} + +export interface GroupedToolSelectorProps { + selectedTools: string[]; + onSelectionChange: (tools: string[]) => void; +} + +export function GroupedToolSelector({ selectedTools, onSelectionChange }: GroupedToolSelectorProps) { + const [categories, setCategories] = useState>({}); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [collapsedCategories, setCollapsedCategories] = useState>(new Set()); + + useEffect(() => { + const fetchGroupedTools = async () => { + try { + const data = await toolsApi.listGrouped(); + setCategories(data.categories); + } catch { + // API client handles error reporting + } finally { + setIsLoading(false); + } + }; + fetchGroupedTools(); + }, []); + + const toggleTool = (toolName: string) => { + onSelectionChange( + selectedTools.includes(toolName) + ? selectedTools.filter((t) => t !== toolName) + : [...selectedTools, toolName] + ); + }; + + const toggleCategory = (categoryTools: Tool[]) => { + const toolNames = categoryTools.map((t) => t.name); + const allSelected = toolNames.every((name) => selectedTools.includes(name)); + + if (allSelected) { + // Deselect all tools in this category + onSelectionChange(selectedTools.filter((t) => !toolNames.includes(t))); + } else { + // Select all tools in this category + const newTools = toolNames.filter((name) => !selectedTools.includes(name)); + onSelectionChange([...selectedTools, ...newTools]); + } + }; + + const toggleCollapse = (category: string) => { + setCollapsedCategories((prev) => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }; + + const selectAll = () => { + const allTools = Object.values(categories).flatMap((cat) => cat.tools.map((t) => t.name)); + onSelectionChange(allTools); + }; + + const deselectAll = () => { + onSelectionChange([]); + }; + + // Filter categories and tools by search query + const filteredCategories = useMemo(() => { + if (!searchQuery.trim()) return categories; + + const query = searchQuery.toLowerCase(); + const result: Record = {}; + + for (const [key, category] of Object.entries(categories)) { + const matchingTools = category.tools.filter( + (tool) => + tool.name.toLowerCase().includes(query) || + tool.description.toLowerCase().includes(query) + ); + if (matchingTools.length > 0) { + result[key] = { ...category, tools: matchingTools }; + } + } + + return result; + }, [categories, searchQuery]); + + const totalTools = useMemo( + () => Object.values(categories).reduce((sum, cat) => sum + cat.tools.length, 0), + [categories] + ); + + if (isLoading) { + return ; + } + + if (Object.keys(categories).length === 0) { + return ( +

+ No tools available. +

+ ); + } + + return ( +
+ {/* Header bar */} +
+
+ setSearchQuery(e.target.value)} + className="w-full px-3 py-2 pl-8 bg-bg-tertiary dark:bg-dark-bg-tertiary border border-border dark:border-dark-border rounded-lg text-sm text-text-primary dark:text-dark-text-primary focus:outline-none focus:ring-2 focus:ring-primary/50" + placeholder="Search tools..." + /> + + + +
+
+ + {selectedTools.length}/{totalTools} + + + +
+
+ + {/* Category groups */} +
+ {Object.entries(filteredCategories).map(([categoryKey, category]) => { + const toolNames = category.tools.map((t) => t.name); + const selectedCount = toolNames.filter((name) => selectedTools.includes(name)).length; + const allSelected = selectedCount === toolNames.length; + const someSelected = selectedCount > 0 && !allSelected; + const isCollapsed = collapsedCategories.has(categoryKey); + + return ( +
+ {/* Category header */} +
+ + + + {selectedCount}/{toolNames.length} + + + {/* Category toggle button */} + +
+ + {/* Tools list */} + {!isCollapsed && ( +
+ {category.tools.map((tool) => { + const isSelected = selectedTools.includes(tool.name); + return ( + + ); + })} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 720181f2..58701a99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -156,7 +156,7 @@ importers: specifier: ^17.3.1 version: 17.3.1 grammy: - specifier: 1.41.0 + specifier: ^1.41.0 version: 1.41.0 hono: specifier: ^4.12.3 @@ -214,8 +214,8 @@ importers: specifier: ^7.6.13 version: 7.6.13 '@types/node': - specifier: ^25.3.2 - version: 25.3.2 + specifier: ^22.19.13 + version: 22.19.13 '@types/pg': specifier: ^8.18.0 version: 8.18.0 @@ -236,7 +236,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.3.2)(happy-dom@20.7.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + version: 4.0.18(@types/node@22.19.13)(happy-dom@20.7.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) optionalDependencies: '@anthropic-ai/claude-agent-sdk': specifier: ^0.2.63 @@ -4893,13 +4893,13 @@ snapshots: '@slack/logger@4.0.0': dependencies: - '@types/node': 25.3.2 + '@types/node': 22.19.13 '@slack/socket-mode@2.0.5': dependencies: '@slack/logger': 4.0.0 '@slack/web-api': 7.14.1 - '@types/node': 25.3.2 + '@types/node': 22.19.13 '@types/ws': 8.18.1 eventemitter3: 5.0.4 ws: 8.19.0 @@ -4914,7 +4914,7 @@ snapshots: dependencies: '@slack/logger': 4.0.0 '@slack/types': 2.20.0 - '@types/node': 25.3.2 + '@types/node': 22.19.13 '@types/retry': 0.12.0 axios: 1.13.6 eventemitter3: 5.0.4 @@ -5014,7 +5014,7 @@ snapshots: '@types/adm-zip@0.5.7': dependencies: - '@types/node': 25.3.2 + '@types/node': 22.19.13 '@types/archiver@7.0.0': dependencies: @@ -5043,7 +5043,7 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 25.3.2 + '@types/node': 22.19.13 '@types/chai@5.2.3': dependencies: @@ -5092,10 +5092,11 @@ snapshots: '@types/node@25.3.2': dependencies: undici-types: 7.18.2 + optional: true '@types/pg@8.18.0': dependencies: - '@types/node': 25.3.2 + '@types/node': 22.19.13 pg-protocol: 1.12.0 pg-types: 2.2.0 @@ -5103,7 +5104,7 @@ snapshots: '@types/qrcode@1.5.6': dependencies: - '@types/node': 25.3.2 + '@types/node': 22.19.13 '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: @@ -5115,11 +5116,11 @@ snapshots: '@types/readable-stream@4.0.23': dependencies: - '@types/node': 25.3.2 + '@types/node': 22.19.13 '@types/readdir-glob@1.1.5': dependencies: - '@types/node': 25.3.2 + '@types/node': 22.19.13 '@types/retry@0.12.0': {} @@ -5130,11 +5131,11 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 25.3.2 + '@types/node': 22.19.13 '@types/yauzl@2.10.3': dependencies: - '@types/node': 25.3.2 + '@types/node': 22.19.13 optional: true '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': @@ -6237,7 +6238,7 @@ snapshots: happy-dom@20.7.0: dependencies: - '@types/node': 25.3.2 + '@types/node': 22.19.13 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 entities: 7.0.1 @@ -6948,7 +6949,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.3.2 + '@types/node': 22.19.13 long: 5.3.2 proxy-addr@2.0.7: @@ -7511,7 +7512,8 @@ snapshots: undici-types@6.21.0: {} - undici-types@7.18.2: {} + undici-types@7.18.2: + optional: true undici@7.22.0: {}