mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-08 15:22:05 +00:00
feat: cross-fade MCP tools between loading, list, and empty states
This commit is contained in:
parent
da551558fa
commit
999301f10b
5 changed files with 114 additions and 34 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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} />}
|
||||
|
|
|
|||
39
client/src/components/ui/Collapse.tsx
Normal file
39
client/src/components/ui/Collapse.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
client/src/components/ui/__tests__/Collapse.spec.tsx
Normal file
28
client/src/components/ui/__tests__/Collapse.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue