From a0aa1f2b9d3f45e20bc13133668962477f632ba0 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:25:55 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=9A=EF=B8=8F=20feat:=20Explicit=20"Use?= =?UTF-8?q?=20All=20Skills"=20Toggle=20in=20the=20Skills=20Section=20(#141?= =?UTF-8?q?25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add explicit Use All Skills toggle to the Skills section Adding a skill to an agent could silently mean "all skills enabled" (skills_enabled on with an empty allowlist), and the only master control was the Agent Skills kill switch buried in the Advanced panel, which implied skills required a trip to Advanced at all. - Add a "Use all skills" switch directly in the builder's Skills section: on = the agent gets every skill the user can access, including skills added in the future (skills_enabled true, empty allowlist). While on, the per-skill list and Add button are hidden and the header shows an All badge; turning it off restores the previously selected skills. - Remove the kill switch from the Advanced panel. - Simplify skillsEnabledTransition so the master flag tracks the selection on every edit, which also re-enables agents left with a selection but a disabled flag by the old kill switch. * fix: Normalize legacy disabled allowlists and scope the use-all stash per agent Agents saved with the removed Advanced kill switch off (or predating the skills_enabled flag) load a non-empty allowlist with the master flag not true. The builder rendered that selection as active while the runtime ignored it, with no control left to re-enable. Normalize on form load so the flag matches the displayed selection. The use-all stash lives in a ref and ToolsSection is not remounted when switching agents (only the form resets), so toggling off on another agent could restore the previous agent's allowlist. Clear the stash when the agent id changes. --- .../Agents/Advanced/AdvancedPanel.tsx | 19 +-- .../Agents/Advanced/SkillsToggle.tsx | 31 ---- .../SidePanel/Agents/AgentSelect.tsx | 14 ++ .../SidePanel/Agents/Tools/SkillsDialog.tsx | 3 +- .../SidePanel/Agents/Tools/ToolsSection.tsx | 150 ++++++++++++++---- .../Tools/__tests__/ToolsSection.spec.tsx | 92 ++++++++++- .../Tools/items/__tests__/mutations.spec.ts | 29 ++-- .../SidePanel/Agents/Tools/items/mutations.ts | 25 ++- client/src/locales/en/translation.json | 4 +- 9 files changed, 254 insertions(+), 113 deletions(-) delete mode 100644 client/src/components/SidePanel/Agents/Advanced/SkillsToggle.tsx diff --git a/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx b/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx index ba3abe1afa..79249496fb 100644 --- a/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx @@ -1,15 +1,13 @@ -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useToastContext } from '@librechat/client'; import { ChevronLeft, Check, Copy } from 'lucide-react'; -import { AgentCapabilities, PermissionTypes, Permissions } from 'librechat-data-provider'; import type { AgentForm } from '~/common'; import { sectionLabelClass, groupHeadingClass } from './ui'; -import { useLocalize, useHasAccess } from '~/hooks'; import { useAgentPanelContext } from '~/Providers'; import OrchestrationHub from './OrchestrationHub'; import MaxAgentSteps from './MaxAgentSteps'; -import SkillsToggle from './SkillsToggle'; +import { useLocalize } from '~/hooks'; import { Panel } from '~/common'; export default function AdvancedPanel() { @@ -19,7 +17,7 @@ export default function AdvancedPanel() { const currentAgentId = watch('id'); const [copied, setCopied] = useState(false); - const { agentsConfig, setActivePanel } = useAgentPanelContext(); + const { setActivePanel } = useAgentPanelContext(); const handleCopyAgentId = async () => { if (!currentAgentId) return; @@ -33,16 +31,6 @@ export default function AdvancedPanel() { } }; - const skillsEnabled = useMemo( - () => agentsConfig?.capabilities.includes(AgentCapabilities.skills) ?? false, - [agentsConfig], - ); - const hasSkillsAccess = useHasAccess({ - permissionType: PermissionTypes.SKILLS, - permission: Permissions.USE, - }); - const showSkillsKillSwitch = skillsEnabled && hasSkillsAccess; - return (
@@ -64,7 +52,6 @@ export default function AdvancedPanel() {
{localize('com_ui_essentials')} - {showSkillsKillSwitch && }
diff --git a/client/src/components/SidePanel/Agents/Advanced/SkillsToggle.tsx b/client/src/components/SidePanel/Agents/Advanced/SkillsToggle.tsx deleted file mode 100644 index 0b8040b57e..0000000000 --- a/client/src/components/SidePanel/Agents/Advanced/SkillsToggle.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useFormContext, Controller } from 'react-hook-form'; -import type { AgentForm } from '~/common'; -import { useLocalize } from '~/hooks'; -import { ToggleSetting } from './ui'; - -const id = 'skills_enabled_killswitch'; - -export default function SkillsToggle() { - const localize = useLocalize(); - const { control } = useFormContext(); - - return ( - ( - field.onChange(Boolean(value))} - info={ -

- {localize('com_ui_tools_skills_enabled_kill_switch_hint')} -

- } - /> - )} - /> - ); -} diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index 3e18fb4d7c..0deebd96bb 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -150,6 +150,20 @@ function AgentSelect({ } }); + /** Legacy state from the removed Advanced kill switch: a non-empty + * allowlist with the master flag off (or unset, for agents predating + * the flag). The builder has no control left for it and the runtime + * treats it as "no skills", yet the section would render the selection + * as active. Normalize to enabled so the form matches what the UI + * shows and a later save persists the displayed behavior. */ + if ( + Array.isArray(formValues.skills) && + formValues.skills.length > 0 && + formValues.skills_enabled !== true + ) { + formValues.skills_enabled = true; + } + reset(formValues); }, [reset], diff --git a/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx b/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx index ab9610c852..08b934f580 100644 --- a/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx +++ b/client/src/components/SidePanel/Agents/Tools/SkillsDialog.tsx @@ -111,9 +111,8 @@ export default function SkillsDialog({ open, onOpenChange, agentId }: SkillsDial const applySkillsSelection = useCallback( (next: string[]) => { - const current = (getValues('skills') ?? []) as string[]; setValue('skills', next, { shouldDirty: true }); - const flag = skillsEnabledTransition(current, next, getValues('skills_enabled')); + const flag = skillsEnabledTransition(next, getValues('skills_enabled')); if (flag !== undefined) { setValue('skills_enabled', flag, { shouldDirty: true }); } diff --git a/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx b/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx index 901953f855..23cfdfbc32 100644 --- a/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx +++ b/client/src/components/SidePanel/Agents/Tools/ToolsSection.tsx @@ -1,9 +1,19 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useRef } from 'react'; import { Plus } from 'lucide-react'; -import { useFormContext } from 'react-hook-form'; -import { Label, OGDialog, OGDialogTemplate, useToastContext } from '@librechat/client'; +import { useFormContext, useWatch } from 'react-hook-form'; import { PermissionTypes, Permissions, AgentCapabilities } from 'librechat-data-provider'; +import { + Label, + Switch, + OGDialog, + HoverCard, + HoverCardPortal, + HoverCardContent, + OGDialogTemplate, + useToastContext, +} from '@librechat/client'; import type { TPlugin } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; import type { AgentItem } from './items/types'; import type { AgentForm } from '~/common'; import { @@ -18,10 +28,13 @@ import { useRemoveMCPTool, useVisibleTools } from '~/hooks/MCP'; import ToolsMarketplaceDialog from './ToolsMarketplaceDialog'; import { useLocalize, useHasAccess } from '~/hooks'; import { useAgentPanelContext } from '~/Providers'; +import { isEphemeralAgent, ESide } from '~/common'; import ItemDialog from './ItemDialog/ItemDialog'; -import { isEphemeralAgent } from '~/common'; +import { InfoTrigger } from '../Advanced/ui'; +import { Collapse } from '~/components/ui'; import SkillsDialog from './SkillsDialog'; import ToolRow from './ToolRow'; +import { cn } from '~/utils'; interface Props { agentId: string; @@ -36,7 +49,7 @@ export default function ToolsSection({ agentId }: Props) { const [pendingActionRemoval, setPendingActionRemoval] = useState(null); const [pendingMcpRemoval, setPendingMcpRemoval] = useState(null); - const { getValues, setValue } = useFormContext(); + const { control, getValues, setValue } = useFormContext(); const { agentsConfig, regularTools, mcpServersMap } = useAgentPanelContext(); const { removeTool: removeMCPTool } = useRemoveMCPTool(); const deleteAgentAction = useDeleteAgentAction({ @@ -66,6 +79,36 @@ export default function ToolsSection({ agentId }: Props) { const { data: skillsData } = useListSkillsQuery({ limit: 100 }, { enabled: showSkills }); const resolvedSkills = useResolvedSkills(skillsData?.skills); + const skillsValue = useWatch({ control, name: 'skills' }); + const skillsEnabledValue = useWatch({ control, name: 'skills_enabled' }); + const useAllSkills = skillsEnabledValue === true && (skillsValue ?? []).length === 0; + /** Selection stashed when "use all skills" turns on, so turning it back off + * restores the previous picks instead of destroying them. Cleared when the + * agent changes — the section isn't remounted on switch (only the form + * resets), so a stale stash could otherwise restore one agent's allowlist + * into another. */ + const stashedSkillsRef = useRef([]); + const [prevAgentId, setPrevAgentId] = useState(agentId); + if (prevAgentId !== agentId) { + setPrevAgentId(agentId); + stashedSkillsRef.current = []; + } + + const handleUseAllSkillsChange = useCallback( + (checked: boolean) => { + if (checked) { + stashedSkillsRef.current = (getValues('skills') ?? []) as string[]; + setValue('skills', [], { shouldDirty: true }); + setValue('skills_enabled', true, { shouldDirty: true }); + return; + } + const restored = stashedSkillsRef.current; + setValue('skills', restored, { shouldDirty: true }); + setValue('skills_enabled', restored.length > 0, { shouldDirty: true }); + }, + [getValues, setValue], + ); + const uninstallToolCredentials = useUninstallToolCredentials(); const { knowledgeFiles, codeFiles } = useAgentFileEntries(); @@ -123,7 +166,7 @@ export default function ToolsSection({ agentId }: Props) { const current = (getValues('skills') ?? []) as string[]; const next = current.filter((s) => s !== patch.id); setValue('skills', next, { shouldDirty: true }); - const flag = skillsEnabledTransition(current, next, getValues('skills_enabled')); + const flag = skillsEnabledTransition(next, getValues('skills_enabled')); if (flag !== undefined) { setValue('skills_enabled', flag, { shouldDirty: true }); } @@ -240,7 +283,37 @@ export default function ToolsSection({ agentId }: Props) { onAdd={() => setSkillsOpen(true)} onInfo={setDialogItem} onRemove={handleQuickRemove} - /> + badgeText={useAllSkills ? localize('com_ui_all_proper') : undefined} + showAdd={!useAllSkills} + showBody={!useAllSkills} + > +
+
+ + {localize('com_ui_skills_use_all')} + + + + + +

+ {localize('com_ui_skills_use_all_hint')} +

+
+
+
+
+ +
+ )} {open && } {skillsOpen && ( @@ -310,6 +383,12 @@ interface SelectedSectionProps { onAdd: () => void; onInfo: (item: AgentItem) => void; onRemove: (item: AgentItem) => void; + /** Replaces the item-count badge next to the title. */ + badgeText?: string; + showAdd?: boolean; + showBody?: boolean; + /** Rendered between the header and the item list / empty state. */ + children?: ReactNode; } function SelectedSection({ @@ -321,16 +400,21 @@ function SelectedSection({ onAdd, onInfo, onRemove, + badgeText, + showAdd = true, + showBody = true, + children, }: SelectedSectionProps) { const localize = useLocalize(); + const badge = badgeText ?? (items.length > 0 ? String(items.length) : undefined); return (
@@ -338,31 +422,39 @@ function SelectedSection({ type="button" onClick={onAdd} aria-label={addLabel} - className="inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-text-secondary transition-colors hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary" + aria-hidden={!showAdd || undefined} + tabIndex={showAdd ? 0 : -1} + className={cn( + 'inline-flex h-7 items-center gap-1.5 rounded-lg px-2 text-xs font-medium text-text-secondary transition hover:bg-surface-secondary hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary', + !showAdd && 'pointer-events-none opacity-0', + )} >
- {items.length === 0 ? ( - - ) : ( -
    - {items.map((item) => ( -
  • - -
  • - ))} -
- )} + {children} + + {items.length === 0 ? ( + + ) : ( +
    + {items.map((item) => ( +
  • + +
  • + ))} +
+ )} +
); } diff --git a/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx b/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx index 87f4d3242e..6e68baee12 100644 --- a/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx +++ b/client/src/components/SidePanel/Agents/Tools/__tests__/ToolsSection.spec.tsx @@ -11,14 +11,15 @@ let mockFileEntries: { codeFiles: unknown[]; } = { contextFiles: [], knowledgeFiles: [], codeFiles: [] }; const mockSetValue = jest.fn(); +let mockFormValues: Record = {}; jest.mock('react-hook-form', () => ({ useFormContext: () => ({ control: {}, - getValues: () => [], + getValues: (name: string) => mockFormValues[name], setValue: mockSetValue, }), - useWatch: () => undefined, + useWatch: ({ name }: { name: string }) => mockFormValues[name], })); jest.mock('~/hooks', () => ({ @@ -73,6 +74,28 @@ jest.mock('@librechat/client', () => ({ OGDialog: ({ open, children }: { open?: boolean; children?: React.ReactNode }) => open ?
{children}
: null, OGDialogTemplate: () => null, + Switch: ({ + id, + checked, + onCheckedChange, + }: { + id?: string; + checked?: boolean; + onCheckedChange?: (value: boolean) => void; + }) => ( +