Files
SuperCharged-Claude-Code-Up…/dexto/packages/webui/components/ServersPanel.tsx
admin b52318eeae feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 00:27:56 +04:00

1061 lines
60 KiB
TypeScript

import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Button } from './ui/button';
import {
X,
ListChecks,
RefreshCw,
ChevronDown,
Trash2,
RotateCw,
Plus,
Search,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ServerRegistryEntry } from '@dexto/registry';
import type { McpServerConfig } from '@dexto/core';
import { serverRegistry } from '@/lib/serverRegistry';
import { buildConfigFromRegistryEntry, hasEmptyOrPlaceholderValue } from '@/lib/serverConfig';
import ServerRegistryModal from './ServerRegistryModal';
import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip';
import { useAnalytics } from '@/lib/analytics/index.js';
import { useServers, useAddServer, useDeleteServer, useRestartServer } from './hooks/useServers';
import { useAllTools, type ToolInfo } from './hooks/useTools';
import { useAgentPath } from './hooks/useAgents';
interface ServersPanelProps {
isOpen: boolean;
onClose: () => void;
onOpenConnectModal: () => void;
onOpenConnectWithPrefill?: (opts: {
name: string;
config: Partial<McpServerConfig> & { type?: 'stdio' | 'sse' | 'http' };
lockName?: boolean;
registryEntryId?: string;
onCloseRegistryModal?: () => void;
}) => void;
onServerConnected?: (serverName: string) => void;
variant?: 'overlay' | 'inline';
refreshTrigger?: number; // Add a trigger to force refresh
}
// Utility function to strip tool name prefixes (internal--, custom--, mcp--serverName--)
function stripToolPrefix(toolName: string, source: 'internal' | 'custom' | 'mcp'): string {
if (source === 'internal' && toolName.startsWith('internal--')) {
return toolName.replace('internal--', '');
}
if (source === 'custom' && toolName.startsWith('custom--')) {
return toolName.replace('custom--', '');
}
if (source === 'mcp' && toolName.startsWith('mcp--')) {
// Format: mcp--serverName--toolName -> extract toolName
const parts = toolName.split('--');
if (parts.length >= 3) {
return parts.slice(2).join('--'); // Join remaining parts in case tool name has '--'
}
// Fallback: if format is different, just remove 'mcp--'
return toolName.replace('mcp--', '');
}
return toolName;
}
export default function ServersPanel({
isOpen,
onClose,
onOpenConnectModal,
onOpenConnectWithPrefill,
onServerConnected,
variant: variantProp,
refreshTrigger,
}: ServersPanelProps) {
const variant: 'overlay' | 'inline' = variantProp ?? 'overlay';
const analytics = useAnalytics();
const [isRegistryModalOpen, setIsRegistryModalOpen] = useState(false);
const [isRegistryBusy, setIsRegistryBusy] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [expandedToolId, setExpandedToolId] = useState<string | null>(null);
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(new Set());
// Use TanStack Query hooks
const {
data: servers = [],
isLoading: isLoadingServers,
refetch: refetchServers,
} = useServers(isOpen);
const addServerMutation = useAddServer();
const deleteServerMutation = useDeleteServer();
const restartServerMutation = useRestartServer();
// Fetch all tools from all sources (internal, custom, MCP)
const {
data: allToolsData,
isLoading: isLoadingAllTools,
refetch: refetchTools,
} = useAllTools(isOpen);
// Track agent path for auto-refresh on agent switch
const { data: agentPath } = useAgentPath();
// Unified refresh function
const handleRefresh = useCallback(() => {
refetchServers();
refetchTools();
}, [refetchServers, refetchTools]);
// Toggle section collapse
const toggleSection = useCallback((sectionTitle: string) => {
setCollapsedSections((prev) => {
const next = new Set(prev);
if (next.has(sectionTitle)) {
next.delete(sectionTitle);
} else {
next.add(sectionTitle);
}
return next;
});
}, []);
// Group tools by source with server info
const toolsBySource = useMemo(() => {
if (!allToolsData)
return {
internal: [],
custom: [],
mcp: new Map<
string,
{
tools: ToolInfo[];
server: { id: string; name: string; status: string } | null;
}
>(),
};
const internal: ToolInfo[] = [];
const custom: ToolInfo[] = [];
const mcp = new Map<
string,
{ tools: ToolInfo[]; server: { id: string; name: string; status: string } | null }
>();
allToolsData.tools.forEach((tool: ToolInfo) => {
if (tool.source === 'internal') {
internal.push(tool);
} else if (tool.source === 'custom') {
custom.push(tool);
} else if (tool.source === 'mcp' && tool.serverName) {
const existing = mcp.get(tool.serverName) || { tools: [], server: null };
existing.tools.push(tool);
// Try to find the actual server
if (!existing.server) {
const server = servers.find(
(s: { id: string; name: string }) => s.name === tool.serverName
);
existing.server = server || null;
}
mcp.set(tool.serverName, existing);
}
});
return { internal, custom, mcp };
}, [allToolsData, servers]);
// Filter tools based on search query and create sections
const filteredSections = useMemo(() => {
const sections: Array<{
title: string;
tools: ToolInfo[];
type: 'internal' | 'custom' | 'mcp';
server?: { id: string; name: string; status: string } | null;
}> = [];
const query = searchQuery.toLowerCase();
// Internal tools section
if (toolsBySource.internal.length > 0) {
const filtered = searchQuery
? toolsBySource.internal.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
tool.description?.toLowerCase().includes(query)
)
: toolsBySource.internal;
if (filtered.length > 0) {
sections.push({ title: 'Internal', tools: filtered, type: 'internal' });
}
}
// Custom tools section
if (toolsBySource.custom.length > 0) {
const filtered = searchQuery
? toolsBySource.custom.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
tool.description?.toLowerCase().includes(query)
)
: toolsBySource.custom;
if (filtered.length > 0) {
sections.push({ title: 'Custom', tools: filtered, type: 'custom' });
}
}
// MCP server sections
toolsBySource.mcp.forEach(({ tools, server }, serverName) => {
const serverMatches = serverName.toLowerCase().includes(query);
const filtered = searchQuery
? serverMatches
? tools
: tools.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
tool.description?.toLowerCase().includes(query)
)
: tools;
if (filtered.length > 0) {
sections.push({
title: serverName,
tools: filtered,
type: 'mcp',
server,
});
}
});
return sections;
}, [toolsBySource, searchQuery]);
// Calculate total tool count
const totalToolCount = allToolsData?.totalCount || 0;
const handleInstallServer = async (
entry: ServerRegistryEntry
): Promise<'connected' | 'requires-input'> => {
const config = buildConfigFromRegistryEntry(entry);
const needsEnvInput =
config.type === 'stdio' &&
Object.keys(config.env || {}).length > 0 &&
hasEmptyOrPlaceholderValue(config.env || {});
const needsHeaderInput =
(config.type === 'sse' || config.type === 'http') &&
Object.keys(config.headers || {}).length > 0 &&
hasEmptyOrPlaceholderValue(config.headers || {});
// If additional input is needed, show the modal
if (needsEnvInput || needsHeaderInput) {
if (typeof onOpenConnectWithPrefill === 'function') {
onOpenConnectWithPrefill({
name: entry.name,
config,
lockName: true,
registryEntryId: entry.id,
onCloseRegistryModal: () => setIsRegistryModalOpen(false),
});
}
return 'requires-input';
}
// Otherwise, connect directly
try {
setIsRegistryBusy(true);
await addServerMutation.mutateAsync({
name: entry.name,
config,
persistToAgent: false,
});
// Sync registry after installation
try {
await serverRegistry.syncWithServerStatus();
} catch (e) {
console.warn('Failed to sync registry after server install:', e);
}
// Track MCP server connection
analytics.trackMCPServerConnected({
serverName: entry.name,
transportType: config.type as 'stdio' | 'http' | 'sse',
});
setIsRegistryModalOpen(false);
onServerConnected?.(entry.name);
return 'connected';
} catch (error: any) {
throw new Error(error.message || 'Failed to install server');
} finally {
setIsRegistryBusy(false);
}
};
const handleDeleteServer = async (serverId: string) => {
const server = servers.find((s: { id: string; name: string }) => s.id === serverId);
if (!server) return;
if (!window.confirm(`Are you sure you want to remove server "${server.name}"?`)) {
return;
}
try {
await deleteServerMutation.mutateAsync(serverId);
// Mark corresponding registry entry as uninstalled
try {
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-');
const currentId = normalize(serverId);
const entries = await serverRegistry.getEntries();
const match = entries.find((e) => {
const aliases = [e.id, e.name, ...(e.matchIds || [])]
.filter(Boolean)
.map((x) => normalize(String(x)));
return aliases.includes(currentId);
});
if (match) {
await serverRegistry.setInstalled(match.id, false);
}
} catch (e) {
console.warn('Failed to update registry installed state on delete:', e);
}
// Sync registry with updated server status
try {
await serverRegistry.syncWithServerStatus();
} catch (e) {
console.warn('Failed to sync registry status after server deletion:', e);
}
} catch (err: any) {
console.error('Delete server error:', err);
}
};
const handleRestartServer = async (serverId: string) => {
const server = servers.find((s: { id: string; name: string }) => s.id === serverId);
if (!server) return;
if (!window.confirm(`Restart server "${server.name}"?`)) {
return;
}
try {
await restartServerMutation.mutateAsync(serverId);
// Sync registry with updated server status
try {
await serverRegistry.syncWithServerStatus();
} catch (e) {
console.warn('Failed to sync registry status after server restart:', e);
}
} catch (err: any) {
console.error('Restart server error:', err);
}
};
// Auto-refresh on panel open, agent switch, or external trigger
// Consolidated from three separate useEffect hooks to prevent redundant fetches
useEffect(() => {
if (isOpen) {
handleRefresh();
}
}, [isOpen, agentPath, refreshTrigger, handleRefresh]);
// For inline variant, just return the content wrapped
if (variant === 'inline') {
return (
<aside className="h-full w-full flex flex-col bg-card/30">
{/* Panel Header */}
<div className="flex items-center justify-between px-4 py-3.5 shrink-0 border-b border-border/30">
<button onClick={onClose} className="flex items-center gap-2 group">
<h2 className="text-xs font-bold text-foreground/80 tracking-wider uppercase group-hover:text-foreground transition-colors">
Tools & Servers
</h2>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground/50 -rotate-90 transition-transform group-hover:text-foreground/70" />
</button>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setIsRegistryModalOpen(true)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Connect MCP servers</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isLoadingServers || isLoadingAllTools}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<RefreshCw
className={cn(
'h-4 w-4',
(isLoadingServers || isLoadingAllTools) &&
'animate-spin'
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Refresh</TooltipContent>
</Tooltip>
</div>
</div>
{/* Content Area - Tools List */}
<div className="flex-1 overflow-y-auto flex flex-col">
{/* Search Bar */}
<div className="px-4 pt-4 pb-3 border-b border-border/20">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/50 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tools..."
className="w-full pl-9 pr-3 py-2 text-sm bg-background/50 border border-border/40 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/40"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-muted/50 rounded transition-colors"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
</div>
{/* Tools List Container */}
<div className="flex-1 overflow-y-auto px-4 py-4">
{/* Loading State */}
{isLoadingAllTools && (
<div className="flex items-center justify-center py-16">
<div className="flex flex-col items-center space-y-3">
<div className="relative">
<div className="h-8 w-8 rounded-full border-2 border-primary/20" />
<div className="absolute inset-0 h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
</div>
<span className="text-xs font-medium text-muted-foreground/70">
Loading tools...
</span>
</div>
</div>
)}
{/* No Tools Available */}
{!isLoadingAllTools && totalToolCount === 0 && (
<div className="text-center py-16">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-muted/30 mb-4">
<ListChecks className="h-6 w-6 text-muted-foreground/40" />
</div>
<p className="text-sm font-medium text-foreground/70 mb-1">
No tools available
</p>
<p className="text-xs text-muted-foreground/60">
Connect an MCP server to get started
</p>
</div>
)}
{/* No Search Results */}
{!isLoadingAllTools &&
totalToolCount > 0 &&
filteredSections.length === 0 && (
<div className="text-center py-16">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-muted/30 mb-4">
<Search className="h-6 w-6 text-muted-foreground/40" />
</div>
<p className="text-sm font-medium text-foreground/70 mb-1">
No tools match your search
</p>
<p className="text-xs text-muted-foreground/60 mb-3">
Try a different search term
</p>
<button
onClick={() => setSearchQuery('')}
className="text-xs font-medium text-primary hover:text-primary/80 transition-colors"
>
Clear search
</button>
</div>
)}
{/* Tools Grouped by Source/Server */}
{filteredSections.map((section) => {
const isCollapsed = collapsedSections.has(section.title);
return (
<div key={section.title} className="mb-4 last:mb-0">
{/* Section Header */}
<div className="w-full flex items-center justify-between gap-2 mb-3 px-1 group transition-all duration-150">
{/* Clickable collapse/expand area */}
<button
onClick={() => toggleSection(section.title)}
className="flex items-center gap-2.5 flex-1 min-w-0 pb-2 border-b border-border/40 text-left"
>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground/50 shrink-0 transition-transform duration-200',
isCollapsed && '-rotate-90'
)}
/>
<h4 className="text-base font-semibold text-foreground tracking-tight flex items-center gap-2.5">
<span className="tracking-normal">
{section.title}
</span>
{section.type === 'mcp' && (
<>
<span className="inline-flex items-center px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[10px] font-semibold uppercase tracking-wider">
MCP
</span>
{section.server && (
<span
className={cn(
'inline-flex items-center justify-center w-5 h-5 rounded-full',
section.server.status ===
'connected'
? 'bg-green-500/10'
: 'bg-red-500/10'
)}
>
<span
className={cn(
'w-2 h-2 rounded-full',
section.server.status ===
'connected'
? 'bg-green-600 dark:bg-green-400'
: 'bg-red-600 dark:bg-red-400'
)}
/>
</span>
)}
</>
)}
</h4>
<span className="text-xs text-muted-foreground/50 font-medium">
{section.tools.length}
</span>
</button>
{/* MCP Server Controls - outside the collapse button */}
{section.type === 'mcp' && section.server && (
<div className="flex items-center gap-0.5">
{/* Restart button */}
{restartServerMutation.isPending &&
restartServerMutation.variables ===
section.server.id ? (
<div className="h-6 w-6 flex items-center justify-center">
<RefreshCw className="h-3 w-3 animate-spin text-muted-foreground" />
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRestartServer(
section.server!.id
)
}
className="h-6 w-6 p-0 text-muted-foreground/60 hover:text-primary hover:bg-muted/50"
disabled={
deleteServerMutation.isPending &&
deleteServerMutation.variables ===
section.server.id
}
>
<RotateCw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
Restart
</TooltipContent>
</Tooltip>
)}
{/* Delete button */}
{deleteServerMutation.isPending &&
deleteServerMutation.variables ===
section.server.id ? (
<div className="h-6 w-6 flex items-center justify-center">
<RefreshCw className="h-3 w-3 animate-spin text-muted-foreground" />
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDeleteServer(
section.server!.id
)
}
className="h-6 w-6 p-0 text-muted-foreground/60 hover:text-destructive hover:bg-muted/50"
disabled={
restartServerMutation.isPending &&
restartServerMutation.variables ===
section.server.id
}
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
Remove
</TooltipContent>
</Tooltip>
)}
</div>
)}
</div>
{/* Tool Items - Only show if not collapsed */}
{!isCollapsed && (
<div className="space-y-0.5 ml-2">
{section.tools.map((tool) => {
const toolId = tool.id;
const isExpanded = expandedToolId === toolId;
return (
<div key={toolId}>
<button
onClick={() =>
setExpandedToolId(
isExpanded ? null : toolId
)
}
className="w-full px-3 py-2 rounded-md text-left hover:bg-muted/30 transition-all duration-150 group flex items-center justify-between gap-2"
>
<span className="text-sm text-foreground/90 truncate font-medium">
{stripToolPrefix(
tool.name,
section.type
)}
</span>
<ChevronDown
className={cn(
'h-3.5 w-3.5 text-muted-foreground/40 shrink-0 transition-transform duration-200',
isExpanded && 'rotate-180'
)}
/>
</button>
{/* Expanded Details */}
{isExpanded && (
<div className="px-3 py-2.5 mb-1 bg-muted/15 rounded-md ml-2">
{tool.description && (
<p className="text-xs text-muted-foreground/70 mb-2.5 leading-relaxed">
{tool.description}
</p>
)}
{tool.inputSchema?.properties &&
Object.keys(
tool.inputSchema.properties
).length > 0 && (
<div className="space-y-1.5">
<p className="text-[10px] text-muted-foreground/50 uppercase tracking-wider font-semibold">
Parameters
</p>
<div className="flex flex-wrap gap-1.5">
{Object.keys(
tool.inputSchema
.properties
).map((param) => (
<span
key={param}
className="inline-flex items-center px-2 py-0.5 rounded bg-muted/50 text-[10px] font-mono text-foreground/60 border border-border/20"
>
{param}
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Server Registry Modal */}
<ServerRegistryModal
isOpen={isRegistryModalOpen}
onClose={() => setIsRegistryModalOpen(false)}
onInstallServer={handleInstallServer}
onOpenConnectModal={onOpenConnectModal}
disableClose={isRegistryBusy}
/>
</aside>
);
}
// Overlay variant with slide animation
return (
<>
{/* Backdrop */}
<div
className={cn(
'fixed inset-0 bg-black/50 z-30 transition-opacity duration-300',
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
)}
onClick={onClose}
/>
{/* Panel - slides from right */}
<aside
className={cn(
'fixed top-0 right-0 z-40 h-screen w-80 bg-card/95 backdrop-blur-xl border-l border-border/40 shadow-xl transition-transform duration-300 ease-in-out flex flex-col',
isOpen ? 'translate-x-0' : 'translate-x-full'
)}
>
{/* Panel Header */}
<div className="flex items-center justify-between px-4 py-3.5 shrink-0">
<h2 className="text-sm font-semibold text-foreground">Tools & Servers</h2>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setIsRegistryModalOpen(true)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">Connect MCP servers</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isLoadingServers || isLoadingAllTools}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<RefreshCw
className={cn(
'h-4 w-4',
(isLoadingServers || isLoadingAllTools) &&
'animate-spin'
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent side="left">Refresh</TooltipContent>
</Tooltip>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground hover:bg-muted/40"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Content Area - Tools List */}
<div className="flex-1 overflow-y-auto flex flex-col">
{/* Search Bar */}
<div className="px-4 pt-3 pb-2 border-b border-border/20">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground/50 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tools..."
className="w-full pl-9 pr-3 py-2 text-sm bg-background/50 border border-border/40 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary/40 transition-all placeholder:text-muted-foreground/40"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-muted/50 rounded transition-colors"
>
<X className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
</div>
{/* Tools List Container */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{/* Loading State */}
{isLoadingAllTools && (
<div className="flex items-center justify-center py-12">
<div className="flex flex-col items-center space-y-2">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground/50" />
<span className="text-xs text-muted-foreground/70">
Loading tools...
</span>
</div>
</div>
)}
{/* No Tools Available */}
{!isLoadingAllTools && totalToolCount === 0 && (
<div className="text-center py-12">
<ListChecks className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-xs text-muted-foreground/70">
No tools available
</p>
<p className="text-xs text-muted-foreground/50 mt-1">
Connect an MCP server to get started
</p>
</div>
)}
{/* No Search Results */}
{!isLoadingAllTools &&
totalToolCount > 0 &&
filteredSections.length === 0 && (
<div className="text-center py-12">
<Search className="h-8 w-8 text-muted-foreground/40 mx-auto mb-2" />
<p className="text-xs text-muted-foreground/70">
No tools match your search
</p>
<button
onClick={() => setSearchQuery('')}
className="mt-2 text-xs text-primary hover:text-primary/80 transition-colors"
>
Clear search
</button>
</div>
)}
{/* Tools Grouped by Source/Server */}
{filteredSections.map((section) => {
const isCollapsed = collapsedSections.has(section.title);
return (
<div key={section.title} className="mb-4 last:mb-0">
{/* Section Header */}
<div className="w-full flex items-center justify-between gap-2 mb-3 px-1 group transition-all duration-150">
{/* Clickable collapse/expand area */}
<button
onClick={() => toggleSection(section.title)}
className="flex items-center gap-2.5 flex-1 min-w-0 pb-2 border-b border-border/40 text-left"
>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground/50 shrink-0 transition-transform duration-200',
isCollapsed && '-rotate-90'
)}
/>
<h4 className="text-base font-semibold text-foreground tracking-tight flex items-center gap-2.5">
<span className="tracking-normal">
{section.title}
</span>
{section.type === 'mcp' && (
<>
<span className="inline-flex items-center px-1.5 py-0.5 rounded bg-primary/10 text-primary text-[10px] font-semibold uppercase tracking-wider">
MCP
</span>
{section.server && (
<span
className={cn(
'inline-flex items-center justify-center w-5 h-5 rounded-full',
section.server.status ===
'connected'
? 'bg-green-500/10'
: 'bg-red-500/10'
)}
>
<span
className={cn(
'w-2 h-2 rounded-full',
section.server.status ===
'connected'
? 'bg-green-600 dark:bg-green-400'
: 'bg-red-600 dark:bg-red-400'
)}
/>
</span>
)}
</>
)}
</h4>
<span className="text-xs text-muted-foreground/50 font-medium">
{section.tools.length}
</span>
</button>
{/* MCP Server Controls - outside the collapse button */}
{section.type === 'mcp' && section.server && (
<div className="flex items-center gap-0.5">
{/* Restart button */}
{restartServerMutation.isPending &&
restartServerMutation.variables ===
section.server.id ? (
<div className="h-6 w-6 flex items-center justify-center">
<RefreshCw className="h-3 w-3 animate-spin text-muted-foreground" />
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRestartServer(
section.server!.id
)
}
className="h-6 w-6 p-0 text-muted-foreground/60 hover:text-primary hover:bg-muted/50"
disabled={
deleteServerMutation.isPending &&
deleteServerMutation.variables ===
section.server.id
}
>
<RotateCw className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
Restart
</TooltipContent>
</Tooltip>
)}
{/* Delete button */}
{deleteServerMutation.isPending &&
deleteServerMutation.variables ===
section.server.id ? (
<div className="h-6 w-6 flex items-center justify-center">
<RefreshCw className="h-3 w-3 animate-spin text-muted-foreground" />
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDeleteServer(
section.server!.id
)
}
className="h-6 w-6 p-0 text-muted-foreground/60 hover:text-destructive hover:bg-muted/50"
disabled={
restartServerMutation.isPending &&
restartServerMutation.variables ===
section.server.id
}
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
Remove
</TooltipContent>
</Tooltip>
)}
</div>
)}
</div>
{/* Tool Items - Only show if not collapsed */}
{!isCollapsed && (
<div className="space-y-0.5 ml-2">
{section.tools.map((tool) => {
const toolId = tool.id;
const isExpanded = expandedToolId === toolId;
return (
<div key={toolId}>
<button
onClick={() =>
setExpandedToolId(
isExpanded ? null : toolId
)
}
className="w-full px-3 py-2 rounded-md text-left hover:bg-muted/30 transition-all duration-150 group flex items-center justify-between gap-2"
>
<span className="text-sm text-foreground/90 truncate font-medium">
{stripToolPrefix(
tool.name,
section.type
)}
</span>
<ChevronDown
className={cn(
'h-3.5 w-3.5 text-muted-foreground/40 shrink-0 transition-transform duration-200',
isExpanded && 'rotate-180'
)}
/>
</button>
{/* Expanded Details */}
{isExpanded && (
<div className="px-3 py-2.5 mb-1 bg-muted/15 rounded-md ml-2">
{tool.description && (
<p className="text-xs text-muted-foreground/70 mb-2.5 leading-relaxed">
{tool.description}
</p>
)}
{tool.inputSchema?.properties &&
Object.keys(
tool.inputSchema.properties
).length > 0 && (
<div className="space-y-1.5">
<p className="text-[10px] text-muted-foreground/50 uppercase tracking-wider font-semibold">
Parameters
</p>
<div className="flex flex-wrap gap-1.5">
{Object.keys(
tool.inputSchema
.properties
).map((param) => (
<span
key={param}
className="inline-flex items-center px-2 py-0.5 rounded bg-muted/50 text-[10px] font-mono text-foreground/60 border border-border/20"
>
{param}
</span>
))}
</div>
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
{/* Server Registry Modal */}
<ServerRegistryModal
isOpen={isRegistryModalOpen}
onClose={() => setIsRegistryModalOpen(false)}
onInstallServer={handleInstallServer}
onOpenConnectModal={onOpenConnectModal}
disableClose={isRegistryBusy}
/>
</aside>
</>
);
}