feat: add CLI Command Node and Prompt Node components for orchestrator

- Implemented CliCommandNode component for executing CLI tools with AI models.
- Implemented PromptNode component for constructing AI prompts with context.
- Added styling for mode and tool badges in both components.
- Enhanced user experience with command and argument previews, execution status, and error handling.

test: add comprehensive tests for ask_question tool

- Created direct test for ask_question tool execution.
- Developed end-to-end tests to validate ask_question tool integration with WebSocket and A2UI surfaces.
- Implemented simple and integrated WebSocket tests to ensure proper message handling and surface reception.
- Added tool registration test to verify ask_question tool is correctly registered.

chore: add WebSocket listener and simulation tests

- Added WebSocket listener for A2UI surfaces to facilitate testing.
- Implemented frontend simulation test to validate complete flow from backend to frontend.
- Created various test scripts to ensure robust testing of ask_question tool functionality.
This commit is contained in:
catlog22
2026-02-03 23:10:36 +08:00
parent a806d70d9b
commit c6093ef741
134 changed files with 6392 additions and 634 deletions

View File

@@ -69,8 +69,9 @@ export function QueueActions({
const [mergeTargetId, setMergeTargetId] = useState('');
const [selectedItemIds, setSelectedItemIds] = useState<string[]>([]);
// Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key
const queueId = (queue.tasks?.join(',') || queue.solutions?.join(',') || 'default');
// Use "current" as the queue ID for single-queue model
// This matches the API pattern where deactivate works on the current queue
const queueId = 'current';
// Get all items from grouped_items for split dialog
const allItems: QueueItem[] = Object.values(queue.grouped_items || {}).flat();

View File

@@ -51,8 +51,8 @@ export function QueueCard({
}: QueueCardProps) {
const { formatMessage } = useIntl();
// Get queue ID - IssueQueue interface doesn't have an id field, using tasks array as key
const queueId = (queue.tasks || []).join(',') || (queue.solutions || []).join(',') || 'unknown';
// Use "current" for queue ID display
const queueId = 'current';
// Calculate item counts
const taskCount = queue.tasks?.length || 0;

View File

@@ -14,7 +14,7 @@ import { NotificationPanel } from '@/components/notification';
import { AskQuestionDialog } from '@/components/a2ui/AskQuestionDialog';
import { useNotificationStore, selectCurrentQuestion } from '@/stores';
import { useWorkflowStore } from '@/stores/workflowStore';
import { useWebSocketNotifications } from '@/hooks';
import { useWebSocketNotifications, useWebSocket } from '@/hooks';
export interface AppShellProps {
/** Initial sidebar collapsed state */
@@ -103,7 +103,8 @@ export function AppShell({
const currentQuestion = useNotificationStore(selectCurrentQuestion);
const setCurrentQuestion = useNotificationStore((state) => state.setCurrentQuestion);
// Initialize WebSocket notifications handler
// Initialize WebSocket connection and notifications handler
useWebSocket();
useWebSocketNotifications();
// Load persistent notifications from localStorage on mount

View File

@@ -117,6 +117,7 @@ const navGroupDefinitions: NavGroupDef[] = [
icon: Cog,
items: [
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings },
{ path: '/settings/mcp', labelKey: 'navigation.main.mcp', icon: Server },
{ path: '/settings/rules', labelKey: 'navigation.main.rules', icon: Shield },
{ path: '/settings/codexlens', labelKey: 'navigation.main.codexlens', icon: Sparkles },
{ path: '/api-settings', labelKey: 'navigation.main.apiSettings', icon: Server },

View File

@@ -0,0 +1,267 @@
// ========================================
// All Projects Table Component
// ========================================
// Table component displaying all recent projects with MCP server statistics
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Folder, Clock, Database, ExternalLink } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { useProjectOperations } from '@/hooks';
import { cn } from '@/lib/utils';
import { formatDistanceToNow } from 'date-fns';
// ========== Types ==========
export interface ProjectServerStats {
name: string;
path: string;
serverCount: number;
enabledCount: number;
lastModified?: string;
isCurrent?: boolean;
}
export interface AllProjectsTableProps {
/** Callback when a project is clicked */
onProjectClick?: (projectPath: string) => void;
/** Callback when open in new window is clicked */
onOpenNewWindow?: (projectPath: string) => void;
/** Additional class name */
className?: string;
/** Maximum number of projects to display */
maxProjects?: number;
}
// ========== Component ==========
export function AllProjectsTable({
onProjectClick,
onOpenNewWindow,
className,
maxProjects,
}: AllProjectsTableProps) {
const { formatMessage } = useIntl();
const [sortField, setSortField] = useState<'name' | 'serverCount' | 'lastModified'>('lastModified');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
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;
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,
};
});
// Sort projects
const sortedProjects = [...projectStats].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'name':
comparison = a.name.localeCompare(b.name);
break;
case 'serverCount':
comparison = a.serverCount - b.serverCount;
break;
case 'lastModified':
comparison = a.lastModified && b.lastModified
? new Date(a.lastModified).getTime() - new Date(b.lastModified).getTime()
: 0;
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
// Handle sort
const handleSort = (field: typeof sortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('desc');
}
};
// Handle project click
const handleProjectClick = (projectPath: string) => {
onProjectClick?.(projectPath);
};
// Handle open in new window
const handleOpenNewWindow = (e: React.MouseEvent, projectPath: string) => {
e.stopPropagation();
onOpenNewWindow?.(projectPath);
};
if (isLoading) {
return (
<Card className={cn('p-8', className)}>
<div className="flex items-center justify-center">
<div className="animate-spin text-muted-foreground">-</div>
<span className="ml-2 text-sm text-muted-foreground">
{formatMessage({ id: 'common.actions.loading' })}
</span>
</div>
</Card>
);
}
if (sortedProjects.length === 0) {
return (
<Card className={cn('p-8', className)}>
<div className="text-center">
<Folder className="w-12 h-12 mx-auto text-muted-foreground/50 mb-3" />
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'mcp.allProjects.empty' })}
</p>
</div>
</Card>
);
}
return (
<Card className={cn('overflow-hidden', className)}>
{/* Table Header */}
<div className="grid grid-cols-12 gap-2 px-4 py-3 bg-muted/50 border-b border-border text-xs font-medium text-muted-foreground uppercase">
<div className="col-span-4">
<button
onClick={() => handleSort('name')}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
{formatMessage({ id: 'mcp.allProjects.name' })}
{sortField === 'name' && (
<span className="text-foreground">{sortDirection === 'asc' ? '↑' : '↓'}</span>
)}
</button>
</div>
<div className="col-span-3">
<button
onClick={() => handleSort('serverCount')}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
{formatMessage({ id: 'mcp.allProjects.servers' })}
{sortField === 'serverCount' && (
<span className="text-foreground">{sortDirection === 'asc' ? '↑' : '↓'}</span>
)}
</button>
</div>
<div className="col-span-3">
<button
onClick={() => handleSort('lastModified')}
className="flex items-center gap-1 hover:text-foreground transition-colors"
>
{formatMessage({ id: 'mcp.allProjects.lastModified' })}
{sortField === 'lastModified' && (
<span className="text-foreground">{sortDirection === 'asc' ? '↑' : '↓'}</span>
)}
</button>
</div>
<div className="col-span-2 text-right">
{formatMessage({ id: 'mcp.allProjects.actions' })}
</div>
</div>
{/* Table Rows */}
<div className="divide-y divide-border">
{sortedProjects.map((project) => (
<div
key={project.path}
onClick={() => handleProjectClick(project.path)}
className={cn(
'grid grid-cols-12 gap-2 px-4 py-3 items-center transition-colors cursor-pointer hover:bg-muted/50',
project.isCurrent && 'bg-primary/5 hover:bg-primary/10'
)}
>
{/* Project Name */}
<div className="col-span-4 min-w-0">
<div className="flex items-center gap-2">
<Folder className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground truncate">
{project.name}
</span>
{project.isCurrent && (
<Badge variant="default" className="text-xs">
{formatMessage({ id: 'mcp.allProjects.current' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground font-mono truncate">
{project.path}
</p>
</div>
</div>
</div>
{/* Server Count */}
<div className="col-span-3">
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-muted-foreground" />
<span className="text-sm text-foreground">
{project.serverCount} {formatMessage({ id: 'mcp.allProjects.servers' })}
</span>
<Badge
variant="outline"
className={cn(
'text-xs',
project.enabledCount > 0 ? 'text-success border-success/30' : 'text-muted-foreground'
)}
>
{project.enabledCount} {formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
</div>
</div>
{/* Last Modified */}
<div className="col-span-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>
{project.lastModified
? formatDistanceToNow(new Date(project.lastModified), { addSuffix: true })
: '-'}
</span>
</div>
</div>
{/* Actions */}
<div className="col-span-2 flex justify-end gap-1">
<button
onClick={(e) => handleOpenNewWindow(e, project.path)}
className="p-1.5 rounded hover:bg-muted transition-colors"
title={formatMessage({ id: 'mcp.allProjects.openNewWindow' })}
>
<ExternalLink className="w-4 h-4 text-muted-foreground" />
</button>
</div>
</div>
))}
</div>
{/* Footer */}
<div className="px-4 py-2 bg-muted/30 border-t border-border text-xs text-muted-foreground">
{formatMessage(
{ id: 'mcp.allProjects.summary' },
{ count: sortedProjects.length }
)}
</div>
</Card>
);
}
export default AllProjectsTable;

View File

@@ -24,6 +24,8 @@ export interface CodexMcpCardProps {
enabled: boolean;
isExpanded: boolean;
onToggleExpand: () => void;
/** Optional: When true, indicates this card is in editable mode (for CodexMcpEditableCard extension) */
isEditable?: boolean;
}
// ========== Component ==========
@@ -33,6 +35,8 @@ export function CodexMcpCard({
enabled,
isExpanded,
onToggleExpand,
// isEditable prop is for CodexMcpEditableCard extension compatibility
isEditable: _isEditable = false,
}: CodexMcpCardProps) {
const { formatMessage } = useIntl();

View File

@@ -0,0 +1,306 @@
// ========================================
// Codex MCP Editable Card Component
// ========================================
// Editable Codex MCP server card with remove and toggle actions
// Extends CodexMcpCard with additional action buttons when editing is enabled
import { useState } from 'react';
import { useIntl } from 'react-intl';
import {
Server,
Power,
PowerOff,
ChevronDown,
ChevronUp,
Edit3,
Trash2,
Lock,
} from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/AlertDialog';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
import type { McpServer } from '@/lib/api';
// ========== Types ==========
export interface CodexMcpEditableCardProps {
server: McpServer;
enabled: boolean;
isExpanded: boolean;
onToggleExpand: () => void;
/** When enabled, shows remove/toggle buttons instead of read-only badge */
isEditable?: boolean;
/** Callback when server is removed (after confirmation) */
onRemove?: (serverName: string) => void | Promise<void>;
/** Callback when server is toggled */
onToggle?: (serverName: string, enabled: boolean) => void | Promise<void>;
/** Whether remove operation is in progress */
isRemoving?: boolean;
/** Whether toggle operation is in progress */
isToggling?: boolean;
}
// ========== Component ==========
export function CodexMcpEditableCard({
server,
enabled,
isExpanded,
onToggleExpand,
isEditable = false,
onRemove,
onToggle,
isRemoving = false,
isToggling = false,
}: CodexMcpEditableCardProps) {
const { formatMessage } = useIntl();
const [isConfirmDeleteOpen, setIsConfirmDeleteOpen] = useState(false);
// Handle toggle with optimistic update
const handleToggle = async () => {
if (onToggle) {
await onToggle(server.name, !enabled);
}
};
// Handle remove with confirmation
const handleRemove = async () => {
if (onRemove) {
await onRemove(server.name);
setIsConfirmDeleteOpen(false);
}
};
// Prevent click propagation when clicking action buttons
const stopPropagation = (e: React.MouseEvent) => {
e.stopPropagation();
};
return (
<Card className={cn('overflow-hidden', !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',
enabled ? 'bg-primary/10' : 'bg-muted'
)}>
<Server className={cn(
'w-5 h-5',
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>
{isEditable ? (
<>
{/* Editable badge with actions */}
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<Edit3 className="w-3 h-3" />
{formatMessage({ id: 'mcp.codex.editable' })}
</Badge>
{enabled && (
<Badge variant="outline" className="text-xs text-green-600">
<Power className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.status.enabled' })}
</Badge>
)}
</>
) : (
<>
{/* Read-only badge */}
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<Lock className="w-3 h-3" />
{formatMessage({ id: 'mcp.codex.readOnly' })}
</Badge>
{enabled && (
<Badge variant="outline" className="text-xs text-green-600">
<Power className="w-3 h-3 mr-1" />
{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">
{isEditable ? (
<>
{/* Toggle button (active in editable mode) */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
stopPropagation(e);
handleToggle();
}}
disabled={isToggling}
>
{enabled ? (
<Power className="w-4 h-4 text-green-600" />
) : (
<PowerOff className="w-4 h-4 text-muted-foreground" />
)}
</Button>
{/* Remove button with confirmation */}
<AlertDialog open={isConfirmDeleteOpen} onOpenChange={setIsConfirmDeleteOpen}>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={stopPropagation}
disabled={isRemoving}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage({ id: 'mcp.codex.deleteConfirm.title' }, { name: server.name })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage({ id: 'mcp.codex.deleteConfirm.description' }, { name: server.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemoving}>
{formatMessage({ id: 'mcp.codex.deleteConfirm.cancel' })}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleRemove}
disabled={isRemoving}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isRemoving ? (
<>
<span className="animate-spin mr-2"></span>
{formatMessage({ id: 'mcp.codex.deleteConfirm.deleting' })}
</>
) : (
formatMessage({ id: 'mcp.codex.deleteConfirm.confirm' })
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
) : (
<>
{/* Disabled toggle button (visual only, no edit capability) */}
<div className={cn(
'w-8 h-8 rounded-md flex items-center justify-center',
enabled ? 'bg-green-100 text-green-600' : 'bg-muted text-muted-foreground'
)}>
{enabled ? <Power className="w-4 h-4" /> : <PowerOff className="w-4 h-4" />}
</div>
</>
)}
{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>
)}
{/* Notice based on editable state */}
<div className={cn(
'flex items-center gap-2 px-3 py-2 rounded-md border',
isEditable
? 'bg-info/10 border-info/20'
: 'bg-muted/50 border-border'
)}>
{isEditable ? (
<>
<Edit3 className="w-4 h-4 text-info" />
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.codex.editableNotice' })}
</p>
</>
) : (
<>
<Lock className="w-4 h-4 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.codex.readOnlyNotice' })}
</p>
</>
)}
</div>
</div>
)}
</Card>
);
}
export default CodexMcpEditableCard;

View File

@@ -0,0 +1,247 @@
// ========================================
// Config Type Toggle Component
// ========================================
// Toggle between .mcp.json and .claude.json config storage formats with localStorage persistence
import { useEffect, useState } from 'react';
import { useIntl } from 'react-intl';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/AlertDialog';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
// ========== Types ==========
/**
* MCP config file type
*/
export type McpConfigType = 'mcp-json' | 'claude-json';
/**
* Props for ConfigTypeToggle component
*/
export interface ConfigTypeToggleProps {
/** Current config type */
currentType: McpConfigType;
/** Callback when config type changes */
onTypeChange: (type: McpConfigType) => void;
/** Whether to show warning when switching (default: true) */
showWarning?: boolean;
/** Number of existing servers in current config (for warning message) */
existingServersCount?: number;
}
// ========== Constants ==========
/**
* localStorage key for config type persistence
*/
const CONFIG_TYPE_STORAGE_KEY = 'mcp-config-type';
/**
* Default config type
*/
const DEFAULT_CONFIG_TYPE: McpConfigType = 'mcp-json';
// ========== Helper Functions ==========
/**
* Load config type from localStorage
*/
export function loadConfigType(): McpConfigType {
try {
const stored = localStorage.getItem(CONFIG_TYPE_STORAGE_KEY);
if (stored === 'mcp-json' || stored === 'claude-json') {
return stored;
}
} catch {
// Ignore localStorage errors
}
return DEFAULT_CONFIG_TYPE;
}
/**
* Save config type to localStorage
*/
export function saveConfigType(type: McpConfigType): void {
try {
localStorage.setItem(CONFIG_TYPE_STORAGE_KEY, type);
} catch {
// Ignore localStorage errors
}
}
/**
* Get file extension for config type
*/
export function getConfigFileExtension(type: McpConfigType): string {
switch (type) {
case 'mcp-json':
return '.mcp.json';
case 'claude-json':
return '.claude.json';
default:
return '.json';
}
}
// ========== Component ==========
/**
* Config type toggle segmented control
*/
export function ConfigTypeToggle({
currentType,
onTypeChange,
showWarning = true,
existingServersCount = 0,
}: ConfigTypeToggleProps) {
const { formatMessage } = useIntl();
const [internalType, setInternalType] = useState<McpConfigType>(currentType);
const [showWarningDialog, setShowWarningDialog] = useState(false);
const [pendingType, setPendingType] = useState<McpConfigType | null>(null);
// Sync internal state with prop changes
useEffect(() => {
setInternalType(currentType);
}, [currentType]);
// Load saved preference on mount (only if no current type is set)
useEffect(() => {
if (!currentType || currentType === DEFAULT_CONFIG_TYPE) {
const savedType = loadConfigType();
if (savedType !== currentType) {
setInternalType(savedType);
onTypeChange(savedType);
}
}
}, []); // Run once on mount
// Handle type toggle click
const handleTypeClick = (type: McpConfigType) => {
if (type === internalType) return;
if (showWarning && existingServersCount > 0) {
setPendingType(type);
setShowWarningDialog(true);
} else {
applyTypeChange(type);
}
};
// Apply the type change
const applyTypeChange = (type: McpConfigType) => {
setInternalType(type);
saveConfigType(type);
onTypeChange(type);
setShowWarningDialog(false);
setPendingType(null);
};
// Handle warning dialog confirm
const handleWarningConfirm = () => {
if (pendingType) {
applyTypeChange(pendingType);
}
};
// Handle warning dialog cancel
const handleWarningCancel = () => {
setShowWarningDialog(false);
setPendingType(null);
};
return (
<>
<div className="space-y-3">
{/* Label */}
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.configType.label' })}
</span>
<Badge variant="outline" className="text-xs">
{getConfigFileExtension(internalType)}
</Badge>
</div>
{/* Toggle Buttons */}
<div className="flex gap-2 p-1 bg-muted rounded-lg">
<Button
variant={internalType === 'mcp-json' ? 'default' : 'ghost'}
size="sm"
onClick={() => handleTypeClick('mcp-json')}
className={cn(
'flex-1',
internalType === 'mcp-json' && 'shadow-sm'
)}
>
<span className="text-sm">
{formatMessage({ id: 'mcp.configType.claudeJson' })}
</span>
</Button>
<Button
variant={internalType === 'claude-json' ? 'default' : 'ghost'}
size="sm"
onClick={() => handleTypeClick('claude-json')}
className={cn(
'flex-1',
internalType === 'claude-json' && 'shadow-sm'
)}
>
<span className="text-sm">
{formatMessage({ id: 'mcp.configType.claudeJson' })}
</span>
</Button>
</div>
{/* Current Format Display */}
<div className="flex items-center gap-2 px-3 py-2 bg-muted/50 rounded-md border border-border">
<code className="text-xs text-muted-foreground font-mono">
{getConfigFileExtension(internalType)}
</code>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.configType.' + internalType.replace('-', '') })}
</span>
</div>
</div>
{/* Warning Dialog */}
<AlertDialog open={showWarningDialog} onOpenChange={setShowWarningDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage({ id: 'mcp.configType.switchConfirm' })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage({ id: 'mcp.configType.switchWarning' })}
{existingServersCount > 0 && (
<span className="block mt-2 text-amber-600 dark:text-amber-400">
{existingServersCount} {formatMessage({ id: 'mcp.stats.total' }).toLowerCase()}
</span>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleWarningCancel}>
{formatMessage({ id: 'mcp.configType.switchCancel' })}
</AlertDialogCancel>
<AlertDialogAction onClick={handleWarningConfirm}>
{formatMessage({ id: 'mcp.configType.switchConfirm' })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
export default ConfigTypeToggle;

View File

@@ -0,0 +1,313 @@
// ========================================
// Cross-CLI Copy Button Component
// ========================================
// Button component for copying MCP servers between Claude and Codex configurations
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Copy, ArrowRight, ArrowLeft, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import { Checkbox } from '@/components/ui/Checkbox';
import { Badge } from '@/components/ui/Badge';
import { useMcpServers } from '@/hooks';
import { crossCliCopy } from '@/lib/api';
import { cn } from '@/lib/utils';
// ========== Types ==========
export type CliType = 'claude' | 'codex';
export type CopyDirection = 'claude-to-codex' | 'codex-to-claude';
export interface CrossCliCopyButtonProps {
/** Current CLI mode */
currentMode?: CliType;
/** Button variant */
variant?: 'default' | 'outline' | 'ghost';
/** Button size */
size?: 'default' | 'sm' | 'icon';
/** Additional class name */
className?: string;
/** Callback when copy is successful */
onSuccess?: (copiedCount: number) => void;
}
interface ServerCheckboxItem {
name: string;
command: string;
enabled: boolean;
selected: boolean;
}
// ========== Constants ==========
const CLI_LABELS: Record<CliType, string> = {
claude: 'Claude',
codex: 'Codex',
};
// ========== Component ==========
export function CrossCliCopyButton({
currentMode = 'claude',
variant = 'outline',
size = 'sm',
className,
onSuccess,
}: CrossCliCopyButtonProps) {
const { formatMessage } = useIntl();
const [isOpen, setIsOpen] = useState(false);
const [direction, setDirection] = useState<CopyDirection>(
currentMode === 'claude' ? 'claude-to-codex' : 'codex-to-claude'
);
const [serverItems, setServerItems] = useState<ServerCheckboxItem[]>([]);
const { servers } = useMcpServers();
const [isCopying, setIsCopying] = useState(false);
// Initialize server items when dialog opens
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
if (open) {
setDirection(currentMode === 'claude' ? 'claude-to-codex' : 'codex-to-claude');
setServerItems(
servers.map((s) => ({
name: s.name,
command: s.command,
enabled: s.enabled,
selected: false,
}))
);
}
};
// Get source and target CLI labels
const sourceCli = direction === 'claude-to-codex' ? 'claude' : 'codex';
const targetCli = direction === 'claude-to-codex' ? 'codex' : 'claude';
// Toggle direction
const handleToggleDirection = () => {
setDirection((prev) =>
prev === 'claude-to-codex' ? 'codex-to-claude' : 'claude-to-codex'
);
setServerItems((prev) => prev.map((item) => ({ ...item, selected: false })));
};
// Toggle server selection
const handleToggleServer = (serverName: string) => {
setServerItems((prev) =>
prev.map((item) =>
item.name === serverName ? { ...item, selected: !item.selected } : item
)
);
};
// Select/deselect all
const handleToggleAll = () => {
const allSelected = serverItems.every((item) => item.selected);
setServerItems((prev) => prev.map((item) => ({ ...item, selected: !allSelected })));
};
// Handle copy operation
const handleCopy = async () => {
const selectedServers = serverItems.filter((item) => item.selected).map((item) => item.name);
if (selectedServers.length === 0) {
return;
}
setIsCopying(true);
try {
const result = await crossCliCopy({
source: sourceCli,
target: targetCli,
serverNames: selectedServers,
});
if (result.success) {
onSuccess?.(result.copied.length);
setIsOpen(false);
if (result.failed.length > 0) {
console.warn('Some servers failed to copy:', result.failed);
}
}
} catch (error) {
console.error('Failed to copy servers:', error);
} finally {
setIsCopying(false);
}
};
const selectedCount = serverItems.filter((item) => item.selected).length;
const allSelected = serverItems.length > 0 && serverItems.every((item) => item.selected);
const someSelected = serverItems.some((item) => item.selected);
return (
<>
<Button
variant={variant}
size={size}
onClick={() => handleOpenChange(true)}
className={className}
>
<Copy className="w-4 h-4 mr-2" />
{formatMessage({ id: 'mcp.crossCli.button' })}
</Button>
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="w-5 h-5" />
{formatMessage({ id: 'mcp.crossCli.title' })}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Direction Selector */}
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<Badge variant="outline" className="text-sm font-mono">
{CLI_LABELS[sourceCli]}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={handleToggleDirection}
className="p-2"
>
<RefreshCw className="w-4 h-4" />
</Button>
<Badge variant="default" className="text-sm font-mono">
{CLI_LABELS[targetCli]}
</Badge>
</div>
<Button
variant="ghost"
size="sm"
onClick={handleToggleAll}
disabled={serverItems.length === 0}
>
{allSelected
? formatMessage({ id: 'common.actions.deselectAll' })
: formatMessage({ id: 'common.actions.selectAll' })
}
</Button>
</div>
{/* Server Selection List */}
<div className="space-y-2">
<p className="text-sm font-medium text-foreground">
{formatMessage(
{ id: 'mcp.crossCli.selectServers' },
{ source: CLI_LABELS[sourceCli] }
)}
</p>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.crossCli.selectServersHint' })}
</p>
{serverItems.length === 0 ? (
<div className="p-4 text-center text-muted-foreground text-sm border border-dashed rounded-lg">
{formatMessage({ id: 'mcp.crossCli.noServers' })}
</div>
) : (
<div className="space-y-1 max-h-60 overflow-y-auto">
{serverItems.map((server) => (
<div
key={server.name}
className={cn(
'flex items-center gap-3 p-2 rounded-lg transition-colors cursor-pointer',
server.selected ? 'bg-primary/10' : 'hover:bg-muted/50'
)}
onClick={() => handleToggleServer(server.name)}
>
<Checkbox
id={`server-${server.name}`}
checked={server.selected}
onChange={() => handleToggleServer(server.name)}
className="w-4 h-4"
/>
<label
htmlFor={`server-${server.name}`}
className="flex-1 cursor-pointer min-w-0"
>
<div className="flex items-center gap-2">
<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>
{/* Selection Summary */}
{someSelected && (
<div className="flex items-center justify-between p-3 bg-primary/5 rounded-lg border border-primary/20">
<span className="text-sm text-foreground">
{formatMessage(
{ id: 'mcp.crossCli.selectedCount' },
{ count: selectedCount }
)}
</span>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isCopying}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
<Button
onClick={handleCopy}
disabled={selectedCount === 0 || isCopying}
>
{isCopying ? (
<>
<span className="animate-spin mr-2">-</span>
{formatMessage({ id: 'mcp.crossCli.copying' })}
</>
) : (
<>
{direction === 'claude-to-codex' ? (
<ArrowRight className="w-4 h-4 mr-2" />
) : (
<ArrowLeft className="w-4 h-4 mr-2" />
)}
{formatMessage(
{ id: 'mcp.crossCli.copyButton' },
{ count: selectedCount, target: CLI_LABELS[targetCli] }
)}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default CrossCliCopyButton;

View File

@@ -0,0 +1,159 @@
// ========================================
// Enterprise MCP Badge Component
// ========================================
// Badge component for enterprise MCP server identification
import { useIntl } from 'react-intl';
import { Building2, Crown } from 'lucide-react';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface EnterpriseMcpBadgeProps {
/** Server configuration to check for enterprise status */
server?: {
name: string;
command?: string;
args?: string[];
env?: Record<string, string>;
};
/** Additional class name */
className?: string;
/** Badge variant */
variant?: 'default' | 'subtle' | 'icon-only';
/** Enterprise server patterns for detection */
enterprisePatterns?: string[];
}
// ========== Constants ==========
// Default patterns that indicate enterprise MCP servers
const DEFAULT_ENTERPRISE_PATTERNS = [
// Command patterns
'^enterprise-',
'-enterprise$',
'^ent-',
'claude-.*enterprise',
'anthropic-.*',
// Server name patterns
'^claude.*enterprise',
'^anthropic',
'^enterprise',
// Env var patterns (API keys, endpoints)
'ANTHROPIC.*',
'ENTERPRISE.*',
'.*_ENTERPRISE_ENDPOINT',
];
// ========== Helper Functions ==========
/**
* Check if a server matches enterprise patterns
*/
function isEnterpriseServer(
server: EnterpriseMcpBadgeProps['server'],
patterns: string[]
): boolean {
if (!server) return false;
const allPatterns = [...patterns, ...DEFAULT_ENTERPRISE_PATTERNS];
const searchText = [
server.name,
server.command || '',
...(server.args || []),
...Object.keys(server.env || {}),
...Object.values(server.env || {}),
]
.join(' ')
.toLowerCase();
return allPatterns.some((pattern) => {
try {
const regex = new RegExp(pattern, 'i');
return regex.test(searchText);
} catch {
// Fallback to simple string match if regex is invalid
return searchText.includes(pattern.toLowerCase());
}
});
}
// ========== Component ==========
export function EnterpriseMcpBadge({
server,
className,
variant = 'default',
enterprisePatterns = [],
}: EnterpriseMcpBadgeProps) {
const { formatMessage } = useIntl();
const isEnterprise = isEnterpriseServer(server, enterprisePatterns);
if (!isEnterprise || !server) {
return null;
}
// Icon-only variant
if (variant === 'icon-only') {
return (
<div
className={cn(
'inline-flex items-center justify-center w-5 h-5 rounded-full',
'bg-gradient-to-br from-amber-500 to-orange-600',
'text-white shadow-sm',
className
)}
title={formatMessage({ id: 'mcp.enterprise.tooltip' })}
>
<Crown className="w-3 h-3" />
</div>
);
}
// Subtle variant (smaller, less prominent)
if (variant === 'subtle') {
return (
<Badge
variant="outline"
className={cn(
'text-xs border-amber-500/30 text-amber-600 dark:text-amber-400',
'bg-amber-500/10',
className
)}
>
<Building2 className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.enterprise.label' })}
</Badge>
);
}
// Default variant (prominent)
return (
<Badge
className={cn(
'bg-gradient-to-r from-amber-500 to-orange-600 text-white border-0',
'shadow-sm',
className
)}
>
<Crown className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.enterprise.label' })}
</Badge>
);
}
// ========== Hook for Enterprise Detection ==========
/**
* Hook to check if a server is an enterprise server
*/
export function useEnterpriseServer(
server: EnterpriseMcpBadgeProps['server'] | undefined,
customPatterns: string[] = []
): boolean {
return isEnterpriseServer(server, customPatterns);
}
export default EnterpriseMcpBadge;

View File

@@ -0,0 +1,283 @@
// ========================================
// Install Command Dialog Component
// ========================================
// Dialog for generating and displaying MCP server install commands
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { Copy, Check, Terminal, Download } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface InstallCommandDialogProps {
/** Server configuration to generate command for */
server?: {
name: string;
command: string;
args?: string[];
env?: Record<string, string>;
};
/** Whether dialog is open */
open: boolean;
/** Callback when dialog is closed */
onClose: () => void;
/** Installation scope */
scope?: 'project' | 'global';
/** Config type (mcp for .mcp.json, claude for .claude.json) */
configType?: 'mcp' | 'claude';
}
// ========== Component ==========
export function InstallCommandDialog({
server,
open,
onClose,
scope = 'project',
configType = 'mcp',
}: InstallCommandDialogProps) {
const { formatMessage } = useIntl();
const [copied, setCopied] = useState(false);
// Generate install command
const generateCommand = (): string => {
if (!server) return '';
const envArgs = server.env
? Object.entries(server.env).flatMap(([key, value]) => ['--env', `${key}=${value}`])
: [];
const args = [
'ccw',
'mcp',
'install',
server.name,
'--command',
server.command,
...(server.args || []).flatMap((arg) => ['--args', arg]),
...envArgs,
'--scope',
scope,
];
if (configType === 'claude') {
args.push('--config-type', 'claude');
}
return args.join(' ');
};
// Generate JSON config snippet
const generateJsonConfig = (): string => {
if (!server) return '';
const config = {
mcpServers: {
[server.name]: {
command: server.command,
...(server.args && { args: server.args }),
...(server.env && { env: server.env }),
},
},
};
return JSON.stringify(config, null, 2);
};
// Handle copy to clipboard
const handleCopy = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
const command = generateCommand();
const jsonConfig = generateJsonConfig();
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5" />
{formatMessage({ id: 'mcp.installCmd.title' })}
</DialogTitle>
</DialogHeader>
{server && (
<div className="space-y-4">
{/* Server Info */}
<div className="p-3 bg-muted rounded-lg">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-foreground">{server.name}</span>
<Badge variant="outline" className="text-xs">
{scope}
</Badge>
<Badge variant="secondary" className="text-xs font-mono">
.{configType === 'mcp' ? 'mcp.json' : 'claude.json'}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{server.command} {(server.args || []).join(' ')}
</p>
</div>
{/* CLI Command */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground flex items-center gap-2">
<Terminal className="w-4 h-4" />
{formatMessage({ id: 'mcp.installCmd.cliCommand' })}
</label>
<Button
variant="outline"
size="sm"
onClick={() => handleCopy(command)}
className="h-7"
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.success.copied' })}
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.actions.copy' })}
</>
)}
</Button>
</div>
<div className="relative">
<pre
className={cn(
'bg-slate-950 text-slate-50 p-4 rounded-lg text-sm font-mono overflow-x-auto',
'border border-slate-800'
)}
>
<code>{command}</code>
</pre>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.installCmd.cliCommandHint' })}
</p>
</div>
{/* JSON Config */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground flex items-center gap-2">
<Download className="w-4 h-4" />
{formatMessage({ id: 'mcp.installCmd.jsonConfig' })}
</label>
<Button
variant="outline"
size="sm"
onClick={() => handleCopy(jsonConfig)}
className="h-7"
>
{copied ? (
<>
<Check className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.success.copied' })}
</>
) : (
<>
<Copy className="w-3 h-3 mr-1" />
{formatMessage({ id: 'common.actions.copy' })}
</>
)}
</Button>
</div>
<div className="relative">
<pre
className={cn(
'bg-slate-950 text-slate-50 p-4 rounded-lg text-sm font-mono overflow-x-auto',
'border border-slate-800'
)}
>
<code>{jsonConfig}</code>
</pre>
</div>
<p className="text-xs text-muted-foreground">
{formatMessage(
{ id: 'mcp.installCmd.jsonConfigHint' },
{ filename: `.${configType === 'mcp' ? 'mcp' : 'claude'}.json` }
)}
</p>
</div>
{/* Environment Variables */}
{server.env && Object.keys(server.env).length > 0 && (
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.installCmd.envVars' })}
</label>
<div className="p-3 bg-muted rounded-lg space-y-1">
{Object.entries(server.env).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 text-sm font-mono">
<span className="text-foreground font-medium">{key}=</span>
<span className="text-muted-foreground break-all">{value}</span>
</div>
))}
</div>
</div>
)}
{/* Installation Steps */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.installCmd.steps' })}
</label>
<ol className="space-y-2 text-sm text-muted-foreground">
<li className="flex gap-2">
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center bg-primary/20 text-primary rounded-full text-xs font-medium">
1
</span>
<span>{formatMessage({ id: 'mcp.installCmd.step1' })}</span>
</li>
<li className="flex gap-2">
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center bg-primary/20 text-primary rounded-full text-xs font-medium">
2
</span>
<span>
{formatMessage({ id: 'mcp.installCmd.step2' })}
</span>
</li>
<li className="flex gap-2">
<span className="flex-shrink-0 w-5 h-5 flex items-center justify-center bg-primary/20 text-primary rounded-full text-xs font-medium">
3
</span>
<span>
{formatMessage({ id: 'mcp.installCmd.step3' })}
</span>
</li>
</ol>
</div>
</div>
)}
<div className="flex justify-end pt-4 border-t border-border">
<Button variant="outline" onClick={onClose}>
{formatMessage({ id: 'common.actions.close' })}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
export default InstallCommandDialog;

View File

@@ -1,7 +1,7 @@
// ========================================
// MCP Server Dialog Component
// ========================================
// Add/Edit dialog for MCP server configuration with template presets
// Add/Edit dialog for MCP server configuration with dynamic template loading
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
@@ -29,7 +29,7 @@ import {
fetchMcpServers,
type McpServer,
} from '@/lib/api';
import { mcpServersKeys } from '@/hooks';
import { mcpServersKeys, useMcpTemplates } from '@/hooks';
import { cn } from '@/lib/utils';
// ========== Types ==========
@@ -42,14 +42,8 @@ export interface McpServerDialogProps {
onSave?: () => void;
}
export interface McpTemplate {
id: string;
name: string;
description: string;
command: string;
args: string[];
env?: Record<string, string>;
}
// Re-export McpTemplate for convenience
export type { McpTemplate } from '@/types/store';
interface McpServerFormData {
name: string;
@@ -67,32 +61,6 @@ interface FormErrors {
env?: string;
}
// ========== Template Presets ==========
const TEMPLATE_PRESETS: McpTemplate[] = [
{
id: 'npx-stdio',
name: 'NPX STDIO',
description: 'Node.js package using stdio transport',
command: 'npx',
args: ['{package}'],
},
{
id: 'python-stdio',
name: 'Python STDIO',
description: 'Python script using stdio transport',
command: 'python',
args: ['{script}.py'],
},
{
id: 'sse-server',
name: 'SSE Server',
description: 'HTTP server with Server-Sent Events transport',
command: 'node',
args: ['{server}.js'],
},
];
// ========== Component ==========
export function McpServerDialog({
@@ -105,6 +73,9 @@ export function McpServerDialog({
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
// Fetch templates from backend
const { templates, isLoading: templatesLoading } = useMcpTemplates();
// Form state
const [formData, setFormData] = useState<McpServerFormData>({
name: '',
@@ -181,17 +152,17 @@ export function McpServerDialog({
};
const handleTemplateSelect = (templateId: string) => {
const template = TEMPLATE_PRESETS.find((t) => t.id === templateId);
const template = templates.find((t) => t.name === templateId);
if (template) {
setFormData((prev) => ({
...prev,
command: template.command,
args: template.args,
env: template.env || {},
command: template.serverConfig.command,
args: template.serverConfig.args || [],
env: template.serverConfig.env || {},
}));
setArgsInput(template.args.join(', '));
setArgsInput((template.serverConfig.args || []).join(', '));
setEnvInput(
Object.entries(template.env || {})
Object.entries(template.serverConfig.env || {})
.map(([k, v]) => `${k}=${v}`)
.join('\n')
);
@@ -324,23 +295,32 @@ export function McpServerDialog({
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.dialog.form.template' })}
</label>
<Select value={selectedTemplate} onValueChange={handleTemplateSelect}>
<Select value={selectedTemplate} onValueChange={handleTemplateSelect} disabled={templatesLoading}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={formatMessage({ id: 'mcp.dialog.form.templatePlaceholder' })}
placeholder={templatesLoading
? formatMessage({ id: 'mcp.templates.loading' })
: formatMessage({ id: 'mcp.dialog.form.templatePlaceholder' })
}
/>
</SelectTrigger>
<SelectContent>
{TEMPLATE_PRESETS.map((template) => (
<SelectItem key={template.id} value={template.id}>
<div className="flex flex-col">
<span className="font-medium">{template.name}</span>
<span className="text-xs text-muted-foreground">
{template.description}
</span>
</div>
{templates.length === 0 ? (
<SelectItem value="" disabled>
{formatMessage({ id: 'mcp.templates.empty.title' })}
</SelectItem>
))}
) : (
templates.map((template) => (
<SelectItem key={template.name} value={template.name}>
<div className="flex flex-col">
<span className="font-medium">{template.name}</span>
<span className="text-xs text-muted-foreground">
{template.description || formatMessage({ id: 'mcp.dialog.form.templatePlaceholder' })}
</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>

View File

@@ -0,0 +1,507 @@
// ========================================
// MCP Templates Section Component
// ========================================
// Template management component with card/list view, search input, category filter, save/delete/install actions
import { useState, useEffect, useCallback } from 'react';
import { useIntl } from 'react-intl';
import { useQuery } from '@tanstack/react-query';
import {
Copy,
Trash2,
Download,
Search,
Filter,
Plus,
FileCode,
Folder,
Globe,
} 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/AlertDialog';
import {
fetchMcpTemplateCategories,
searchMcpTemplates,
fetchMcpTemplatesByCategory,
} from '@/lib/api';
import { mcpTemplatesKeys, useDeleteTemplate, useInstallTemplate } from '@/hooks';
import type { McpTemplate } from '@/types/store';
// ========== Types ==========
export interface McpTemplatesSectionProps {
/** Callback when template is installed (opens McpServerDialog) */
onInstallTemplate?: (template: McpTemplate) => void;
/** Callback when current server should be saved as template */
onSaveAsTemplate?: (serverName: string, config: { command: string; args: string[]; env?: Record<string, string> }) => void;
}
interface TemplateSaveDialogProps {
open: boolean;
onClose: () => void;
onSave: (name: string, category: string, description: string) => void;
defaultName?: string;
defaultCommand?: string;
defaultArgs?: string[];
}
interface TemplateCardProps {
template: McpTemplate;
onInstall: (template: McpTemplate) => void;
onDelete: (templateName: string) => void;
isInstalling: boolean;
isDeleting: boolean;
}
// ========== Constants ==========
const SEARCH_DEBOUNCE_MS = 300;
// ========== Helper Components ==========
/**
* Template Card Component - Display single template with actions
*/
function TemplateCard({ template, onInstall, onDelete, isInstalling, isDeleting }: TemplateCardProps) {
const { formatMessage } = useIntl();
const handleInstall = () => {
onInstall(template);
};
const handleDelete = () => {
onDelete(template.name);
};
// Get icon based on category
const getCategoryIcon = () => {
const iconProps = { className: 'w-4 h-4' };
switch (template.category?.toLowerCase()) {
case 'stdio':
case 'transport':
return <FileCode {...iconProps} />;
case 'language':
case 'python':
case 'node':
return <Folder {...iconProps} />;
case 'official':
case 'builtin':
return <Globe {...iconProps} />;
default:
return <Copy {...iconProps} />;
}
};
return (
<Card className="p-4 hover:border-primary/50 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="p-2 rounded-lg bg-muted flex-shrink-0">
{getCategoryIcon()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">
{template.name}
</span>
{template.category && (
<Badge variant="secondary" className="text-xs">
{template.category}
</Badge>
)}
</div>
{template.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
{template.description}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<code className="text-xs bg-muted px-2 py-1 rounded font-mono truncate max-w-[150px]">
{template.serverConfig.command}
</code>
{template.serverConfig.args && template.serverConfig.args.length > 0 && (
<Badge variant="outline" className="text-xs">
{template.serverConfig.args.length} args
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={handleInstall}
disabled={isInstalling}
title={formatMessage({ id: 'mcp.templates.actions.install' })}
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={isDeleting}
title={formatMessage({ id: 'mcp.templates.actions.delete' })}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
);
}
/**
* Template Save Dialog - Save current server configuration as template
*/
function TemplateSaveDialog({
open,
onClose,
onSave,
defaultName = '',
}: TemplateSaveDialogProps) {
const { formatMessage } = useIntl();
const [name, setName] = useState(defaultName);
const [category, setCategory] = useState('');
const [description, setDescription] = useState('');
const [errors, setErrors] = useState<{ name?: string }>({});
// Reset form when dialog opens
useEffect(() => {
if (open) {
setName(defaultName || '');
setCategory('');
setDescription('');
setErrors({});
}
}, [open, defaultName]);
const handleSave = () => {
if (!name.trim()) {
setErrors({ name: formatMessage({ id: 'mcp.templates.saveDialog.validation.nameRequired' }) });
return;
}
onSave(name.trim(), category.trim(), description.trim());
setName('');
setCategory('');
setDescription('');
setErrors({});
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{formatMessage({ id: 'mcp.templates.saveDialog.title' })}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Name */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.templates.saveDialog.name' })}
<span className="text-destructive ml-1">*</span>
</label>
<Input
value={name}
onChange={(e) => {
setName(e.target.value);
if (errors.name) setErrors({});
}}
placeholder={formatMessage({ id: 'mcp.templates.saveDialog.namePlaceholder' })}
error={!!errors.name}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name}</p>
)}
</div>
{/* Category */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.templates.saveDialog.category' })}
</label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger className="w-full">
<SelectValue placeholder={formatMessage({ id: 'mcp.templates.saveDialog.categoryPlaceholder' })} />
</SelectTrigger>
<SelectContent>
<SelectItem value="stdio">STDIO</SelectItem>
<SelectItem value="sse">SSE</SelectItem>
<SelectItem value="language">Language</SelectItem>
<SelectItem value="official">Official</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
{/* Description */}
<div className="space-y-2">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.templates.saveDialog.description' })}
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={formatMessage({ id: 'mcp.templates.saveDialog.descriptionPlaceholder' })}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
{formatMessage({ id: 'mcp.templates.saveDialog.cancel' })}
</Button>
<Button onClick={handleSave}>
{formatMessage({ id: 'mcp.templates.saveDialog.save' })}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ========== Main Component ==========
/**
* McpTemplatesSection - Main template management component
*
* Features:
* - Template list view with search and category filter
* - Install template action (populates McpServerDialog)
* - Delete template with confirmation
* - Save current server as template
*/
export function McpTemplatesSection({ onInstallTemplate, onSaveAsTemplate }: McpTemplatesSectionProps) {
const { formatMessage } = useIntl();
// State
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [templateToDelete, setTemplateToDelete] = useState<string | null>(null);
// Fetch categories for filter dropdown
const { data: categories = [], isLoading: categoriesLoading } = useQuery({
queryKey: mcpTemplatesKeys.categories(),
queryFn: fetchMcpTemplateCategories,
staleTime: 10 * 60 * 1000, // 10 minutes
});
// Mutations
const deleteMutation = useDeleteTemplate();
const installMutation = useInstallTemplate();
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchQuery);
}, SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [searchQuery]);
// Fetch templates based on search and category filter
const { data: templates = [], isLoading: templatesLoading } = useQuery({
queryKey: mcpTemplatesKeys.list(selectedCategory === 'all' ? undefined : selectedCategory),
queryFn: async () => {
if (debouncedSearch.trim()) {
return searchMcpTemplates(debouncedSearch.trim());
} else if (selectedCategory === 'all') {
// Fetch all templates by iterating categories
const allTemplates: McpTemplate[] = [];
for (const category of categories) {
const categoryTemplates = await fetchMcpTemplatesByCategory(category);
allTemplates.push(...categoryTemplates);
}
return allTemplates;
} else {
return fetchMcpTemplatesByCategory(selectedCategory);
}
},
staleTime: 5 * 60 * 1000, // 5 minutes
enabled: !categoriesLoading,
});
// Handlers
const handleInstallTemplate = useCallback((template: McpTemplate) => {
// Call parent callback to open McpServerDialog with template data
onInstallTemplate?.(template);
}, [onInstallTemplate]);
const handleDeleteClick = useCallback((templateName: string) => {
setTemplateToDelete(templateName);
setDeleteConfirmOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!templateToDelete) return;
const result = await deleteMutation.deleteTemplate(templateToDelete);
if (result.success) {
setDeleteConfirmOpen(false);
setTemplateToDelete(null);
}
}, [templateToDelete, deleteMutation]);
const handleSaveTemplate = useCallback((_name: string, _category: string, _description: string) => {
onSaveAsTemplate?.(_name, { command: '', args: [] });
setSaveDialogOpen(false);
}, [onSaveAsTemplate]);
return (
<div className="space-y-4">
{/* Header with search and filter */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-1">
{/* Search Input */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={formatMessage({ id: 'mcp.templates.searchPlaceholder' })}
className="pl-9"
/>
</div>
{/* Category Filter */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger className="w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{formatMessage({ id: 'mcp.templates.filter.allCategories' })}
</SelectItem>
{categories.map((category) => (
<SelectItem key={category} value={category}>
{category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Save Template Button */}
<Button
variant="outline"
size="sm"
onClick={() => setSaveDialogOpen(true)}
>
<Plus className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.templates.actions.saveAsTemplate' })}
</Button>
</div>
{/* Template List */}
{templatesLoading ? (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'mcp.templates.loading' })}
</p>
</div>
) : templates.length === 0 ? (
<Card className="p-12 text-center">
<Copy className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">
{formatMessage({ id: 'mcp.templates.empty.title' })}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{formatMessage({ id: 'mcp.templates.empty.message' })}
</p>
<Button onClick={() => setSaveDialogOpen(true)}>
<Plus className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.templates.empty.createFirst' })}
</Button>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{templates.map((template) => (
<TemplateCard
key={template.name}
template={template}
onInstall={handleInstallTemplate}
onDelete={handleDeleteClick}
isInstalling={installMutation.isInstalling}
isDeleting={deleteMutation.isDeleting}
/>
))}
</div>
)}
{/* Save Template Dialog */}
<TemplateSaveDialog
open={saveDialogOpen}
onClose={() => setSaveDialogOpen(false)}
onSave={handleSaveTemplate}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{formatMessage({ id: 'mcp.templates.deleteDialog.title' })}
</AlertDialogTitle>
<AlertDialogDescription>
{formatMessage(
{ id: 'mcp.templates.deleteDialog.message' },
{ name: templateToDelete }
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{formatMessage({ id: 'mcp.templates.deleteDialog.cancel' })}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleteMutation.isDeleting
? formatMessage({ id: 'mcp.templates.deleteDialog.deleting' })
: formatMessage({ id: 'mcp.templates.deleteDialog.delete' })
}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
export default McpTemplatesSection;

View File

@@ -0,0 +1,239 @@
// ========================================
// Other Projects Section Component
// ========================================
// Section for discovering and importing servers from other projects
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { FolderOpen, Copy, ChevronRight, RefreshCw } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import { useProjectOperations, useMcpServerMutations } from '@/hooks';
import { cn } from '@/lib/utils';
import type { McpServer } from '@/lib/api';
// ========== Types ==========
export interface OtherProjectServer extends McpServer {
projectPath: string;
projectName: string;
}
export interface OtherProjectsSectionProps {
/** Callback when server is successfully imported */
onImportSuccess?: (serverName: string, sourceProject: string) => void;
/** Additional class name */
className?: string;
}
// ========== Component ==========
export function OtherProjectsSection({
onImportSuccess,
className,
}: OtherProjectsSectionProps) {
const { formatMessage } = useIntl();
const [selectedProjectPath, setSelectedProjectPath] = useState<string | null>(null);
const [otherServers, setOtherServers] = useState<OtherProjectServer[]>([]);
const [isFetchingServers, setIsFetchingServers] = useState(false);
const { projects, currentProject, fetchOtherServers, isFetchingServers: isGlobalFetching } =
useProjectOperations();
const { createServer, isCreating } = useMcpServerMutations();
// Get available projects (excluding current)
const availableProjects = projects.filter((p) => p !== currentProject);
// Handle project selection
const handleProjectSelect = async (projectPath: string) => {
setSelectedProjectPath(projectPath);
setIsFetchingServers(true);
try {
const response = await fetchOtherServers([projectPath]);
const servers: OtherProjectServer[] = [];
for (const [path, serverList] of Object.entries(response.servers)) {
const projectName = path.split(/[/\\]/).filter(Boolean).pop() || path;
for (const server of (serverList as McpServer[])) {
servers.push({
...server,
projectPath: path,
projectName,
});
}
}
setOtherServers(servers);
} catch (error) {
console.error('Failed to fetch other projects servers:', error);
setOtherServers([]);
} finally {
setIsFetchingServers(false);
}
};
// Handle server import
const handleImportServer = async (server: OtherProjectServer) => {
try {
// Generate a unique name by combining project name and server name
const uniqueName = `${server.projectName}-${server.name}`.toLowerCase().replace(/\s+/g, '-');
await createServer({
command: server.command,
args: server.args,
env: server.env,
scope: 'project',
enabled: server.enabled,
});
onImportSuccess?.(uniqueName, server.projectPath);
} catch (error) {
console.error('Failed to import server:', error);
}
};
const isLoading = isFetchingServers || isGlobalFetching || isCreating;
const selectedProjectName = selectedProjectPath
? selectedProjectPath.split(/[/\\]/).filter(Boolean).pop()
: null;
return (
<Card className={cn('p-4', className)}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<FolderOpen className="w-5 h-5 text-primary" />
<h3 className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.otherProjects.title' })}
</h3>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => selectedProjectPath && handleProjectSelect(selectedProjectPath)}
disabled={!selectedProjectPath || isLoading}
className="h-8"
>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground mb-4">
{formatMessage({ id: 'mcp.otherProjects.description' })}
</p>
{/* Project Selector */}
<div className="mb-4">
<label className="text-sm font-medium text-foreground mb-2 block">
{formatMessage({ id: 'mcp.otherProjects.selectProject' })}
</label>
<Select value={selectedProjectPath ?? ''} onValueChange={handleProjectSelect}>
<SelectTrigger className="w-full">
<SelectValue
placeholder={formatMessage({ id: 'mcp.otherProjects.selectProjectPlaceholder' })}
/>
</SelectTrigger>
<SelectContent>
{availableProjects.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground text-center">
{formatMessage({ id: 'mcp.otherProjects.noProjects' })}
</div>
) : (
availableProjects.map((path) => {
const name = path.split(/[/\\]/).filter(Boolean).pop() || path;
return (
<SelectItem key={path} value={path}>
<div className="flex items-center gap-2">
<FolderOpen className="w-4 h-4 text-muted-foreground" />
<span className="truncate">{name}</span>
</div>
</SelectItem>
);
})
)}
</SelectContent>
</Select>
</div>
{/* Servers List */}
{selectedProjectPath && (
<div className="space-y-2">
{isFetchingServers ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin text-muted-foreground">-</div>
<span className="ml-2 text-sm text-muted-foreground">
{formatMessage({ id: 'common.actions.loading' })}
</span>
</div>
) : otherServers.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground border border-dashed rounded-lg">
{formatMessage(
{ id: 'mcp.otherProjects.noServers' },
{ project: selectedProjectName }
)}
</div>
) : (
<div className="space-y-2 max-h-60 overflow-y-auto">
{otherServers.map((server) => (
<div
key={`${server.projectPath}-${server.name}`}
className="flex items-center gap-3 p-3 rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<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} {(server.args || []).join(' ')}
</p>
<p className="text-xs text-muted-foreground truncate">
<span className="font-medium">{server.projectName}</span>
<ChevronRight className="inline w-3 h-3 mx-1" />
<span className="font-mono">{server.projectPath}</span>
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleImportServer(server)}
disabled={isCreating}
className="flex-shrink-0"
>
<Copy className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.otherProjects.import' })}
</Button>
</div>
))}
</div>
)}
</div>
)}
{/* Hint */}
{selectedProjectPath && otherServers.length > 0 && (
<p className="text-xs text-muted-foreground mt-3">
{formatMessage({ id: 'mcp.otherProjects.hint' })}
</p>
)}
</Card>
);
}
export default OtherProjectsSection;

View File

@@ -0,0 +1,346 @@
// ========================================
// Recommended MCP Section Component
// ========================================
// Display recommended MCP servers with one-click install functionality
import { useState } from 'react';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
Search,
Globe,
Sparkles,
Download,
Check,
Loader2,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/Dialog';
import {
createMcpServer,
fetchMcpServers,
} from '@/lib/api';
import { mcpServersKeys } from '@/hooks';
import { useNotifications } from '@/hooks/useNotifications';
import { cn } from '@/lib/utils';
// ========== Types ==========
/**
* Recommended server configuration
*/
export interface RecommendedServer {
id: string;
name: string;
description: string;
command: string;
args: string[];
icon: React.ComponentType<{ className?: string }>;
category: 'search' | 'browser' | 'ai';
}
/**
* Props for RecommendedMcpSection component
*/
export interface RecommendedMcpSectionProps {
/** Callback when server is installed */
onInstallComplete?: () => void;
}
interface RecommendedServerCardProps {
server: RecommendedServer;
isInstalled: boolean;
isInstalling: boolean;
onInstall: (server: RecommendedServer) => void;
}
// ========== Constants ==========
/**
* Pre-configured recommended MCP servers
*/
const RECOMMENDED_SERVERS: RecommendedServer[] = [
{
id: 'ace-tool',
name: 'ACE Tool',
description: 'Advanced code search and context engine for intelligent code discovery',
command: 'mcp__ace-tool__search_context',
args: [],
icon: Search,
category: 'search',
},
{
id: 'chrome-devtools',
name: 'Chrome DevTools',
description: 'Browser automation and debugging tools for web development',
command: 'mcp__chrome-devtools',
args: [],
icon: Globe,
category: 'browser',
},
{
id: 'exa-search',
name: 'Exa Search',
description: 'AI-powered web search with real-time crawling capabilities',
command: 'mcp__exa__search',
args: [],
icon: Sparkles,
category: 'ai',
},
];
// ========== Helper Component ==========
/**
* Individual recommended server card
*/
function RecommendedServerCard({
server,
isInstalled,
isInstalling,
onInstall,
}: RecommendedServerCardProps) {
const { formatMessage } = useIntl();
const Icon = server.icon;
return (
<Card className="p-4 hover:shadow-md transition-shadow">
<div className="flex items-start gap-3">
{/* Icon */}
<div className={cn(
'p-2.5 rounded-lg',
isInstalled ? 'bg-primary/20' : 'bg-muted'
)}>
<Icon className={cn(
'w-5 h-5',
isInstalled ? 'text-primary' : 'text-muted-foreground'
)} />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="text-sm font-medium text-foreground truncate">
{server.name}
</h4>
{isInstalled && (
<Badge variant="default" className="text-xs">
{formatMessage({ id: 'mcp.recommended.actions.installed' })}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground line-clamp-2 mb-3">
{server.description}
</p>
{/* Install Button */}
{!isInstalled && (
<Button
variant="outline"
size="sm"
onClick={() => onInstall(server)}
disabled={isInstalling}
className="w-full"
>
{isInstalling ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{formatMessage({ id: 'mcp.recommended.actions.installing' })}
</>
) : (
<>
<Download className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.recommended.actions.install' })}
</>
)}
</Button>
)}
{/* Installed Indicator */}
{isInstalled && (
<div className="flex items-center gap-1 text-xs text-primary">
<Check className="w-4 h-4" />
<span>{formatMessage({ id: 'mcp.recommended.actions.installed' })}</span>
</div>
)}
</div>
</div>
</Card>
);
}
// ========== Main Component ==========
/**
* Recommended MCP servers section with one-click install
*/
export function RecommendedMcpSection({
onInstallComplete,
}: RecommendedMcpSectionProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const { success, error } = useNotifications();
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [selectedServer, setSelectedServer] = useState<RecommendedServer | null>(null);
const [installingServerId, setInstallingServerId] = useState<string | null>(null);
const [installedServerIds, setInstalledServerIds] = useState<Set<string>>(new Set());
// Check which servers are already installed
const checkInstalledServers = async () => {
try {
const data = await fetchMcpServers();
const allServers = [...data.project, ...data.global];
const installedIds = new Set(
allServers
.filter(s => s.command.startsWith('mcp__'))
.map(s => s.command)
);
setInstalledServerIds(installedIds);
} catch {
// Ignore errors during check
}
};
// Check on mount
useState(() => {
checkInstalledServers();
});
// Create server mutation
const createMutation = useMutation({
mutationFn: (server: Omit<RecommendedServer, 'id' | 'icon' | 'category'>) =>
createMcpServer({
command: server.command,
args: server.args,
scope: 'global',
enabled: true,
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
setInstalledServerIds(prev => new Set(prev).add(variables.command));
setInstallingServerId(null);
setConfirmDialogOpen(false);
setSelectedServer(null);
success(
formatMessage({ id: 'mcp.recommended.actions.installed' }),
formatMessage({ id: 'mcp.recommended.servers.' + selectedServer?.id + '.name' })
);
onInstallComplete?.();
},
onError: () => {
setInstallingServerId(null);
error(
formatMessage({ id: 'mcp.dialog.validation.nameRequired' }),
formatMessage({ id: 'mcp.dialog.validation.commandRequired' })
);
},
});
// Handle install click
const handleInstallClick = (server: RecommendedServer) => {
setSelectedServer(server);
setConfirmDialogOpen(true);
};
// Handle confirm install
const handleConfirmInstall = () => {
if (!selectedServer) return;
setInstallingServerId(selectedServer.id);
setConfirmDialogOpen(false);
createMutation.mutate(selectedServer);
};
// Check if server is installed
const isServerInstalled = (server: RecommendedServer) => {
return installedServerIds.has(server.command);
};
return (
<>
<section className="space-y-4">
{/* Header */}
<div>
<h2 className="text-lg font-semibold text-foreground">
{formatMessage({ id: 'mcp.recommended.title' })}
</h2>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'mcp.recommended.description' })}
</p>
</div>
{/* Server Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{RECOMMENDED_SERVERS.map((server) => (
<RecommendedServerCard
key={server.id}
server={server}
isInstalled={isServerInstalled(server)}
isInstalling={installingServerId === server.id}
onInstall={handleInstallClick}
/>
))}
</div>
</section>
{/* Confirmation Dialog */}
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{formatMessage({ id: 'mcp.recommended.actions.install' })} {selectedServer?.name}
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-muted-foreground">
{formatMessage(
{ id: 'mcp.recommended.description' },
{ server: selectedServer?.name }
)}
</p>
<div className="mt-4 p-3 bg-muted rounded-lg">
<code className="text-xs font-mono">
{selectedServer?.command} {selectedServer?.args.join(' ')}
</code>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmDialogOpen(false)}
disabled={createMutation.isPending}
>
{formatMessage({ id: 'mcp.dialog.actions.cancel' })}
</Button>
<Button
onClick={handleConfirmInstall}
disabled={createMutation.isPending}
>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{formatMessage({ id: 'mcp.recommended.actions.installing' })}
</>
) : (
<>
<Download className="w-4 h-4 mr-1" />
{formatMessage({ id: 'mcp.recommended.actions.install' })}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default RecommendedMcpSection;

View File

@@ -0,0 +1,272 @@
// ========================================
// Windows Compatibility Warning Component
// ========================================
// Windows-specific warning banner with command detection and auto-fix functionality
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Download, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface WindowsCompatibilityWarningProps {
/** Optional: Project path to check commands against */
projectPath?: string;
/** Optional: Callback when compatibility check completes */
onComplete?: (result: CompatibilityCheckResult) => void;
}
export interface CommandDetectionResult {
command: string;
available: boolean;
installUrl?: string;
}
export interface CompatibilityCheckResult {
isWindows: boolean;
missingCommands: string[];
commands: CommandDetectionResult[];
canAutoFix: boolean;
}
// ========== Constants ==========
const COMMON_COMMANDS = [
{ name: 'npm', installUrl: 'https://docs.npmjs.com/downloading-and-installing-node-js-and-npm' },
{ name: 'node', installUrl: 'https://nodejs.org/' },
{ name: 'python', installUrl: 'https://www.python.org/downloads/' },
{ name: 'npx', installUrl: 'https://docs.npmjs.com/downloading-and-installing-node-js-and-npm' },
];
// Helper function to check if a command can be auto-fixed
function canAutoFixCommand(command: string): boolean {
return COMMON_COMMANDS.some((cmd) => cmd.name === command);
}
// ========== API Functions ==========
async function detectWindowsCommands(projectPath?: string): Promise<CommandDetectionResult[]> {
const response = await fetch('/api/mcp/detect-commands', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectPath }),
});
if (!response.ok) {
throw new Error('Failed to detect commands');
}
return response.json();
}
async function applyWindowsAutoFix(projectPath?: string): Promise<{ success: boolean; message: string }> {
const response = await fetch('/api/mcp/apply-windows-fix', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectPath }),
});
if (!response.ok) {
throw new Error('Failed to apply Windows fix');
}
return response.json();
}
// ========== Sub-Components ==========
interface CommandDetectionListProps {
commands: CommandDetectionResult[];
}
function CommandDetectionList({ commands }: CommandDetectionListProps) {
const { formatMessage } = useIntl();
return (
<div className="space-y-2 mt-3">
{commands.map((cmd) => (
<div
key={cmd.command}
className={cn(
'flex items-center gap-2 text-sm p-2 rounded-md',
cmd.available ? 'bg-success/10' : 'bg-destructive/10'
)}
>
{cmd.available ? (
<CheckCircle className="w-4 h-4 text-success" />
) : (
<XCircle className="w-4 h-4 text-destructive" />
)}
<code className="font-mono flex-1">{cmd.command}</code>
{!cmd.available && cmd.installUrl && (
<a
href={cmd.installUrl}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<Download className="w-3 h-3" />
{formatMessage({ id: 'mcp.windows.install' })}
</a>
)}
</div>
))}
</div>
);
}
// ========== Main Component ==========
export function WindowsCompatibilityWarning({
projectPath,
onComplete,
}: WindowsCompatibilityWarningProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const [checkResult, setCheckResult] = useState<CompatibilityCheckResult | null>(null);
const [isChecking, setIsChecking] = useState(false);
const [dismissed, setDismissed] = useState(false);
// Detect Windows platform
const isWindows = typeof window !== 'undefined' && window.navigator.platform.toLowerCase().includes('win');
// Mutation for auto-fix
const autoFixMutation = useMutation({
mutationFn: () => applyWindowsAutoFix(projectPath),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['mcpServers'] });
if (onComplete && checkResult) {
onComplete({ ...checkResult, canAutoFix: false });
}
},
});
// Check compatibility on mount (Windows only)
useEffect(() => {
if (!isWindows || dismissed) return;
const checkCompatibility = async () => {
setIsChecking(true);
try {
const results = await detectWindowsCommands(projectPath);
const missingCommands = results.filter((r) => !r.available).map((r) => r.command);
const canAutoFix = missingCommands.length > 0 && missingCommands.every(canAutoFixCommand);
const result: CompatibilityCheckResult = {
isWindows: true,
missingCommands,
commands: results,
canAutoFix,
};
setCheckResult(result);
if (onComplete) {
onComplete(result);
}
} catch (error) {
console.error('Failed to check Windows compatibility:', error);
} finally {
setIsChecking(false);
}
};
checkCompatibility();
}, [isWindows, dismissed, projectPath, onComplete]);
// Don't render if not Windows, dismissed, or still checking without errors
if (!isWindows || dismissed || (isChecking && !checkResult)) {
return null;
}
// Don't show warning if all commands are available
if (checkResult && checkResult.missingCommands.length === 0) {
return null;
}
const handleAutoFix = async () => {
autoFixMutation.mutate();
};
const handleDismiss = () => {
setDismissed(true);
};
return (
<div className="border border-warning/50 bg-warning/5 rounded-lg p-4 mb-4">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning flex-shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between">
<div>
<h4 className="text-sm font-semibold text-foreground">
{formatMessage({ id: 'mcp.windows.title' })}
</h4>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'mcp.windows.description' })}
</p>
</div>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={handleDismiss}
>
×
</Button>
</div>
{checkResult && (
<>
{checkResult.missingCommands.length > 0 && (
<div className="flex items-center gap-2">
<Badge variant="warning" className="text-xs">
{formatMessage({ id: 'mcp.windows.missingCount' }, { count: checkResult.missingCommands.length })}
</Badge>
</div>
)}
<CommandDetectionList commands={checkResult.commands} />
{checkResult.canAutoFix && (
<div className="flex items-center gap-2 pt-2 border-t border-warning/20">
<Button
variant="outline"
size="sm"
onClick={handleAutoFix}
disabled={autoFixMutation.isPending}
className="text-xs"
>
{autoFixMutation.isPending ? (
<>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
{formatMessage({ id: 'mcp.windows.fixing' })}
</>
) : (
<>
<CheckCircle className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.windows.autoFix' })}
</>
)}
</Button>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: 'mcp.windows.autoFixHint' })}
</span>
</div>
)}
</>
)}
{isChecking && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
{formatMessage({ id: 'mcp.windows.checking' })}
</div>
)}
</div>
</div>
</div>
);
}
export default WindowsCompatibilityWarning;

View File

@@ -0,0 +1,260 @@
// ========================================
// Context Assembler Component
// ========================================
// Provides UI for managing context assembly rules
// with variable interpolation templates
import * as React from "react";
import { useIntl } from "react-intl";
import { Info, Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
export interface ContextRule {
nodeId: string;
label?: string;
variable?: string;
includeOutput?: boolean;
transform?: "raw" | "json" | "markdown" | "summary";
}
export interface ContextAssemblerProps {
value: string; // Template with {{node:nodeId}} or {{var:variableName}} syntax
onChange: (value: string) => void;
availableNodes: Array<{ id: string; label: string; type: string; outputVariable?: string }>;
availableVariables?: string[];
className?: string;
}
const TRANSFORM_OPTIONS = [
{ value: "raw", label: "Raw Output" },
{ value: "json", label: "JSON Format" },
{ value: "markdown", label: "Markdown" },
{ value: "summary", label: "Summary" },
] as const;
const ContextAssembler = React.forwardRef<HTMLDivElement, ContextAssemblerProps>(
({ value, onChange, availableNodes, availableVariables = [], className }, ref) => {
const { formatMessage } = useIntl();
const [showHelp, setShowHelp] = React.useState(false);
const [rules, setRules] = React.useState<ContextRule[]>([]);
// Parse template to extract existing rules
React.useEffect(() => {
const extracted: ContextRule[] = [];
const nodeRegex = /\{\{node:([^}]+)\}\}/g;
const varRegex = /\{\{var:([^}]+)\}\}/g;
let match;
while ((match = nodeRegex.exec(value)) !== null) {
const node = availableNodes.find((n) => n.id === match[1]);
extracted.push({
nodeId: match[1],
label: node?.label,
variable: node?.outputVariable,
includeOutput: true,
transform: "raw",
});
}
while ((match = varRegex.exec(value)) !== null) {
extracted.push({
nodeId: "",
variable: match[1],
includeOutput: true,
transform: "raw",
});
}
setRules(extracted);
}, [value, availableNodes]);
const updateTemplate = React.useCallback(
(newRules: ContextRule[]) => {
let template = value;
// Remove all existing node and var references
template = template.replace(/\{\{node:[^}]+\}\}/g, "").replace(/\{\{var:[^}]+\}\}/g, "");
// Build new template with rules
const sections: string[] = [];
newRules.forEach((rule, index) => {
const prefix = rule.nodeId ? "node" : "var";
const ref = rule.nodeId || rule.variable;
const node = rule.nodeId ? availableNodes.find((n) => n.id === rule.nodeId) : null;
const label = rule.label || node?.label || ref;
if (rule.includeOutput) {
sections.push(`## ${label || `Source ${index + 1}`}`);
sections.push(`{{${prefix}:${ref}}}`);
sections.push("");
}
});
onChange(sections.join("\n"));
},
[value, availableNodes, onChange]
);
const addNode = (nodeId: string) => {
const node = availableNodes.find((n) => n.id === nodeId);
if (node && !rules.find((r) => r.nodeId === nodeId)) {
const newRules = [...rules, { nodeId, label: node.label, variable: node.outputVariable, includeOutput: true, transform: "raw" }];
setRules(newRules);
updateTemplate(newRules);
}
};
const addVariable = (variableName: string) => {
if (!rules.find((r) => r.variable === variableName && !r.nodeId)) {
const newRules = [...rules, { nodeId: "", variable: variableName, includeOutput: true, transform: "raw" }];
setRules(newRules);
updateTemplate(newRules);
}
};
const removeRule = (index: number) => {
const newRules = rules.filter((_, i) => i !== index);
setRules(newRules);
updateTemplate(newRules);
};
const updateRuleTransform = (index: number, transform: ContextRule["transform"]) => {
const newRules = [...rules];
newRules[index] = { ...newRules[index], transform };
setRules(newRules);
updateTemplate(newRules);
};
return (
<div ref={ref} className={cn("space-y-3", className)}>
{/* Header with help toggle */}
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: "orchestrator.contextAssembler.title" })}
</label>
<button
type="button"
onClick={() => setShowHelp(!showHelp)}
className="text-muted-foreground hover:text-foreground"
>
<Info className="w-4 h-4" />
</button>
</div>
{/* Help panel */}
{showHelp && (
<div className="p-3 rounded-md bg-muted/50 border border-border text-xs text-muted-foreground">
<p className="mb-2 font-medium text-foreground">
{formatMessage({ id: "orchestrator.contextAssembler.helpTitle" })}
</p>
<ul className="space-y-1 list-disc list-inside">
<li>{formatMessage({ id: "orchestrator.contextAssembler.helpSyntax1" })}</li>
<li>{formatMessage({ id: "orchestrator.contextAssembler.helpSyntax2" })}</li>
<li>{formatMessage({ id: "orchestrator.contextAssembler.helpSyntax3" })}</li>
</ul>
</div>
)}
{/* Current rules list */}
{rules.length > 0 && (
<div className="space-y-2">
{rules.map((rule, index) => {
const node = rule.nodeId ? availableNodes.find((n) => n.id === rule.nodeId) : null;
const label = rule.label || node?.label || rule.variable || `Source ${index + 1}`;
return (
<div key={index} className="flex items-center gap-2 p-2 rounded-md border border-border bg-background">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground truncate">{label}</div>
<div className="text-xs text-muted-foreground">
{rule.nodeId ? `{{node:${rule.nodeId}}}` : `{{var:${rule.variable}}}`}
</div>
</div>
<select
value={rule.transform || "raw"}
onChange={(e) => updateRuleTransform(index, e.target.value as ContextRule["transform"])}
className="h-8 px-2 text-xs rounded border border-border bg-background"
>
{TRANSFORM_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
type="button"
onClick={() => removeRule(index)}
className="p-1 text-muted-foreground hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
);
})}
</div>
)}
{/* Add node selector */}
<div>
<label className="block text-xs text-muted-foreground mb-1">
{formatMessage({ id: "orchestrator.contextAssembler.addNode" })}
</label>
<select
value=""
onChange={(e) => e.target.value && addNode(e.target.value)}
className="w-full h-9 px-2 text-sm rounded-md border border-border bg-background"
>
<option value="">{formatMessage({ id: "orchestrator.contextAssembler.selectNode" })}</option>
{availableNodes
.filter((n) => !rules.find((r) => r.nodeId === n.id))
.map((node) => (
<option key={node.id} value={node.id}>
{node.label || node.id} ({node.type})
</option>
))}
</select>
</div>
{/* Add variable selector */}
{availableVariables.length > 0 && (
<div>
<label className="block text-xs text-muted-foreground mb-1">
{formatMessage({ id: "orchestrator.contextAssembler.addVariable" })}
</label>
<select
value=""
onChange={(e) => e.target.value && addVariable(e.target.value)}
className="w-full h-9 px-2 text-sm rounded-md border border-border bg-background"
>
<option value="">{formatMessage({ id: "orchestrator.contextAssembler.selectVariable" })}</option>
{availableVariables
.filter((v) => !rules.find((r) => r.variable === v && !r.nodeId))
.map((variableName) => (
<option key={variableName} value={variableName}>
{`{{${variableName}}}`}
</option>
))}
</select>
</div>
)}
{/* Manual template editor */}
<div>
<label className="block text-xs text-muted-foreground mb-1">
{formatMessage({ id: "orchestrator.contextAssembler.manualEdit" })}
</label>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={formatMessage({ id: "orchestrator.propertyPanel.placeholders.contextTemplate" })}
className="w-full h-24 px-3 py-2 rounded-md border border-border bg-background text-sm resize-none font-mono"
/>
</div>
</div>
);
}
);
ContextAssembler.displayName = "ContextAssembler";
export { ContextAssembler };