-
- {!navVisible && (
-
-
-
-
- )}
-
+
{!(navVisible && isSmallScreen) && (
@@ -73,7 +62,7 @@ function Header() {
-
+ {hasAccessToTemporaryChat === true && }
>
)}
@@ -85,7 +74,7 @@ function Header() {
-
+ {hasAccessToTemporaryChat === true &&
}
)}
diff --git a/client/src/components/Chat/Input/Files/AttachFileChat.tsx b/client/src/components/Chat/Input/Files/AttachFileChat.tsx
index 37b3584d3e..2f954d01d5 100644
--- a/client/src/components/Chat/Input/Files/AttachFileChat.tsx
+++ b/client/src/components/Chat/Input/Files/AttachFileChat.tsx
@@ -2,10 +2,9 @@ import { memo, useMemo } from 'react';
import {
Constants,
supportsFiles,
- EModelEndpoint,
mergeFileConfig,
isAgentsEndpoint,
- getEndpointField,
+ resolveEndpointType,
isAssistantsEndpoint,
getEndpointFileConfig,
} from 'librechat-data-provider';
@@ -55,21 +54,31 @@ function AttachFileChat({
const { data: endpointsConfig } = useGetEndpointsQuery();
- const endpointType = useMemo(() => {
- return (
- getEndpointField(endpointsConfig, endpoint, 'type') ||
- (endpoint as EModelEndpoint | undefined)
- );
- }, [endpoint, endpointsConfig]);
+ const agentProvider = useMemo(() => {
+ if (!isAgents || !conversation?.agent_id) {
+ return undefined;
+ }
+ const agent = agentData || agentsMap?.[conversation.agent_id];
+ return agent?.provider;
+ }, [isAgents, conversation?.agent_id, agentData, agentsMap]);
+ const endpointType = useMemo(
+ () => resolveEndpointType(endpointsConfig, endpoint, agentProvider),
+ [endpointsConfig, endpoint, agentProvider],
+ );
+
+ const fileConfigEndpoint = useMemo(
+ () => (isAgents && agentProvider ? agentProvider : endpoint),
+ [isAgents, agentProvider, endpoint],
+ );
const endpointFileConfig = useMemo(
() =>
getEndpointFileConfig({
- endpoint,
fileConfig,
endpointType,
+ endpoint: fileConfigEndpoint,
}),
- [endpoint, fileConfig, endpointType],
+ [fileConfigEndpoint, fileConfig, endpointType],
);
const endpointSupportsFiles: boolean = useMemo(
() => supportsFiles[endpointType ?? endpoint ?? ''] ?? false,
@@ -82,7 +91,7 @@ function AttachFileChat({
if (isAssistants && endpointSupportsFiles && !isUploadDisabled) {
return
;
- } else if (isAgents || (endpointSupportsFiles && !isUploadDisabled)) {
+ } else if ((isAgents || endpointSupportsFiles) && !isUploadDisabled) {
return (
{
const handleDelete = () => {
- showToast({
- message: localize('com_ui_deleting_file'),
- status: 'info',
- });
if (abortUpload && file.progress < 1) {
abortUpload();
}
+ if (file.progress >= 1) {
+ showToast({
+ message: localize('com_ui_deleting_file'),
+ status: 'info',
+ });
+ }
deleteFile({ file, setFiles });
};
const isImage = file.type?.startsWith('image') ?? false;
@@ -134,7 +136,7 @@ export default function FileRow({
>
{isImage ? (
> = {};
+let mockAgentQueryData: Partial | undefined;
+
+jest.mock('~/data-provider', () => ({
+ useGetEndpointsQuery: () => ({ data: mockEndpointsConfig }),
+ useGetFileConfig: ({ select }: { select?: (data: unknown) => unknown }) => ({
+ data: select != null ? select(mockFileConfig) : mockFileConfig,
+ }),
+ useGetAgentByIdQuery: () => ({ data: mockAgentQueryData }),
+}));
+
+jest.mock('~/Providers', () => ({
+ useAgentsMapContext: () => mockAgentsMap,
+}));
+
+/** Capture the props passed to AttachFileMenu */
+let mockAttachFileMenuProps: Record = {};
+jest.mock('../AttachFileMenu', () => {
+ return function MockAttachFileMenu(props: Record) {
+ mockAttachFileMenuProps = props;
+ return
;
+ };
+});
+
+jest.mock('../AttachFile', () => {
+ return function MockAttachFile() {
+ return
;
+ };
+});
+
+const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+
+function renderComponent(conversation: Record | null, disableInputs = false) {
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe('AttachFileChat', () => {
+ beforeEach(() => {
+ mockFileConfig = defaultFileConfig;
+ mockAgentsMap = {};
+ mockAgentQueryData = undefined;
+ mockAttachFileMenuProps = {};
+ });
+
+ describe('rendering decisions', () => {
+ it('renders AttachFileMenu for agents endpoint', () => {
+ renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
+ expect(screen.getByTestId('attach-file-menu')).toBeInTheDocument();
+ });
+
+ it('renders AttachFileMenu for custom endpoint with file support', () => {
+ renderComponent({ endpoint: 'Moonshot' });
+ expect(screen.getByTestId('attach-file-menu')).toBeInTheDocument();
+ });
+
+ it('renders null for null conversation', () => {
+ const { container } = renderComponent(null);
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('endpointType resolution for agents', () => {
+ it('passes custom endpointType when agent provider is a custom endpoint', () => {
+ mockAgentsMap = {
+ 'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial,
+ };
+ renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
+ expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
+ });
+
+ it('passes openAI endpointType when agent provider is openAI', () => {
+ mockAgentsMap = {
+ 'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial,
+ };
+ renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
+ expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.openAI);
+ });
+
+ it('passes agents endpointType when no agent provider', () => {
+ renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
+ expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.agents);
+ });
+
+ it('passes agents endpointType when no agent_id', () => {
+ renderComponent({ endpoint: EModelEndpoint.agents });
+ expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.agents);
+ });
+
+ it('uses agentData query when agent not in agentsMap', () => {
+ mockAgentQueryData = { provider: 'Moonshot' } as Partial;
+ renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-2' });
+ expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
+ });
+ });
+
+ describe('endpointType resolution for non-agents', () => {
+ it('passes custom endpointType for a custom endpoint', () => {
+ renderComponent({ endpoint: 'Moonshot' });
+ expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.custom);
+ });
+
+ it('passes openAI endpointType for openAI endpoint', () => {
+ renderComponent({ endpoint: EModelEndpoint.openAI });
+ expect(mockAttachFileMenuProps.endpointType).toBe(EModelEndpoint.openAI);
+ });
+ });
+
+ describe('consistency: same endpoint type for direct vs agent usage', () => {
+ it('resolves Moonshot the same way whether used directly or through an agent', () => {
+ renderComponent({ endpoint: 'Moonshot' });
+ const directType = mockAttachFileMenuProps.endpointType;
+
+ mockAgentsMap = {
+ 'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial,
+ };
+ renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
+ const agentType = mockAttachFileMenuProps.endpointType;
+
+ expect(directType).toBe(agentType);
+ });
+ });
+
+ describe('upload disabled rendering', () => {
+ it('renders null for agents endpoint when fileConfig.agents.disabled is true', () => {
+ mockFileConfig = mergeFileConfig({
+ endpoints: {
+ [EModelEndpoint.agents]: { disabled: true },
+ },
+ });
+ const { container } = renderComponent({
+ endpoint: EModelEndpoint.agents,
+ agent_id: 'agent-1',
+ });
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders null for agents endpoint when disableInputs is true', () => {
+ const { container } = renderComponent(
+ { endpoint: EModelEndpoint.agents, agent_id: 'agent-1' },
+ true,
+ );
+ expect(container.innerHTML).toBe('');
+ });
+
+ it('renders AttachFile for assistants endpoint when not disabled', () => {
+ renderComponent({ endpoint: EModelEndpoint.assistants });
+ expect(screen.getByTestId('attach-file')).toBeInTheDocument();
+ });
+
+ it('renders AttachFileMenu when provider-specific config overrides agents disabled', () => {
+ mockFileConfig = mergeFileConfig({
+ endpoints: {
+ Moonshot: { disabled: false, fileLimit: 5 },
+ [EModelEndpoint.agents]: { disabled: true },
+ },
+ });
+ mockAgentsMap = {
+ 'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial,
+ };
+ renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
+ expect(screen.getByTestId('attach-file-menu')).toBeInTheDocument();
+ });
+
+ it('renders null for assistants endpoint when fileConfig.assistants.disabled is true', () => {
+ mockFileConfig = mergeFileConfig({
+ endpoints: {
+ [EModelEndpoint.assistants]: { disabled: true },
+ },
+ });
+ const { container } = renderComponent({
+ endpoint: EModelEndpoint.assistants,
+ });
+ expect(container.innerHTML).toBe('');
+ });
+ });
+
+ describe('endpointFileConfig resolution', () => {
+ it('passes Moonshot-specific file config for agent with Moonshot provider', () => {
+ mockAgentsMap = {
+ 'agent-1': { provider: 'Moonshot', model_parameters: {} } as Partial,
+ };
+ renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
+ const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
+ expect(config?.fileLimit).toBe(5);
+ });
+
+ it('passes agents file config when agent has no specific provider config', () => {
+ mockAgentsMap = {
+ 'agent-1': { provider: EModelEndpoint.openAI, model_parameters: {} } as Partial,
+ };
+ renderComponent({ endpoint: EModelEndpoint.agents, agent_id: 'agent-1' });
+ const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
+ expect(config?.fileLimit).toBe(10);
+ });
+
+ it('passes agents file config when no agent provider', () => {
+ renderComponent({ endpoint: EModelEndpoint.agents });
+ const config = mockAttachFileMenuProps.endpointFileConfig as { fileLimit?: number };
+ expect(config?.fileLimit).toBe(20);
+ });
+ });
+});
diff --git a/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx
index d3f0fb65bc..cf08721207 100644
--- a/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx
+++ b/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx
@@ -1,12 +1,10 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
-import '@testing-library/jest-dom';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
-import { EModelEndpoint } from 'librechat-data-provider';
+import { EModelEndpoint, Providers } from 'librechat-data-provider';
import AttachFileMenu from '../AttachFileMenu';
-// Mock all the hooks
jest.mock('~/hooks', () => ({
useAgentToolPermissions: jest.fn(),
useAgentCapabilities: jest.fn(),
@@ -25,53 +23,45 @@ jest.mock('~/data-provider', () => ({
}));
jest.mock('~/components/SharePoint', () => ({
- SharePointPickerDialog: jest.fn(() => null),
+ SharePointPickerDialog: () => null,
}));
jest.mock('@librechat/client', () => {
- const React = jest.requireActual('react');
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const R = require('react');
return {
- FileUpload: React.forwardRef(({ children, handleFileChange }: any, ref: any) => (
-
-
- {children}
-
- )),
- TooltipAnchor: ({ render }: any) => render,
- DropdownPopup: ({ trigger, items, isOpen, setIsOpen }: any) => {
- const handleTriggerClick = () => {
- if (setIsOpen) {
- setIsOpen(!isOpen);
- }
- };
-
- return (
-
-
{trigger}
- {isOpen && (
-
- {items.map((item: any, idx: number) => (
-
- {item.label}
-
- ))}
-
- )}
-
- );
- },
- AttachmentIcon: () => 📎 ,
- SharePointIcon: () => SP ,
+ FileUpload: (props) => R.createElement('div', { 'data-testid': 'file-upload' }, props.children),
+ TooltipAnchor: (props) => props.render,
+ DropdownPopup: (props) =>
+ R.createElement(
+ 'div',
+ null,
+ R.createElement('div', { onClick: () => props.setIsOpen(!props.isOpen) }, props.trigger),
+ props.isOpen &&
+ R.createElement(
+ 'div',
+ { 'data-testid': 'dropdown-menu' },
+ props.items.map((item, idx) =>
+ R.createElement(
+ 'button',
+ { key: idx, onClick: item.onClick, 'data-testid': `menu-item-${idx}` },
+ item.label,
+ ),
+ ),
+ ),
+ ),
+ AttachmentIcon: () => R.createElement('span', { 'data-testid': 'attachment-icon' }),
+ SharePointIcon: () => R.createElement('span', { 'data-testid': 'sharepoint-icon' }),
};
});
-jest.mock('@ariakit/react', () => ({
- MenuButton: ({ children, onClick, disabled, ...props }: any) => (
-
- {children}
-
- ),
-}));
+jest.mock('@ariakit/react', () => {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const R = require('react');
+ return {
+ MenuButton: (props) => R.createElement('button', props, props.children),
+ };
+});
const mockUseAgentToolPermissions = jest.requireMock('~/hooks').useAgentToolPermissions;
const mockUseAgentCapabilities = jest.requireMock('~/hooks').useAgentCapabilities;
@@ -83,558 +73,283 @@ const mockUseSharePointFileHandling = jest.requireMock(
).default;
const mockUseGetStartupConfig = jest.requireMock('~/data-provider').useGetStartupConfig;
-describe('AttachFileMenu', () => {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: { retry: false },
- },
- });
+const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
- const mockHandleFileChange = jest.fn();
-
- beforeEach(() => {
- jest.clearAllMocks();
-
- // Default mock implementations
- mockUseLocalize.mockReturnValue((key: string) => {
- const translations: Record = {
- com_ui_upload_provider: 'Upload to Provider',
- com_ui_upload_image_input: 'Upload Image',
- com_ui_upload_ocr_text: 'Upload OCR Text',
- com_ui_upload_file_search: 'Upload for File Search',
- com_ui_upload_code_files: 'Upload Code Files',
- com_sidepanel_attach_files: 'Attach Files',
- com_files_upload_sharepoint: 'Upload from SharePoint',
- };
- return translations[key] || key;
- });
-
- mockUseAgentCapabilities.mockReturnValue({
- contextEnabled: false,
- fileSearchEnabled: false,
- codeEnabled: false,
- });
-
- mockUseGetAgentsConfig.mockReturnValue({
- agentsConfig: {
- capabilities: {
- contextEnabled: false,
- fileSearchEnabled: false,
- codeEnabled: false,
- },
- },
- });
-
- mockUseFileHandling.mockReturnValue({
- handleFileChange: mockHandleFileChange,
- });
-
- mockUseSharePointFileHandling.mockReturnValue({
- handleSharePointFiles: jest.fn(),
- isProcessing: false,
- downloadProgress: 0,
- });
-
- mockUseGetStartupConfig.mockReturnValue({
- data: {
- sharePointFilePickerEnabled: false,
- },
- });
-
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: undefined,
- });
- });
-
- const renderAttachFileMenu = (props: any = {}) => {
- return render(
-
-
-
-
- ,
- );
+function setupMocks(overrides: { provider?: string } = {}) {
+ const translations: Record = {
+ com_ui_upload_provider: 'Upload to Provider',
+ com_ui_upload_image_input: 'Upload Image',
+ com_ui_upload_ocr_text: 'Upload as Text',
+ com_ui_upload_file_search: 'Upload for File Search',
+ com_ui_upload_code_files: 'Upload Code Files',
+ com_sidepanel_attach_files: 'Attach Files',
+ com_files_upload_sharepoint: 'Upload from SharePoint',
};
-
- describe('Basic Rendering', () => {
- it('should render the attachment button', () => {
- renderAttachFileMenu();
- const button = screen.getByRole('button', { name: /attach file options/i });
- expect(button).toBeInTheDocument();
- });
-
- it('should be disabled when disabled prop is true', () => {
- renderAttachFileMenu({ disabled: true });
- const button = screen.getByRole('button', { name: /attach file options/i });
- expect(button).toBeDisabled();
- });
-
- it('should not be disabled when disabled prop is false', () => {
- renderAttachFileMenu({ disabled: false });
- const button = screen.getByRole('button', { name: /attach file options/i });
- expect(button).not.toBeDisabled();
- });
+ mockUseLocalize.mockReturnValue((key: string) => translations[key] || key);
+ mockUseAgentCapabilities.mockReturnValue({
+ contextEnabled: false,
+ fileSearchEnabled: false,
+ codeEnabled: false,
});
+ mockUseGetAgentsConfig.mockReturnValue({ agentsConfig: {} });
+ mockUseFileHandling.mockReturnValue({ handleFileChange: jest.fn() });
+ mockUseSharePointFileHandling.mockReturnValue({
+ handleSharePointFiles: jest.fn(),
+ isProcessing: false,
+ downloadProgress: 0,
+ });
+ mockUseGetStartupConfig.mockReturnValue({ data: { sharePointFilePickerEnabled: false } });
+ mockUseAgentToolPermissions.mockReturnValue({
+ fileSearchAllowedByAgent: false,
+ codeAllowedByAgent: false,
+ provider: overrides.provider ?? undefined,
+ });
+}
- describe('Provider Detection Fix - endpointType Priority', () => {
- it('should prioritize endpointType over currentProvider for LiteLLM gateway', () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: 'litellm', // Custom gateway name NOT in documentSupportedProviders
- });
+function renderMenu(props: Record = {}) {
+ return render(
+
+
+
+
+ ,
+ );
+}
- renderAttachFileMenu({
- endpoint: 'litellm',
- endpointType: EModelEndpoint.openAI, // Backend override IS in documentSupportedProviders
- });
+function openMenu() {
+ fireEvent.click(screen.getByRole('button', { name: /attach file options/i }));
+}
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
+describe('AttachFileMenu', () => {
+ beforeEach(jest.clearAllMocks);
- // With the fix, should show "Upload to Provider" because endpointType is checked first
+ describe('Upload to Provider vs Upload Image', () => {
+ it('shows "Upload to Provider" when endpointType is custom (resolved from agent provider)', () => {
+ setupMocks({ provider: 'Moonshot' });
+ renderMenu({ endpointType: EModelEndpoint.custom });
+ openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
expect(screen.queryByText('Upload Image')).not.toBeInTheDocument();
});
- it('should show Upload to Provider for custom endpoints with OpenAI endpointType', () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: 'my-custom-gateway',
- });
-
- renderAttachFileMenu({
- endpoint: 'my-custom-gateway',
- endpointType: EModelEndpoint.openAI,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
+ it('shows "Upload to Provider" when endpointType is openAI', () => {
+ setupMocks({ provider: EModelEndpoint.openAI });
+ renderMenu({ endpointType: EModelEndpoint.openAI });
+ openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
- it('should show Upload Image when neither endpointType nor provider support documents', () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: 'unsupported-provider',
- });
+ it('shows "Upload to Provider" when endpointType is anthropic', () => {
+ setupMocks({ provider: EModelEndpoint.anthropic });
+ renderMenu({ endpointType: EModelEndpoint.anthropic });
+ openMenu();
+ expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
+ });
- renderAttachFileMenu({
- endpoint: 'unsupported-provider',
- endpointType: 'unsupported-endpoint' as any,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
+ it('shows "Upload to Provider" when endpointType is google', () => {
+ setupMocks({ provider: Providers.GOOGLE });
+ renderMenu({ endpointType: EModelEndpoint.google });
+ openMenu();
+ expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
+ });
+ it('shows "Upload Image" when endpointType is agents (no provider resolution)', () => {
+ setupMocks();
+ renderMenu({ endpointType: EModelEndpoint.agents });
+ openMenu();
expect(screen.getByText('Upload Image')).toBeInTheDocument();
expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
});
- it('should fallback to currentProvider when endpointType is undefined', () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: EModelEndpoint.openAI,
- });
-
- renderAttachFileMenu({
- endpoint: EModelEndpoint.openAI,
- endpointType: undefined,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
+ it('shows "Upload Image" when neither endpointType nor provider supports documents', () => {
+ setupMocks({ provider: 'unknown-provider' });
+ renderMenu({ endpointType: 'unknown-type' });
+ openMenu();
+ expect(screen.getByText('Upload Image')).toBeInTheDocument();
+ });
+ it('shows "Upload to Provider" for azureOpenAI with useResponsesApi', () => {
+ setupMocks({ provider: EModelEndpoint.azureOpenAI });
+ renderMenu({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: true });
+ openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
- it('should fallback to currentProvider when endpointType is null', () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: EModelEndpoint.anthropic,
- });
-
- renderAttachFileMenu({
- endpoint: EModelEndpoint.anthropic,
- endpointType: null,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
- expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
+ it('shows "Upload Image" for azureOpenAI without useResponsesApi', () => {
+ setupMocks({ provider: EModelEndpoint.azureOpenAI });
+ renderMenu({ endpointType: EModelEndpoint.azureOpenAI, useResponsesApi: false });
+ openMenu();
+ expect(screen.getByText('Upload Image')).toBeInTheDocument();
});
});
- describe('Supported Providers', () => {
- const supportedProviders = [
- { name: 'OpenAI', endpoint: EModelEndpoint.openAI },
- { name: 'Anthropic', endpoint: EModelEndpoint.anthropic },
- { name: 'Google', endpoint: EModelEndpoint.google },
- { name: 'Custom', endpoint: EModelEndpoint.custom },
- ];
-
- supportedProviders.forEach(({ name, endpoint }) => {
- it(`should show Upload to Provider for ${name}`, () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: endpoint,
- });
-
- renderAttachFileMenu({
- endpoint,
- endpointType: endpoint,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
- expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
+ describe('agent provider resolution scenario', () => {
+ it('shows "Upload to Provider" when agents endpoint has custom endpointType from provider', () => {
+ setupMocks({ provider: 'Moonshot' });
+ renderMenu({
+ endpoint: EModelEndpoint.agents,
+ endpointType: EModelEndpoint.custom,
});
- });
-
- it('should show Upload to Provider for Azure OpenAI with useResponsesApi', () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: EModelEndpoint.azureOpenAI,
- });
-
- renderAttachFileMenu({
- endpoint: EModelEndpoint.azureOpenAI,
- endpointType: EModelEndpoint.azureOpenAI,
- useResponsesApi: true,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
+ openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
});
- it('should NOT show Upload to Provider for Azure OpenAI without useResponsesApi', () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: EModelEndpoint.azureOpenAI,
+ it('shows "Upload Image" when agents endpoint has no resolved provider type', () => {
+ setupMocks();
+ renderMenu({
+ endpoint: EModelEndpoint.agents,
+ endpointType: EModelEndpoint.agents,
});
-
- renderAttachFileMenu({
- endpoint: EModelEndpoint.azureOpenAI,
- endpointType: EModelEndpoint.azureOpenAI,
- useResponsesApi: false,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
- expect(screen.queryByText('Upload to Provider')).not.toBeInTheDocument();
+ openMenu();
expect(screen.getByText('Upload Image')).toBeInTheDocument();
});
});
+ describe('Basic Rendering', () => {
+ it('renders the attachment button', () => {
+ setupMocks();
+ renderMenu();
+ expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
+ });
+
+ it('is disabled when disabled prop is true', () => {
+ setupMocks();
+ renderMenu({ disabled: true });
+ expect(screen.getByRole('button', { name: /attach file options/i })).toBeDisabled();
+ });
+
+ it('is not disabled when disabled prop is false', () => {
+ setupMocks();
+ renderMenu({ disabled: false });
+ expect(screen.getByRole('button', { name: /attach file options/i })).not.toBeDisabled();
+ });
+ });
+
describe('Agent Capabilities', () => {
- it('should show OCR Text option when context is enabled', () => {
+ it('shows OCR Text option when context is enabled', () => {
+ setupMocks();
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: true,
fileSearchEnabled: false,
codeEnabled: false,
});
-
- renderAttachFileMenu({
- endpointType: EModelEndpoint.openAI,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
- expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
+ renderMenu({ endpointType: EModelEndpoint.openAI });
+ openMenu();
+ expect(screen.getByText('Upload as Text')).toBeInTheDocument();
});
- it('should show File Search option when enabled and allowed by agent', () => {
+ it('shows File Search option when enabled and allowed by agent', () => {
+ setupMocks();
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: true,
codeEnabled: false,
});
-
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: true,
codeAllowedByAgent: false,
provider: undefined,
});
-
- renderAttachFileMenu({
- endpointType: EModelEndpoint.openAI,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
+ renderMenu({ endpointType: EModelEndpoint.openAI });
+ openMenu();
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
});
- it('should NOT show File Search when enabled but not allowed by agent', () => {
+ it('does NOT show File Search when enabled but not allowed by agent', () => {
+ setupMocks();
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: true,
codeEnabled: false,
});
-
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: undefined,
- });
-
- renderAttachFileMenu({
- endpointType: EModelEndpoint.openAI,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
+ renderMenu({ endpointType: EModelEndpoint.openAI });
+ openMenu();
expect(screen.queryByText('Upload for File Search')).not.toBeInTheDocument();
});
- it('should show Code Files option when enabled and allowed by agent', () => {
+ it('shows Code Files option when enabled and allowed by agent', () => {
+ setupMocks();
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: false,
fileSearchEnabled: false,
codeEnabled: true,
});
-
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: false,
codeAllowedByAgent: true,
provider: undefined,
});
-
- renderAttachFileMenu({
- endpointType: EModelEndpoint.openAI,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
+ renderMenu({ endpointType: EModelEndpoint.openAI });
+ openMenu();
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
});
- it('should show all options when all capabilities are enabled', () => {
+ it('shows all options when all capabilities are enabled', () => {
+ setupMocks();
mockUseAgentCapabilities.mockReturnValue({
contextEnabled: true,
fileSearchEnabled: true,
codeEnabled: true,
});
-
mockUseAgentToolPermissions.mockReturnValue({
fileSearchAllowedByAgent: true,
codeAllowedByAgent: true,
provider: undefined,
});
-
- renderAttachFileMenu({
- endpointType: EModelEndpoint.openAI,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
+ renderMenu({ endpointType: EModelEndpoint.openAI });
+ openMenu();
expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
- expect(screen.getByText('Upload OCR Text')).toBeInTheDocument();
+ expect(screen.getByText('Upload as Text')).toBeInTheDocument();
expect(screen.getByText('Upload for File Search')).toBeInTheDocument();
expect(screen.getByText('Upload Code Files')).toBeInTheDocument();
});
});
describe('SharePoint Integration', () => {
- it('should show SharePoint option when enabled', () => {
+ it('shows SharePoint option when enabled', () => {
+ setupMocks();
mockUseGetStartupConfig.mockReturnValue({
- data: {
- sharePointFilePickerEnabled: true,
- },
+ data: { sharePointFilePickerEnabled: true },
});
-
- renderAttachFileMenu({
- endpointType: EModelEndpoint.openAI,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
+ renderMenu({ endpointType: EModelEndpoint.openAI });
+ openMenu();
expect(screen.getByText('Upload from SharePoint')).toBeInTheDocument();
});
- it('should NOT show SharePoint option when disabled', () => {
- mockUseGetStartupConfig.mockReturnValue({
- data: {
- sharePointFilePickerEnabled: false,
- },
- });
-
- renderAttachFileMenu({
- endpointType: EModelEndpoint.openAI,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
+ it('does NOT show SharePoint option when disabled', () => {
+ setupMocks();
+ renderMenu({ endpointType: EModelEndpoint.openAI });
+ openMenu();
expect(screen.queryByText('Upload from SharePoint')).not.toBeInTheDocument();
});
});
describe('Edge Cases', () => {
- it('should handle undefined endpoint and provider gracefully', () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: undefined,
- });
-
- renderAttachFileMenu({
- endpoint: undefined,
- endpointType: undefined,
- });
-
+ it('handles undefined endpoint and provider gracefully', () => {
+ setupMocks();
+ renderMenu({ endpoint: undefined, endpointType: undefined });
const button = screen.getByRole('button', { name: /attach file options/i });
expect(button).toBeInTheDocument();
fireEvent.click(button);
-
- // Should show Upload Image as fallback
expect(screen.getByText('Upload Image')).toBeInTheDocument();
});
- it('should handle null endpoint and provider gracefully', () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: null,
- });
-
- renderAttachFileMenu({
- endpoint: null,
- endpointType: null,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- expect(button).toBeInTheDocument();
+ it('handles null endpoint and provider gracefully', () => {
+ setupMocks();
+ renderMenu({ endpoint: null, endpointType: null });
+ expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
});
- it('should handle missing agentId gracefully', () => {
- renderAttachFileMenu({
- agentId: undefined,
- endpointType: EModelEndpoint.openAI,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- expect(button).toBeInTheDocument();
+ it('handles missing agentId gracefully', () => {
+ setupMocks();
+ renderMenu({ agentId: undefined, endpointType: EModelEndpoint.openAI });
+ expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
});
- it('should handle empty string agentId', () => {
- renderAttachFileMenu({
- agentId: '',
- endpointType: EModelEndpoint.openAI,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- expect(button).toBeInTheDocument();
- });
- });
-
- describe('Google Provider Special Case', () => {
- it('should use image_document_video_audio file type for Google provider', () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: EModelEndpoint.google,
- });
-
- renderAttachFileMenu({
- endpoint: EModelEndpoint.google,
- endpointType: EModelEndpoint.google,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
- const uploadProviderButton = screen.getByText('Upload to Provider');
- expect(uploadProviderButton).toBeInTheDocument();
-
- // Click the upload to provider option
- fireEvent.click(uploadProviderButton);
-
- // The file input should have been clicked (indirectly tested through the implementation)
- });
-
- it('should use image_document file type for non-Google providers', () => {
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: EModelEndpoint.openAI,
- });
-
- renderAttachFileMenu({
- endpoint: EModelEndpoint.openAI,
- endpointType: EModelEndpoint.openAI,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
- const uploadProviderButton = screen.getByText('Upload to Provider');
- expect(uploadProviderButton).toBeInTheDocument();
- fireEvent.click(uploadProviderButton);
-
- // Implementation detail - image_document type is used
- });
- });
-
- describe('Regression Tests', () => {
- it('should not break the previous behavior for direct provider attachments', () => {
- // When using a direct supported provider (not through a gateway)
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: EModelEndpoint.anthropic,
- });
-
- renderAttachFileMenu({
- endpoint: EModelEndpoint.anthropic,
- endpointType: EModelEndpoint.anthropic,
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
- expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
- });
-
- it('should maintain correct priority when both are supported', () => {
- // Both endpointType and provider are supported, endpointType should be checked first
- mockUseAgentToolPermissions.mockReturnValue({
- fileSearchAllowedByAgent: false,
- codeAllowedByAgent: false,
- provider: EModelEndpoint.google,
- });
-
- renderAttachFileMenu({
- endpoint: EModelEndpoint.google,
- endpointType: EModelEndpoint.openAI, // Different but both supported
- });
-
- const button = screen.getByRole('button', { name: /attach file options/i });
- fireEvent.click(button);
-
- // Should still work because endpointType (openAI) is supported
- expect(screen.getByText('Upload to Provider')).toBeInTheDocument();
+ it('handles empty string agentId', () => {
+ setupMocks();
+ renderMenu({ agentId: '', endpointType: EModelEndpoint.openAI });
+ expect(screen.getByRole('button', { name: /attach file options/i })).toBeInTheDocument();
});
});
});
diff --git a/client/src/components/Chat/Input/Files/__tests__/FileRow.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/FileRow.spec.tsx
index 90c1c3a7b5..ccfa19ffc8 100644
--- a/client/src/components/Chat/Input/Files/__tests__/FileRow.spec.tsx
+++ b/client/src/components/Chat/Input/Files/__tests__/FileRow.spec.tsx
@@ -21,6 +21,7 @@ jest.mock('~/utils', () => ({
logger: {
log: jest.fn(),
},
+ getCachedPreview: jest.fn(() => undefined),
}));
jest.mock('../Image', () => {
@@ -95,7 +96,7 @@ describe('FileRow', () => {
};
describe('Image URL Selection Logic', () => {
- it('should use filepath instead of preview when progress is 1 (upload complete)', () => {
+ it('should prefer cached preview over filepath when upload is complete', () => {
const file = createMockFile({
file_id: 'uploaded-file',
preview: 'blob:http://localhost:3080/temp-preview',
@@ -109,8 +110,7 @@ describe('FileRow', () => {
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
- expect(imageUrl).toBe('/images/user123/uploaded-file__image.png');
- expect(imageUrl).not.toContain('blob:');
+ expect(imageUrl).toBe('blob:http://localhost:3080/temp-preview');
});
it('should use preview when progress is less than 1 (uploading)', () => {
@@ -147,7 +147,7 @@ describe('FileRow', () => {
expect(imageUrl).toBe('/images/user123/file-without-preview__image.png');
});
- it('should use filepath when both preview and filepath exist and progress is exactly 1', () => {
+ it('should prefer preview over filepath when both exist and progress is 1', () => {
const file = createMockFile({
file_id: 'complete-file',
preview: 'blob:http://localhost:3080/old-blob',
@@ -161,7 +161,7 @@ describe('FileRow', () => {
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
- expect(imageUrl).toBe('/images/user123/complete-file__image.png');
+ expect(imageUrl).toBe('blob:http://localhost:3080/old-blob');
});
});
@@ -284,7 +284,7 @@ describe('FileRow', () => {
const urls = screen.getAllByTestId('image-url').map((el) => el.textContent);
expect(urls).toContain('blob:http://localhost:3080/preview-1');
- expect(urls).toContain('/images/user123/file-2__image.png');
+ expect(urls).toContain('blob:http://localhost:3080/preview-2');
});
it('should deduplicate files with the same file_id', () => {
@@ -321,10 +321,10 @@ describe('FileRow', () => {
});
});
- describe('Regression: Blob URL Bug Fix', () => {
- it('should NOT use revoked blob URL after upload completes', () => {
+ describe('Preview Cache Integration', () => {
+ it('should prefer preview blob URL over filepath for zero-flicker rendering', () => {
const file = createMockFile({
- file_id: 'regression-test',
+ file_id: 'cache-test',
preview: 'blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b',
filepath:
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
@@ -337,8 +337,24 @@ describe('FileRow', () => {
renderFileRow(filesMap);
const imageUrl = screen.getByTestId('image-url').textContent;
+ expect(imageUrl).toBe('blob:http://localhost:3080/d25f730c-152d-41f7-8d79-c9fa448f606b');
+ });
- expect(imageUrl).not.toContain('blob:');
+ it('should fall back to filepath when no preview exists', () => {
+ const file = createMockFile({
+ file_id: 'no-preview',
+ preview: undefined,
+ filepath:
+ '/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
+ progress: 1,
+ });
+
+ const filesMap = new Map();
+ filesMap.set(file.file_id, file);
+
+ renderFileRow(filesMap);
+
+ const imageUrl = screen.getByTestId('image-url').textContent;
expect(imageUrl).toBe(
'/images/68c98b26901ebe2d87c193a2/c0fe1b93-ba3d-456c-80be-9a492bfd9ed0__image.png',
);
diff --git a/client/src/components/Chat/Input/MCPSelect.tsx b/client/src/components/Chat/Input/MCPSelect.tsx
index a5356f5094..13a86c856a 100644
--- a/client/src/components/Chat/Input/MCPSelect.tsx
+++ b/client/src/components/Chat/Input/MCPSelect.tsx
@@ -1,4 +1,4 @@
-import React, { memo, useMemo, useCallback, useRef } from 'react';
+import React, { memo, useMemo } from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown } from 'lucide-react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
@@ -27,24 +27,9 @@ function MCPSelectContent() {
const menuStore = Ariakit.useMenuStore({ focusLoop: true });
const isOpen = menuStore.useState('open');
- const focusedElementRef = useRef(null);
const selectedCount = mcpValues?.length ?? 0;
- // Wrap toggleServerSelection to preserve focus after state update
- const handleToggle = useCallback(
- (serverName: string) => {
- // Save currently focused element
- focusedElementRef.current = document.activeElement as HTMLElement;
- toggleServerSelection(serverName);
- // Restore focus after React re-renders
- requestAnimationFrame(() => {
- focusedElementRef.current?.focus();
- });
- },
- [toggleServerSelection],
- );
-
const selectedServers = useMemo(() => {
if (!mcpValues || mcpValues.length === 0) {
return [];
@@ -103,6 +88,8 @@ function MCPSelectContent() {
))}
diff --git a/client/src/components/Chat/Input/MCPSubMenu.tsx b/client/src/components/Chat/Input/MCPSubMenu.tsx
index b0b8fad1bb..f8e617cba3 100644
--- a/client/src/components/Chat/Input/MCPSubMenu.tsx
+++ b/client/src/components/Chat/Input/MCPSubMenu.tsx
@@ -35,7 +35,6 @@ const MCPSubMenu = React.forwardRef