mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-23 18:56:12 +01:00
🔌 refactor: MCP UI with Improved Accessibility and Reusable Components (#11118)
* feat: enhance MCP server selection UI with new components and improved accessibility * fix(i18n): add missing com_ui_mcp_servers translation key The MCP server menu aria-label was referencing a non-existent translation key. Added the missing key for accessibility. * feat(MCP): enhance MCP components with improved accessibility and focus management * fix(i18n): remove outdated MCP server translation keys * fix(MCPServerList): improve color contrast by updating text color for no MCP servers message * refactor(MCP): Server status components and improve user action handling Updated MCPServerStatusIcon to use a unified icon system for better clarity Introduced new MCPCardActions component for standardized action buttons on server cards Created MCPServerCard component to encapsulate server display logic and actions Enhanced MCPServerList to render MCPServerCard components, improving code organization Added MCPStatusBadge for consistent status representation in dialogs Updated utility functions for status color and text retrieval to align with new design Improved localization keys for better clarity and consistency in user messages * style(MCP): update button and card background styles for improved UI consistency * feat(MCP): implement global server initialization state management using Jotai * refactor(MCP): modularize MCPServerDialog into structured component architecture - Split monolithic dialog into dedicated section components (Auth, BasicInfo, Connection, Transport, Trust) - Extract form logic into useMCPServerForm custom hook - Add utility modules for JSON import and URL handling - Introduce reusable SecretInput component in @librechat/client - Remove deprecated MCPAuth component * style(MCP): update button styles for improved layout and adjust empty state background color * refactor(Radio): enhance component mounting logic and background style updates * refactor(translation): remove unused keys and streamline localization strings
This commit is contained in:
parent
0b8e0fcede
commit
e4870ed0b0
32 changed files with 2594 additions and 1646 deletions
|
|
@ -1,8 +1,11 @@
|
|||
import React, { memo, useCallback } from 'react';
|
||||
import React, { memo, useMemo, useCallback, useRef } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { MultiSelect, MCPIcon } from '@librechat/client';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import { TooltipAnchor } from '@librechat/client';
|
||||
import MCPServerMenuItem from '~/components/MCP/MCPServerMenuItem';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import StackedMCPIcons from '~/components/MCP/StackedMCPIcons';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { useHasAccess } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -13,96 +16,117 @@ function MCPSelectContent() {
|
|||
localize,
|
||||
isPinned,
|
||||
mcpValues,
|
||||
isInitializing,
|
||||
placeholderText,
|
||||
batchToggleServers,
|
||||
getConfigDialogProps,
|
||||
getServerStatusIconProps,
|
||||
selectableServers,
|
||||
connectionStatus,
|
||||
isInitializing,
|
||||
getConfigDialogProps,
|
||||
toggleServerSelection,
|
||||
getServerStatusIconProps,
|
||||
} = mcpServerManager;
|
||||
|
||||
const renderSelectedValues = useCallback(
|
||||
(
|
||||
values: string[],
|
||||
placeholder?: string,
|
||||
items?: (string | { label: string; value: string })[],
|
||||
) => {
|
||||
if (values.length === 0) {
|
||||
return placeholder || localize('com_ui_select_placeholder');
|
||||
}
|
||||
if (values.length === 1) {
|
||||
const selectedItem = items?.find((i) => typeof i !== 'string' && i.value == values[0]);
|
||||
return selectedItem && typeof selectedItem !== 'string' ? selectedItem.label : values[0];
|
||||
}
|
||||
return localize('com_ui_x_selected', { 0: values.length });
|
||||
const menuStore = Ariakit.useMenuStore({ focusLoop: true });
|
||||
const isOpen = menuStore.useState('open');
|
||||
const focusedElementRef = useRef<HTMLElement | null>(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();
|
||||
});
|
||||
},
|
||||
[localize],
|
||||
[toggleServerSelection],
|
||||
);
|
||||
|
||||
const renderItemContent = useCallback(
|
||||
(serverName: string, defaultContent: React.ReactNode) => {
|
||||
const statusIconProps = getServerStatusIconProps(serverName);
|
||||
const isServerInitializing = isInitializing(serverName);
|
||||
const selectedServers = useMemo(() => {
|
||||
if (!mcpValues || mcpValues.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return selectableServers.filter((s) => mcpValues.includes(s.serverName));
|
||||
}, [selectableServers, mcpValues]);
|
||||
|
||||
/**
|
||||
Common wrapper for the main content (check mark + text).
|
||||
Ensures Check & Text are adjacent and the group takes available space.
|
||||
*/
|
||||
const mainContentWrapper = (
|
||||
<button
|
||||
type="button"
|
||||
className={`flex flex-grow items-center rounded bg-transparent p-0 text-left transition-colors focus:outline-none ${
|
||||
isServerInitializing ? 'opacity-50' : ''
|
||||
}`}
|
||||
tabIndex={0}
|
||||
disabled={isServerInitializing}
|
||||
>
|
||||
{defaultContent}
|
||||
</button>
|
||||
);
|
||||
|
||||
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
|
||||
|
||||
if (statusIcon) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{mainContentWrapper}
|
||||
<div className="ml-2 flex items-center">{statusIcon}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return mainContentWrapper;
|
||||
},
|
||||
[getServerStatusIconProps, isInitializing],
|
||||
);
|
||||
const displayText = useMemo(() => {
|
||||
if (selectedCount === 0) {
|
||||
return null;
|
||||
}
|
||||
if (selectedCount === 1) {
|
||||
const server = selectableServers.find((s) => s.serverName === mcpValues?.[0]);
|
||||
return server?.config?.title || mcpValues?.[0];
|
||||
}
|
||||
return localize('com_ui_x_selected', { 0: selectedCount });
|
||||
}, [selectedCount, selectableServers, mcpValues, localize]);
|
||||
|
||||
if (!isPinned && mcpValues?.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const configDialogProps = getConfigDialogProps();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MultiSelect
|
||||
items={selectableServers.map((s) => ({
|
||||
label: s.config.title || s.serverName,
|
||||
value: s.serverName,
|
||||
}))}
|
||||
selectedValues={mcpValues ?? []}
|
||||
setSelectedValues={batchToggleServers}
|
||||
renderSelectedValues={renderSelectedValues}
|
||||
renderItemContent={renderItemContent}
|
||||
placeholder={placeholderText}
|
||||
popoverClassName="min-w-fit"
|
||||
className="badge-icon min-w-fit"
|
||||
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
||||
selectClassName={cn(
|
||||
'group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all',
|
||||
'md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner',
|
||||
)}
|
||||
/>
|
||||
<Ariakit.MenuProvider store={menuStore}>
|
||||
<TooltipAnchor
|
||||
description={placeholderText}
|
||||
disabled={isOpen}
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
className={cn(
|
||||
'group relative inline-flex items-center justify-center gap-1.5',
|
||||
'border border-border-medium text-sm font-medium transition-all',
|
||||
'h-9 min-w-9 rounded-full bg-transparent px-2.5 shadow-sm',
|
||||
'hover:bg-surface-hover hover:shadow-md active:shadow-inner',
|
||||
'md:w-fit md:justify-start md:px-3',
|
||||
isOpen && 'bg-surface-hover',
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<StackedMCPIcons selectedServers={selectedServers} maxIcons={3} iconSize="sm" />
|
||||
<span className="hidden truncate text-text-primary md:block">
|
||||
{displayText || placeholderText}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'hidden h-3 w-3 text-text-secondary transition-transform md:block',
|
||||
isOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</TooltipAnchor>
|
||||
|
||||
<Ariakit.Menu
|
||||
portal={true}
|
||||
gutter={8}
|
||||
aria-label={localize('com_ui_mcp_servers')}
|
||||
className={cn(
|
||||
'z-50 flex min-w-[260px] max-w-[320px] flex-col rounded-xl',
|
||||
'border border-border-light bg-presentation p-1.5 shadow-lg',
|
||||
'origin-top opacity-0 transition-[opacity,transform] duration-200 ease-out',
|
||||
'data-[enter]:scale-100 data-[enter]:opacity-100',
|
||||
'scale-95 data-[leave]:scale-95 data-[leave]:opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="flex max-h-[320px] flex-col gap-1 overflow-y-auto">
|
||||
{selectableServers.map((server) => (
|
||||
<MCPServerMenuItem
|
||||
key={server.serverName}
|
||||
server={server}
|
||||
isSelected={mcpValues?.includes(server.serverName) ?? false}
|
||||
connectionStatus={connectionStatus}
|
||||
isInitializing={isInitializing}
|
||||
statusIconProps={getServerStatusIconProps(server.serverName)}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Ariakit.Menu>
|
||||
</Ariakit.MenuProvider>
|
||||
{configDialogProps && (
|
||||
<MCPConfigDialog {...configDialogProps} conversationId={conversationId} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { PinIcon, MCPIcon } from '@librechat/client';
|
||||
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
|
||||
import { MCPIcon, PinIcon } from '@librechat/client';
|
||||
import MCPServerMenuItem from '~/components/MCP/MCPServerMenuItem';
|
||||
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface MCPSubMenuProps {
|
||||
|
|
@ -13,14 +14,16 @@ interface MCPSubMenuProps {
|
|||
|
||||
const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
||||
({ placeholder, ...props }, ref) => {
|
||||
const localize = useLocalize();
|
||||
const { mcpServerManager } = useBadgeRowContext();
|
||||
const {
|
||||
isPinned,
|
||||
mcpValues,
|
||||
setIsPinned,
|
||||
isInitializing,
|
||||
placeholderText,
|
||||
availableMCPServers,
|
||||
selectableServers,
|
||||
connectionStatus,
|
||||
isInitializing,
|
||||
getConfigDialogProps,
|
||||
toggleServerSelection,
|
||||
getServerStatusIconProps,
|
||||
|
|
@ -33,7 +36,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
|||
});
|
||||
|
||||
// Don't render if no MCP servers are configured
|
||||
if (!availableMCPServers || availableMCPServers.length === 0) {
|
||||
if (!selectableServers || selectableServers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +47,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
|||
<Ariakit.MenuProvider store={menuStore}>
|
||||
<Ariakit.MenuItem
|
||||
{...props}
|
||||
hideOnClick={false}
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
|
@ -55,9 +59,9 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
|||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<MCPIcon className="icon-md" />
|
||||
<MCPIcon className="h-5 w-5 flex-shrink-0 text-text-primary" aria-hidden="true" />
|
||||
<span>{placeholder || placeholderText}</span>
|
||||
<ChevronRight className="ml-auto h-3 w-3" />
|
||||
<ChevronRight className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -70,55 +74,36 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
|
|||
'hover:bg-surface-tertiary hover:shadow-sm',
|
||||
!isPinned && 'text-text-secondary hover:text-text-primary',
|
||||
)}
|
||||
aria-label={isPinned ? 'Unpin' : 'Pin'}
|
||||
aria-label={isPinned ? localize('com_ui_unpin') : localize('com_ui_pin')}
|
||||
>
|
||||
<div className="h-4 w-4">
|
||||
<PinIcon unpin={isPinned} />
|
||||
</div>
|
||||
</button>
|
||||
</Ariakit.MenuItem>
|
||||
|
||||
<Ariakit.Menu
|
||||
portal={true}
|
||||
unmountOnHide={true}
|
||||
aria-label={localize('com_ui_mcp_servers')}
|
||||
className={cn(
|
||||
'animate-popover-left z-50 ml-3 flex min-w-[200px] flex-col rounded-xl',
|
||||
'border border-border-light bg-surface-secondary p-1 shadow-lg',
|
||||
'animate-popover-left z-50 ml-3 flex min-w-[260px] max-w-[320px] flex-col rounded-xl',
|
||||
'border border-border-light bg-presentation p-1.5 shadow-lg',
|
||||
)}
|
||||
>
|
||||
{availableMCPServers.map((s) => {
|
||||
const statusIconProps = getServerStatusIconProps(s.serverName);
|
||||
const isSelected = mcpValues?.includes(s.serverName) ?? false;
|
||||
const isServerInitializing = isInitializing(s.serverName);
|
||||
|
||||
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
|
||||
|
||||
return (
|
||||
<Ariakit.MenuItem
|
||||
key={s.serverName}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
toggleServerSelection(s.serverName);
|
||||
}}
|
||||
disabled={isServerInitializing}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-text-primary hover:cursor-pointer',
|
||||
'scroll-m-1 outline-none transition-colors',
|
||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||
'w-full min-w-0 justify-between text-sm',
|
||||
isServerInitializing &&
|
||||
'opacity-50 hover:bg-transparent dark:hover:bg-transparent',
|
||||
isSelected && 'bg-surface-active',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-grow items-center gap-2">
|
||||
<Ariakit.MenuItemCheck checked={isSelected} />
|
||||
<span>{s.config.title || s.serverName}</span>
|
||||
</div>
|
||||
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
|
||||
</Ariakit.MenuItem>
|
||||
);
|
||||
})}
|
||||
<div className="flex max-h-[320px] flex-col gap-1 overflow-y-auto">
|
||||
{selectableServers.map((server) => (
|
||||
<MCPServerMenuItem
|
||||
key={server.serverName}
|
||||
server={server}
|
||||
isSelected={mcpValues?.includes(server.serverName) ?? false}
|
||||
connectionStatus={connectionStatus}
|
||||
isInitializing={isInitializing}
|
||||
statusIconProps={getServerStatusIconProps(server.serverName)}
|
||||
onToggle={toggleServerSelection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Ariakit.Menu>
|
||||
</Ariakit.MenuProvider>
|
||||
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue