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; + }) => ( +