From 999301f10ba18b920a11ebd2a584fb3cd93ede24 Mon Sep 17 00:00:00 2001
From: Marco Beretta <81851188+berry-13@users.noreply.github.com>
Date: Wed, 1 Jul 2026 13:06:25 +0200
Subject: [PATCH] feat: cross-fade MCP tools between loading, list, and empty
states
---
.../ItemDialog/__tests__/McpSection.spec.tsx | 6 ++
.../Tools/ItemDialog/sections/McpSection.tsx | 74 ++++++++++---------
client/src/components/ui/Collapse.tsx | 39 ++++++++++
.../components/ui/__tests__/Collapse.spec.tsx | 28 +++++++
client/src/components/ui/index.ts | 1 +
5 files changed, 114 insertions(+), 34 deletions(-)
create mode 100644 client/src/components/ui/Collapse.tsx
create mode 100644 client/src/components/ui/__tests__/Collapse.spec.tsx
diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx
index 4564c1701f..c3cf943320 100644
--- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx
+++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/__tests__/McpSection.spec.tsx
@@ -1,5 +1,6 @@
import '@testing-library/jest-dom/extend-expect';
import { fireEvent, render, screen } from '@testing-library/react';
+import type { ReactNode } from 'react';
import type { McpItem } from '../../items/types';
import McpSection from '../sections/McpSection';
@@ -16,6 +17,11 @@ jest.mock('~/Providers', () => ({
useAgentPanelContext: () => ({ mcpServersMap: new Map() }),
}));
+jest.mock('~/components/ui', () => ({
+ Collapse: ({ open, children }: { open: boolean; children: ReactNode }) =>
+ open ? children : null,
+}));
+
jest.mock('~/hooks', () => ({
useLocalize: () => (key: string) => key,
useCopyToClipboard: () => jest.fn(),
diff --git a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx
index b67f8de1c4..81d48462b2 100644
--- a/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx
+++ b/client/src/components/SidePanel/Agents/Tools/ItemDialog/sections/McpSection.tsx
@@ -18,6 +18,7 @@ import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import McpOAuthDialog from '~/components/MCP/McpOAuthDialog';
import { useAgentPanelContext } from '~/Providers';
import MCPToolItem from '../../../MCPToolItem';
+import { Collapse } from '~/components/ui';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@@ -208,7 +209,7 @@ export default function McpSection({ item }: Props) {
-
+
{localize('com_ui_tools_mcp_tools_section')}
@@ -299,39 +300,44 @@ export default function McpSection({ item }: Props) {
)}
- {hasTools && (
-
- {tools.map((tool) => (
- toggleToolSelect(tool.tool_id)}
- onToggleDefer={() => toggleToolDefer(tool.tool_id)}
- onToggleProgrammatic={() => toggleToolProgrammatic(tool.tool_id)}
- />
- ))}
-
- )}
- {!hasTools && toolsLoading && (
-
- {['w-3/5', 'w-1/2', 'w-2/5'].map((width) => (
-
-
-
-
- ))}
-
- )}
- {!hasTools && !toolsLoading && (
-
- {localize('com_ui_tools_mcp_no_tools')}
-
- )}
+ {/* Loading skeleton, tool list, and empty state share one slot and
+ * cross-swap via stacked collapses so their differing heights morph
+ * smoothly; the dialog's auto-height follows in a single motion. */}
+
+
+
+ {tools.map((tool) => (
+ toggleToolSelect(tool.tool_id)}
+ onToggleDefer={() => toggleToolDefer(tool.tool_id)}
+ onToggleProgrammatic={() => toggleToolProgrammatic(tool.tool_id)}
+ />
+ ))}
+
+
+
+
+ {['w-3/5', 'w-1/2', 'w-2/5'].map((width) => (
+
+
+
+
+ ))}
+
+
+
+
+ {localize('com_ui_tools_mcp_no_tools')}
+
+
+
{configDialogProps && }
diff --git a/client/src/components/ui/Collapse.tsx b/client/src/components/ui/Collapse.tsx
new file mode 100644
index 0000000000..bffa300389
--- /dev/null
+++ b/client/src/components/ui/Collapse.tsx
@@ -0,0 +1,39 @@
+import type { ReactNode } from 'react';
+import { cn } from '~/utils';
+
+interface CollapseProps {
+ open: boolean;
+ children: ReactNode;
+ className?: string;
+}
+
+/**
+ * Auto-height reveal via grid-template-rows 0fr -> 1fr, matching MCPToolItem and
+ * the OAuth QR. The parent's content-driven height follows the tween in a single
+ * motion, so stacked collapses (e.g. a loading skeleton swapping to a list) can
+ * cross-fade smoothly without a measuring wrapper fighting nested reveals.
+ * Content fades to soften the swap and is hidden from assistive tech while closed.
+ */
+export default function Collapse({ open, children, className }: CollapseProps) {
+ return (
+
+ );
+}
diff --git a/client/src/components/ui/__tests__/Collapse.spec.tsx b/client/src/components/ui/__tests__/Collapse.spec.tsx
new file mode 100644
index 0000000000..59144e6048
--- /dev/null
+++ b/client/src/components/ui/__tests__/Collapse.spec.tsx
@@ -0,0 +1,28 @@
+import '@testing-library/jest-dom/extend-expect';
+import { render, screen } from '@testing-library/react';
+import Collapse from '../Collapse';
+
+describe('Collapse', () => {
+ test('reveals its content when open', () => {
+ const { container } = render(
+
+
+ ,
+ );
+ expect(screen.getByTestId('child')).toBeInTheDocument();
+ const root = container.firstChild as HTMLElement;
+ expect(root).toHaveClass('grid-rows-[1fr]');
+ expect(root).not.toHaveAttribute('aria-hidden');
+ });
+
+ test('collapses and hides from assistive tech when closed', () => {
+ const { container } = render(
+
+
+ ,
+ );
+ const root = container.firstChild as HTMLElement;
+ expect(root).toHaveClass('grid-rows-[0fr]');
+ expect(root).toHaveAttribute('aria-hidden', 'true');
+ });
+});
diff --git a/client/src/components/ui/index.ts b/client/src/components/ui/index.ts
index 1412cf4aff..295a1921a0 100644
--- a/client/src/components/ui/index.ts
+++ b/client/src/components/ui/index.ts
@@ -1,4 +1,5 @@
export { Button } from '@librechat/client';
+export { default as Collapse } from './Collapse';
export { default as TermsAndConditionsModal } from './TermsAndConditionsModal';
export { default as AdminSettingsDialog } from './AdminSettingsDialog';
export type { PermissionConfig, AdminSettingsDialogProps } from './AdminSettingsDialog';