🤖 refactor: Side Panel Agent UI To Account For Ephemeral Agents (#9763)

* refactor: Remove unused imports and consolidate ephemeral agent logic

* refactor: Side Panel agent handling to account for ephemeral agents for UI

* refactor: Replace Constants.EPHEMERAL_AGENT_ID checks with isEphemeralAgent utility for consistency

* ci: AgentPanel tests with additional mock configurations and utility functions
This commit is contained in:
Danny Avila 2025-09-22 09:48:05 -04:00 committed by GitHub
parent a6bf2b6ce3
commit 8a60e8990f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 87 additions and 77 deletions

View file

@ -9,7 +9,7 @@ import {
useMCPToolsQuery,
} from '~/data-provider';
import { useLocalize, useGetAgentsConfig, useMCPConnectionStatus } from '~/hooks';
import { Panel } from '~/common';
import { Panel, isEphemeralAgent } from '~/common';
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
@ -32,15 +32,15 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
const { data: startupConfig } = useGetStartupConfig();
const { data: actions } = useGetActionsQuery(EModelEndpoint.agents, {
enabled: !!agent_id,
enabled: !isEphemeralAgent(agent_id),
});
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents, {
enabled: !!agent_id,
enabled: !isEphemeralAgent(agent_id),
});
const { data: mcpData } = useMCPToolsQuery({
enabled: !!agent_id && startupConfig?.mcpServers != null,
enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null,
});
const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
@ -50,7 +50,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
);
const { connectionStatus } = useMCPConnectionStatus({
enabled: !!agent_id && mcpServerNames.length > 0,
enabled: !isEphemeralAgent(agent_id) && mcpServerNames.length > 0,
});
const mcpServersMap = useMemo(() => {

View file

@ -1,5 +1,5 @@
import React from 'react';
import { TModelSpec, TStartupConfig } from 'librechat-data-provider';
import { TStartupConfig } from 'librechat-data-provider';
export interface Endpoint {
value: string;

View file

@ -1,5 +1,5 @@
import { RefObject } from 'react';
import { FileSources, EModelEndpoint } from 'librechat-data-provider';
import { Constants, FileSources, EModelEndpoint } from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type * as InputNumberPrimitive from 'rc-input-number';
import type { SetterOrUpdater, RecoilState } from 'recoil';
@ -8,6 +8,10 @@ import type * as t from 'librechat-data-provider';
import type { LucideIcon } from 'lucide-react';
import type { TranslationKeys } from '~/hooks';
export function isEphemeralAgent(agentId: string | null | undefined): boolean {
return agentId == null || agentId === '' || agentId === Constants.EPHEMERAL_AGENT_ID;
}
export interface ConfigFieldDetail {
title: string;
description: string;

View file

@ -1,27 +1,26 @@
import { useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { useForm, FormProvider } from 'react-hook-form';
import {
AuthTypeEnum,
AuthorizationTypeEnum,
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
import {
OGDialogTemplate,
TrashIcon,
OGDialog,
OGDialogTrigger,
Label,
OGDialog,
TrashIcon,
OGDialogTrigger,
useToastContext,
OGDialogTemplate,
} from '@librechat/client';
import type { ActionAuthForm } from '~/common';
import ActionsAuth from '~/components/SidePanel/Builder/ActionsAuth';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { useDeleteAgentAction } from '~/data-provider';
import type { ActionAuthForm } from '~/common';
import { Panel, isEphemeralAgent } from '~/common';
import ActionsInput from './ActionsInput';
import { useLocalize } from '~/hooks';
import { Panel } from '~/common';
export default function ActionsPanel() {
const localize = useLocalize();
@ -109,7 +108,7 @@ export default function ActionsPanel() {
<div className="absolute right-0 top-6">
<button
type="button"
disabled={!agent_id || !action.action_id}
disabled={isEphemeralAgent(agent_id) || !action.action_id}
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
>
<TrashIcon className="text-red-500" />
@ -127,7 +126,7 @@ export default function ActionsPanel() {
}
selection={{
selectHandler: () => {
if (!agent_id) {
if (isEphemeralAgent(agent_id)) {
return showToast({
message: localize('com_agents_no_agent_id_error'),
status: 'error',
@ -135,7 +134,7 @@ export default function ActionsPanel() {
}
deleteAgentAction.mutate({
action_id: action.action_id,
agent_id,
agent_id: agent_id || '',
});
},
selectClasses:

View file

@ -18,6 +18,7 @@ import { useFileMapContext, useAgentPanelContext } from '~/Providers';
import AgentCategorySelector from './AgentCategorySelector';
import Action from '~/components/SidePanel/Builder/Action';
import { useLocalize, useVisibleTools } from '~/hooks';
import { Panel, isEphemeralAgent } from '~/common';
import { useGetAgentFiles } from '~/data-provider';
import { icons } from '~/hooks/Endpoint/Icons';
import Instructions from './Instructions';
@ -29,7 +30,6 @@ import Artifacts from './Artifacts';
import AgentTool from './AgentTool';
import CodeForm from './Code/Form';
import MCPTools from './MCPTools';
import { Panel } from '~/common';
const labelClass = 'mb-2 text-token-text-primary block font-medium';
const inputClass = cn(
@ -149,7 +149,7 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
}, [agent, agent_id, mergedFileMap]);
const handleAddActions = useCallback(() => {
if (!agent_id) {
if (isEphemeralAgent(agent_id)) {
showToast({
message: localize('com_assistants_actions_disabled'),
status: 'warning',
@ -370,7 +370,7 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
{(actionsEnabled ?? false) && (
<button
type="button"
disabled={!agent_id}
disabled={isEphemeralAgent(agent_id)}
onClick={handleAddActions}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="dialog"

View file

@ -37,22 +37,28 @@ jest.mock('librechat-data-provider', () => {
dataService: {
updateAgent: jest.fn(),
},
Tools: {
Tools: actualModule.Tools || {
execute_code: 'execute_code',
file_search: 'file_search',
web_search: 'web_search',
},
Constants: {
Constants: actualModule.Constants || {
EPHEMERAL_AGENT_ID: 'ephemeral',
},
SystemRoles: {
SystemRoles: actualModule.SystemRoles || {
ADMIN: 'ADMIN',
},
EModelEndpoint: {
EModelEndpoint: actualModule.EModelEndpoint || {
agents: 'agents',
chatGPTBrowser: 'chatGPTBrowser',
gptPlugins: 'gptPlugins',
},
ResourceType: actualModule.ResourceType || {
AGENT: 'agent',
},
PermissionBits: actualModule.PermissionBits || {
EDIT: 2,
},
isAssistantsEndpoint: jest.fn(() => false),
};
});
@ -97,6 +103,13 @@ jest.mock('~/hooks', () => ({
useAuthContext: () => ({ user: { id: 'user-123', role: 'USER' } }),
}));
jest.mock('~/hooks/useResourcePermissions', () => ({
useResourcePermissions: () => ({
hasPermission: jest.fn(() => true),
isLoading: false,
}),
}));
jest.mock('~/Providers/AgentPanelContext', () => ({
useAgentPanelContext: () => ({
activePanel: 'builder',
@ -109,6 +122,9 @@ jest.mock('~/Providers/AgentPanelContext', () => ({
}));
jest.mock('~/common', () => ({
isEphemeralAgent: (agentId: string | null | undefined): boolean => {
return agentId == null || agentId === '' || agentId === 'ephemeral';
},
Panel: {
model: 'model',
builder: 'builder',
@ -199,6 +215,10 @@ jest.mock('~/data-provider', () => {
return {
...actual,
useGetAgentByIdQuery: jest.fn(),
useGetExpandedAgentByIdQuery: jest.fn(() => ({
data: null,
isInitialLoading: false,
})),
useUpdateAgentMutation: actual.useUpdateAgentMutation,
};
});

View file

@ -5,7 +5,6 @@ import { useWatch, useForm, FormProvider } from 'react-hook-form';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import {
Tools,
Constants,
SystemRoles,
ResourceType,
EModelEndpoint,
@ -25,11 +24,11 @@ import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import AgentPanelSkeleton from './AgentPanelSkeleton';
import AdvancedPanel from './Advanced/AdvancedPanel';
import { Panel, isEphemeralAgent } from '~/common';
import AgentConfig from './AgentConfig';
import AgentSelect from './AgentSelect';
import AgentFooter from './AgentFooter';
import ModelPanel from './ModelPanel';
import { Panel } from '~/common';
export default function AgentPanel() {
const localize = useLocalize();
@ -57,11 +56,7 @@ export default function AgentPanel() {
const canEdit = hasPermission(PermissionBits.EDIT);
const expandedAgentQuery = useGetExpandedAgentByIdQuery(current_agent_id ?? '', {
enabled:
!!(current_agent_id ?? '') &&
current_agent_id !== Constants.EPHEMERAL_AGENT_ID &&
canEdit &&
!permissionsLoading,
enabled: !isEphemeralAgent(current_agent_id) && canEdit && !permissionsLoading,
});
const agentQuery = canEdit && expandedAgentQuery.data ? expandedAgentQuery : basicAgentQuery;
@ -298,7 +293,7 @@ export default function AgentPanel() {
</Button>
<Button
variant="submit"
disabled={!agent_id || agentQuery.isInitialLoading}
disabled={isEphemeralAgent(agent_id) || agentQuery.isInitialLoading}
onClick={(e) => {
e.preventDefault();
handleSelectAgent();

View file

@ -1,11 +1,11 @@
import { useEffect } from 'react';
import { AgentPanelProvider, useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { Panel, isEphemeralAgent } from '~/common';
import VersionPanel from './Version/VersionPanel';
import { useChatContext } from '~/Providers';
import ActionsPanel from './ActionsPanel';
import AgentPanel from './AgentPanel';
import MCPPanel from './MCPPanel';
import { Panel } from '~/common';
export default function AgentPanelSwitch() {
return (
@ -21,7 +21,7 @@ function AgentPanelSwitchWithContext() {
useEffect(() => {
const agent_id = conversation?.agent_id ?? '';
if (agent_id) {
if (!isEphemeralAgent(agent_id)) {
setCurrentAgentId(agent_id);
}
}, [setCurrentAgentId, conversation?.agent_id]);

View file

@ -14,6 +14,7 @@ import { useFileHandling, useLocalize, useLazyEffect } from '~/hooks';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import { useGetFileConfig } from '~/data-provider';
import { useChatContext } from '~/Providers';
import { isEphemeralAgent } from '~/common';
const tool_resource = EToolResources.execute_code;
@ -85,7 +86,7 @@ export default function Files({
<div>
<button
type="button"
disabled={!agent_id || codeChecked === false}
disabled={isEphemeralAgent(agent_id) || codeChecked === false}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
onClick={handleButtonClick}
>
@ -96,7 +97,7 @@ export default function Files({
style={{ display: 'none' }}
tabIndex={-1}
ref={fileInputRef}
disabled={!agent_id || codeChecked === false}
disabled={isEphemeralAgent(agent_id) || codeChecked === false}
onChange={handleFileChange}
/>
<AttachmentIcon className="text-token-text-primary h-4 w-4" />

View file

@ -14,6 +14,7 @@ import { logger, getDefaultAgentFormValues } from '~/utils';
import { useLocalize, useSetIndexOptions } from '~/hooks';
import { useDeleteAgentMutation } from '~/data-provider';
import { useChatContext } from '~/Providers';
import { isEphemeralAgent } from '~/common';
export default function DeleteButton({
agent_id,
@ -76,7 +77,7 @@ export default function DeleteButton({
},
});
if (!agent_id) {
if (isEphemeralAgent(agent_id)) {
return null;
}

View file

@ -1,6 +1,7 @@
import { CopyIcon } from 'lucide-react';
import { useToastContext, Button } from '@librechat/client';
import { useDuplicateAgentMutation } from '~/data-provider';
import { isEphemeralAgent } from '~/common';
import { useLocalize } from '~/hooks';
export default function DuplicateAgent({ agent_id }: { agent_id: string }) {
@ -23,7 +24,7 @@ export default function DuplicateAgent({ agent_id }: { agent_id: string }) {
},
});
if (!agent_id) {
if (isEphemeralAgent(agent_id)) {
return null;
}

View file

@ -22,8 +22,8 @@ import { useFileHandling, useLocalize, useLazyEffect, useSharePointFileHandling
import { useGetFileConfig, useGetStartupConfig } from '~/data-provider';
import { SharePointPickerDialog } from '~/components/SharePoint';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import { ESide, isEphemeralAgent } from '~/common';
import { useChatContext } from '~/Providers';
import { ESide } from '~/common';
export default function FileContext({
agent_id,
@ -156,7 +156,7 @@ export default function FileContext({
) : (
<button
type="button"
disabled={!agent_id}
disabled={isEphemeralAgent(agent_id)}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
onClick={handleLocalFileClick}
>
@ -173,7 +173,7 @@ export default function FileContext({
style={{ display: 'none' }}
tabIndex={-1}
ref={fileInputRef}
disabled={!agent_id}
disabled={isEphemeralAgent(agent_id)}
onChange={handleFileChange}
/>
</div>

View file

@ -18,6 +18,7 @@ import { SharePointPickerDialog } from '~/components/SharePoint';
import FileRow from '~/components/Chat/Input/Files/FileRow';
import FileSearchCheckbox from './FileSearchCheckbox';
import { useChatContext } from '~/Providers';
import { isEphemeralAgent } from '~/common';
export default function FileSearch({
agent_id,
@ -69,7 +70,7 @@ export default function FileSearch({
const isUploadDisabled = endpointFileConfig.disabled ?? false;
const sharePointEnabled = startupConfig?.sharePointFilePickerEnabled;
const disabledUploadButton = !agent_id || fileSearchChecked === false;
const disabledUploadButton = isEphemeralAgent(agent_id) || fileSearchChecked === false;
const handleSharePointFilesSelected = async (sharePointFiles: any[]) => {
try {

View file

@ -7,21 +7,19 @@ import {
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
import {
OGDialog,
OGDialogTrigger,
Label,
OGDialogTemplate,
OGDialog,
TrashIcon,
OGDialogTrigger,
useToastContext,
OGDialogTemplate,
} from '@librechat/client';
import type { MCPForm } from '~/common';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { defaultMCPFormValues } from '~/common/mcp';
import type { MCPForm } from '~/common';
import { Panel, isEphemeralAgent } from '~/common';
import { useLocalize } from '~/hooks';
import MCPInput from './MCPInput';
import { Panel } from '~/common';
// TODO: Add MCP delete (for now mocked for ui)
// import { useDeleteAgentMCP } from '~/data-provider';
function useDeleteAgentMCP({
onSuccess,
@ -127,7 +125,7 @@ export default function MCPPanel() {
<div className="absolute right-0 top-6">
<button
type="button"
disabled={!agent_id || !mcp.mcp_id}
disabled={isEphemeralAgent(agent_id) || !mcp.mcp_id}
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
>
<TrashIcon className="text-red-500" />
@ -145,7 +143,7 @@ export default function MCPPanel() {
}
selection={{
selectHandler: () => {
if (!agent_id) {
if (isEphemeralAgent(agent_id)) {
return showToast({
message: localize('com_agents_no_agent_id_error'),
status: 'error',
@ -153,7 +151,7 @@ export default function MCPPanel() {
}
deleteAgentMCP.mutate({
mcp_id: mcp.mcp_id,
agent_id,
agent_id: agent_id || '',
});
},
selectClasses:

View file

@ -3,15 +3,15 @@ import { useLocalize } from '~/hooks';
import { useToastContext } from '@librechat/client';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import MCP from '~/components/SidePanel/Builder/MCP';
import { Panel } from '~/common';
import { Panel, isEphemeralAgent } from '~/common';
export default function MCPSection() {
const { showToast } = useToastContext();
const localize = useLocalize();
const { showToast } = useToastContext();
const { mcps = [], agent_id, setMcp, setActivePanel } = useAgentPanelContext();
const handleAddMCP = useCallback(() => {
if (!agent_id) {
if (isEphemeralAgent(agent_id)) {
showToast({
message: localize('com_agents_mcps_disabled'),
status: 'warning',

View file

@ -1,17 +1,12 @@
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import {
Constants,
QueryKeys,
dataService,
EModelEndpoint,
PermissionBits,
} from 'librechat-data-provider';
import { QueryKeys, dataService, EModelEndpoint, PermissionBits } from 'librechat-data-provider';
import type {
QueryObserverResult,
UseQueryOptions,
UseInfiniteQueryOptions,
} from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import { isEphemeralAgent } from '~/common';
/**
* AGENTS
@ -73,11 +68,7 @@ export const useGetAgentByIdQuery = (
agent_id: string | null | undefined,
config?: UseQueryOptions<t.Agent>,
): QueryObserverResult<t.Agent> => {
const isValidAgentId = !!(
agent_id &&
agent_id !== '' &&
agent_id !== Constants.EPHEMERAL_AGENT_ID
);
const isValidAgentId = !!agent_id && !isEphemeralAgent(agent_id);
return useQuery<t.Agent>(
[QueryKeys.agent, agent_id],

View file

@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { QueryKeys, DynamicQueryKeys, dataService } from 'librechat-data-provider';
import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
import { isEphemeralAgent } from '~/common';
import { addFileToCache } from '~/utils';
import store from '~/store';
@ -32,7 +33,7 @@ export const useGetAgentFiles = <TData = t.TFile[]>(
refetchOnReconnect: false,
refetchOnMount: false,
...config,
enabled: (config?.enabled ?? true) === true && queriesEnabled && !!agentId,
enabled: (config?.enabled ?? true) === true && queriesEnabled && !isEphemeralAgent(agentId),
},
);
};

View file

@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { Tools, Constants, EToolResources } from 'librechat-data-provider';
import { Tools, EToolResources } from 'librechat-data-provider';
import type { TEphemeralAgent } from 'librechat-data-provider';
import { useGetAgentByIdQuery } from '~/data-provider';
import { useAgentsMapContext } from '~/Providers';
import { isEphemeralAgent } from '~/common';
interface AgentToolPermissionsResult {
fileSearchAllowedByAgent: boolean;
@ -10,10 +11,6 @@ interface AgentToolPermissionsResult {
tools: string[] | undefined;
}
function isEphemeralAgent(agentId: string | null | undefined): boolean {
return agentId == null || agentId === '' || agentId === Constants.EPHEMERAL_AGENT_ID;
}
/**
* Hook to determine whether specific tools are allowed for a given agent.
*

View file

@ -17,6 +17,7 @@ import type { DropTargetMonitor } from 'react-dnd';
import type * as t from 'librechat-data-provider';
import store, { ephemeralAgentByConvoId } from '~/store';
import useFileHandling from './useFileHandling';
import { isEphemeralAgent } from '~/common';
export default function useDragHelpers() {
const queryClient = useQueryClient();
@ -78,7 +79,7 @@ export default function useDragHelpers() {
let fileSearchAllowedByAgent = true;
let codeAllowedByAgent = true;
if (agentId && agentId !== Constants.EPHEMERAL_AGENT_ID) {
if (agentId && !isEphemeralAgent(agentId)) {
/** Agent data from cache */
const agent = queryClient.getQueryData<t.Agent>([QueryKeys.agent, agentId]);
if (agent) {

View file

@ -1,10 +1,10 @@
import {
Constants,
isAgentsEndpoint,
tQueryParamsSchema,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import type { TConversation, TPreset } from 'librechat-data-provider';
import { isEphemeralAgent } from '~/common';
const allowedParams = Object.keys(tQueryParamsSchema.shape);
export default function createChatSearchParams(
@ -33,7 +33,7 @@ export default function createChatSearchParams(
if (
isAgentsEndpoint(endpoint) &&
conversation.agent_id &&
conversation.agent_id !== Constants.EPHEMERAL_AGENT_ID
!isEphemeralAgent(conversation.agent_id)
) {
return new URLSearchParams({ agent_id: String(conversation.agent_id) });
} else if (isAssistantsEndpoint(endpoint) && conversation.assistant_id) {
@ -53,7 +53,7 @@ export default function createChatSearchParams(
const paramMap: Record<string, any> = {};
allowedParams.forEach((key) => {
if (key === 'agent_id' && conversation.agent_id === Constants.EPHEMERAL_AGENT_ID) {
if (key === 'agent_id' && isEphemeralAgent(conversation.agent_id)) {
return;
}
if (key !== 'endpoint' && key !== 'model') {