From 55567239b0ba48b60b20330736600e31dcbaf163 Mon Sep 17 00:00:00 2001 From: Sean Hatfield Date: Wed, 22 Apr 2026 17:41:09 -0700 Subject: [PATCH] Show agent skills, flows, and MCP tools in chat tools menu (#5444) * show agent skills, flows, and MCP tools in collapsible sections in chat tools menu * fix tools menu toggle disabled bypass, add border-none to buttons, and useMemo improvements * replace mcp server cache with loading state for mcp servers * enable sub-skill management * refactor * Translations for chat tools menu improvements (#5448) * normalize translations * update translations * norm translations --------- Co-authored-by: Timothy Carambat --------- Co-authored-by: Timothy Carambat --- .../Tabs/AgentSkills/SkillRow/index.jsx | 27 +- .../Tabs/AgentSkills/SkillSection/index.jsx | 57 +++ .../ToolsMenu/Tabs/AgentSkills/index.jsx | 367 ++++++++++++------ .../Tabs/AgentSkills/skillRegistry.js | 81 ++++ .../Tabs/AgentSkills/useAgentSkillsState.js | 182 +++++++++ .../Tabs/AgentSkills/useSkillSections.js | 187 +++++++++ .../AgentSkills/useSubSkillPreferences.js | 78 ++++ .../PromptInput/ToolsMenu/index.jsx | 2 +- frontend/src/locales/ar/common.js | 6 + frontend/src/locales/ca/common.js | 6 + frontend/src/locales/cs/common.js | 6 + frontend/src/locales/da/common.js | 6 + frontend/src/locales/de/common.js | 6 + frontend/src/locales/en/common.js | 6 + frontend/src/locales/es/common.js | 6 + frontend/src/locales/et/common.js | 6 + frontend/src/locales/fa/common.js | 6 + frontend/src/locales/fr/common.js | 6 + frontend/src/locales/he/common.js | 6 + frontend/src/locales/it/common.js | 6 + frontend/src/locales/ja/common.js | 6 + frontend/src/locales/ko/common.js | 6 + frontend/src/locales/lt/common.js | 6 + frontend/src/locales/lv/common.js | 6 + frontend/src/locales/nl/common.js | 6 + frontend/src/locales/pl/common.js | 6 + frontend/src/locales/pt_BR/common.js | 6 + frontend/src/locales/ro/common.js | 6 + frontend/src/locales/ru/common.js | 6 + frontend/src/locales/tr/common.js | 6 + frontend/src/locales/vn/common.js | 6 + frontend/src/locales/zh/common.js | 6 + frontend/src/locales/zh_TW/common.js | 6 + .../Agents/CreateFileSkillPanel/index.jsx | 2 +- .../Agents/FileSystemSkillPanel/index.jsx | 2 +- 35 files changed, 994 insertions(+), 141 deletions(-) create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillSection/index.jsx create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/skillRegistry.js create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/useAgentSkillsState.js create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/useSkillSections.js create mode 100644 frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/useSubSkillPreferences.js diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillRow/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillRow/index.jsx index 12006898..47182b61 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillRow/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillRow/index.jsx @@ -1,4 +1,5 @@ -import Toggle from "@/components/lib/Toggle"; +import { useRef, useEffect } from "react"; +import { SimpleToggleSwitch } from "@/components/lib/Toggle"; export default function SkillRow({ name, @@ -7,24 +8,30 @@ export default function SkillRow({ highlighted = false, disabled = false, }) { - let classNames = "flex items-center justify-between px-2 py-1 rounded"; + const ref = useRef(null); + useEffect(() => { + if (highlighted) ref.current?.scrollIntoView({ block: "nearest" }); + }, [highlighted]); + + let classNames = + "border-none bg-transparent w-full flex items-center justify-between px-2 py-1 rounded"; if (highlighted) classNames += " bg-zinc-700/50 light:bg-slate-100"; else classNames += " hover:bg-zinc-700/50 light:hover:bg-slate-100"; if (disabled) classNames += " opacity-60 cursor-not-allowed"; else classNames += " cursor-pointer"; return ( -
!disabled && onToggle()} data-tooltip-id={disabled ? "agent-skill-disabled-tooltip" : undefined} > {name} - -
+ + ); } diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillSection/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillSection/index.jsx new file mode 100644 index 00000000..07a1d3b5 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillSection/index.jsx @@ -0,0 +1,57 @@ +import { useRef, useEffect } from "react"; +import { CaretDown } from "@phosphor-icons/react"; + +export default function SkillSection({ + name, + expanded, + onToggle, + enabledCount, + totalCount, + isMcp = false, + indented = false, + highlighted = false, + children, +}) { + const ref = useRef(null); + useEffect(() => { + if (highlighted) ref.current?.scrollIntoView({ block: "nearest" }); + }, [highlighted]); + + let headerClasses = + "border-none bg-transparent w-full flex items-center justify-between px-2 py-1 rounded cursor-pointer"; + if (highlighted) headerClasses += " bg-zinc-700/50 light:bg-slate-100"; + else headerClasses += " hover:bg-zinc-700/30 light:hover:bg-slate-50"; + + return ( +
+ + {expanded &&
{children}
} +
+ ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/index.jsx index 5ed32c37..27fb03b9 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/index.jsx @@ -1,20 +1,22 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import paths from "@/utils/paths"; -import Admin from "@/models/admin"; -import System from "@/models/system"; -import AgentPlugins from "@/models/experimental/agentPlugins"; -import AgentFlows from "@/models/agentFlows"; import { getDefaultSkills, getConfigurableSkills, + getAppIntegrationSkills, } from "@/pages/Admin/Agents/skills"; import useToolsMenuItems from "../../useToolsMenuItems"; +import useAgentSkillsState from "./useAgentSkillsState"; +import useSkillSections from "./useSkillSections"; import SkillRow from "./SkillRow"; -import { Wrench } from "@phosphor-icons/react"; +import SkillSection from "./SkillSection"; +import { Wrench, MagnifyingGlass, CircleNotch } from "@phosphor-icons/react"; import { useIsAgentSessionActive } from "@/utils/chat/agent"; +const MIN_ITEMS_TO_SHOW_SEARCH = 10; + export default function AgentSkillsTab({ highlightedIndex = -1, registerItemCount, @@ -23,133 +25,151 @@ export default function AgentSkillsTab({ const { t } = useTranslation(); const { showAgentCommand = true } = workspace ?? {}; const agentSessionActive = useIsAgentSessionActive(); + + // Get skill definitions const defaultSkills = getDefaultSkills(t); - const [fileSystemAgentAvailable, setFileSystemAgentAvailable] = - useState(false); + const appIntegrationSkills = getAppIntegrationSkills(t); + + // All skill state management + const { + fileSystemAgentAvailable, + importedSkills, + flows, + mcpServers, + loading, + mcpLoading, + isSkillEnabled, + toggleSkill, + toggleImportedSkill, + toggleFlow, + toggleMcpTool, + isSubSkillEnabled, + toggleSubSkill, + disabledSubSkills, + } = useAgentSkillsState(defaultSkills); + const configurableSkills = getConfigurableSkills(t, { fileSystemAgentAvailable, }); - const [disabledDefaults, setDisabledDefaults] = useState([]); - const [enabledConfigurable, setEnabledConfigurable] = useState([]); - const [importedSkills, setImportedSkills] = useState([]); - const [flows, setFlows] = useState([]); - const [loading, setLoading] = useState(true); + + // UI state + const [expandedSections, setExpandedSections] = useState({}); + const [expandedSubSections, setExpandedSubSections] = useState({}); + const [searchQuery, setSearchQuery] = useState(""); + const showAgentCmdActivationAlert = showAgentCommand && !agentSessionActive; - useEffect(() => { - fetchSkillSettings(); - }, []); + // Build all sections + const sections = useSkillSections({ + t, + defaultSkills, + configurableSkills, + appIntegrationSkills, + importedSkills, + flows, + mcpServers, + isSkillEnabled, + toggleSkill, + isSubSkillEnabled, + toggleSubSkill, + toggleImportedSkill, + toggleFlow, + toggleMcpTool, + disabledSubSkills, + }); - async function fetchSkillSettings() { - try { - const [prefs, flowsRes, fsAgentAvailable] = await Promise.all([ - Admin.systemPreferencesByFields([ - "disabled_agent_skills", - "default_agent_skills", - "imported_agent_skills", - ]), - AgentFlows.listFlows(), - System.isFileSystemAgentAvailable(), - ]); + // Section expansion helpers + function isSectionExpanded(sectionId) { + return !!(searchQuery.trim() || expandedSections[sectionId]); + } - if (prefs?.settings) { - setDisabledDefaults(prefs.settings.disabled_agent_skills ?? []); - setEnabledConfigurable(prefs.settings.default_agent_skills ?? []); - setImportedSkills(prefs.settings.imported_agent_skills ?? []); + function toggleSection(sectionId) { + setExpandedSections((prev) => ({ + ...prev, + [sectionId]: !prev[sectionId], + })); + } + + function isSubSectionExpanded(subSectionId) { + return !!(searchQuery.trim() || expandedSubSections[subSectionId]); + } + + function toggleSubSection(subSectionId) { + setExpandedSubSections((prev) => ({ + ...prev, + [subSectionId]: !prev[subSectionId], + })); + } + + // Filter sections by search query + const filteredSections = useMemo(() => { + if (!searchQuery.trim()) return sections; + const q = searchQuery.toLowerCase(); + return sections + .map((section) => { + const items = section.items.filter((item) => { + const nameMatches = item.name.toLowerCase().includes(q); + const subSkillMatches = + item.subSkills?.some((sub) => sub.name.toLowerCase().includes(q)) ?? + false; + return nameMatches || subSkillMatches; + }); + return { + ...section, + items, + enabledCount: items.filter((i) => i.enabled).length, + }; + }) + .filter((section) => section.items.length > 0); + }, [sections, searchQuery]); + + // Flat list of navigable items for keyboard nav + const { flatItems, flatIndexMap } = useMemo(() => { + const items = []; + const indexMap = {}; + for (const section of filteredSections) { + indexMap[section.id] = items.length; + items.push({ + type: "header", + id: section.id, + onToggle: () => toggleSection(section.id), + }); + if (isSectionExpanded(section.id)) { + for (const item of section.items) { + indexMap[item.id] = items.length; + items.push(item); + + if (item.hasSubSkills && item.subSkills) { + indexMap[`subsection-${item.id}`] = items.length; + items.push({ + type: "subheader", + id: `subsection-${item.id}`, + parentId: item.id, + onToggle: () => toggleSubSection(item.id), + }); + + if (isSubSectionExpanded(item.id)) { + for (const subItem of item.subSkills) { + indexMap[subItem.id] = items.length; + items.push(subItem); + } + } + } + } } - if (flowsRes?.flows) setFlows(flowsRes.flows); - setFileSystemAgentAvailable(fsAgentAvailable); - } catch (e) { - console.error(e); - } finally { - setLoading(false); } - } + return { flatItems: items, flatIndexMap: indexMap }; + }, [filteredSections, expandedSections, expandedSubSections, searchQuery]); - function toggleItem(arr, item) { - return arr.includes(item) ? arr.filter((s) => s !== item) : [...arr, item]; - } - - function isSkillEnabled(key) { - return key in defaultSkills - ? !disabledDefaults.includes(key) - : enabledConfigurable.includes(key); - } - - async function toggleSkill(key) { - if (key in defaultSkills) { - const updated = toggleItem(disabledDefaults, key); - setDisabledDefaults(updated); - await Admin.updateSystemPreferences({ - disabled_agent_skills: updated.join(","), - default_agent_skills: enabledConfigurable.join(","), - }); - return; - } - - const updated = toggleItem(enabledConfigurable, key); - setEnabledConfigurable(updated); - await Admin.updateSystemPreferences({ - disabled_agent_skills: disabledDefaults.join(","), - default_agent_skills: updated.join(","), - }); - } - - async function toggleImportedSkill(skill) { - const newActive = !skill.active; - setImportedSkills((prev) => - prev.map((s) => - s.hubId === skill.hubId ? { ...s, active: newActive } : s - ) - ); - await AgentPlugins.toggleFeature(skill.hubId, newActive); - } - - async function toggleFlow(flow) { - const newActive = !flow.active; - setFlows((prev) => - prev.map((f) => (f.uuid === flow.uuid ? { ...f, active: newActive } : f)) - ); - await AgentFlows.toggleFlow(flow.uuid, newActive); - } - - // Build list of all skill items for rendering/keyboard navigation - const items = useMemo(() => { - const list = []; - for (const [key, { title }] of Object.entries({ - ...defaultSkills, - ...configurableSkills, - })) { - list.push({ - id: key, - name: title, - enabled: isSkillEnabled(key), - onToggle: () => toggleSkill(key), - }); - } - for (const skill of importedSkills) { - list.push({ - id: skill.hubId, - name: skill.name, - enabled: skill.active, - onToggle: () => toggleImportedSkill(skill), - }); - } - for (const flow of flows) { - list.push({ - id: flow.uuid, - name: flow.name, - enabled: flow.active, - onToggle: () => toggleFlow(flow), - }); - } - return list; - }, [disabledDefaults, enabledConfigurable, importedSkills, flows]); + const totalItemCount = sections.reduce((sum, s) => sum + s.items.length, 0); useToolsMenuItems({ - items, + items: flatItems, highlightedIndex, - onSelect: agentSessionActive ? () => {} : (item) => item.onToggle(), + onSelect: (item) => { + if (item.type === "header") return item.onToggle(); + if (!agentSessionActive) item.onToggle(); + }, registerItemCount, }); @@ -162,18 +182,82 @@ export default function AgentSkillsTab({ {t("chat_window.use_agent_session_to_use_tools")}

)} - {items.map((item, index) => ( - = MIN_ITEMS_TO_SHOW_SEARCH && ( + + )} + {filteredSections.map((section) => ( + toggleSection(section.id)} + enabledCount={section.enabledCount} + totalCount={section.items.length} + isMcp={section.isMcp} + highlighted={highlightedIndex === flatIndexMap[section.id]} + > + {section.items.map((item) => ( +
+ + {item.hasSubSkills && item.subSkills && item.enabled && ( + toggleSubSection(item.id)} + enabledCount={item.subSkills.filter((s) => s.enabled).length} + totalCount={item.subSkills.length} + highlighted={ + highlightedIndex === flatIndexMap[`subsection-${item.id}`] + } + indented + > + {item.subSkills.map((subItem) => ( + + ))} + + )} +
+ ))} +
))} + {mcpLoading && ( +
+ + + {t("chat_window.loading_mcp_servers")} + +
+ )} + {filteredSections.length === 0 && !mcpLoading && searchQuery.trim() && ( +

+ {t("chat_window.no_tools_found")} +

+ )} -