🗂️ refactor: Make MCPSubMenu consistent with MCPSelect (#8650)

- Refactored MCPSelect and MCPSubMenu components to utilize a new custom hook, `useMCPServerManager`, for improved state management and server initialization logic.
- Added functionality to handle simultaneous MCP server initialization requests, including cancellation and user notifications.
- Updated translation files to include new messages for initialization cancellation.
- Improved the configuration dialog handling for MCP servers, streamlining the user experience when managing server settings.
This commit is contained in:
Dustin Healy 2025-07-25 11:51:42 -07:00 committed by GitHub
parent cd436dc6a8
commit 545a909953
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 503 additions and 424 deletions

View file

@ -2,28 +2,26 @@ import React from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronRight } from 'lucide-react';
import { PinIcon, MCPIcon } from '~/components/svg';
import { useLocalize } from '~/hooks';
import MCPConfigDialog from '~/components/ui/MCP/MCPConfigDialog';
import MCPServerStatusIcon from '~/components/ui/MCP/MCPServerStatusIcon';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import { cn } from '~/utils';
interface MCPSubMenuProps {
isMCPPinned: boolean;
setIsMCPPinned: (value: boolean) => void;
mcpValues?: string[];
mcpServerNames: string[];
handleMCPToggle: (serverName: string) => void;
placeholder?: string;
}
const MCPSubMenu = ({
mcpValues,
isMCPPinned,
mcpServerNames,
setIsMCPPinned,
handleMCPToggle,
placeholder,
...props
}: MCPSubMenuProps) => {
const localize = useLocalize();
const MCPSubMenu = ({ placeholder, ...props }: MCPSubMenuProps) => {
const {
configuredServers,
mcpValues,
isPinned,
setIsPinned,
placeholderText,
toggleServerSelection,
getServerStatusIconProps,
getConfigDialogProps,
} = useMCPServerManager();
const menuStore = Ariakit.useMenuStore({
focusLoop: true,
@ -31,72 +29,96 @@ const MCPSubMenu = ({
placement: 'right',
});
// Don't render if no MCP servers are configured
if (!configuredServers || configuredServers.length === 0) {
return null;
}
const configDialogProps = getConfigDialogProps();
return (
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
menuStore.toggle();
}}
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
/>
}
>
<div className="flex items-center gap-2">
<MCPIcon className="icon-md" />
<span>{placeholder || localize('com_ui_mcp_servers')}</span>
<ChevronRight className="ml-auto h-3 w-3" />
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsMCPPinned(!isMCPPinned);
}}
className={cn(
'rounded p-1 transition-all duration-200',
'hover:bg-surface-tertiary hover:shadow-sm',
!isMCPPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isMCPPinned ? 'Unpin' : 'Pin'}
<>
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
menuStore.toggle();
}}
className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover"
/>
}
>
<div className="h-4 w-4">
<PinIcon unpin={isMCPPinned} />
<div className="flex items-center gap-2">
<MCPIcon className="icon-md" />
<span>{placeholder || placeholderText}</span>
<ChevronRight className="ml-auto h-3 w-3" />
</div>
</button>
</Ariakit.MenuItem>
<Ariakit.Menu
portal={true}
unmountOnHide={true}
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',
)}
>
{mcpServerNames.map((serverName) => (
<Ariakit.MenuItem
key={serverName}
onClick={(event) => {
event.preventDefault();
handleMCPToggle(serverName);
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsPinned(!isPinned);
}}
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 text-sm',
'rounded p-1 transition-all duration-200',
'hover:bg-surface-tertiary hover:shadow-sm',
!isPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isPinned ? 'Unpin' : 'Pin'}
>
<Ariakit.MenuItemCheck checked={mcpValues?.includes(serverName) ?? false} />
<span>{serverName}</span>
</Ariakit.MenuItem>
))}
</Ariakit.Menu>
</Ariakit.MenuProvider>
<div className="h-4 w-4">
<PinIcon unpin={isPinned} />
</div>
</button>
</Ariakit.MenuItem>
<Ariakit.Menu
portal={true}
unmountOnHide={true}
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',
)}
>
{configuredServers.map((serverName) => {
const statusIconProps = getServerStatusIconProps(serverName);
const isSelected = mcpValues?.includes(serverName) ?? false;
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
return (
<Ariakit.MenuItem
key={serverName}
onClick={(event) => {
event.preventDefault();
toggleServerSelection(serverName);
}}
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',
)}
>
<button
type="button"
className="flex flex-grow items-center gap-2 rounded bg-transparent p-0 text-left transition-colors focus:outline-none"
tabIndex={0}
>
<Ariakit.MenuItemCheck checked={isSelected} />
<span>{serverName}</span>
</button>
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
</Ariakit.MenuItem>
);
})}
</Ariakit.Menu>
</Ariakit.MenuProvider>
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
</>
);
};