Files
Claude-Code-Workflow/ccw/frontend/src/pages/McpManagerPage.tsx
catlog22 25e27286b4 feat(docs): add full documentation generation phase and related documentation generation phase
- Implement Phase 4: Full Documentation Generation with multi-layered strategy and tool fallback.
- Introduce Phase 5: Related Documentation Generation for incremental updates based on git changes.
- Create new utility components for displaying execution status in the terminal panel.
- Add helper functions for rendering execution status icons and formatting relative time.
- Establish a recent paths configuration for improved path resolution.
2026-02-13 21:46:28 +08:00

875 lines
31 KiB
TypeScript

// ========================================
// MCP Manager Page
// ========================================
// Manage MCP servers (Model Context Protocol) with tabbed interface
// Supports Templates, Servers, and Cross-CLI tabs
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
Server,
Plus,
Search,
RefreshCw,
Globe,
Folder,
Power,
PowerOff,
Edit,
Trash2,
ChevronDown,
ChevronUp,
BookmarkPlus,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Badge } from '@/components/ui/Badge';
import { McpServerDialog } from '@/components/mcp/McpServerDialog';
import { CliModeToggle, type CliMode } from '@/components/mcp/CliModeToggle';
import { CodexMcpEditableCard } from '@/components/mcp/CodexMcpEditableCard';
import { CcwToolsMcpCard } from '@/components/mcp/CcwToolsMcpCard';
import { McpTemplatesSection, TemplateSaveDialog } from '@/components/mcp/McpTemplatesSection';
import { RecommendedMcpSection } from '@/components/mcp/RecommendedMcpSection';
import { WindowsCompatibilityWarning } from '@/components/mcp/WindowsCompatibilityWarning';
import { CrossCliSyncPanel } from '@/components/mcp/CrossCliSyncPanel';
import { AllProjectsTable } from '@/components/mcp/AllProjectsTable';
import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { useMcpServers, useMcpServerMutations, useNotifications } from '@/hooks';
import {
fetchCodexMcpServers,
fetchCcwMcpConfig,
fetchCcwMcpConfigForCodex,
updateCcwConfig,
updateCcwConfigForCodex,
codexRemoveServer,
codexToggleServer,
saveMcpTemplate,
type McpServer,
type CcwMcpConfig,
} from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== MCP Server Card Component ==========
interface McpServerCardProps {
server: McpServer;
isExpanded: boolean;
onToggleExpand: () => void;
onToggle: (serverName: string, enabled: boolean) => void;
onEdit: (server: McpServer) => void;
onDelete: (server: McpServer) => void;
onSaveAsTemplate: (server: McpServer) => void;
}
function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete, onSaveAsTemplate }: McpServerCardProps) {
const { formatMessage } = useIntl();
return (
<Card className={cn('overflow-hidden', !server.enabled && 'opacity-60')}>
{/* Header */}
<div
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={onToggleExpand}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-3">
<div className={cn(
'p-2 rounded-lg',
server.enabled ? 'bg-primary/10' : 'bg-muted'
)}>
<Server className={cn(
'w-5 h-5',
server.enabled ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{server.name}
</span>
<Badge variant={server.scope === 'global' ? 'default' : 'secondary'} className="text-xs">
{server.scope === 'global' ? (
<><Globe className="w-3 h-3 mr-1" />{formatMessage({ id: 'mcp.scope.global' })}</>
) : (
<><Folder className="w-3 h-3 mr-1" />{formatMessage({ id: 'mcp.scope.project' })}</>
)}
</Badge>
{server.enabled && (
<Badge variant="outline" className="text-xs text-green-600">
{formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1 font-mono">
{server.command} {server.args?.join(' ') || ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onToggle(server.name, !server.enabled);
}}
>
{server.enabled ? <Power className="w-4 h-4 text-green-600" /> : <PowerOff className="w-4 h-4" />}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onSaveAsTemplate(server);
}}
title={formatMessage({ id: 'mcp.templates.actions.saveAsTemplate' })}
>
<BookmarkPlus className="w-4 h-4 text-primary" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onEdit(server);
}}
>
<Edit className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
onDelete(server);
}}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
{isExpanded ? (
<ChevronUp className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
)}
</div>
</div>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="border-t border-border p-4 space-y-3 bg-muted/30">
{/* Command details */}
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.command' })}</p>
<code className="text-sm bg-background px-2 py-1 rounded block overflow-x-auto">
{server.command}
</code>
</div>
{/* Args */}
{server.args && server.args.length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.args' })}</p>
<div className="flex flex-wrap gap-1">
{server.args.map((arg, idx) => (
<Badge key={idx} variant="outline" className="font-mono text-xs">
{arg}
</Badge>
))}
</div>
</div>
)}
{/* Environment variables */}
{server.env && Object.keys(server.env).length > 0 && (
<div>
<p className="text-xs text-muted-foreground mb-1">{formatMessage({ id: 'mcp.env' })}</p>
<div className="space-y-1">
{Object.entries(server.env).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 text-sm">
<Badge variant="secondary" className="font-mono">{key}</Badge>
<span className="text-muted-foreground">=</span>
<code className="text-xs bg-background px-2 py-1 rounded flex-1 overflow-x-auto">
{value as string}
</code>
</div>
))}
</div>
</div>
)}
</div>
)}
</Card>
);
}
// ========== Main Page Component ==========
export function McpManagerPage() {
const { formatMessage } = useIntl();
const [activeTab, setActiveTab] = useState<'templates' | 'servers' | 'cross-cli'>('servers');
const [searchQuery, setSearchQuery] = useState('');
const [scopeFilter, setScopeFilter] = useState<'all' | 'project' | 'global'>('all');
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
const [dialogOpen, setDialogOpen] = useState(false);
const [editingServer, setEditingServer] = useState<McpServer | undefined>(undefined);
const [cliMode, setCliMode] = useState<CliMode>('claude');
const [codexExpandedServers, setCodexExpandedServers] = useState<Set<string>>(new Set());
const [saveTemplateDialogOpen, setSaveTemplateDialogOpen] = useState(false);
const [serverToSaveAsTemplate, setServerToSaveAsTemplate] = useState<McpServer | undefined>(undefined);
const queryClient = useQueryClient();
const notifications = useNotifications();
const {
servers,
projectServers,
globalServers,
totalCount,
enabledCount,
isLoading,
isFetching,
refetch,
} = useMcpServers({
scope: scopeFilter === 'all' ? undefined : scopeFilter,
});
// Fetch Codex MCP servers when in codex mode
const codexQuery = useQuery({
queryKey: ['codexMcpServers'],
queryFn: fetchCodexMcpServers,
enabled: cliMode === 'codex',
staleTime: 2 * 60 * 1000, // 2 minutes
});
// Fetch CCW Tools MCP configuration (Claude mode only)
const ccwMcpQuery = useQuery({
queryKey: ['ccwMcpConfig'],
queryFn: fetchCcwMcpConfig,
enabled: cliMode === 'claude',
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Fetch CCW Tools MCP configuration (Codex mode only)
const ccwMcpCodexQuery = useQuery({
queryKey: ['ccwMcpConfigCodex'],
queryFn: fetchCcwMcpConfigForCodex,
enabled: cliMode === 'codex',
staleTime: 5 * 60 * 1000, // 5 minutes
});
const {
toggleServer,
deleteServer,
} = useMcpServerMutations();
const toggleExpand = (serverName: string) => {
setExpandedServers((prev) => {
const next = new Set(prev);
if (next.has(serverName)) {
next.delete(serverName);
} else {
next.add(serverName);
}
return next;
});
};
const toggleCodexExpand = (serverName: string) => {
setCodexExpandedServers((prev) => {
const next = new Set(prev);
if (next.has(serverName)) {
next.delete(serverName);
} else {
next.add(serverName);
}
return next;
});
};
const handleToggle = (serverName: string, enabled: boolean) => {
toggleServer(serverName, enabled);
};
const handleDelete = async (server: McpServer) => {
if (confirm(formatMessage({ id: 'mcp.deleteConfirm' }, { name: server.name }))) {
try {
await deleteServer(server.name, server.scope);
notifications.success(
formatMessage({ id: 'mcp.actions.delete' }),
server.name
);
refetch();
} catch (error) {
console.error('Failed to delete MCP server:', error);
notifications.error(
formatMessage({ id: 'mcp.actions.delete' }),
error instanceof Error ? error.message : String(error)
);
}
}
};
const handleEdit = (server: McpServer) => {
setEditingServer(server);
setDialogOpen(true);
};
const handleAddClick = () => {
setEditingServer(undefined);
setDialogOpen(true);
};
const handleDialogClose = () => {
setDialogOpen(false);
setEditingServer(undefined);
};
const handleDialogSave = () => {
setDialogOpen(false);
setEditingServer(undefined);
refetch();
};
const handleModeChange = (mode: CliMode) => {
setCliMode(mode);
};
// CCW MCP handlers
const ccwConfig = ccwMcpQuery.data ?? {
isInstalled: false,
enabledTools: [],
projectRoot: undefined,
allowedDirs: undefined,
enableSandbox: undefined,
};
const handleToggleCcwTool = async (tool: string, enabled: boolean) => {
// Read latest from cache to avoid stale closures
const currentConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfig']) ?? ccwConfig;
const currentTools = currentConfig.enabledTools;
const previousConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfig']);
const updatedTools = enabled
? (currentTools.includes(tool) ? currentTools : [...currentTools, tool])
: currentTools.filter((t) => t !== tool);
// Optimistic cache update for immediate UI response
queryClient.setQueryData(['ccwMcpConfig'], (old: CcwMcpConfig | undefined) => {
if (!old) return old;
return { ...old, enabledTools: updatedTools };
});
try {
await updateCcwConfig({ ...currentConfig, enabledTools: updatedTools });
} catch (error) {
console.error('Failed to toggle CCW tool:', error);
queryClient.setQueryData(['ccwMcpConfig'], previousConfig);
}
ccwMcpQuery.refetch();
};
const handleUpdateCcwConfig = async (config: Partial<CcwMcpConfig>) => {
// Read BEFORE optimistic update to capture actual server state
const currentConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfig']) ?? ccwConfig;
const previousConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfig']);
// Optimistic cache update for immediate UI response
queryClient.setQueryData(['ccwMcpConfig'], (old: CcwMcpConfig | undefined) => {
if (!old) return old;
return { ...old, ...config };
});
try {
await updateCcwConfig({ ...currentConfig, ...config });
} catch (error) {
console.error('Failed to update CCW config:', error);
queryClient.setQueryData(['ccwMcpConfig'], previousConfig);
}
ccwMcpQuery.refetch();
};
const handleCcwInstall = () => {
ccwMcpQuery.refetch();
};
// CCW MCP handlers for Codex mode
const ccwCodexConfig = ccwMcpCodexQuery.data ?? {
isInstalled: false,
enabledTools: [],
projectRoot: undefined,
allowedDirs: undefined,
enableSandbox: undefined,
};
const handleToggleCcwToolCodex = async (tool: string, enabled: boolean) => {
const currentConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfigCodex']) ?? ccwCodexConfig;
const currentTools = currentConfig.enabledTools;
const previousConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfigCodex']);
const updatedTools = enabled
? (currentTools.includes(tool) ? currentTools : [...currentTools, tool])
: currentTools.filter((t) => t !== tool);
queryClient.setQueryData(['ccwMcpConfigCodex'], (old: CcwMcpConfig | undefined) => {
if (!old) return old;
return { ...old, enabledTools: updatedTools };
});
try {
await updateCcwConfigForCodex({ ...currentConfig, enabledTools: updatedTools });
} catch (error) {
console.error('Failed to toggle CCW tool (Codex):', error);
queryClient.setQueryData(['ccwMcpConfigCodex'], previousConfig);
}
ccwMcpCodexQuery.refetch();
};
const handleUpdateCcwConfigCodex = async (config: Partial<CcwMcpConfig>) => {
const currentConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfigCodex']) ?? ccwCodexConfig;
const previousConfig = queryClient.getQueryData<CcwMcpConfig>(['ccwMcpConfigCodex']);
queryClient.setQueryData(['ccwMcpConfigCodex'], (old: CcwMcpConfig | undefined) => {
if (!old) return old;
return { ...old, ...config };
});
try {
await updateCcwConfigForCodex({ ...currentConfig, ...config });
} catch (error) {
console.error('Failed to update CCW config (Codex):', error);
queryClient.setQueryData(['ccwMcpConfigCodex'], previousConfig);
}
ccwMcpCodexQuery.refetch();
};
const handleCcwInstallCodex = () => {
ccwMcpCodexQuery.refetch();
};
// Template handlers
const handleInstallTemplate = (template: any) => {
setEditingServer({
name: template.name,
command: template.serverConfig.command,
args: template.serverConfig.args || [],
env: template.serverConfig.env,
scope: 'project',
enabled: true,
});
setDialogOpen(true);
};
const handleSaveServerAsTemplate = (server: McpServer) => {
setServerToSaveAsTemplate(server);
setSaveTemplateDialogOpen(true);
};
const handleSaveAsTemplate = async (
name: string,
category: string,
description: string,
serverConfig: { command: string; args: string[]; env: Record<string, string> },
) => {
try {
const result = await saveMcpTemplate({
name,
description: description || undefined,
category: category || 'custom',
serverConfig: {
command: serverConfig.command,
args: serverConfig.args.length > 0 ? serverConfig.args : undefined,
env: Object.keys(serverConfig.env).length > 0 ? serverConfig.env : undefined,
},
});
if (result.success) {
notifications.success(
formatMessage({ id: 'mcp.templates.feedback.saveSuccess' }),
name
);
setSaveTemplateDialogOpen(false);
setServerToSaveAsTemplate(undefined);
} else {
notifications.error(
formatMessage({ id: 'mcp.templates.feedback.saveError' }),
result.error || ''
);
}
} catch (error) {
notifications.error(
formatMessage({ id: 'mcp.templates.feedback.saveError' }),
error instanceof Error ? error.message : String(error)
);
}
};
// Codex MCP handlers
const handleCodexRemove = async (serverName: string) => {
try {
await codexRemoveServer(serverName);
notifications.success(
formatMessage({ id: 'mcp.actions.delete' }),
serverName
);
codexQuery.refetch();
} catch (error) {
console.error('Failed to remove Codex MCP server:', error);
notifications.error(
formatMessage({ id: 'mcp.actions.delete' }),
error instanceof Error ? error.message : String(error)
);
}
};
const handleCodexToggle = async (serverName: string, enabled: boolean) => {
try {
await codexToggleServer(serverName, enabled);
codexQuery.refetch();
} catch (error) {
console.error('Failed to toggle Codex MCP server:', error);
}
};
// Filter servers by search query
const filteredServers = servers.filter((s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.command.toLowerCase().includes(searchQuery.toLowerCase())
);
// Filter Codex servers by search query
const codexServers = codexQuery.data?.servers ?? [];
const codexConfigPath = codexQuery.data?.configPath ?? '';
const filteredCodexServers = codexServers.filter((s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.command.toLowerCase().includes(searchQuery.toLowerCase())
);
const currentServers = cliMode === 'codex' ? filteredCodexServers : filteredServers;
const currentExpanded = cliMode === 'codex' ? codexExpandedServers : expandedServers;
const currentToggleExpand = cliMode === 'codex' ? toggleCodexExpand : toggleExpand;
const currentIsLoading = cliMode === 'codex' ? codexQuery.isLoading : isLoading;
const currentIsFetching = cliMode === 'codex' ? codexQuery.isFetching : isFetching;
const currentRefetch = cliMode === 'codex' ? (() => codexQuery.refetch()) : refetch;
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3">
<div>
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
<Server className="w-6 h-6 text-primary" />
{formatMessage({ id: 'mcp.title' })}
</h1>
<p className="text-muted-foreground mt-1">
{formatMessage({ id: 'mcp.description' })}
</p>
</div>
{/* CLI Mode Badge Switcher */}
<div className="ml-3 flex-shrink-0">
<CliModeToggle
currentMode={cliMode}
onModeChange={handleModeChange}
codexConfigPath={codexConfigPath}
/>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => currentRefetch()} disabled={currentIsFetching}>
<RefreshCw className={cn('w-4 h-4 mr-2', currentIsFetching && 'animate-spin')} />
{formatMessage({ id: 'common.actions.refresh' })}
</Button>
{cliMode === 'claude' && (
<Button onClick={handleAddClick}>
<Plus className="w-4 h-4 mr-2" />
{formatMessage({ id: 'mcp.actions.add' })}
</Button>
)}
</div>
</div>
{/* Tabbed Interface */}
<TabsNavigation
value={activeTab}
onValueChange={(value) => setActiveTab(value as 'templates' | 'servers' | 'cross-cli')}
tabs={[
{ value: 'templates', label: formatMessage({ id: 'mcp.tabs.templates' }) },
{ value: 'servers', label: formatMessage({ id: 'mcp.tabs.servers' }) },
{ value: 'cross-cli', label: formatMessage({ id: 'mcp.tabs.crossCli' }) },
]}
/>
{/* Tab Content: Templates */}
{activeTab === 'templates' && (
<div className="mt-4 space-y-4">
{/* Recommended MCP Servers */}
<RecommendedMcpSection onInstallComplete={() => refetch()} />
{/* Templates Section */}
<McpTemplatesSection
onInstallTemplate={handleInstallTemplate}
onSaveAsTemplate={handleSaveAsTemplate}
/>
</div>
)}
{/* Tab Content: Servers */}
{activeTab === 'servers' && (
<div className="mt-4 space-y-4">
{/* Windows Compatibility Warning */}
<WindowsCompatibilityWarning />
{/* Stats Cards - Claude mode only */}
{cliMode === 'claude' && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-2">
<Server className="w-5 h-5 text-primary" />
<span className="text-2xl font-bold">{totalCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.total' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Power className="w-5 h-5 text-green-600" />
<span className="text-2xl font-bold">{enabledCount}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.enabled' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-info" />
<span className="text-2xl font-bold">{globalServers.length}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.global' })}</p>
</Card>
<Card className="p-4">
<div className="flex items-center gap-2">
<Folder className="w-5 h-5 text-warning" />
<span className="text-2xl font-bold">{projectServers.length}</span>
</div>
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.project' })}</p>
</Card>
</div>
)}
{/* Filters and Search - Claude mode only */}
{cliMode === 'claude' && (
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'mcp.filters.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex gap-2">
<Button
variant={scopeFilter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => setScopeFilter('all')}
>
{formatMessage({ id: 'mcp.filters.all' })}
</Button>
<Button
variant={scopeFilter === 'global' ? 'default' : 'outline'}
size="sm"
onClick={() => setScopeFilter('global')}
>
<Globe className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.scope.global' })}
</Button>
<Button
variant={scopeFilter === 'project' ? 'default' : 'outline'}
size="sm"
onClick={() => setScopeFilter('project')}
>
<Folder className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.scope.project' })}
</Button>
</div>
</div>
)}
{/* Codex mode search only */}
{cliMode === 'codex' && (
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={formatMessage({ id: 'mcp.filters.searchPlaceholder' })}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
)}
{/* CCW Tools MCP Card */}
{cliMode === 'claude' && (
<CcwToolsMcpCard
isInstalled={ccwConfig.isInstalled}
enabledTools={ccwConfig.enabledTools}
projectRoot={ccwConfig.projectRoot}
allowedDirs={ccwConfig.allowedDirs}
enableSandbox={ccwConfig.enableSandbox}
onToggleTool={handleToggleCcwTool}
onUpdateConfig={handleUpdateCcwConfig}
onInstall={handleCcwInstall}
/>
)}
{cliMode === 'codex' && (
<CcwToolsMcpCard
target="codex"
isInstalled={ccwCodexConfig.isInstalled}
enabledTools={ccwCodexConfig.enabledTools}
projectRoot={ccwCodexConfig.projectRoot}
allowedDirs={ccwCodexConfig.allowedDirs}
enableSandbox={ccwCodexConfig.enableSandbox}
onToggleTool={handleToggleCcwToolCodex}
onUpdateConfig={handleUpdateCcwConfigCodex}
onInstall={handleCcwInstallCodex}
/>
)}
{/* Servers List */}
{currentIsLoading ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-24 bg-muted animate-pulse rounded-lg" />
))}
</div>
) : currentServers.length === 0 ? (
<Card className="p-8 text-center">
<Server className="w-12 h-12 mx-auto text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium text-foreground">{formatMessage({ id: 'mcp.emptyState.title' })}</h3>
<p className="mt-2 text-muted-foreground">
{formatMessage({ id: 'mcp.emptyState.message' })}
</p>
</Card>
) : (
<div className="space-y-3">
{currentServers.map((server) => (
cliMode === 'codex' ? (
<CodexMcpEditableCard
key={server.name}
server={server as McpServer}
enabled={server.enabled}
isExpanded={currentExpanded.has(server.name)}
onToggleExpand={() => currentToggleExpand(server.name)}
isEditable={true}
onRemove={handleCodexRemove}
onToggle={handleCodexToggle}
/>
) : (
<McpServerCard
key={server.name}
server={server}
isExpanded={currentExpanded.has(server.name)}
onToggleExpand={() => currentToggleExpand(server.name)}
onToggle={handleToggle}
onEdit={handleEdit}
onDelete={handleDelete}
onSaveAsTemplate={handleSaveServerAsTemplate}
/>
)
))}
</div>
)}
</div>
)}
{/* Tab Content: Cross-CLI */}
{activeTab === 'cross-cli' && (
<div className="mt-4 space-y-6">
{/* Section 1: Claude ↔ Codex 同步 */}
<section>
<div className="flex items-center gap-2 mb-3">
<RefreshCw className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.sync.title' })}
</h3>
</div>
<Card className="p-4">
<CrossCliSyncPanel onSuccess={() => refetch()} />
</Card>
</section>
{/* Section 2: 项目概览 */}
<section>
<div className="flex items-center gap-2 mb-3">
<Folder className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.projects.title' })}
</h3>
</div>
<p className="text-xs text-muted-foreground mb-3">
{formatMessage({ id: 'mcp.projects.description' })}
</p>
<AllProjectsTable
maxProjects={10}
onProjectClick={(path) => console.log('Open project:', path)}
onOpenNewWindow={(path) => window.open(`/?project=${encodeURIComponent(path)}`, '_blank')}
/>
</section>
{/* Section 3: 跨项目导入 */}
<section>
<div className="flex items-center gap-2 mb-3">
<Globe className="w-4 h-4 text-muted-foreground" />
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.crossProject.title' })}
</h3>
</div>
<p className="text-xs text-muted-foreground mb-3">
{formatMessage({ id: 'mcp.crossProject.description' })}
</p>
<OtherProjectsSection
onImportSuccess={(serverName, sourceProject) => {
console.log('Imported server:', serverName, 'from:', sourceProject);
refetch();
}}
/>
</section>
</div>
)}
{/* Add/Edit Dialog - Claude mode only (shared across tabs) */}
{cliMode === 'claude' && (
<McpServerDialog
mode={editingServer ? 'edit' : 'add'}
server={editingServer}
open={dialogOpen}
onClose={handleDialogClose}
onSave={handleDialogSave}
/>
)}
{/* Save as Template Dialog */}
<TemplateSaveDialog
open={saveTemplateDialogOpen}
onClose={() => {
setSaveTemplateDialogOpen(false);
setServerToSaveAsTemplate(undefined);
}}
onSave={handleSaveAsTemplate}
defaultName={serverToSaveAsTemplate?.name}
defaultCommand={serverToSaveAsTemplate?.command}
defaultArgs={serverToSaveAsTemplate?.args}
defaultEnv={serverToSaveAsTemplate?.env as Record<string, string>}
/>
</div>
);
}
export default McpManagerPage;