mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: Implement Cross-CLI Sync Panel for MCP servers
- Added CrossCliSyncPanel component for synchronizing MCP servers between Claude and Codex. - Implemented server selection, copy operations, and result handling. - Added tests for path mapping on Windows drives. - Created E2E tests for ask_question Answer Broker functionality. - Introduced MCP Tools Test Script for validating modified read_file and edit_file tools. - Updated path_mapper to ensure correct drive formatting on Windows. - Added .gitignore for ace-tool directory.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
// ========================================
|
||||
// Table component displaying all recent projects with MCP server statistics
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Folder, Clock, Database, ExternalLink } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
@@ -11,6 +11,7 @@ import { Badge } from '@/components/ui/Badge';
|
||||
import { useProjectOperations } from '@/hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { fetchOtherProjectsServers } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
@@ -32,6 +33,8 @@ export interface AllProjectsTableProps {
|
||||
className?: string;
|
||||
/** Maximum number of projects to display */
|
||||
maxProjects?: number;
|
||||
/** Project paths to display (if not provided, fetches from useProjectOperations) */
|
||||
projectPaths?: string[];
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
@@ -41,29 +44,65 @@ export function AllProjectsTable({
|
||||
onOpenNewWindow,
|
||||
className,
|
||||
maxProjects,
|
||||
projectPaths: propProjectPaths,
|
||||
}: AllProjectsTableProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [sortField, setSortField] = useState<'name' | 'serverCount' | 'lastModified'>('lastModified');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [projectStats, setProjectStats] = useState<ProjectServerStats[]>([]);
|
||||
const [isStatsLoading, setIsStatsLoading] = useState(false);
|
||||
|
||||
const { projects, currentProject, isLoading } = useProjectOperations();
|
||||
|
||||
// Mock server counts since backend doesn't provide per-project stats
|
||||
// In production, this would come from a dedicated API endpoint
|
||||
const projectStats: ProjectServerStats[] = projects.slice(0, maxProjects).map((path) => {
|
||||
const isCurrent = path === currentProject;
|
||||
// Extract name from path (last segment)
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || path;
|
||||
// Use provided project paths or default to all projects
|
||||
const targetProjectPaths = propProjectPaths ?? projects;
|
||||
const displayProjects = maxProjects ? targetProjectPaths.slice(0, maxProjects) : targetProjectPaths;
|
||||
|
||||
return {
|
||||
name,
|
||||
path,
|
||||
serverCount: Math.floor(Math.random() * 10), // Mock data
|
||||
enabledCount: Math.floor(Math.random() * 8), // Mock data
|
||||
lastModified: new Date(Date.now() - Math.random() * 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
isCurrent,
|
||||
// Fetch real project server stats on mount
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
if (displayProjects.length === 0) {
|
||||
setProjectStats([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsStatsLoading(true);
|
||||
try {
|
||||
const response = await fetchOtherProjectsServers(displayProjects);
|
||||
const stats: ProjectServerStats[] = displayProjects.map((path) => {
|
||||
const isCurrent = path === currentProject;
|
||||
const name = path.split(/[/\\]/).filter(Boolean).pop() || path;
|
||||
const servers = response.servers[path] ?? [];
|
||||
|
||||
return {
|
||||
name,
|
||||
path,
|
||||
serverCount: servers.length,
|
||||
enabledCount: servers.filter((s) => s.enabled).length,
|
||||
lastModified: undefined, // Backend doesn't provide this yet
|
||||
isCurrent,
|
||||
};
|
||||
});
|
||||
setProjectStats(stats);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project server stats:', error);
|
||||
// Fallback to empty stats on error
|
||||
setProjectStats(
|
||||
displayProjects.map((path) => ({
|
||||
name: path.split(/[/\\]/).filter(Boolean).pop() || path,
|
||||
path,
|
||||
serverCount: 0,
|
||||
enabledCount: 0,
|
||||
isCurrent: path === currentProject,
|
||||
}))
|
||||
);
|
||||
} finally {
|
||||
setIsStatsLoading(false);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
void fetchStats();
|
||||
}, [displayProjects, currentProject]);
|
||||
|
||||
// Sort projects
|
||||
const sortedProjects = [...projectStats].sort((a, b) => {
|
||||
@@ -107,7 +146,7 @@ export function AllProjectsTable({
|
||||
onOpenNewWindow?.(projectPath);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading || isStatsLoading) {
|
||||
return (
|
||||
<Card className={cn('p-8', className)}>
|
||||
<div className="flex items-center justify-center">
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
installCcwMcp,
|
||||
uninstallCcwMcp,
|
||||
updateCcwConfig,
|
||||
installCcwMcpToCodex,
|
||||
uninstallCcwMcpFromCodex,
|
||||
updateCcwConfigForCodex,
|
||||
} from '@/lib/api';
|
||||
import { mcpServersKeys } from '@/hooks';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
@@ -77,6 +80,8 @@ export interface CcwToolsMcpCardProps {
|
||||
onUpdateConfig: (config: Partial<CcwConfig>) => void;
|
||||
/** Callback when install/uninstall is triggered */
|
||||
onInstall: () => void;
|
||||
/** Installation target: Claude or Codex */
|
||||
target?: 'claude' | 'codex';
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
@@ -105,6 +110,7 @@ export function CcwToolsMcpCard({
|
||||
onToggleTool,
|
||||
onUpdateConfig,
|
||||
onInstall,
|
||||
target = 'claude',
|
||||
}: CcwToolsMcpCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -117,22 +123,36 @@ export function CcwToolsMcpCard({
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [installScope, setInstallScope] = useState<'global' | 'project'>('global');
|
||||
|
||||
const isCodex = target === 'codex';
|
||||
|
||||
// Mutations for install/uninstall
|
||||
const installMutation = useMutation({
|
||||
mutationFn: (params: { scope: 'global' | 'project'; projectPath?: string }) =>
|
||||
installCcwMcp(params.scope, params.projectPath),
|
||||
mutationFn: isCodex
|
||||
? () => installCcwMcpToCodex()
|
||||
: (params: { scope: 'global' | 'project'; projectPath?: string }) =>
|
||||
installCcwMcp(params.scope, params.projectPath),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
||||
if (isCodex) {
|
||||
queryClient.invalidateQueries({ queryKey: ['codexMcpServers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfigCodex'] });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
||||
}
|
||||
onInstall();
|
||||
},
|
||||
});
|
||||
|
||||
const uninstallMutation = useMutation({
|
||||
mutationFn: uninstallCcwMcp,
|
||||
mutationFn: isCodex ? uninstallCcwMcpFromCodex : uninstallCcwMcp,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
||||
if (isCodex) {
|
||||
queryClient.invalidateQueries({ queryKey: ['codexMcpServers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfigCodex'] });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
||||
}
|
||||
onInstall();
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -141,9 +161,13 @@ export function CcwToolsMcpCard({
|
||||
});
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
mutationFn: updateCcwConfig,
|
||||
mutationFn: isCodex ? updateCcwConfigForCodex : updateCcwConfig,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
if (isCodex) {
|
||||
queryClient.invalidateQueries({ queryKey: ['codexMcpServers'] });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -170,10 +194,14 @@ export function CcwToolsMcpCard({
|
||||
};
|
||||
|
||||
const handleInstallClick = () => {
|
||||
installMutation.mutate({
|
||||
scope: installScope,
|
||||
projectPath: installScope === 'project' ? currentProjectPath : undefined,
|
||||
});
|
||||
if (isCodex) {
|
||||
(installMutation as any).mutate(undefined);
|
||||
} else {
|
||||
(installMutation as any).mutate({
|
||||
scope: installScope,
|
||||
projectPath: installScope === 'project' ? currentProjectPath : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstallClick = () => {
|
||||
@@ -213,6 +241,11 @@ export function CcwToolsMcpCard({
|
||||
<Badge variant={isInstalled ? 'default' : 'secondary'} className="text-xs">
|
||||
{isInstalled ? formatMessage({ id: 'mcp.ccw.status.installed' }) : formatMessage({ id: 'mcp.ccw.status.notInstalled' })}
|
||||
</Badge>
|
||||
{isCodex && (
|
||||
<Badge variant="outline" className="text-xs text-blue-500">
|
||||
Codex
|
||||
</Badge>
|
||||
)}
|
||||
{isInstalled && (
|
||||
<Badge variant="outline" className="text-xs text-info">
|
||||
{formatMessage({ id: 'mcp.ccw.status.special' })}
|
||||
@@ -388,8 +421,8 @@ export function CcwToolsMcpCard({
|
||||
|
||||
{/* Install/Uninstall Button */}
|
||||
<div className="pt-3 border-t border-border space-y-3">
|
||||
{/* Scope Selection */}
|
||||
{!isInstalled && (
|
||||
{/* Scope Selection - Claude only (Codex is always global) */}
|
||||
{!isInstalled && !isCodex && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase">
|
||||
{formatMessage({ id: 'mcp.scope' })}
|
||||
@@ -422,6 +455,12 @@ export function CcwToolsMcpCard({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Codex note */}
|
||||
{isCodex && !isInstalled && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.ccw.codexNote' })}
|
||||
</p>
|
||||
)}
|
||||
{!isInstalled ? (
|
||||
<Button
|
||||
onClick={handleInstallClick}
|
||||
@@ -430,7 +469,7 @@ export function CcwToolsMcpCard({
|
||||
>
|
||||
{isPending
|
||||
? formatMessage({ id: 'mcp.ccw.actions.installing' })
|
||||
: formatMessage({ id: 'mcp.ccw.actions.install' })
|
||||
: formatMessage({ id: isCodex ? 'mcp.ccw.actions.installCodex' : 'mcp.ccw.actions.install' })
|
||||
}
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
510
ccw/frontend/src/components/mcp/CrossCliSyncPanel.tsx
Normal file
510
ccw/frontend/src/components/mcp/CrossCliSyncPanel.tsx
Normal file
@@ -0,0 +1,510 @@
|
||||
// ========================================
|
||||
// Cross-CLI Sync Panel Component
|
||||
// ========================================
|
||||
// Inline panel for synchronizing MCP servers between Claude and Codex
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ArrowRight, ArrowLeft, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useMcpServers } from '@/hooks';
|
||||
import { crossCliCopy, fetchCodexMcpServers } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface CrossCliSyncPanelProps {
|
||||
/** Callback when copy is successful */
|
||||
onSuccess?: (copiedCount: number, direction: 'to-codex' | 'from-codex') => void;
|
||||
/** Additional class name */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface ServerCheckboxItem {
|
||||
name: string;
|
||||
command: string;
|
||||
enabled: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
type CopyDirection = 'to-codex' | 'from-codex';
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function CrossCliSyncPanel({ onSuccess, className }: CrossCliSyncPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
// Claude servers state
|
||||
const { servers: claudeServers } = useMcpServers();
|
||||
const [selectedClaude, setSelectedClaude] = useState<Set<string>>(new Set());
|
||||
|
||||
// Codex servers state
|
||||
const [codexServers, setCodexServers] = useState<ServerCheckboxItem[]>([]);
|
||||
const [selectedCodex, setSelectedCodex] = useState<Set<string>>(new Set());
|
||||
const [isLoadingCodex, setIsLoadingCodex] = useState(false);
|
||||
const [codexError, setCodexError] = useState<string | null>(null);
|
||||
|
||||
// Copy operation state
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const [copyResult, setCopyResult] = useState<{
|
||||
type: 'success' | 'partial' | null;
|
||||
copied: number;
|
||||
failed: number;
|
||||
}>({ type: null, copied: 0, failed: 0 });
|
||||
|
||||
// Load Codex servers on mount
|
||||
useEffect(() => {
|
||||
const loadCodexServers = async () => {
|
||||
setIsLoadingCodex(true);
|
||||
setCodexError(null);
|
||||
try {
|
||||
const codex = await fetchCodexMcpServers();
|
||||
setCodexServers(
|
||||
(codex.servers ?? []).map((s) => ({
|
||||
name: s.name,
|
||||
command: s.command,
|
||||
enabled: s.enabled,
|
||||
selected: false,
|
||||
}))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to load Codex MCP servers:', error);
|
||||
setCodexError(formatMessage({ id: 'mcp.sync.codexLoadError' }));
|
||||
setCodexServers([]);
|
||||
} finally {
|
||||
setIsLoadingCodex(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadCodexServers();
|
||||
}, [formatMessage]);
|
||||
|
||||
// Claude server handlers
|
||||
const toggleClaudeServer = (name: string) => {
|
||||
setSelectedClaude((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAllClaude = () => {
|
||||
setSelectedClaude(new Set(claudeServers.map((s) => s.name)));
|
||||
};
|
||||
|
||||
const clearAllClaude = () => {
|
||||
setSelectedClaude(new Set());
|
||||
};
|
||||
|
||||
// Codex server handlers
|
||||
const toggleCodexServer = (name: string) => {
|
||||
setSelectedCodex((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setCodexServers((prev) =>
|
||||
prev.map((s) => (s.name === name ? { ...s, selected: !s.selected } : s))
|
||||
);
|
||||
};
|
||||
|
||||
const selectAllCodex = () => {
|
||||
const allNames = codexServers.map((s) => s.name);
|
||||
setSelectedCodex(new Set(allNames));
|
||||
setCodexServers((prev) => prev.map((s) => ({ ...s, selected: true })));
|
||||
};
|
||||
|
||||
const clearAllCodex = () => {
|
||||
setSelectedCodex(new Set());
|
||||
setCodexServers((prev) => prev.map((s) => ({ ...s, selected: false })));
|
||||
};
|
||||
|
||||
// Copy handlers
|
||||
const handleCopyToCodex = async () => {
|
||||
if (selectedClaude.size === 0) return;
|
||||
|
||||
setIsCopying(true);
|
||||
setCopyResult({ type: null, copied: 0, failed: 0 });
|
||||
|
||||
try {
|
||||
const result = await crossCliCopy({
|
||||
source: 'claude',
|
||||
target: 'codex',
|
||||
serverNames: Array.from(selectedClaude),
|
||||
projectPath: projectPath ?? undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
const failedCount = result.failed.length;
|
||||
const copiedCount = result.copied.length;
|
||||
|
||||
setCopyResult({
|
||||
type: failedCount > 0 ? 'partial' : 'success',
|
||||
copied: copiedCount,
|
||||
failed: failedCount,
|
||||
});
|
||||
|
||||
onSuccess?.(copiedCount, 'to-codex');
|
||||
|
||||
// Clear selection after successful copy
|
||||
setSelectedClaude(new Set());
|
||||
|
||||
// Auto-hide result after 3 seconds
|
||||
setTimeout(() => {
|
||||
setCopyResult({ type: null, copied: 0, failed: 0 });
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy to Codex:', error);
|
||||
setCopyResult({ type: 'partial', copied: 0, failed: selectedClaude.size });
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyFromCodex = async () => {
|
||||
if (selectedCodex.size === 0 || !projectPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCopying(true);
|
||||
setCopyResult({ type: null, copied: 0, failed: 0 });
|
||||
|
||||
try {
|
||||
const result = await crossCliCopy({
|
||||
source: 'codex',
|
||||
target: 'claude',
|
||||
serverNames: Array.from(selectedCodex),
|
||||
projectPath,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
const failedCount = result.failed.length;
|
||||
const copiedCount = result.copied.length;
|
||||
|
||||
setCopyResult({
|
||||
type: failedCount > 0 ? 'partial' : 'success',
|
||||
copied: copiedCount,
|
||||
failed: failedCount,
|
||||
});
|
||||
|
||||
onSuccess?.(copiedCount, 'from-codex');
|
||||
|
||||
// Clear selection after successful copy
|
||||
setSelectedCodex(new Set());
|
||||
setCodexServers((prev) => prev.map((s) => ({ ...s, selected: false })));
|
||||
|
||||
// Auto-hide result after 3 seconds
|
||||
setTimeout(() => {
|
||||
setCopyResult({ type: null, copied: 0, failed: 0 });
|
||||
}, 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to copy from Codex:', error);
|
||||
setCopyResult({ type: 'partial', copied: 0, failed: selectedCodex.size });
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Computed values
|
||||
const claudeTotal = claudeServers.length;
|
||||
const claudeSelected = selectedClaude.size;
|
||||
const codexTotal = codexServers.length;
|
||||
const codexSelected = selectedCodex.size;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{formatMessage({ id: 'mcp.sync.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'mcp.sync.description' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Result Message */}
|
||||
{copyResult.type !== null && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-3 rounded-lg border',
|
||||
copyResult.type === 'success'
|
||||
? 'bg-success/10 border-success/30 text-success'
|
||||
: 'bg-warning/10 border-warning/30 text-warning'
|
||||
)}
|
||||
>
|
||||
{copyResult.type === 'success' ? (
|
||||
<CheckCircle2 className="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm">
|
||||
{copyResult.type === 'success'
|
||||
? formatMessage(
|
||||
{ id: 'mcp.sync.copySuccess' },
|
||||
{ count: copyResult.copied }
|
||||
)
|
||||
: formatMessage(
|
||||
{ id: 'mcp.sync.copyPartial' },
|
||||
{ copied: copyResult.copied, failed: copyResult.failed }
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Claude Column */}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
{/* Column Header */}
|
||||
<div className="bg-muted/50 px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.sync.claudeColumn' })}
|
||||
</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatMessage(
|
||||
{ id: 'mcp.sync.selectedCount' },
|
||||
{ count: claudeSelected, total: claudeTotal }
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claude Server List */}
|
||||
<div className="p-2 max-h-64 overflow-y-auto">
|
||||
{claudeTotal === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.sync.noServers' })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{claudeServers.map((server) => (
|
||||
<div
|
||||
key={server.name}
|
||||
className={cn(
|
||||
'flex items-start gap-2 p-2 rounded cursor-pointer transition-colors',
|
||||
selectedClaude.has(server.name)
|
||||
? 'bg-primary/10'
|
||||
: 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={() => toggleClaudeServer(server.name)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`claude-${server.name}`}
|
||||
checked={selectedClaude.has(server.name)}
|
||||
onChange={() => toggleClaudeServer(server.name)}
|
||||
className="w-4 h-4 mt-0.5"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`claude-${server.name}`}
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{server.name}
|
||||
</span>
|
||||
{server.enabled && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
{formatMessage({ id: 'mcp.status.enabled' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
{server.command}
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Claude Footer Actions */}
|
||||
{claudeTotal > 0 && (
|
||||
<div className="px-2 py-2 bg-muted/30 border-t border-border flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={selectAllClaude}
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
{formatMessage({ id: 'mcp.sync.selectAll' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllClaude}
|
||||
className="flex-1 text-xs"
|
||||
disabled={claudeSelected === 0}
|
||||
>
|
||||
{formatMessage({ id: 'mcp.sync.clearAll' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Codex Column */}
|
||||
<div className="border border-border rounded-lg overflow-hidden">
|
||||
{/* Column Header */}
|
||||
<div className="bg-muted/50 px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.sync.codexColumn' })}
|
||||
</h4>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatMessage(
|
||||
{ id: 'mcp.sync.selectedCount' },
|
||||
{ count: codexSelected, total: codexTotal }
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Codex Server List */}
|
||||
<div className="p-2 max-h-64 overflow-y-auto">
|
||||
{isLoadingCodex ? (
|
||||
<div className="py-8 flex items-center justify-center">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : codexError ? (
|
||||
<div className="py-8 text-center text-sm text-destructive">
|
||||
{codexError}
|
||||
</div>
|
||||
) : codexTotal === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.sync.noServers' })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{codexServers.map((server) => (
|
||||
<div
|
||||
key={server.name}
|
||||
className={cn(
|
||||
'flex items-start gap-2 p-2 rounded cursor-pointer transition-colors',
|
||||
server.selected
|
||||
? 'bg-primary/10'
|
||||
: 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={() => toggleCodexServer(server.name)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`codex-${server.name}`}
|
||||
checked={server.selected}
|
||||
onChange={() => toggleCodexServer(server.name)}
|
||||
className="w-4 h-4 mt-0.5"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`codex-${server.name}`}
|
||||
className="flex-1 min-w-0 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{server.name}
|
||||
</span>
|
||||
{server.enabled && (
|
||||
<Badge variant="success" className="text-xs">
|
||||
{formatMessage({ id: 'mcp.status.enabled' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">
|
||||
{server.command}
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Codex Footer Actions */}
|
||||
{codexTotal > 0 && (
|
||||
<div className="px-2 py-2 bg-muted/30 border-t border-border flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={selectAllCodex}
|
||||
className="flex-1 text-xs"
|
||||
>
|
||||
{formatMessage({ id: 'mcp.sync.selectAll' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearAllCodex}
|
||||
className="flex-1 text-xs"
|
||||
disabled={codexSelected === 0}
|
||||
>
|
||||
{formatMessage({ id: 'mcp.sync.clearAll' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copy Buttons */}
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<Button
|
||||
onClick={handleCopyToCodex}
|
||||
disabled={claudeSelected === 0 || isCopying}
|
||||
variant="default"
|
||||
className="min-w-40"
|
||||
>
|
||||
{isCopying ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'mcp.sync.syncInProgress' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowRight className="w-4 h-4 mr-2" />
|
||||
{formatMessage(
|
||||
{ id: 'mcp.sync.copyToCodex' },
|
||||
{ count: claudeSelected }
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleCopyFromCodex}
|
||||
disabled={codexSelected === 0 || isCopying || !projectPath}
|
||||
variant="default"
|
||||
className="min-w-40"
|
||||
>
|
||||
{isCopying ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{formatMessage({ id: 'mcp.sync.syncInProgress' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
{formatMessage(
|
||||
{ id: 'mcp.sync.copyFromCodex' },
|
||||
{ count: codexSelected }
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CrossCliSyncPanel;
|
||||
@@ -3411,6 +3411,113 @@ export async function uninstallCcwMcp(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== CCW Tools MCP - Codex API ==========
|
||||
|
||||
/**
|
||||
* Fetch CCW Tools MCP configuration from Codex config.toml
|
||||
*/
|
||||
export async function fetchCcwMcpConfigForCodex(): Promise<CcwMcpConfig> {
|
||||
try {
|
||||
const { servers } = await fetchCodexMcpServers();
|
||||
const ccwServer = servers.find((s) => s.name === 'ccw-tools');
|
||||
|
||||
if (!ccwServer) {
|
||||
return { isInstalled: false, enabledTools: [] };
|
||||
}
|
||||
|
||||
const env = ccwServer.env || {};
|
||||
const enabledToolsStr = env.CCW_ENABLED_TOOLS || 'all';
|
||||
const enabledTools = enabledToolsStr === 'all'
|
||||
? ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search']
|
||||
: enabledToolsStr.split(',').map((t: string) => t.trim());
|
||||
|
||||
return {
|
||||
isInstalled: true,
|
||||
enabledTools,
|
||||
projectRoot: env.CCW_PROJECT_ROOT,
|
||||
allowedDirs: env.CCW_ALLOWED_DIRS,
|
||||
disableSandbox: env.CCW_DISABLE_SANDBOX === '1',
|
||||
};
|
||||
} catch {
|
||||
return { isInstalled: false, enabledTools: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build CCW MCP server config for Codex (uses global ccw-mcp command)
|
||||
*/
|
||||
function buildCcwMcpServerConfigForCodex(config: {
|
||||
enabledTools?: string[];
|
||||
projectRoot?: string;
|
||||
allowedDirs?: string;
|
||||
disableSandbox?: boolean;
|
||||
}): { command: string; args: string[]; env: Record<string, string> } {
|
||||
const env: Record<string, string> = {};
|
||||
|
||||
if (config.enabledTools && config.enabledTools.length > 0) {
|
||||
env.CCW_ENABLED_TOOLS = config.enabledTools.join(',');
|
||||
} else {
|
||||
env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
|
||||
}
|
||||
|
||||
if (config.projectRoot) {
|
||||
env.CCW_PROJECT_ROOT = config.projectRoot;
|
||||
}
|
||||
if (config.allowedDirs) {
|
||||
env.CCW_ALLOWED_DIRS = config.allowedDirs;
|
||||
}
|
||||
if (config.disableSandbox) {
|
||||
env.CCW_DISABLE_SANDBOX = '1';
|
||||
}
|
||||
|
||||
return { command: 'ccw-mcp', args: [], env };
|
||||
}
|
||||
|
||||
/**
|
||||
* Install CCW Tools MCP to Codex config.toml
|
||||
*/
|
||||
export async function installCcwMcpToCodex(): Promise<CcwMcpConfig> {
|
||||
const serverConfig = buildCcwMcpServerConfigForCodex({
|
||||
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'],
|
||||
});
|
||||
|
||||
const result = await addCodexMcpServer('ccw-tools', serverConfig);
|
||||
if (result.error) {
|
||||
throw new Error(result.error || 'Failed to install CCW MCP to Codex');
|
||||
}
|
||||
|
||||
return fetchCcwMcpConfigForCodex();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall CCW Tools MCP from Codex config.toml
|
||||
*/
|
||||
export async function uninstallCcwMcpFromCodex(): Promise<void> {
|
||||
const result = await codexRemoveServer('ccw-tools');
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to uninstall CCW MCP from Codex');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update CCW Tools MCP configuration in Codex config.toml
|
||||
*/
|
||||
export async function updateCcwConfigForCodex(config: {
|
||||
enabledTools?: string[];
|
||||
projectRoot?: string;
|
||||
allowedDirs?: string;
|
||||
disableSandbox?: boolean;
|
||||
}): Promise<CcwMcpConfig> {
|
||||
const serverConfig = buildCcwMcpServerConfigForCodex(config);
|
||||
|
||||
const result = await addCodexMcpServer('ccw-tools', serverConfig);
|
||||
if (result.error) {
|
||||
throw new Error(result.error || 'Failed to update CCW config in Codex');
|
||||
}
|
||||
|
||||
return fetchCcwMcpConfigForCodex();
|
||||
}
|
||||
|
||||
// ========== Index Management API ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -151,13 +151,15 @@
|
||||
"enableAll": "Enable All",
|
||||
"disableAll": "Disable All",
|
||||
"install": "Install CCW MCP",
|
||||
"installCodex": "Install to Codex",
|
||||
"installing": "Installing...",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstalling": "Uninstalling...",
|
||||
"uninstallConfirm": "Are you sure you want to uninstall CCW MCP?",
|
||||
"saveConfig": "Save Configuration",
|
||||
"saving": "Saving..."
|
||||
}
|
||||
},
|
||||
"codexNote": "Requires: npm install -g claude-code-workflow"
|
||||
},
|
||||
"recommended": {
|
||||
"title": "Recommended Servers",
|
||||
@@ -266,6 +268,30 @@
|
||||
"copying": "Copying...",
|
||||
"copyButton": "Copy to {target}"
|
||||
},
|
||||
"sync": {
|
||||
"title": "MCP Server Sync",
|
||||
"description": "Synchronize MCP server configurations between Claude and Codex",
|
||||
"claudeColumn": "Claude Servers",
|
||||
"codexColumn": "Codex Servers",
|
||||
"selectedCount": "Selected: {count} / Total: {total}",
|
||||
"selectAll": "Select All",
|
||||
"clearAll": "Clear All",
|
||||
"copyToCodex": "→ Copy to Codex ({count})",
|
||||
"copyFromCodex": "← Copy from Codex ({count})",
|
||||
"noServers": "No servers available",
|
||||
"codexLoadError": "Failed to load Codex servers",
|
||||
"copySuccess": "Successfully copied {count} server(s)",
|
||||
"copyPartial": "{copied} succeeded, {failed} failed",
|
||||
"syncInProgress": "Syncing..."
|
||||
},
|
||||
"projects": {
|
||||
"title": "Projects Overview",
|
||||
"description": "View MCP server configurations for all projects"
|
||||
},
|
||||
"crossProject": {
|
||||
"title": "Cross-Project Import",
|
||||
"description": "Import MCP server configurations from other projects"
|
||||
},
|
||||
"allProjects": {
|
||||
"title": "All Projects",
|
||||
"name": "Project Name",
|
||||
|
||||
@@ -151,13 +151,15 @@
|
||||
"enableAll": "全部启用",
|
||||
"disableAll": "全部禁用",
|
||||
"install": "安装 CCW MCP",
|
||||
"installCodex": "安装到 Codex",
|
||||
"installing": "安装中...",
|
||||
"uninstall": "卸载",
|
||||
"uninstalling": "卸载中...",
|
||||
"uninstallConfirm": "确定要卸载 CCW MCP 吗?",
|
||||
"saveConfig": "保存配置",
|
||||
"saving": "保存中..."
|
||||
}
|
||||
},
|
||||
"codexNote": "需要全局安装:npm install -g claude-code-workflow"
|
||||
},
|
||||
"recommended": {
|
||||
"title": "推荐服务器",
|
||||
@@ -266,6 +268,30 @@
|
||||
"copying": "复制中...",
|
||||
"copyButton": "复制到 {target}"
|
||||
},
|
||||
"sync": {
|
||||
"title": "MCP 服务器同步",
|
||||
"description": "在 Claude 和 Codex 之间同步 MCP 服务器配置",
|
||||
"claudeColumn": "Claude 服务器",
|
||||
"codexColumn": "Codex 服务器",
|
||||
"selectedCount": "已选: {count} / 总计: {total}",
|
||||
"selectAll": "全选",
|
||||
"clearAll": "清空",
|
||||
"copyToCodex": "→ 复制到 Codex ({count})",
|
||||
"copyFromCodex": "← 从 Codex 复制 ({count})",
|
||||
"noServers": "没有可用的服务器",
|
||||
"codexLoadError": "加载 Codex 服务器失败",
|
||||
"copySuccess": "成功复制 {count} 个服务器",
|
||||
"copyPartial": "成功 {copied} 个,失败 {failed} 个",
|
||||
"syncInProgress": "同步中..."
|
||||
},
|
||||
"projects": {
|
||||
"title": "项目概览",
|
||||
"description": "查看所有项目的 MCP 服务器配置"
|
||||
},
|
||||
"crossProject": {
|
||||
"title": "跨项目导入",
|
||||
"description": "从其他项目导入 MCP 服务器配置"
|
||||
},
|
||||
"allProjects": {
|
||||
"title": "所有项目",
|
||||
"name": "项目名称",
|
||||
|
||||
@@ -33,7 +33,7 @@ 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 { CrossCliCopyButton } from '@/components/mcp/CrossCliCopyButton';
|
||||
import { CrossCliSyncPanel } from '@/components/mcp/CrossCliSyncPanel';
|
||||
import { AllProjectsTable } from '@/components/mcp/AllProjectsTable';
|
||||
import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection';
|
||||
import { TabsNavigation } from '@/components/ui/TabsNavigation';
|
||||
@@ -41,7 +41,9 @@ import { useMcpServers, useMcpServerMutations, useNotifications } from '@/hooks'
|
||||
import {
|
||||
fetchCodexMcpServers,
|
||||
fetchCcwMcpConfig,
|
||||
fetchCcwMcpConfigForCodex,
|
||||
updateCcwConfig,
|
||||
updateCcwConfigForCodex,
|
||||
codexRemoveServer,
|
||||
codexToggleServer,
|
||||
saveMcpTemplate,
|
||||
@@ -255,6 +257,14 @@ export function McpManagerPage() {
|
||||
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,
|
||||
@@ -358,6 +368,32 @@ export function McpManagerPage() {
|
||||
ccwMcpQuery.refetch();
|
||||
};
|
||||
|
||||
// CCW MCP handlers for Codex mode
|
||||
const ccwCodexConfig = ccwMcpCodexQuery.data ?? {
|
||||
isInstalled: false,
|
||||
enabledTools: [],
|
||||
projectRoot: undefined,
|
||||
allowedDirs: undefined,
|
||||
disableSandbox: undefined,
|
||||
};
|
||||
|
||||
const handleToggleCcwToolCodex = async (tool: string, enabled: boolean) => {
|
||||
const updatedTools = enabled
|
||||
? [...ccwCodexConfig.enabledTools, tool]
|
||||
: ccwCodexConfig.enabledTools.filter((t) => t !== tool);
|
||||
await updateCcwConfigForCodex({ enabledTools: updatedTools });
|
||||
ccwMcpCodexQuery.refetch();
|
||||
};
|
||||
|
||||
const handleUpdateCcwConfigCodex = async (config: Partial<CcwMcpConfig>) => {
|
||||
await updateCcwConfigForCodex(config);
|
||||
ccwMcpCodexQuery.refetch();
|
||||
};
|
||||
|
||||
const handleCcwInstallCodex = () => {
|
||||
ccwMcpCodexQuery.refetch();
|
||||
};
|
||||
|
||||
// Template handlers
|
||||
const handleInstallTemplate = (template: any) => {
|
||||
setEditingServer({
|
||||
@@ -617,7 +653,7 @@ export function McpManagerPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CCW Tools MCP Card - Claude mode only */}
|
||||
{/* CCW Tools MCP Card */}
|
||||
{cliMode === 'claude' && (
|
||||
<CcwToolsMcpCard
|
||||
isInstalled={ccwConfig.isInstalled}
|
||||
@@ -630,6 +666,19 @@ export function McpManagerPage() {
|
||||
onInstall={handleCcwInstall}
|
||||
/>
|
||||
)}
|
||||
{cliMode === 'codex' && (
|
||||
<CcwToolsMcpCard
|
||||
target="codex"
|
||||
isInstalled={ccwCodexConfig.isInstalled}
|
||||
enabledTools={ccwCodexConfig.enabledTools}
|
||||
projectRoot={ccwCodexConfig.projectRoot}
|
||||
allowedDirs={ccwCodexConfig.allowedDirs}
|
||||
disableSandbox={ccwCodexConfig.disableSandbox}
|
||||
onToggleTool={handleToggleCcwToolCodex}
|
||||
onUpdateConfig={handleUpdateCcwConfigCodex}
|
||||
onInstall={handleCcwInstallCodex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Servers List */}
|
||||
{currentIsLoading ? (
|
||||
@@ -680,37 +729,56 @@ export function McpManagerPage() {
|
||||
|
||||
{/* Tab Content: Cross-CLI */}
|
||||
{activeTab === 'cross-cli' && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Cross-CLI Copy Button */}
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex-1">
|
||||
<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.crossCli.title' })}
|
||||
{formatMessage({ id: 'mcp.sync.title' })}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'mcp.crossCli.selectServersHint' })}
|
||||
</p>
|
||||
</div>
|
||||
<CrossCliCopyButton
|
||||
currentMode={cliMode}
|
||||
onSuccess={() => refetch()}
|
||||
<Card className="p-4">
|
||||
<CrossCliSyncPanel onSuccess={(count, direction) => 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')}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* All Projects Table */}
|
||||
<AllProjectsTable
|
||||
maxProjects={10}
|
||||
onProjectClick={(path) => console.log('Open project:', path)}
|
||||
onOpenNewWindow={(path) => window.open(`/?project=${encodeURIComponent(path)}`, '_blank')}
|
||||
/>
|
||||
|
||||
{/* Other Projects Section */}
|
||||
<OtherProjectsSection
|
||||
onImportSuccess={(serverName, sourceProject) => {
|
||||
console.log('Imported server:', serverName, 'from:', sourceProject);
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user