import React from 'react'; import type { McpStatus } from '@opencode-ai/sdk/v2'; import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger, } from '@/components/ui/tooltip'; import { Switch } from '@/components/ui/switch'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { cn } from '@/lib/utils'; import { useDeviceInfo } from '@/lib/device'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useMcpConfigStore } from '@/stores/useMcpConfigStore'; import { computeMcpHealth, useMcpStore } from '@/stores/useMcpStore'; import { McpIcon } from '@/components/icons/McpIcon'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; const statusTooltip = ( status: McpStatus | undefined, t: (key: 'mcpDropdown.status.unknown' | 'mcpDropdown.status.connected' | 'mcpDropdown.status.failed' | 'mcpDropdown.status.unknownError' | 'mcpDropdown.status.needsAuth' | 'mcpDropdown.status.needsRegistration', params?: { error?: string }) => string ): string => { if (!status) return t('mcpDropdown.status.unknown'); switch (status.status) { case 'connected': return t('mcpDropdown.status.connected'); case 'failed': return t('mcpDropdown.status.failed', { error: (status as { error?: string }).error || t('mcpDropdown.status.unknownError') }); case 'needs_auth': return t('mcpDropdown.status.needsAuth'); case 'needs_client_registration': return t('mcpDropdown.status.needsRegistration', { error: (status as { error?: string }).error || '' }); default: return status.status; } }; const statusTone = (status: McpStatus | undefined): 'default' | 'success' | 'warning' | 'error' => { switch (status?.status) { case 'connected': return 'success'; case 'failed': return 'error'; case 'needs_auth': case 'needs_client_registration': return 'warning'; default: return 'default'; } }; interface McpDropdownProps { headerIconButtonClass: string; } interface McpDropdownContentProps { active: boolean; className?: string; headerAction?: React.ReactNode; listClassName?: string; hideHeader?: boolean; mobileListDensity?: boolean; } export const McpDropdownContent: React.FC = ({ active, className, headerAction, listClassName, hideHeader = false, mobileListDensity = false }) => { const { t } = useI18n(); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const directory = currentDirectory ?? null; const status = useMcpStore((state) => state.getStatusForDirectory(directory)); const refresh = useMcpStore((state) => state.refresh); const connect = useMcpStore((state) => state.connect); const disconnect = useMcpStore((state) => state.disconnect); const mcpServers = useMcpConfigStore((state) => state.mcpServers); const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs); const [isSpinning, setIsSpinning] = React.useState(false); const [busyName, setBusyName] = React.useState(null); React.useEffect(() => { void refresh({ directory, silent: true }); }, [refresh, directory]); React.useEffect(() => { void loadMcpConfigs({ force: true }); }, [loadMcpConfigs]); React.useEffect(() => { if (!active) return; void Promise.all([ refresh({ directory, silent: true }), loadMcpConfigs({ force: true }), ]); }, [active, refresh, directory, loadMcpConfigs]); const sortedNames = React.useMemo(() => { const names = new Set(Object.keys(status)); for (const server of mcpServers) { if (server?.name) { names.add(server.name); } } return Array.from(names).sort((a, b) => a.localeCompare(b)); }, [mcpServers, status]); const handleRefresh = React.useCallback((e?: React.MouseEvent) => { e?.preventDefault(); if (isSpinning) return; setIsSpinning(true); const minSpinPromise = new Promise(resolve => setTimeout(resolve, 500)); Promise.all([refresh({ directory }), minSpinPromise]).finally(() => { setIsSpinning(false); }); }, [isSpinning, refresh, directory]); return (
{!hideHeader ?
{t('mcpDropdown.title')}
{directory && (
{directory.split('/').pop() || directory}
)}
{headerAction}
: null} {/* Desktop dropdown: servers grouped in one mobile-style card; the mobile sheet variant keeps its own density and chrome. */}
0 && 'rounded-xl bg-[var(--surface-muted)] p-1.5')}> {sortedNames.map((serverName) => { const serverStatus = status[serverName]; const tone = statusTone(serverStatus); const isConnected = serverStatus?.status === 'connected'; const isBusy = busyName === serverName; const tooltip = statusTooltip(serverStatus, t); return (

{tooltip}

{serverName}
{ setBusyName(serverName); try { if (checked) { await connect(serverName, directory); } else { await disconnect(serverName, directory); } } finally { setBusyName(null); } }} />
); })} {sortedNames.length === 0 && (
{t('mcpDropdown.empty.configureInConfig')}
)}
); }; export const McpDropdown: React.FC = ({ headerIconButtonClass }) => { const { t } = useI18n(); const [open, setOpen] = React.useState(false); const [tooltipOpen, setTooltipOpen] = React.useState(false); const blockTooltipRef = React.useRef(false); const { isMobile } = useDeviceInfo(); const currentDirectory = useDirectoryStore((state) => state.currentDirectory); const directory = currentDirectory ?? null; const status = useMcpStore((state) => state.getStatusForDirectory(directory)); const refresh = useMcpStore((state) => state.refresh); const connect = useMcpStore((state) => state.connect); const disconnect = useMcpStore((state) => state.disconnect); const mcpServers = useMcpConfigStore((state) => state.mcpServers); const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs); const handleDropdownOpenChange = React.useCallback((isOpen: boolean) => { if (!isOpen) { blockTooltipRef.current = true; setTooltipOpen(false); setTimeout(() => { blockTooltipRef.current = false; }, 200); } setOpen(isOpen); }, []); const handleTooltipOpenChange = React.useCallback((isOpen: boolean) => { if (blockTooltipRef.current) return; setTooltipOpen(isOpen); }, []); const [isSpinning, setIsSpinning] = React.useState(false); const [busyName, setBusyName] = React.useState(null); // Fetch on mount and when directory changes React.useEffect(() => { void refresh({ directory, silent: true }); void loadMcpConfigs({ force: true }); }, [refresh, directory, loadMcpConfigs]); // Refresh when dropdown opens React.useEffect(() => { if (!open) return; void Promise.all([ refresh({ directory, silent: true }), loadMcpConfigs({ force: true }), ]); }, [open, refresh, directory, loadMcpConfigs]); const health = React.useMemo(() => computeMcpHealth(status), [status]); const sortedNames = React.useMemo(() => { const names = new Set(Object.keys(status)); for (const server of mcpServers) { if (server?.name) { names.add(server.name); } } return Array.from(names).sort((a, b) => a.localeCompare(b)); }, [mcpServers, status]); const handleRefresh = React.useCallback((e?: React.MouseEvent) => { e?.preventDefault(); if (isSpinning) return; setIsSpinning(true); const minSpinPromise = new Promise(resolve => setTimeout(resolve, 500)); Promise.all([refresh({ directory }), minSpinPromise]).finally(() => { setIsSpinning(false); }); }, [isSpinning, refresh, directory]); const renderServerList = () => ( <> {sortedNames.map((serverName) => { const serverStatus = status[serverName]; const tone = statusTone(serverStatus); const isConnected = serverStatus?.status === 'connected'; const isBusy = busyName === serverName; const tooltip = statusTooltip(serverStatus, t); return (
{isMobile ? ( ) : (

{tooltip}

)} {serverName}
{ setBusyName(serverName); try { if (checked) { await connect(serverName, directory); } else { await disconnect(serverName, directory); } } finally { setBusyName(null); } }} />
); })} {sortedNames.length === 0 && (
{t('mcpDropdown.empty.configureInConfig')}
)} ); const triggerButton = ( ); // Mobile: use MobileOverlayPanel if (isMobile) { return ( <> {triggerButton} setOpen(false)} renderHeader={(closeButton) => (

{t('mcpDropdown.title')}

{closeButton}
)} >
{renderServerList()}
{directory && (
{directory.split('/').pop() || directory}
)}
); } // Desktop: use DropdownMenu return ( {triggerButton}

{t('mcpDropdown.title')}

); };