mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-12 20:44:24 +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
|
|
@ -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 (
|
||||
<div className="flex items-center gap-2 rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600 dark:bg-blue-950 dark:text-blue-400">
|
||||
<Spinner className="h-3 w-3" />
|
||||
<Spinner className="size-3" />
|
||||
<span>{localize('com_ui_connecting')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Disconnected: check if needs action
|
||||
if (connectionState === 'disconnected') {
|
||||
if (requiresOAuth) {
|
||||
// Needs OAuth: amber (requires action)
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600 dark:bg-amber-950 dark:text-amber-400">
|
||||
<KeyRound className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{localize('com_ui_oauth')}</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-orange-50 px-2 py-0.5 text-xs font-medium text-orange-600 dark:bg-orange-950 dark:text-orange-400">
|
||||
<PlugZap className="h-3 w-3" aria-hidden="true" />
|
||||
<span>{localize('com_ui_offline')}</span>
|
||||
<KeyRound className="size-3" aria-hidden="true" />
|
||||
<span>{localize('com_nav_mcp_status_needs_auth')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Simply disconnected: gray (neutral)
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||||
<PlugZap className="size-3" aria-hidden="true" />
|
||||
<span>{localize('com_nav_mcp_status_disconnected')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error: red
|
||||
if (connectionState === 'error') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-600 dark:bg-red-950 dark:text-red-400">
|
||||
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
|
||||
<AlertTriangle className="size-3" aria-hidden="true" />
|
||||
<span>{localize('com_ui_error')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Connected: green
|
||||
if (connectionState === 'connected') {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900 dark:text-green-300">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
<div className="flex items-center gap-2 rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-600 dark:bg-green-950 dark:text-green-400">
|
||||
<div className="size-1.5 rounded-full bg-green-500" />
|
||||
<span>{localize('com_ui_active')}</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
113
client/src/components/MCP/MCPServerMenuItem.tsx
Normal file
113
client/src/components/MCP/MCPServerMenuItem.tsx
Normal file
|
|
@ -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<typeof localize>[0]);
|
||||
const showActionButton = shouldShowActionButton(statusIconProps);
|
||||
|
||||
// Include status in aria-label so screen readers announce it
|
||||
const accessibleLabel = `${displayName}, ${statusText}`;
|
||||
|
||||
return (
|
||||
<Ariakit.MenuItemCheckbox
|
||||
hideOnClick={false}
|
||||
name="mcp-servers"
|
||||
value={server.serverName}
|
||||
checked={isSelected}
|
||||
setValueOnChange={false}
|
||||
onChange={() => 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 */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{server.config?.iconPath ? (
|
||||
<img
|
||||
src={server.config.iconPath}
|
||||
className="h-8 w-8 rounded-lg object-cover"
|
||||
alt={displayName}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-surface-tertiary">
|
||||
<MCPIcon className="h-5 w-5 text-text-secondary" />
|
||||
</div>
|
||||
)}
|
||||
{/* Status dot - decorative, status is announced via aria-label on MenuItem */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-surface-secondary',
|
||||
statusColor,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server Info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium text-text-primary">{displayName}</span>
|
||||
</div>
|
||||
{server.config?.description && (
|
||||
<p className="truncate text-xs text-text-secondary">{server.config.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Button - only show when actionable */}
|
||||
{showActionButton && statusIconProps && (
|
||||
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<MCPServerStatusIcon {...statusIconProps} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection Indicator - purely visual, state conveyed by aria-checked on MenuItem */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border',
|
||||
isSelected
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border-xheavy bg-transparent',
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="h-4 w-4" />}
|
||||
</span>
|
||||
</Ariakit.MenuItemCheckbox>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<typeof useLocalize>;
|
||||
|
||||
|
|
@ -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 <CompactStatusDot serverStatus={serverStatus} isInitializing={isInitializing} />;
|
||||
}
|
||||
|
||||
// Loading state: show spinner (with cancel option if available)
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<InitializingStatusIcon
|
||||
<LoadingStatusIcon
|
||||
serverName={serverName}
|
||||
onConfigClick={onConfigClick}
|
||||
onCancel={onCancel}
|
||||
|
|
@ -56,178 +71,126 @@ export default function MCPServerStatusIcon({
|
|||
return null;
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
const { connectionState } = serverStatus;
|
||||
|
||||
// Connecting: show spinner only (no action available)
|
||||
if (connectionState === 'connecting') {
|
||||
return <ConnectingStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
return <ConnectingSpinner serverName={serverName} />;
|
||||
}
|
||||
|
||||
if (connectionState === 'disconnected') {
|
||||
if (requiresOAuth) {
|
||||
return <DisconnectedOAuthStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
return <DisconnectedStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
// Disconnected or Error: show connect button (PlugZap icon)
|
||||
if (connectionState === 'disconnected' || connectionState === 'error') {
|
||||
return <ConnectButton serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
|
||||
if (connectionState === 'error') {
|
||||
return <ErrorStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
|
||||
if (connectionState === 'connected') {
|
||||
// Only show config button if there are customUserVars to configure
|
||||
if (hasCustomUserVars) {
|
||||
const isAuthenticated = tool?.authenticated || requiresOAuth;
|
||||
return (
|
||||
<AuthenticatedStatusIcon
|
||||
serverName={serverName}
|
||||
onConfigClick={onConfigClick}
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ConnectedStatusIcon
|
||||
serverName={serverName}
|
||||
requiresOAuth={requiresOAuth}
|
||||
onConfigClick={onConfigClick}
|
||||
/>
|
||||
);
|
||||
// Connected: only show config button if there are custom vars to configure
|
||||
if (connectionState === 'connected' && hasCustomUserVars) {
|
||||
return <ConfigureButton serverName={serverName} onConfigClick={onConfigClick} />;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="flex size-3.5 items-center justify-center rounded-full border-2 border-surface-secondary bg-blue-500">
|
||||
<div className="size-1.5 animate-pulse rounded-full bg-white" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!serverStatus) {
|
||||
return <div className="size-3 rounded-full border-2 border-surface-secondary bg-gray-400" />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn('size-3 rounded-full border-2 border-surface-secondary', colorClass)} />
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingStatusIcon({ serverName, onCancel, canCancel }: InitializingStatusProps) {
|
||||
if (canCancel) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="group flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-red-100 dark:hover:bg-red-900/20"
|
||||
className="group flex size-6 items-center justify-center rounded p-1 hover:bg-red-100 dark:hover:bg-red-900/20"
|
||||
aria-label={localize('com_ui_cancel')}
|
||||
title={localize('com_ui_cancel')}
|
||||
>
|
||||
<div className="relative h-4 w-4">
|
||||
<Spinner className="h-4 w-4 group-hover:opacity-0" />
|
||||
<X className="absolute inset-0 h-4 w-4 text-red-500 opacity-0 group-hover:opacity-100" />
|
||||
<div className="relative size-4">
|
||||
<Spinner className="size-4 text-text-primary group-hover:opacity-0" />
|
||||
<X className="absolute inset-0 size-4 text-red-500 opacity-0 group-hover:opacity-100" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
|
||||
<div className="flex size-6 items-center justify-center rounded p-1">
|
||||
<Spinner
|
||||
className="h-4 w-4"
|
||||
className="size-4 text-text-primary"
|
||||
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectingStatusIcon({ serverName }: StatusIconProps) {
|
||||
function ConnectingSpinner({ serverName }: { serverName: string }) {
|
||||
return (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
|
||||
<div className="flex size-6 items-center justify-center rounded p-1">
|
||||
<Spinner
|
||||
className="h-4 w-4"
|
||||
className="size-4 text-text-primary"
|
||||
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DisconnectedOAuthStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
|
||||
/** Connect button - shown for disconnected/error states. Uses PlugZap icon. */
|
||||
function ConnectButton({ serverName, onConfigClick }: StatusIconProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
className="flex size-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_connect_server', { 0: serverName })}
|
||||
>
|
||||
<KeyRound className="h-4 w-4 text-amber-500" aria-hidden="true" />
|
||||
<PlugZap className="size-4 text-text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DisconnectedStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
|
||||
/** Configure button - shown for connected servers with custom vars. Uses SlidersHorizontal icon. */
|
||||
function ConfigureButton({ serverName, onConfigClick }: StatusIconProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
className="flex size-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
>
|
||||
<PlugZap className="h-4 w-4 text-orange-500" aria-hidden="true" />
|
||||
<SlidersHorizontal className="size-4 text-text-secondary" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 text-red-500" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface AuthenticatedStatusProps extends StatusIconProps {
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
function AuthenticatedStatusIcon({
|
||||
serverName,
|
||||
onConfigClick,
|
||||
isAuthenticated,
|
||||
}: AuthenticatedStatusProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
>
|
||||
<SettingsIcon
|
||||
className={`h-4 w-4 ${isAuthenticated ? 'text-green-500' : 'text-gray-400'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConnectedStatusProps {
|
||||
serverName: string;
|
||||
requiresOAuth?: boolean;
|
||||
onConfigClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
function ConnectedStatusIcon({ serverName, requiresOAuth, onConfigClick }: ConnectedStatusProps) {
|
||||
if (requiresOAuth) {
|
||||
return (
|
||||
<TooltipAnchor
|
||||
role="button"
|
||||
onClick={onConfigClick}
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
|
||||
description={localize('com_nav_mcp_status_connected')}
|
||||
side="top"
|
||||
>
|
||||
<CircleCheck className="h-4 w-4 text-green-500" />
|
||||
</TooltipAnchor>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipAnchor
|
||||
className="flex h-6 w-6 items-center justify-center rounded p-1"
|
||||
description={localize('com_nav_mcp_status_connected')}
|
||||
side="top"
|
||||
>
|
||||
<CircleCheck className="h-4 w-4 text-green-500" />
|
||||
</TooltipAnchor>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
101
client/src/components/MCP/StackedMCPIcons.tsx
Normal file
101
client/src/components/MCP/StackedMCPIcons.tsx
Normal file
|
|
@ -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 (
|
||||
<MCPIcon
|
||||
aria-hidden="true"
|
||||
className={cn('flex-shrink-0 text-text-primary', sizeConfig.md.icon)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sizes = sizeConfig[iconSize];
|
||||
const colors = variantConfig[variant];
|
||||
|
||||
return (
|
||||
<div className="flex items-center" aria-hidden="true">
|
||||
{icons.map((icon, index) => (
|
||||
<div
|
||||
key={icon.key}
|
||||
title={icon.displayName}
|
||||
className={cn(
|
||||
'relative flex items-center justify-center rounded-full border',
|
||||
colors.border,
|
||||
colors.bg,
|
||||
sizes.container,
|
||||
index > 0 && sizes.overlap,
|
||||
)}
|
||||
style={{ zIndex: icons.length - index }}
|
||||
>
|
||||
{icon.iconPath ? (
|
||||
<img
|
||||
src={icon.iconPath}
|
||||
alt={icon.displayName}
|
||||
className={cn('rounded-full object-cover', sizes.icon)}
|
||||
/>
|
||||
) : (
|
||||
<MCPIcon className={cn('text-text-primary', sizes.icon)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{overflowCount > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex items-center justify-center rounded-full border border-surface-primary bg-surface-tertiary text-xs font-medium text-text-secondary',
|
||||
sizes.container,
|
||||
sizes.overlap,
|
||||
)}
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
+{overflowCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
196
client/src/components/MCP/mcpServerUtils.ts
Normal file
196
client/src/components/MCP/mcpServerUtils.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import type { MCPServerStatus } from 'librechat-data-provider';
|
||||
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
|
||||
import type { MCPServerStatusIconProps } from './MCPServerStatusIcon';
|
||||
|
||||
export type { MCPServerStatus };
|
||||
|
||||
export interface SelectedIconInfo {
|
||||
key: string;
|
||||
serverName: string;
|
||||
iconPath: string | null;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export type ConnectionStatusMap = Record<string, MCPServerStatus>;
|
||||
|
||||
/**
|
||||
* Generates a list of icons to display for selected MCP servers.
|
||||
* - Custom icons are shown individually
|
||||
* - Multiple default icons are consolidated into one
|
||||
* - Limited to maxIcons with overflow count
|
||||
*/
|
||||
export function getSelectedServerIcons(
|
||||
selectedServers: MCPServerDefinition[],
|
||||
maxIcons: number = 3,
|
||||
): { icons: SelectedIconInfo[]; overflowCount: number; defaultServerNames: string[] } {
|
||||
const customIcons: SelectedIconInfo[] = [];
|
||||
const defaultServerNames: string[] = [];
|
||||
|
||||
for (const server of selectedServers) {
|
||||
const displayName = server.config?.title || server.serverName;
|
||||
if (server.config?.iconPath) {
|
||||
customIcons.push({
|
||||
key: server.serverName,
|
||||
serverName: server.serverName,
|
||||
iconPath: server.config.iconPath,
|
||||
displayName,
|
||||
});
|
||||
} else {
|
||||
defaultServerNames.push(server.serverName);
|
||||
}
|
||||
}
|
||||
|
||||
// Add one default icon entry if any server uses default icon
|
||||
// Custom icons are prioritized first, default icon comes last
|
||||
const allIcons: SelectedIconInfo[] =
|
||||
defaultServerNames.length > 0
|
||||
? [
|
||||
...customIcons,
|
||||
{
|
||||
key: '_default_',
|
||||
serverName: defaultServerNames[0],
|
||||
iconPath: null,
|
||||
displayName: 'MCP',
|
||||
},
|
||||
]
|
||||
: customIcons;
|
||||
|
||||
const visibleIcons = allIcons.slice(0, maxIcons);
|
||||
const overflowCount = Math.max(0, allIcons.length - maxIcons);
|
||||
|
||||
return { icons: visibleIcons, overflowCount, defaultServerNames };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified status color system following UX best practices:
|
||||
* - Green: Connected/Active (success)
|
||||
* - Blue: Connecting/In-progress (processing)
|
||||
* - Amber: Needs user action (OAuth required, config missing)
|
||||
* - Gray: Disconnected/Inactive (neutral - server is simply off)
|
||||
* - Red: Error (failed, needs retry)
|
||||
*
|
||||
* Key insight: "Disconnected" is neutral (gray), not a warning.
|
||||
* Amber is reserved for states requiring user intervention.
|
||||
*/
|
||||
export function getStatusColor(
|
||||
serverName: string,
|
||||
connectionStatus?: ConnectionStatusMap,
|
||||
isInitializing?: (serverName: string) => boolean,
|
||||
): string {
|
||||
// In-progress states: blue
|
||||
if (isInitializing?.(serverName)) {
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
const status = connectionStatus?.[serverName];
|
||||
if (!status) {
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = status;
|
||||
|
||||
// Connecting: blue (in progress)
|
||||
if (connectionState === 'connecting') {
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
// Connected: green (success)
|
||||
if (connectionState === 'connected') {
|
||||
return 'bg-green-500';
|
||||
}
|
||||
|
||||
// Error: red
|
||||
if (connectionState === 'error') {
|
||||
return 'bg-red-500';
|
||||
}
|
||||
|
||||
// Disconnected: check if needs action or just inactive
|
||||
if (connectionState === 'disconnected') {
|
||||
// Needs OAuth = amber (requires user action)
|
||||
if (requiresOAuth) {
|
||||
return 'bg-amber-500';
|
||||
}
|
||||
// Simply disconnected = gray (neutral/inactive)
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
|
||||
export function getStatusTextKey(
|
||||
serverName: string,
|
||||
connectionStatus?: ConnectionStatusMap,
|
||||
isInitializing?: (serverName: string) => boolean,
|
||||
): string {
|
||||
if (isInitializing?.(serverName)) {
|
||||
return 'com_nav_mcp_status_initializing';
|
||||
}
|
||||
|
||||
const status = connectionStatus?.[serverName];
|
||||
if (!status) {
|
||||
return 'com_nav_mcp_status_unknown';
|
||||
}
|
||||
|
||||
const { connectionState, requiresOAuth } = status;
|
||||
|
||||
// Special case: disconnected but needs OAuth shows different text
|
||||
if (connectionState === 'disconnected' && requiresOAuth) {
|
||||
return 'com_nav_mcp_status_needs_auth';
|
||||
}
|
||||
|
||||
const keyMap: Record<string, string> = {
|
||||
connected: 'com_nav_mcp_status_connected',
|
||||
connecting: 'com_nav_mcp_status_connecting',
|
||||
disconnected: 'com_nav_mcp_status_disconnected',
|
||||
error: 'com_nav_mcp_status_error',
|
||||
};
|
||||
|
||||
return keyMap[connectionState] || 'com_nav_mcp_status_unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a server requires user action to connect.
|
||||
* Used to show action buttons and amber status color.
|
||||
*/
|
||||
export function serverNeedsAction(
|
||||
serverStatus?: MCPServerStatus,
|
||||
_hasCustomUserVars?: boolean,
|
||||
): boolean {
|
||||
if (!serverStatus) return false;
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
|
||||
// Needs OAuth authentication
|
||||
if (connectionState === 'disconnected' && requiresOAuth) return true;
|
||||
|
||||
// Has error - needs retry
|
||||
if (connectionState === 'error') return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if an action button should be shown for a server status.
|
||||
* Returns true only when the button would be actionable (not just informational).
|
||||
*/
|
||||
export function shouldShowActionButton(statusIconProps?: MCPServerStatusIconProps | null): boolean {
|
||||
if (!statusIconProps) return false;
|
||||
|
||||
const { serverStatus, canCancel, hasCustomUserVars, isInitializing } = statusIconProps;
|
||||
|
||||
// Show cancel button during OAuth flow
|
||||
if (isInitializing && canCancel) return true;
|
||||
// Don't show spinner-only state (no action available)
|
||||
if (isInitializing) return false;
|
||||
|
||||
if (!serverStatus) return false;
|
||||
const { connectionState, requiresOAuth } = serverStatus;
|
||||
|
||||
// Show for disconnected/error (can reconnect/configure)
|
||||
if (connectionState === 'disconnected' || connectionState === 'error') return true;
|
||||
// Don't show connecting spinner (no action)
|
||||
if (connectionState === 'connecting') return false;
|
||||
// Connected: only show if there's config to manage
|
||||
if (connectionState === 'connected') return hasCustomUserVars || requiresOAuth;
|
||||
|
||||
return false;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue