diff --git a/client/src/components/Chat/Input/MCPSelect.tsx b/client/src/components/Chat/Input/MCPSelect.tsx index 90e22ce17c..278e603db0 100644 --- a/client/src/components/Chat/Input/MCPSelect.tsx +++ b/client/src/components/Chat/Input/MCPSelect.tsx @@ -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(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 = ( - - ); - - const statusIcon = statusIconProps && ; - - if (statusIcon) { - return ( -
- {mainContentWrapper} -
{statusIcon}
-
- ); - } - - 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 ( <> - ({ - 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={} - 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', - )} - /> + + + } + > + + + {displayText || placeholderText} + + + + + +
+ {selectableServers.map((server) => ( + + ))} +
+
+
{configDialogProps && ( )} diff --git a/client/src/components/Chat/Input/MCPSubMenu.tsx b/client/src/components/Chat/Input/MCPSubMenu.tsx index 38e6167b65..66b816e934 100644 --- a/client/src/components/Chat/Input/MCPSubMenu.tsx +++ b/client/src/components/Chat/Input/MCPSubMenu.tsx @@ -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( ({ 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( }); // 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( ) => { @@ -55,9 +59,9 @@ const MCPSubMenu = React.forwardRef( } >
- +
+ - {availableMCPServers.map((s) => { - const statusIconProps = getServerStatusIconProps(s.serverName); - const isSelected = mcpValues?.includes(s.serverName) ?? false; - const isServerInitializing = isInitializing(s.serverName); - - const statusIcon = statusIconProps && ; - - return ( - { - 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', - )} - > -
- - {s.config.title || s.serverName} -
- {statusIcon &&
{statusIcon}
} -
- ); - })} +
+ {selectableServers.map((server) => ( + + ))} +
{configDialogProps && } diff --git a/client/src/components/MCP/MCPConfigDialog.tsx b/client/src/components/MCP/MCPConfigDialog.tsx index ecd0f8dae4..a3727971e9 100644 --- a/client/src/components/MCP/MCPConfigDialog.tsx +++ b/client/src/components/MCP/MCPConfigDialog.tsx @@ -71,7 +71,14 @@ export default function MCPConfigDialog({ }); }, [serverStatus, serverName, localize]); - // Helper function to render status badge based on connection state + /** + * Render status badge with unified color system: + * - Blue: Connecting/In-progress + * - Amber: Needs action (OAuth required) + * - Gray: Disconnected (neutral/inactive) + * - Green: Connected (success) + * - Red: Error + */ const renderStatusBadge = () => { if (!serverStatus) { return null; @@ -79,46 +86,51 @@ export default function MCPConfigDialog({ const { connectionState, requiresOAuth } = serverStatus; + // Connecting: blue (in progress) if (connectionState === 'connecting') { return (
- + {localize('com_ui_connecting')}
); } + // Disconnected: check if needs action if (connectionState === 'disconnected') { if (requiresOAuth) { + // Needs OAuth: amber (requires action) return (
-
- ); - } else { - return ( -
-
); } + // Simply disconnected: gray (neutral) + return ( +
+
+ ); } + // Error: red if (connectionState === 'error') { return (
-
); } + // Connected: green if (connectionState === 'connected') { return ( -
-
+
+
{localize('com_ui_active')}
); diff --git a/client/src/components/MCP/MCPServerMenuItem.tsx b/client/src/components/MCP/MCPServerMenuItem.tsx new file mode 100644 index 0000000000..2291a5233e --- /dev/null +++ b/client/src/components/MCP/MCPServerMenuItem.tsx @@ -0,0 +1,113 @@ +import * as Ariakit from '@ariakit/react'; +import { Check } from 'lucide-react'; +import { MCPIcon } from '@librechat/client'; +import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager'; +import type { MCPServerStatusIconProps } from './MCPServerStatusIcon'; +import MCPServerStatusIcon from './MCPServerStatusIcon'; +import { + getStatusColor, + getStatusTextKey, + shouldShowActionButton, + type ConnectionStatusMap, +} from './mcpServerUtils'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +interface MCPServerMenuItemProps { + server: MCPServerDefinition; + isSelected: boolean; + connectionStatus?: ConnectionStatusMap; + isInitializing?: (serverName: string) => boolean; + statusIconProps?: MCPServerStatusIconProps | null; + onToggle: (serverName: string) => void; +} + +export default function MCPServerMenuItem({ + server, + isSelected, + connectionStatus, + isInitializing, + statusIconProps, + onToggle, +}: MCPServerMenuItemProps) { + const localize = useLocalize(); + const displayName = server.config?.title || server.serverName; + const statusColor = getStatusColor(server.serverName, connectionStatus, isInitializing); + const statusTextKey = getStatusTextKey(server.serverName, connectionStatus, isInitializing); + const statusText = localize(statusTextKey as Parameters[0]); + const showActionButton = shouldShowActionButton(statusIconProps); + + // Include status in aria-label so screen readers announce it + const accessibleLabel = `${displayName}, ${statusText}`; + + return ( + onToggle(server.serverName)} + aria-label={accessibleLabel} + className={cn( + 'group flex w-full cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2', + 'outline-none transition-all duration-150', + 'hover:bg-surface-hover data-[active-item]:bg-surface-hover', + isSelected && 'bg-surface-active-alt', + )} + > + {/* Server Icon with Status Dot */} +
+ {server.config?.iconPath ? ( + {displayName} + ) : ( +
+ +
+ )} + {/* Status dot - decorative, status is announced via aria-label on MenuItem */} + + + {/* Server Info */} +
+
+ {displayName} +
+ {server.config?.description && ( +

{server.config.description}

+ )} +
+ + {/* Action Button - only show when actionable */} + {showActionButton && statusIconProps && ( +
e.stopPropagation()}> + +
+ )} + + {/* Selection Indicator - purely visual, state conveyed by aria-checked on MenuItem */} + + + ); +} diff --git a/client/src/components/MCP/MCPServerStatusIcon.tsx b/client/src/components/MCP/MCPServerStatusIcon.tsx index 36a876e23e..8ae225b2e2 100644 --- a/client/src/components/MCP/MCPServerStatusIcon.tsx +++ b/client/src/components/MCP/MCPServerStatusIcon.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Spinner, TooltipAnchor } from '@librechat/client'; -import { SettingsIcon, AlertTriangle, KeyRound, PlugZap, X, CircleCheck } from 'lucide-react'; -import type { MCPServerStatus, TPlugin } from 'librechat-data-provider'; +import { Spinner } from '@librechat/client'; +import { PlugZap, SlidersHorizontal, X } from 'lucide-react'; +import type { MCPServerStatus } from 'librechat-data-provider'; import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; let localize: ReturnType; @@ -16,34 +17,48 @@ interface InitializingStatusProps extends StatusIconProps { canCancel: boolean; } -interface MCPServerStatusIconProps { +export interface MCPServerStatusIconProps { serverName: string; serverStatus?: MCPServerStatus; - tool?: TPlugin; onConfigClick: (e: React.MouseEvent) => void; isInitializing: boolean; canCancel: boolean; onCancel: (e: React.MouseEvent) => void; hasCustomUserVars?: boolean; + /** When true, renders as a small status dot for compact layouts */ + compact?: boolean; } /** - * Renders the appropriate status icon for an MCP server based on its state + * Renders the appropriate status icon for an MCP server based on its state. + * + * Unified icon system: + * - PlugZap: Connect/Authenticate (for disconnected servers that need connection) + * - SlidersHorizontal: Configure (for connected servers with custom vars) + * - Spinner: Loading state (during connection) + * - X: Cancel (during OAuth flow, shown on hover over spinner) */ export default function MCPServerStatusIcon({ serverName, serverStatus, - tool, onConfigClick, isInitializing, canCancel, onCancel, hasCustomUserVars = false, + compact = false, }: MCPServerStatusIconProps) { localize = useLocalize(); + + // Compact mode: render as a small status dot + if (compact) { + return ; + } + + // Loading state: show spinner (with cancel option if available) if (isInitializing) { return ( - ; + return ; } - if (connectionState === 'disconnected') { - if (requiresOAuth) { - return ; - } - return ; + // Disconnected or Error: show connect button (PlugZap icon) + if (connectionState === 'disconnected' || connectionState === 'error') { + return ; } - if (connectionState === 'error') { - return ; - } - - if (connectionState === 'connected') { - // Only show config button if there are customUserVars to configure - if (hasCustomUserVars) { - const isAuthenticated = tool?.authenticated || requiresOAuth; - return ( - - ); - } - return ( - - ); + // Connected: only show config button if there are custom vars to configure + if (connectionState === 'connected' && hasCustomUserVars) { + return ; } + // Connected without custom vars: no action needed, status shown via dot return null; } -function InitializingStatusIcon({ serverName, onCancel, canCancel }: InitializingStatusProps) { +interface CompactStatusDotProps { + serverStatus?: MCPServerStatus; + isInitializing: boolean; +} + +function CompactStatusDot({ serverStatus, isInitializing }: CompactStatusDotProps) { + if (isInitializing) { + return ( +
+
+
+ ); + } + + if (!serverStatus) { + return
; + } + + const { connectionState, requiresOAuth } = serverStatus; + + let colorClass = 'bg-gray-400'; + if (connectionState === 'connected') { + colorClass = 'bg-green-500'; + } else if (connectionState === 'connecting') { + colorClass = 'bg-blue-500'; + } else if (connectionState === 'error') { + colorClass = 'bg-red-500'; + } else if (connectionState === 'disconnected' && requiresOAuth) { + colorClass = 'bg-amber-500'; + } + + return ( +
+ ); +} + +function LoadingStatusIcon({ serverName, onCancel, canCancel }: InitializingStatusProps) { if (canCancel) { return ( ); } return ( -
+
); } -function ConnectingStatusIcon({ serverName }: StatusIconProps) { +function ConnectingSpinner({ serverName }: { serverName: string }) { return ( -
+
); } -function DisconnectedOAuthStatusIcon({ serverName, onConfigClick }: StatusIconProps) { +/** Connect button - shown for disconnected/error states. Uses PlugZap icon. */ +function ConnectButton({ serverName, onConfigClick }: StatusIconProps) { return ( ); } -function DisconnectedStatusIcon({ serverName, onConfigClick }: StatusIconProps) { +/** Configure button - shown for connected servers with custom vars. Uses SlidersHorizontal icon. */ +function ConfigureButton({ serverName, onConfigClick }: StatusIconProps) { return ( ); } - -function ErrorStatusIcon({ serverName, onConfigClick }: StatusIconProps) { - return ( - - ); -} - -interface AuthenticatedStatusProps extends StatusIconProps { - isAuthenticated: boolean; -} - -function AuthenticatedStatusIcon({ - serverName, - onConfigClick, - isAuthenticated, -}: AuthenticatedStatusProps) { - return ( - - ); -} - -interface ConnectedStatusProps { - serverName: string; - requiresOAuth?: boolean; - onConfigClick: (e: React.MouseEvent) => void; -} - -function ConnectedStatusIcon({ serverName, requiresOAuth, onConfigClick }: ConnectedStatusProps) { - if (requiresOAuth) { - return ( - - - - ); - } - - return ( - - - - ); -} diff --git a/client/src/components/MCP/StackedMCPIcons.tsx b/client/src/components/MCP/StackedMCPIcons.tsx new file mode 100644 index 0000000000..fa04928210 --- /dev/null +++ b/client/src/components/MCP/StackedMCPIcons.tsx @@ -0,0 +1,101 @@ +import { useMemo } from 'react'; +import { MCPIcon } from '@librechat/client'; +import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager'; +import { getSelectedServerIcons } from './mcpServerUtils'; +import { cn } from '~/utils'; + +interface StackedMCPIconsProps { + selectedServers: MCPServerDefinition[]; + maxIcons?: number; + iconSize?: 'sm' | 'md'; + variant?: 'default' | 'submenu'; +} + +const sizeConfig = { + sm: { + icon: 'h-[18px] w-[18px]', + container: 'h-[22px] w-[22px]', + overlap: '-ml-2.5', + }, + md: { + icon: 'h-5 w-5', + container: 'h-6 w-6', + overlap: '-ml-3', + }, +}; + +const variantConfig = { + default: { + border: 'border-border-medium', + bg: 'bg-surface-secondary', + }, + submenu: { + border: 'border-surface-primary', + bg: 'bg-surface-primary', + }, +}; + +export default function StackedMCPIcons({ + selectedServers, + maxIcons = 3, + iconSize = 'md', + variant = 'default', +}: StackedMCPIconsProps) { + const { icons, overflowCount } = useMemo( + () => getSelectedServerIcons(selectedServers, maxIcons), + [selectedServers, maxIcons], + ); + + if (icons.length === 0) { + return ( +