feat: cross-fade MCP tools between loading, list, and empty states

This commit is contained in:
Marco Beretta 2026-07-01 13:06:25 +02:00
parent da551558fa
commit 999301f10b
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
5 changed files with 114 additions and 34 deletions

View file

@ -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(),

View file

@ -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) {
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex min-h-7 items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-wide text-text-secondary">
{localize('com_ui_tools_mcp_tools_section')}
</span>
@ -299,39 +300,44 @@ export default function McpSection({ item }: Props) {
</div>
)}
</div>
{hasTools && (
<div className="flex flex-col gap-1">
{tools.map((tool) => (
<MCPToolItem
key={tool.tool_id}
tool={tool}
isSelected={selectedTools.includes(tool.tool_id)}
isDeferred={deferredToolsEnabled && isToolDeferred(tool.tool_id)}
isProgrammatic={programmaticToolsEnabled && isToolProgrammatic(tool.tool_id)}
deferredToolsEnabled={deferredToolsEnabled}
programmaticToolsEnabled={programmaticToolsEnabled}
onToggleSelect={() => toggleToolSelect(tool.tool_id)}
onToggleDefer={() => toggleToolDefer(tool.tool_id)}
onToggleProgrammatic={() => toggleToolProgrammatic(tool.tool_id)}
/>
))}
</div>
)}
{!hasTools && toolsLoading && (
<div className="flex flex-col gap-1" aria-busy="true" aria-live="polite">
{['w-3/5', 'w-1/2', 'w-2/5'].map((width) => (
<div key={width} className="flex items-center gap-2.5 rounded-lg px-2 py-2">
<Skeleton className="size-4 shrink-0 rounded" />
<Skeleton className={cn('h-4 rounded', width)} />
</div>
))}
</div>
)}
{!hasTools && !toolsLoading && (
<p className="rounded-xl border border-dashed border-border-light p-3 text-center text-xs text-text-tertiary">
{localize('com_ui_tools_mcp_no_tools')}
</p>
)}
{/* 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. */}
<div>
<Collapse open={hasTools}>
<div className="flex flex-col gap-1">
{tools.map((tool) => (
<MCPToolItem
key={tool.tool_id}
tool={tool}
isSelected={selectedTools.includes(tool.tool_id)}
isDeferred={deferredToolsEnabled && isToolDeferred(tool.tool_id)}
isProgrammatic={programmaticToolsEnabled && isToolProgrammatic(tool.tool_id)}
deferredToolsEnabled={deferredToolsEnabled}
programmaticToolsEnabled={programmaticToolsEnabled}
onToggleSelect={() => toggleToolSelect(tool.tool_id)}
onToggleDefer={() => toggleToolDefer(tool.tool_id)}
onToggleProgrammatic={() => toggleToolProgrammatic(tool.tool_id)}
/>
))}
</div>
</Collapse>
<Collapse open={!hasTools && toolsLoading}>
<div className="flex flex-col gap-1" aria-busy="true" aria-live="polite">
{['w-3/5', 'w-1/2', 'w-2/5'].map((width) => (
<div key={width} className="flex items-center gap-2.5 rounded-lg px-2 py-2">
<Skeleton className="size-4 shrink-0 rounded" />
<Skeleton className={cn('h-4 rounded', width)} />
</div>
))}
</div>
</Collapse>
<Collapse open={!hasTools && !toolsLoading}>
<p className="rounded-xl border border-dashed border-border-light p-3 text-center text-xs text-text-tertiary">
{localize('com_ui_tools_mcp_no_tools')}
</p>
</Collapse>
</div>
</div>
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}

View file

@ -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 (
<div
aria-hidden={!open || undefined}
className={cn(
'grid transition-[grid-template-rows] [transition-duration:var(--resize-dur)] [transition-timing-function:var(--resize-ease)] motion-reduce:transition-none',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className="min-h-0 overflow-hidden">
<div
className={cn(
'transition-opacity duration-200 ease-out motion-reduce:transition-none',
open ? 'opacity-100' : 'opacity-0',
className,
)}
>
{children}
</div>
</div>
</div>
);
}

View file

@ -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(
<Collapse open={true}>
<span data-testid="child" />
</Collapse>,
);
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(
<Collapse open={false}>
<span data-testid="child" />
</Collapse>,
);
const root = container.firstChild as HTMLElement;
expect(root).toHaveClass('grid-rows-[0fr]');
expect(root).toHaveAttribute('aria-hidden', 'true');
});
});

View file

@ -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';