feat: add experimental support for AST parsing and static graph indexing

- Introduced CLI options for using AST grep parsers and enabling static graph relationships during indexing.
- Updated configuration management to load new settings for AST parsing and static graph types.
- Enhanced AST grep processor to handle imports with aliases and improve relationship tracking.
- Modified TreeSitter parsers to support synthetic module scopes for better static graph persistence.
- Implemented global relationship updates in the incremental indexer for static graph expansion.
- Added new ArtifactTag and FloatingFileBrowser components to the frontend for improved terminal dashboard functionality.
- Created utility functions for detecting CCW artifacts in terminal output with associated tests.
This commit is contained in:
catlog22
2026-02-15 23:12:06 +08:00
parent 48a6a1f2aa
commit 8938c47f88
39 changed files with 2956 additions and 297 deletions

View File

@@ -704,7 +704,7 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
const isLastOdd = currentSession.tasks!.length % 2 === 1 && index === currentSession.tasks!.length - 1;
return (
<div
key={`${currentSession.session_id}-${task.task_id}`}
key={`${currentSession.session_id}-${task.task_id}-${index}`}
className={cn(
'flex items-center gap-2 p-2 rounded hover:bg-background/50 transition-colors',
isLastOdd && 'col-span-2'

View File

@@ -23,6 +23,7 @@ import {
ChevronRight,
Globe,
Folder,
AlertTriangle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -84,6 +85,12 @@ export interface CcwToolsMcpCardProps {
onInstall: () => void;
/** Installation target: Claude or Codex */
target?: 'claude' | 'codex';
/** Scopes where CCW MCP is currently installed */
installedScopes?: ('global' | 'project')[];
/** Callback to uninstall from a specific scope */
onUninstallScope?: (scope: 'global' | 'project') => void;
/** Callback to install to an additional scope */
onInstallToScope?: (scope: 'global' | 'project') => void;
}
// ========== Constants ==========
@@ -115,6 +122,9 @@ export function CcwToolsMcpCard({
onUpdateConfig,
onInstall,
target = 'claude',
installedScopes = [],
onUninstallScope,
onInstallToScope,
}: CcwToolsMcpCardProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
@@ -242,9 +252,26 @@ export function CcwToolsMcpCard({
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'mcp.ccw.title' })}
</span>
<Badge variant={isInstalled ? 'default' : 'secondary'} className="text-xs">
{isInstalled ? formatMessage({ id: 'mcp.ccw.status.installed' }) : formatMessage({ id: 'mcp.ccw.status.notInstalled' })}
</Badge>
{isInstalled && installedScopes.length > 0 ? (
<>
{installedScopes.map((s) => (
<Badge key={s} variant="default" className="text-xs">
{s === 'global' ? <Globe className="w-3 h-3 mr-1" /> : <Folder className="w-3 h-3 mr-1" />}
{formatMessage({ id: `mcp.ccw.scope.${s}` })}
</Badge>
))}
{installedScopes.length >= 2 && (
<Badge variant="outline" className="text-xs text-orange-500 border-orange-300">
<AlertTriangle className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.conflict.badge' })}
</Badge>
)}
</>
) : (
<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
@@ -425,7 +452,7 @@ export function CcwToolsMcpCard({
{/* Install/Uninstall Button */}
<div className="pt-3 border-t border-border space-y-3">
{/* Scope Selection - Claude only (Codex is always global) */}
{/* Scope Selection - Claude only, only when not installed */}
{!isInstalled && !isCodex && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase">
@@ -465,6 +492,20 @@ export function CcwToolsMcpCard({
{formatMessage({ id: 'mcp.ccw.codexNote' })}
</p>
)}
{/* Dual-scope conflict warning */}
{isInstalled && !isCodex && installedScopes.length >= 2 && (
<div className="p-3 bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 rounded-lg space-y-1">
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">{formatMessage({ id: 'mcp.conflict.title' })}</span>
</div>
<p className="text-xs text-orange-600 dark:text-orange-400/80">
{formatMessage({ id: 'mcp.conflict.description' }, { scope: formatMessage({ id: 'mcp.scope.global' }) })}
</p>
</div>
)}
{!isInstalled ? (
<Button
onClick={handleInstallClick}
@@ -476,7 +517,8 @@ export function CcwToolsMcpCard({
: formatMessage({ id: isCodex ? 'mcp.ccw.actions.installCodex' : 'mcp.ccw.actions.install' })
}
</Button>
) : (
) : isCodex ? (
/* Codex: single uninstall button */
<Button
variant="destructive"
onClick={handleUninstallClick}
@@ -488,6 +530,63 @@ export function CcwToolsMcpCard({
: formatMessage({ id: 'mcp.ccw.actions.uninstall' })
}
</Button>
) : (
/* Claude: per-scope install/uninstall */
<div className="space-y-2">
{/* Install to missing scope */}
{installedScopes.length === 1 && onInstallToScope && (
<Button
variant="outline"
onClick={() => {
const missingScope = installedScopes.includes('global') ? 'project' : 'global';
onInstallToScope(missingScope);
}}
disabled={isPending}
className="w-full"
>
{installedScopes.includes('global')
? formatMessage({ id: 'mcp.ccw.scope.installToProject' })
: formatMessage({ id: 'mcp.ccw.scope.installToGlobal' })
}
</Button>
)}
{/* Per-scope uninstall buttons */}
{onUninstallScope && installedScopes.map((s) => (
<Button
key={s}
variant="destructive"
size="sm"
onClick={() => {
if (confirm(formatMessage({ id: 'mcp.ccw.actions.uninstallScopeConfirm' }, { scope: formatMessage({ id: `mcp.ccw.scope.${s}` }) }))) {
onUninstallScope(s);
}
}}
disabled={isPending}
className="w-full"
>
{s === 'global'
? formatMessage({ id: 'mcp.ccw.scope.uninstallGlobal' })
: formatMessage({ id: 'mcp.ccw.scope.uninstallProject' })
}
</Button>
))}
{/* Fallback: full uninstall if no scope info */}
{(!onUninstallScope || installedScopes.length === 0) && (
<Button
variant="destructive"
onClick={handleUninstallClick}
disabled={isPending}
className="w-full"
>
{isPending
? formatMessage({ id: 'mcp.ccw.actions.uninstalling' })
: formatMessage({ id: 'mcp.ccw.actions.uninstall' })
}
</Button>
)}
</div>
)}
</div>
</div>

View File

@@ -16,6 +16,10 @@ import {
TestTube,
Eye,
EyeOff,
MessageSquare,
Bell,
Users,
Mail,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -28,6 +32,10 @@ import type {
DiscordConfig,
TelegramConfig,
WebhookConfig,
FeishuConfig,
DingTalkConfig,
WeComConfig,
EmailConfig,
} from '@/types/remote-notification';
import { PLATFORM_INFO } from '@/types/remote-notification';
@@ -38,11 +46,11 @@ interface PlatformConfigCardsProps {
onToggleExpand: (platform: NotificationPlatform | null) => void;
onUpdateConfig: (
platform: NotificationPlatform,
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig>
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig>
) => void;
onTest: (
platform: NotificationPlatform,
config: DiscordConfig | TelegramConfig | WebhookConfig
config: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig
) => void;
onSave: () => void;
saving: boolean;
@@ -60,7 +68,7 @@ export function PlatformConfigCards({
}: PlatformConfigCardsProps) {
const { formatMessage } = useIntl();
const platforms: NotificationPlatform[] = ['discord', 'telegram', 'webhook'];
const platforms: NotificationPlatform[] = ['discord', 'telegram', 'feishu', 'dingtalk', 'wecom', 'email', 'webhook'];
const getPlatformIcon = (platform: NotificationPlatform) => {
switch (platform) {
@@ -68,6 +76,14 @@ export function PlatformConfigCards({
return <MessageCircle className="w-4 h-4" />;
case 'telegram':
return <Send className="w-4 h-4" />;
case 'feishu':
return <MessageSquare className="w-4 h-4" />;
case 'dingtalk':
return <Bell className="w-4 h-4" />;
case 'wecom':
return <Users className="w-4 h-4" />;
case 'email':
return <Mail className="w-4 h-4" />;
case 'webhook':
return <Link className="w-4 h-4" />;
}
@@ -75,12 +91,20 @@ export function PlatformConfigCards({
const getPlatformConfig = (
platform: NotificationPlatform
): DiscordConfig | TelegramConfig | WebhookConfig => {
): DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig => {
switch (platform) {
case 'discord':
return config.platforms.discord || { enabled: false, webhookUrl: '' };
case 'telegram':
return config.platforms.telegram || { enabled: false, botToken: '', chatId: '' };
case 'feishu':
return config.platforms.feishu || { enabled: false, webhookUrl: '' };
case 'dingtalk':
return config.platforms.dingtalk || { enabled: false, webhookUrl: '' };
case 'wecom':
return config.platforms.wecom || { enabled: false, webhookUrl: '' };
case 'email':
return config.platforms.email || { enabled: false, host: '', port: 587, username: '', password: '', from: '', to: [] };
case 'webhook':
return config.platforms.webhook || { enabled: false, url: '', method: 'POST' };
}
@@ -93,6 +117,15 @@ export function PlatformConfigCards({
return !!(platformConfig as DiscordConfig).webhookUrl;
case 'telegram':
return !!(platformConfig as TelegramConfig).botToken && !!(platformConfig as TelegramConfig).chatId;
case 'feishu':
return !!(platformConfig as FeishuConfig).webhookUrl;
case 'dingtalk':
return !!(platformConfig as DingTalkConfig).webhookUrl;
case 'wecom':
return !!(platformConfig as WeComConfig).webhookUrl;
case 'email':
const emailConfig = platformConfig as EmailConfig;
return !!(emailConfig.host && emailConfig.username && emailConfig.password && emailConfig.from && emailConfig.to?.length > 0);
case 'webhook':
return !!(platformConfig as WebhookConfig).url;
}
@@ -176,6 +209,30 @@ export function PlatformConfigCards({
onUpdate={(updates) => onUpdateConfig('telegram', updates)}
/>
)}
{platform === 'feishu' && (
<FeishuConfigForm
config={platformConfig as FeishuConfig}
onUpdate={(updates) => onUpdateConfig('feishu', updates)}
/>
)}
{platform === 'dingtalk' && (
<DingTalkConfigForm
config={platformConfig as DingTalkConfig}
onUpdate={(updates) => onUpdateConfig('dingtalk', updates)}
/>
)}
{platform === 'wecom' && (
<WeComConfigForm
config={platformConfig as WeComConfig}
onUpdate={(updates) => onUpdateConfig('wecom', updates)}
/>
)}
{platform === 'email' && (
<EmailConfigForm
config={platformConfig as EmailConfig}
onUpdate={(updates) => onUpdateConfig('email', updates)}
/>
)}
{platform === 'webhook' && (
<WebhookConfigForm
config={platformConfig as WebhookConfig}
@@ -393,4 +450,303 @@ function WebhookConfigForm({
);
}
// ========== Feishu Config Form ==========
function FeishuConfigForm({
config,
onUpdate,
}: {
config: FeishuConfig;
onUpdate: (updates: Partial<FeishuConfig>) => void;
}) {
const { formatMessage } = useIntl();
const [showUrl, setShowUrl] = useState(false);
return (
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.feishu.webhookUrl' })}
</label>
<div className="flex gap-2 mt-1">
<Input
type={showUrl ? 'text' : 'password'}
value={config.webhookUrl || ''}
onChange={(e) => onUpdate({ webhookUrl: e.target.value })}
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
className="flex-1"
/>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => setShowUrl(!showUrl)}
>
{showUrl ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'settings.remoteNotifications.feishu.webhookUrlHint' })}
</p>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="feishu-useCard"
checked={config.useCard || false}
onChange={(e) => onUpdate({ useCard: e.target.checked })}
className="rounded border-border"
/>
<label htmlFor="feishu-useCard" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.feishu.useCard' })}
</label>
</div>
<p className="text-xs text-muted-foreground -mt-2">
{formatMessage({ id: 'settings.remoteNotifications.feishu.useCardHint' })}
</p>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.feishu.title' })}
</label>
<Input
value={config.title || ''}
onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="CCW Notification"
className="mt-1"
/>
</div>
</div>
);
}
// ========== DingTalk Config Form ==========
function DingTalkConfigForm({
config,
onUpdate,
}: {
config: DingTalkConfig;
onUpdate: (updates: Partial<DingTalkConfig>) => void;
}) {
const { formatMessage } = useIntl();
const [showUrl, setShowUrl] = useState(false);
return (
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.dingtalk.webhookUrl' })}
</label>
<div className="flex gap-2 mt-1">
<Input
type={showUrl ? 'text' : 'password'}
value={config.webhookUrl || ''}
onChange={(e) => onUpdate({ webhookUrl: e.target.value })}
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
className="flex-1"
/>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => setShowUrl(!showUrl)}
>
{showUrl ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'settings.remoteNotifications.dingtalk.webhookUrlHint' })}
</p>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.dingtalk.keywords' })}
</label>
<Input
value={config.keywords?.join(', ') || ''}
onChange={(e) => onUpdate({ keywords: e.target.value.split(',').map(k => k.trim()).filter(Boolean) })}
placeholder="keyword1, keyword2"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'settings.remoteNotifications.dingtalk.keywordsHint' })}
</p>
</div>
</div>
);
}
// ========== WeCom Config Form ==========
function WeComConfigForm({
config,
onUpdate,
}: {
config: WeComConfig;
onUpdate: (updates: Partial<WeComConfig>) => void;
}) {
const { formatMessage } = useIntl();
const [showUrl, setShowUrl] = useState(false);
return (
<div className="space-y-3">
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.wecom.webhookUrl' })}
</label>
<div className="flex gap-2 mt-1">
<Input
type={showUrl ? 'text' : 'password'}
value={config.webhookUrl || ''}
onChange={(e) => onUpdate({ webhookUrl: e.target.value })}
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
className="flex-1"
/>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => setShowUrl(!showUrl)}
>
{showUrl ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'settings.remoteNotifications.wecom.webhookUrlHint' })}
</p>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.wecom.mentionedList' })}
</label>
<Input
value={config.mentionedList?.join(', ') || ''}
onChange={(e) => onUpdate({ mentionedList: e.target.value.split(',').map(m => m.trim()).filter(Boolean) })}
placeholder="userid1, userid2, @all"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'settings.remoteNotifications.wecom.mentionedListHint' })}
</p>
</div>
</div>
);
}
// ========== Email Config Form ==========
function EmailConfigForm({
config,
onUpdate,
}: {
config: EmailConfig;
onUpdate: (updates: Partial<EmailConfig>) => void;
}) {
const { formatMessage } = useIntl();
const [showPassword, setShowPassword] = useState(false);
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.email.host' })}
</label>
<Input
value={config.host || ''}
onChange={(e) => onUpdate({ host: e.target.value })}
placeholder="smtp.gmail.com"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'settings.remoteNotifications.email.hostHint' })}
</p>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.email.port' })}
</label>
<Input
type="number"
value={config.port || 587}
onChange={(e) => onUpdate({ port: parseInt(e.target.value, 10) || 587 })}
placeholder="587"
className="mt-1"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="email-secure"
checked={config.secure || false}
onChange={(e) => onUpdate({ secure: e.target.checked })}
className="rounded border-border"
/>
<label htmlFor="email-secure" className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.email.secure' })}
</label>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.email.username' })}
</label>
<Input
value={config.username || ''}
onChange={(e) => onUpdate({ username: e.target.value })}
placeholder="your-email@gmail.com"
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.email.password' })}
</label>
<div className="flex gap-2 mt-1">
<Input
type={showPassword ? 'text' : 'password'}
value={config.password || ''}
onChange={(e) => onUpdate({ password: e.target.value })}
placeholder="********"
className="flex-1"
/>
<Button
variant="outline"
size="sm"
className="shrink-0"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
</div>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.email.from' })}
</label>
<Input
value={config.from || ''}
onChange={(e) => onUpdate({ from: e.target.value })}
placeholder="noreply@example.com"
className="mt-1"
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">
{formatMessage({ id: 'settings.remoteNotifications.email.to' })}
</label>
<Input
value={config.to?.join(', ') || ''}
onChange={(e) => onUpdate({ to: e.target.value.split(',').map(t => t.trim()).filter(Boolean) })}
placeholder="user1@example.com, user2@example.com"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
{formatMessage({ id: 'settings.remoteNotifications.email.toHint' })}
</p>
</div>
</div>
);
}
export default PlatformConfigCards;

View File

@@ -11,15 +11,13 @@ import {
RefreshCw,
Check,
X,
Save,
ChevronDown,
ChevronUp,
TestTube,
Save,
AlertTriangle,
Plus,
} 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 { cn } from '@/lib/utils';
import { toast } from 'sonner';
@@ -30,6 +28,10 @@ import type {
DiscordConfig,
TelegramConfig,
WebhookConfig,
FeishuConfig,
DingTalkConfig,
WeComConfig,
EmailConfig,
} from '@/types/remote-notification';
import { PLATFORM_INFO, EVENT_INFO, getDefaultConfig } from '@/types/remote-notification';
import { PlatformConfigCards } from './PlatformConfigCards';
@@ -45,6 +47,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState<NotificationPlatform | null>(null);
const [expandedPlatform, setExpandedPlatform] = useState<NotificationPlatform | null>(null);
const [expandedEvent, setExpandedEvent] = useState<number | null>(null);
// Load configuration
const loadConfig = useCallback(async () => {
@@ -97,7 +100,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
// Test platform
const testPlatform = useCallback(async (
platform: NotificationPlatform,
platformConfig: DiscordConfig | TelegramConfig | WebhookConfig
platformConfig: DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig
) => {
setTesting(platform);
try {
@@ -136,7 +139,7 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
// Update platform config
const updatePlatformConfig = (
platform: NotificationPlatform,
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig>
updates: Partial<DiscordConfig | TelegramConfig | WebhookConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig>
) => {
if (!config) return;
const newConfig = {
@@ -160,6 +163,19 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
setConfig({ ...config, events: newEvents });
};
// Toggle platform for event
const toggleEventPlatform = (eventIndex: number, platform: NotificationPlatform) => {
if (!config) return;
const eventConfig = config.events[eventIndex];
const platforms = eventConfig.platforms.includes(platform)
? eventConfig.platforms.filter((p) => p !== platform)
: [...eventConfig.platforms, platform];
updateEventConfig(eventIndex, { platforms });
};
// All available platforms
const allPlatforms: NotificationPlatform[] = ['discord', 'telegram', 'feishu', 'dingtalk', 'wecom', 'email', 'webhook'];
// Reset to defaults
const resetConfig = async () => {
if (!confirm(formatMessage({ id: 'settings.remoteNotifications.resetConfirm' }))) {
@@ -266,51 +282,107 @@ export function RemoteNotificationSection({ className }: RemoteNotificationSecti
<div className="grid gap-3">
{config.events.map((eventConfig, index) => {
const info = EVENT_INFO[eventConfig.event];
const isExpanded = expandedEvent === index;
return (
<div
key={eventConfig.event}
className="flex items-center justify-between p-3 rounded-lg border border-border bg-muted/30"
className="rounded-lg border border-border bg-muted/30 overflow-hidden"
>
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
eventConfig.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}>
<span className="text-sm">{info.icon}</span>
{/* Event Header */}
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => setExpandedEvent(isExpanded ? null : index)}
>
<div className="flex items-center gap-3">
<div className={cn(
'p-2 rounded-lg',
eventConfig.enabled ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'
)}>
<span className="text-sm">{info.icon}</span>
</div>
<div>
<p className="text-sm font-medium">{info.name}</p>
<p className="text-xs text-muted-foreground">{info.description}</p>
</div>
</div>
<div>
<p className="text-sm font-medium">{info.name}</p>
<p className="text-xs text-muted-foreground">{info.description}</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Platform badges */}
<div className="flex gap-1">
{eventConfig.platforms.map((platform) => (
<Badge key={platform} variant="secondary" className="text-xs">
{PLATFORM_INFO[platform].name}
</Badge>
))}
{eventConfig.platforms.length === 0 && (
<Badge variant="outline" className="text-xs text-muted-foreground">
{formatMessage({ id: 'settings.remoteNotifications.noPlatforms' })}
</Badge>
)}
</div>
{/* Toggle */}
<Button
variant={eventConfig.enabled ? 'default' : 'outline'}
size="sm"
className="h-7"
onClick={() => updateEventConfig(index, { enabled: !eventConfig.enabled })}
>
{eventConfig.enabled ? (
<Check className="w-3.5 h-3.5" />
<div className="flex items-center gap-2">
{/* Platform badges */}
<div className="flex gap-1 flex-wrap max-w-xs">
{eventConfig.platforms.slice(0, 3).map((platform) => (
<Badge key={platform} variant="secondary" className="text-xs">
{PLATFORM_INFO[platform].name}
</Badge>
))}
{eventConfig.platforms.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{eventConfig.platforms.length - 3}
</Badge>
)}
{eventConfig.platforms.length === 0 && (
<Badge variant="outline" className="text-xs text-muted-foreground">
{formatMessage({ id: 'settings.remoteNotifications.noPlatforms' })}
</Badge>
)}
</div>
{/* Toggle */}
<Button
variant={eventConfig.enabled ? 'default' : 'outline'}
size="sm"
className="h-7"
onClick={(e) => {
e.stopPropagation();
updateEventConfig(index, { enabled: !eventConfig.enabled });
}}
>
{eventConfig.enabled ? (
<Check className="w-3.5 h-3.5" />
) : (
<X className="w-3.5 h-3.5" />
)}
</Button>
{/* Expand icon */}
{isExpanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<X className="w-3.5 h-3.5" />
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</Button>
</div>
</div>
{/* Expanded Content - Platform Selection */}
{isExpanded && (
<div className="border-t border-border p-4 space-y-3 bg-muted/20">
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'settings.remoteNotifications.selectPlatforms' })}
</p>
<div className="flex flex-wrap gap-2">
{allPlatforms.map((platform) => {
const isSelected = eventConfig.platforms.includes(platform);
const platformInfo = PLATFORM_INFO[platform];
const platformConfig = config.platforms[platform];
const isConfigured = platformConfig?.enabled;
return (
<Button
key={platform}
variant={isSelected ? 'default' : 'outline'}
size="sm"
className={cn(
'h-8',
!isConfigured && !isSelected && 'opacity-50'
)}
onClick={() => toggleEventPlatform(index, platform)}
>
{isSelected && <Check className="w-3 h-3 mr-1" />}
{platformInfo.name}
{!isConfigured && !isSelected && (
<Plus className="w-3 h-3 ml-1 opacity-50" />
)}
</Button>
);
})}
</div>
</div>
)}
</div>
);
})}

View File

@@ -0,0 +1,78 @@
// ========================================
// ArtifactTag Component
// ========================================
// Colored, clickable tag for detected CCW artifacts in terminal output.
import { useIntl } from 'react-intl';
import { cn } from '@/lib/utils';
import { badgeVariants } from '@/components/ui/Badge';
import type { ArtifactType } from '@/lib/ccw-artifacts';
export interface ArtifactTagProps {
type: ArtifactType;
path: string;
onClick?: (path: string) => void;
className?: string;
}
function getVariant(type: ArtifactType) {
switch (type) {
case 'workflow-session':
return 'info';
case 'lite-session':
return 'success';
case 'claude-md':
return 'review';
case 'ccw-config':
return 'warning';
case 'issue':
return 'destructive';
default:
return 'secondary';
}
}
function getLabelId(type: ArtifactType): string {
switch (type) {
case 'workflow-session':
return 'terminalDashboard.artifacts.types.workflowSession';
case 'lite-session':
return 'terminalDashboard.artifacts.types.liteSession';
case 'claude-md':
return 'terminalDashboard.artifacts.types.claudeMd';
case 'ccw-config':
return 'terminalDashboard.artifacts.types.ccwConfig';
case 'issue':
return 'terminalDashboard.artifacts.types.issue';
}
}
function basename(p: string): string {
const parts = p.split(/[\\/]/g);
return parts[parts.length - 1] || p;
}
export function ArtifactTag({ type, path, onClick, className }: ArtifactTagProps) {
const { formatMessage } = useIntl();
const label = formatMessage({ id: getLabelId(type) });
const display = basename(path);
return (
<button
type="button"
className={cn(
badgeVariants({ variant: getVariant(type) as any }),
'gap-1 cursor-pointer hover:opacity-90 active:opacity-100',
'px-2 py-0.5 text-[11px] font-semibold',
className
)}
onClick={() => onClick?.(path)}
title={path}
>
<span>{label}</span>
<span className="opacity-80 font-mono">{display}</span>
</button>
);
}
export default ArtifactTag;

View File

@@ -0,0 +1,191 @@
// ========================================
// FloatingFileBrowser Component
// ========================================
// Floating file browser panel for Terminal Dashboard.
import * as React from 'react';
import { useIntl } from 'react-intl';
import { Copy, ArrowRightToLine, Loader2, RefreshCw } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FloatingPanel } from './FloatingPanel';
import { Button } from '@/components/ui/Button';
import { TreeView } from '@/components/shared/TreeView';
import { FilePreview } from '@/components/shared/FilePreview';
import { useFileExplorer, useFileContent } from '@/hooks/useFileExplorer';
import type { FileSystemNode } from '@/types/file-explorer';
export interface FloatingFileBrowserProps {
isOpen: boolean;
onClose: () => void;
rootPath: string;
onInsertPath?: (path: string) => void;
initialSelectedPath?: string | null;
}
export function FloatingFileBrowser({
isOpen,
onClose,
rootPath,
onInsertPath,
initialSelectedPath = null,
}: FloatingFileBrowserProps) {
const { formatMessage } = useIntl();
const {
state,
rootNodes,
isLoading,
isFetching,
error,
refetch,
setSelectedFile,
toggleExpanded,
} = useFileExplorer({
rootPath,
maxDepth: 6,
enabled: isOpen,
});
const selectedPath = state.selectedFile;
const { content, isLoading: isContentLoading, error: contentError } = useFileContent(selectedPath, {
enabled: isOpen && !!selectedPath,
});
const [copied, setCopied] = React.useState(false);
React.useEffect(() => {
if (!isOpen) return;
if (initialSelectedPath) {
setSelectedFile(initialSelectedPath);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, initialSelectedPath]);
const handleNodeClick = (node: FileSystemNode) => {
if (node.type === 'file') {
setSelectedFile(node.path);
}
};
const handleCopyPath = async () => {
if (!selectedPath) return;
try {
await navigator.clipboard.writeText(selectedPath);
setCopied(true);
setTimeout(() => setCopied(false), 1200);
} catch (err) {
console.error('[FloatingFileBrowser] copy path failed:', err);
}
};
const handleInsert = () => {
if (!selectedPath) return;
onInsertPath?.(selectedPath);
};
return (
<FloatingPanel
isOpen={isOpen}
onClose={onClose}
title={formatMessage({ id: 'terminalDashboard.fileBrowser.title' })}
side="right"
width={400}
>
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center justify-between gap-2 px-3 py-2 border-b border-border bg-muted/20 shrink-0">
<div className="min-w-0">
<div className="text-[10px] text-muted-foreground">
{selectedPath
? formatMessage({ id: 'terminalDashboard.fileBrowser.selected' })
: formatMessage({ id: 'terminalDashboard.fileBrowser.noSelection' })}
</div>
<div className="text-xs font-mono truncate" title={selectedPath ?? undefined}>
{selectedPath ?? rootPath}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => void refetch()}
disabled={!isOpen || isFetching}
title={formatMessage({ id: 'common.actions.refresh' })}
>
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleCopyPath}
disabled={!selectedPath}
title={copied
? formatMessage({ id: 'terminalDashboard.fileBrowser.copied' })
: formatMessage({ id: 'terminalDashboard.fileBrowser.copyPath' })}
>
<Copy className="w-4 h-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleInsert}
disabled={!selectedPath || !onInsertPath}
title={formatMessage({ id: 'terminalDashboard.fileBrowser.insertPath' })}
>
<ArrowRightToLine className="w-4 h-4" />
</Button>
</div>
</div>
{/* Body */}
<div className="flex-1 min-h-0 flex overflow-hidden">
{/* Tree */}
<div className="w-[180px] shrink-0 border-r border-border overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="ml-2 text-xs">
{formatMessage({ id: 'terminalDashboard.fileBrowser.loading' })}
</span>
</div>
) : error ? (
<div className="p-3 text-xs text-destructive">
{formatMessage({ id: 'terminalDashboard.fileBrowser.loadFailed' })}
</div>
) : (
<TreeView
nodes={rootNodes}
expandedPaths={state.expandedPaths}
selectedPath={state.selectedFile}
onNodeClick={handleNodeClick}
onToggle={toggleExpanded}
maxDepth={0}
className={cn('py-1')}
/>
)}
</div>
{/* Preview */}
<div className="flex-1 min-w-0 overflow-hidden">
<FilePreview
fileContent={content}
isLoading={isContentLoading}
error={contentError ? String((contentError as any).message ?? contentError) : null}
className="h-full overflow-auto"
/>
</div>
</div>
</div>
</FloatingPanel>
);
}
export default FloatingFileBrowser;

View File

@@ -6,7 +6,7 @@
// XTerm instance in ref, FitAddon, ResizeObserver, batched PTY input (30ms),
// output chunk streaming from cliSessionStore.
import { useEffect, useRef, useCallback } from 'react';
import { useEffect, useRef, useCallback, useState } from 'react';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { useCliSessionStore } from '@/stores/cliSessionStore';
@@ -18,6 +18,8 @@ import {
resizeCliSession,
} from '@/lib/api';
import { cn } from '@/lib/utils';
import { detectCcArtifacts, type CcArtifact } from '@/lib/ccw-artifacts';
import { ArtifactTag } from './ArtifactTag';
// ========== Types ==========
@@ -26,11 +28,54 @@ interface TerminalInstanceProps {
sessionId: string;
/** Additional CSS classes */
className?: string;
/** Optional callback to reveal a detected artifact path (e.g. open file browser) */
onRevealPath?: (path: string) => void;
}
// ========== Component ==========
export function TerminalInstance({ sessionId, className }: TerminalInstanceProps) {
const ARTIFACT_DEBOUNCE_MS = 250;
const MAX_ARTIFACT_TAGS = 12;
function isAbsolutePath(p: string): boolean {
if (!p) return false;
if (p.startsWith('/') || p.startsWith('\\')) return true;
if (/^[A-Za-z]:[\\/]/.test(p)) return true;
if (p.startsWith('~')) return true;
return false;
}
function joinPath(base: string, relative: string): string {
const sep = base.includes('\\') ? '\\' : '/';
const b = base.replace(/[\\/]+$/, '');
const r = relative.replace(/^[\\/]+/, '');
return `${b}${sep}${r}`;
}
function resolveArtifactPath(path: string, projectPath: string | null): string {
if (!path) return path;
if (isAbsolutePath(path)) return path;
if (!projectPath) return path;
return joinPath(projectPath, path);
}
function mergeArtifacts(prev: CcArtifact[], next: CcArtifact[]): CcArtifact[] {
if (next.length === 0) return prev;
const map = new Map<string, CcArtifact>();
for (const a of prev) map.set(`${a.type}:${a.path}`, a);
let changed = false;
for (const a of next) {
const key = `${a.type}:${a.path}`;
if (map.has(key)) continue;
map.set(key, a);
changed = true;
}
if (!changed) return prev;
const merged = Array.from(map.values());
return merged.length > MAX_ARTIFACT_TAGS ? merged.slice(merged.length - MAX_ARTIFACT_TAGS) : merged;
}
export function TerminalInstance({ sessionId, className, onRevealPath }: TerminalInstanceProps) {
const projectPath = useWorkflowStore(selectProjectPath);
// cliSessionStore selectors
@@ -38,6 +83,8 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
const setBuffer = useCliSessionStore((s) => s.setBuffer);
const clearOutput = useCliSessionStore((s) => s.clearOutput);
const [artifacts, setArtifacts] = useState<CcArtifact[]>([]);
// ========== xterm Refs ==========
const terminalHostRef = useRef<HTMLDivElement | null>(null);
@@ -45,6 +92,10 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
const fitAddonRef = useRef<FitAddon | null>(null);
const lastChunkIndexRef = useRef<number>(0);
// Debounced artifact detection
const pendingArtifactTextRef = useRef<string>('');
const artifactTimerRef = useRef<number | null>(null);
// PTY input batching (30ms, matching TerminalMainArea)
const pendingInputRef = useRef<string>('');
const flushTimerRef = useRef<number | null>(null);
@@ -56,6 +107,37 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
const projectPathRef = useRef<string | null>(projectPath);
projectPathRef.current = projectPath;
const handleArtifactClick = useCallback((path: string) => {
const resolved = resolveArtifactPath(path, projectPathRef.current);
navigator.clipboard.writeText(resolved).catch((err) => {
console.error('[TerminalInstance] copy artifact path failed:', err);
});
onRevealPath?.(resolved);
}, [onRevealPath]);
const scheduleArtifactParse = useCallback((text: string) => {
if (!text) return;
pendingArtifactTextRef.current += text;
if (artifactTimerRef.current !== null) return;
artifactTimerRef.current = window.setTimeout(() => {
artifactTimerRef.current = null;
const pending = pendingArtifactTextRef.current;
pendingArtifactTextRef.current = '';
const detected = detectCcArtifacts(pending);
if (detected.length === 0) return;
setArtifacts((prev) => mergeArtifacts(prev, detected));
}, ARTIFACT_DEBOUNCE_MS);
}, []);
useEffect(() => {
return () => {
if (artifactTimerRef.current !== null) {
window.clearTimeout(artifactTimerRef.current);
artifactTimerRef.current = null;
}
};
}, []);
// ========== PTY Input Batching ==========
const flushInput = useCallback(async () => {
@@ -139,6 +221,14 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
term.reset();
term.clear();
// Reset artifact detection state per session
setArtifacts([]);
pendingArtifactTextRef.current = '';
if (artifactTimerRef.current !== null) {
window.clearTimeout(artifactTimerRef.current);
artifactTimerRef.current = null;
}
if (!sessionId) return;
clearOutput(sessionId);
@@ -164,12 +254,18 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
if (start >= chunks.length) return;
const { feedMonitor } = useSessionManagerStore.getState();
const newTextParts: string[] = [];
for (let i = start; i < chunks.length; i++) {
term.write(chunks[i].data);
feedMonitor(sessionId, chunks[i].data);
newTextParts.push(chunks[i].data);
}
lastChunkIndexRef.current = chunks.length;
}, [outputChunks, sessionId]);
if (newTextParts.length > 0) {
scheduleArtifactParse(newTextParts.join(''));
}
}, [outputChunks, sessionId, scheduleArtifactParse]);
// ResizeObserver -> fit + resize backend
useEffect(() => {
@@ -203,9 +299,21 @@ export function TerminalInstance({ sessionId, className }: TerminalInstanceProps
// ========== Render ==========
return (
<div
ref={terminalHostRef}
className={cn('h-full w-full bg-black/90', className)}
/>
<div className={cn('relative h-full w-full', className)}>
{artifacts.length > 0 && (
<div className="absolute top-2 left-2 right-2 z-10 flex flex-wrap gap-1 pointer-events-none">
{artifacts.map((a) => (
<ArtifactTag
key={`${a.type}:${a.path}`}
type={a.type}
path={a.path}
onClick={handleArtifactClick}
className="pointer-events-auto"
/>
))}
</div>
)}
<div ref={terminalHostRef} className="h-full w-full bg-black/90" />
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { useIntl } from 'react-intl';
import {
SplitSquareHorizontal,
SplitSquareVertical,
FolderOpen,
Eraser,
AlertTriangle,
X,
@@ -21,6 +22,7 @@ import {
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { TerminalInstance } from './TerminalInstance';
import { FloatingFileBrowser } from './FloatingFileBrowser';
import {
useTerminalGridStore,
selectTerminalGridPanes,
@@ -37,6 +39,8 @@ import {
} from '@/stores/issueQueueIntegrationStore';
import { useCliSessionStore } from '@/stores/cliSessionStore';
import { getAllPaneIds } from '@/lib/layout-utils';
import { sendCliSessionText } from '@/lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import type { PaneId } from '@/stores/viewerStore';
import type { TerminalStatus } from '@/types/terminal-dashboard';
@@ -75,6 +79,10 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
const isFocused = focusedPaneId === paneId;
const canClose = getAllPaneIds(layout).length > 1;
const projectPath = useWorkflowStore(selectProjectPath);
const [isFileBrowserOpen, setIsFileBrowserOpen] = useState(false);
const [initialFileBrowserPath, setInitialFileBrowserPath] = useState<string | null>(null);
// Session data
const groups = useSessionManagerStore(selectGroups);
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
@@ -146,6 +154,25 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
}
}, [paneId, sessionId, assignSession]);
const handleOpenFileBrowser = useCallback(() => {
setInitialFileBrowserPath(null);
setIsFileBrowserOpen(true);
}, []);
const handleRevealPath = useCallback((path: string) => {
setInitialFileBrowserPath(path);
setIsFileBrowserOpen(true);
}, []);
const handleInsertPath = useCallback((path: string) => {
if (!sessionId) return;
sendCliSessionText(
sessionId,
{ text: path, appendNewline: false },
projectPath ?? undefined
).catch((err) => console.error('[TerminalPane] insert path failed:', err));
}, [sessionId, projectPath]);
const handleRestart = useCallback(async () => {
if (!sessionId || isRestarting) return;
setIsRestarting(true);
@@ -291,6 +318,19 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
</button>
</>
)}
<button
onClick={handleOpenFileBrowser}
disabled={!projectPath}
className={cn(
'p-1 rounded hover:bg-muted transition-colors',
projectPath
? 'text-muted-foreground hover:text-foreground'
: 'text-muted-foreground/40 cursor-not-allowed'
)}
title={formatMessage({ id: 'terminalDashboard.fileBrowser.open' })}
>
<FolderOpen className="w-3.5 h-3.5" />
</button>
{alertCount > 0 && (
<span className="flex items-center gap-0.5 px-1 text-destructive">
<AlertTriangle className="w-3 h-3" />
@@ -314,7 +354,7 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
{/* Terminal content */}
{sessionId ? (
<div className="flex-1 min-h-0">
<TerminalInstance sessionId={sessionId} />
<TerminalInstance sessionId={sessionId} onRevealPath={handleRevealPath} />
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -329,6 +369,17 @@ export function TerminalPane({ paneId }: TerminalPaneProps) {
</div>
</div>
)}
<FloatingFileBrowser
isOpen={isFileBrowserOpen}
onClose={() => {
setIsFileBrowserOpen(false);
setInitialFileBrowserPath(null);
}}
rootPath={projectPath ?? '/'}
onInsertPath={sessionId ? handleInsertPath : undefined}
initialSelectedPath={initialFileBrowserPath}
/>
</div>
);
}

View File

@@ -21,6 +21,7 @@ import {
crossCliCopy,
type McpServer,
type McpServersResponse,
type McpServerConflict,
type McpProjectConfigType,
type McpTemplate,
type McpTemplateInstallRequest,
@@ -66,6 +67,7 @@ export interface UseMcpServersReturn {
servers: McpServer[];
projectServers: McpServer[];
globalServers: McpServer[];
conflicts: McpServerConflict[];
totalCount: number;
enabledCount: number;
isLoading: boolean;
@@ -95,6 +97,7 @@ export function useMcpServers(options: UseMcpServersOptions = {}): UseMcpServers
const projectServers = query.data?.project ?? [];
const globalServers = query.data?.global ?? [];
const conflicts = query.data?.conflicts ?? [];
const allServers = scope === 'project' ? projectServers : scope === 'global' ? globalServers : [...projectServers, ...globalServers];
const enabledServers = allServers.filter((s) => s.enabled);
@@ -111,6 +114,7 @@ export function useMcpServers(options: UseMcpServersOptions = {}): UseMcpServers
servers: allServers,
projectServers,
globalServers,
conflicts,
totalCount: allServers.length,
enabledCount: enabledServers.length,
isLoading: query.isLoading,
@@ -224,6 +228,7 @@ export function useToggleMcpServer(): UseToggleMcpServerReturn {
return {
project: updateServer(old.project),
global: updateServer(old.global),
conflicts: old.conflicts ?? [],
};
});

View File

@@ -2507,9 +2507,18 @@ export interface McpServer {
scope: 'project' | 'global';
}
export interface McpServerConflict {
name: string;
projectServer: McpServer;
globalServer: McpServer;
/** Runtime effective scope */
effectiveScope: 'global' | 'project';
}
export interface McpServersResponse {
project: McpServer[];
global: McpServer[];
conflicts: McpServerConflict[];
}
/**
@@ -2618,7 +2627,6 @@ export async function fetchMcpServers(projectPath?: string): Promise<McpServersR
const disabledSet = new Set(disabledServers);
const userServers = isUnknownRecord(config.userServers) ? (config.userServers as UnknownRecord) : {};
const enterpriseServers = isUnknownRecord(config.enterpriseServers) ? (config.enterpriseServers as UnknownRecord) : {};
const projectServersRecord = projectConfig && isUnknownRecord(projectConfig.mcpServers)
? (projectConfig.mcpServers as UnknownRecord)
@@ -2635,21 +2643,34 @@ export async function fetchMcpServers(projectPath?: string): Promise<McpServersR
});
const project: McpServer[] = Object.entries(projectServersRecord)
// Avoid duplicates: if defined globally/enterprise, treat it as global
.filter(([name]) => !(name in userServers) && !(name in enterpriseServers))
.map(([name, raw]) => {
const normalized = normalizeServerConfig(raw);
return {
name,
...normalized,
enabled: !disabledSet.has(name),
scope: 'project',
scope: 'project' as const,
};
});
// Detect conflicts: same name exists in both project and global
const conflicts: McpServerConflict[] = [];
for (const ps of project) {
const gs = global.find(g => g.name === ps.name);
if (gs) {
conflicts.push({
name: ps.name,
projectServer: ps,
globalServer: gs,
effectiveScope: 'global',
});
}
}
return {
project,
global,
conflicts,
};
}
@@ -3549,6 +3570,7 @@ export interface CcwMcpConfig {
projectRoot?: string;
allowedDirs?: string;
enableSandbox?: boolean;
installedScopes: ('global' | 'project')[];
}
/**
@@ -3605,22 +3627,24 @@ export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
try {
const config = await fetchMcpConfig();
// Check if ccw-tools server exists in any config
const installedScopes: ('global' | 'project')[] = [];
let ccwServer: any = null;
// Check global servers
// Check global/user servers
if (config.globalServers?.['ccw-tools']) {
installedScopes.push('global');
ccwServer = config.globalServers['ccw-tools'];
}
// Check user servers
if (!ccwServer && config.userServers?.['ccw-tools']) {
} else if (config.userServers?.['ccw-tools']) {
installedScopes.push('global');
ccwServer = config.userServers['ccw-tools'];
}
// Check project servers
if (!ccwServer && config.projects) {
if (config.projects) {
for (const proj of Object.values(config.projects)) {
if (proj.mcpServers?.['ccw-tools']) {
ccwServer = proj.mcpServers['ccw-tools'];
installedScopes.push('project');
if (!ccwServer) ccwServer = proj.mcpServers['ccw-tools'];
break;
}
}
@@ -3630,6 +3654,7 @@ export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
return {
isInstalled: false,
enabledTools: [],
installedScopes: [],
};
}
@@ -3646,11 +3671,13 @@ export async function fetchCcwMcpConfig(): Promise<CcwMcpConfig> {
projectRoot: env.CCW_PROJECT_ROOT,
allowedDirs: env.CCW_ALLOWED_DIRS,
enableSandbox: env.CCW_ENABLE_SANDBOX === '1',
installedScopes,
};
} catch {
return {
isInstalled: false,
enabledTools: [],
installedScopes: [],
};
}
}
@@ -3742,6 +3769,27 @@ export async function uninstallCcwMcp(): Promise<void> {
}
}
/**
* Uninstall CCW Tools MCP server from a specific scope
*/
export async function uninstallCcwMcpFromScope(
scope: 'global' | 'project',
projectPath?: string
): Promise<void> {
if (scope === 'global') {
await fetchApi('/api/mcp-remove-global-server', {
method: 'POST',
body: JSON.stringify({ serverName: 'ccw-tools' }),
});
} else {
if (!projectPath) throw new Error('projectPath required for project scope uninstall');
await fetchApi('/api/mcp-remove-server', {
method: 'POST',
body: JSON.stringify({ projectPath, serverName: 'ccw-tools' }),
});
}
}
// ========== CCW Tools MCP - Codex API ==========
/**
@@ -3753,7 +3801,7 @@ export async function fetchCcwMcpConfigForCodex(): Promise<CcwMcpConfig> {
const ccwServer = servers.find((s) => s.name === 'ccw-tools');
if (!ccwServer) {
return { isInstalled: false, enabledTools: [] };
return { isInstalled: false, enabledTools: [], installedScopes: [] };
}
const env = ccwServer.env || {};
@@ -3768,9 +3816,10 @@ export async function fetchCcwMcpConfigForCodex(): Promise<CcwMcpConfig> {
projectRoot: env.CCW_PROJECT_ROOT,
allowedDirs: env.CCW_ALLOWED_DIRS,
enableSandbox: env.CCW_ENABLE_SANDBOX === '1',
installedScopes: ['global'],
};
} catch {
return { isInstalled: false, enabledTools: [] };
return { isInstalled: false, enabledTools: [], installedScopes: [] };
}
}
@@ -3856,18 +3905,39 @@ export async function updateCcwConfigForCodex(config: {
* @param projectPath - Optional project path to filter data by workspace
*/
export async function fetchIndexStatus(projectPath?: string): Promise<IndexStatus> {
const url = projectPath ? `/api/index/status?path=${encodeURIComponent(projectPath)}` : '/api/index/status';
return fetchApi<IndexStatus>(url);
const url = projectPath
? `/api/codexlens/workspace-status?path=${encodeURIComponent(projectPath)}`
: '/api/codexlens/workspace-status';
const resp = await fetchApi<{
success: boolean;
hasIndex: boolean;
fts?: { indexedFiles: number; totalFiles: number };
}>(url);
return {
totalFiles: resp.fts?.totalFiles ?? 0,
lastUpdated: new Date().toISOString(),
buildTime: 0,
status: resp.hasIndex ? 'completed' : 'idle',
};
}
/**
* Rebuild index
*/
export async function rebuildIndex(request: IndexRebuildRequest = {}): Promise<IndexStatus> {
return fetchApi<IndexStatus>('/api/index/rebuild', {
await fetchApi<{ success: boolean }>('/api/codexlens/init', {
method: 'POST',
body: JSON.stringify(request),
body: JSON.stringify({
path: request.paths?.[0],
indexType: 'vector',
}),
});
return {
totalFiles: 0,
lastUpdated: new Date().toISOString(),
buildTime: 0,
status: 'building',
};
}
// ========== Prompt History API ==========

View File

@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { detectCcArtifacts } from './ccw-artifacts';
describe('ccw-artifacts', () => {
it('returns empty array for empty input', () => {
expect(detectCcArtifacts('')).toEqual([]);
});
it('detects workflow session artifacts', () => {
const text = 'Created: (.workflow/active/WFS-demo/workflow-session.json)';
expect(detectCcArtifacts(text)).toEqual([
{ type: 'workflow-session', path: '.workflow/active/WFS-demo/workflow-session.json' },
]);
});
it('detects lite session artifacts', () => {
const text = 'Plan: .workflow/.lite-plan/terminal-dashboard-enhancement-2026-02-15/plan.json';
expect(detectCcArtifacts(text)).toEqual([
{ type: 'lite-session', path: '.workflow/.lite-plan/terminal-dashboard-enhancement-2026-02-15/plan.json' },
]);
});
it('detects CLAUDE.md artifacts (case-insensitive)', () => {
const text = 'Updated: /repo/docs/claude.md and also CLAUDE.md';
const res = detectCcArtifacts(text);
expect(res).toEqual([
{ type: 'claude-md', path: '/repo/docs/claude.md' },
{ type: 'claude-md', path: 'CLAUDE.md' },
]);
});
it('detects CCW config artifacts', () => {
const text = 'Config: .ccw/config.toml and ccw.config.yaml';
expect(detectCcArtifacts(text)).toEqual([
{ type: 'ccw-config', path: '.ccw/config.toml' },
{ type: 'ccw-config', path: 'ccw.config.yaml' },
]);
});
it('detects issue artifacts', () => {
const text = 'Queue: .workflow/issues/queues/index.json';
expect(detectCcArtifacts(text)).toEqual([
{ type: 'issue', path: '.workflow/issues/queues/index.json' },
]);
});
it('deduplicates repeated artifacts', () => {
const text = '.workflow/issues/issues.jsonl ... .workflow/issues/issues.jsonl';
expect(detectCcArtifacts(text)).toEqual([
{ type: 'issue', path: '.workflow/issues/issues.jsonl' },
]);
});
it('preserves discovery order across types', () => {
const text = [
'Issue: .workflow/issues/issues.jsonl',
'Then plan: .workflow/.lite-plan/abc/plan.json',
'Then session: .workflow/active/WFS-x/workflow-session.json',
'Then config: .ccw/config.toml',
'Then CLAUDE: CLAUDE.md',
].join(' | ');
const res = detectCcArtifacts(text);
expect(res.map((a) => a.type)).toEqual([
'issue',
'lite-session',
'workflow-session',
'ccw-config',
'claude-md',
]);
});
});

View File

@@ -0,0 +1,91 @@
// ========================================
// CCW Artifacts - Types & Detection
// ========================================
export type ArtifactType =
| 'workflow-session'
| 'lite-session'
| 'claude-md'
| 'ccw-config'
| 'issue';
export interface CcArtifact {
type: ArtifactType;
path: string;
}
const TRAILING_PUNCTUATION = /[)\]}>,.;:!?]+$/g;
const WRAP_QUOTES = /^['"`]+|['"`]+$/g;
function normalizePath(raw: string): string {
return raw.trim().replace(WRAP_QUOTES, '').replace(TRAILING_PUNCTUATION, '');
}
/**
* Patterns for detecting CCW-related artifacts in terminal output.
*
* Notes:
* - Prefer relative paths (e.g., `.workflow/...`) so callers can resolve against a project root.
* - Keep patterns conservative to reduce false positives in generic logs.
*/
export const ARTIFACT_PATTERNS: Record<ArtifactType, RegExp[]> = {
'workflow-session': [
/(?:^|[^\w.])(\.workflow[\\/](?:active|archives)[\\/][^\s"'`]+[\\/]workflow-session\.json)\b/g,
],
'lite-session': [
/(?:^|[^\w.])(\.workflow[\\/]\.lite-plan[\\/][^\s"'`]+)\b/g,
],
'claude-md': [
/([^\s"'`]*CLAUDE\.md)\b/gi,
],
'ccw-config': [
/(?:^|[^\w.])(\.ccw[\\/][^\s"'`]+)\b/g,
/(?:^|[^\w.])(ccw\.config\.(?:json|ya?ml|toml))\b/gi,
],
issue: [
/(?:^|[^\w.])(\.workflow[\\/]issues[\\/][^\s"'`]+)\b/g,
],
};
/**
* Detect CCW artifacts from an arbitrary text blob.
*
* Returns a de-duplicated list of `{ type, path }` in discovery order.
*/
export function detectCcArtifacts(text: string): CcArtifact[] {
if (!text) return [];
const candidates: Array<CcArtifact & { index: number }> = [];
for (const type of Object.keys(ARTIFACT_PATTERNS) as ArtifactType[]) {
for (const pattern of ARTIFACT_PATTERNS[type]) {
pattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
const raw = match[1] ?? match[0];
const path = normalizePath(raw);
if (!path) continue;
const full = match[0] ?? '';
const group = match[1] ?? raw;
const rel = full.indexOf(group);
const index = (match.index ?? 0) + (rel >= 0 ? rel : 0);
candidates.push({ type, path, index });
}
}
}
candidates.sort((a, b) => a.index - b.index);
const results: CcArtifact[] = [];
const seen = new Set<string>();
for (const c of candidates) {
const key = `${c.type}:${c.path}`;
if (seen.has(key)) continue;
seen.add(key);
results.push({ type: c.type, path: c.path });
}
return results;
}

View File

@@ -164,11 +164,28 @@
"uninstall": "Uninstall",
"uninstalling": "Uninstalling...",
"uninstallConfirm": "Are you sure you want to uninstall CCW MCP?",
"uninstallScopeConfirm": "Are you sure you want to remove CCW MCP from {scope}?",
"saveConfig": "Save Configuration",
"saving": "Saving..."
},
"scope": {
"global": "Global",
"project": "Project",
"addScope": "Install to other scope",
"installToProject": "Also install to project",
"installToGlobal": "Also install to global",
"uninstallFrom": "Uninstall by scope",
"uninstallGlobal": "Remove from global",
"uninstallProject": "Remove from project"
},
"codexNote": "Requires: npm install -g claude-code-workflow"
},
"conflict": {
"badge": "Conflict",
"title": "Scope Conflict",
"description": "This server exists in both project and global scopes. The {scope} version is used at runtime.",
"resolution": "Consider removing this server from one of the scopes."
},
"recommended": {
"title": "Recommended Servers",
"description": "Quickly install popular MCP servers with one click",

View File

@@ -121,6 +121,7 @@
"disabled": "Disabled",
"platforms": "Platform Configuration",
"events": "Event Triggers",
"selectPlatforms": "Select which platforms to notify for this event:",
"noPlatforms": "No platforms",
"configured": "Configured",
"save": "Save",
@@ -151,6 +152,36 @@
"method": "HTTP Method",
"headers": "Custom Headers (JSON)",
"headersHint": "Optional JSON object with custom headers"
},
"feishu": {
"webhookUrl": "Webhook URL",
"webhookUrlHint": "Get from Feishu robot settings",
"useCard": "Use Card Format",
"useCardHint": "Send as rich interactive card",
"title": "Card Title (optional)"
},
"dingtalk": {
"webhookUrl": "Webhook URL",
"webhookUrlHint": "Get from DingTalk robot settings",
"keywords": "Security Keywords",
"keywordsHint": "Comma-separated keywords for security check"
},
"wecom": {
"webhookUrl": "Webhook URL",
"webhookUrlHint": "Get from WeCom robot settings",
"mentionedList": "Mention Users",
"mentionedListHint": "User IDs to mention, use '@all' for everyone"
},
"email": {
"host": "SMTP Host",
"hostHint": "e.g., smtp.gmail.com",
"port": "Port",
"secure": "Use TLS",
"username": "Username",
"password": "Password",
"from": "Sender Email",
"to": "Recipients",
"toHint": "Comma-separated email addresses"
}
},
"versionCheck": {

View File

@@ -78,9 +78,51 @@
"layoutSplitV": "Split Vertical",
"layoutGrid": "Grid 2x2",
"launchCli": "Launch CLI",
"tool": "Tool",
"mode": "Mode",
"modeDefault": "Default",
"modeYolo": "Yolo",
"quickCreate": "Quick Create",
"configure": "Configure..."
},
"cliConfig": {
"title": "Create CLI Session",
"description": "Configure tool, model, mode, shell, and working directory.",
"tool": "Tool",
"model": "Model",
"modelAuto": "Auto",
"mode": "Mode",
"modeDefault": "Default",
"modeYolo": "Yolo",
"shell": "Shell",
"workingDir": "Working Directory",
"workingDirPlaceholder": "e.g. /path/to/project",
"browse": "Browse",
"errors": {
"workingDirRequired": "Working directory is required.",
"createFailed": "Failed to create session."
}
},
"fileBrowser": {
"title": "File Browser",
"open": "Open file browser",
"selected": "Selected file",
"noSelection": "No file selected",
"copyPath": "Copy path",
"copied": "Copied",
"insertPath": "Insert into terminal",
"loading": "Loading...",
"loadFailed": "Failed to load file tree"
},
"artifacts": {
"types": {
"workflowSession": "Workflow",
"liteSession": "Lite",
"claudeMd": "CLAUDE.md",
"ccwConfig": "CCW Config",
"issue": "Issue"
}
},
"pane": {
"selectSession": "Select a session",
"selectSessionHint": "Choose a terminal session from the dropdown",

View File

@@ -164,11 +164,28 @@
"uninstall": "卸载",
"uninstalling": "卸载中...",
"uninstallConfirm": "确定要卸载 CCW MCP 吗?",
"uninstallScopeConfirm": "确定要从{scope}移除 CCW MCP 吗?",
"saveConfig": "保存配置",
"saving": "保存中..."
},
"scope": {
"global": "全局",
"project": "项目",
"addScope": "安装到其他作用域",
"installToProject": "同时安装到项目",
"installToGlobal": "同时安装到全局",
"uninstallFrom": "按作用域卸载",
"uninstallGlobal": "从全局移除",
"uninstallProject": "从项目移除"
},
"codexNote": "需要全局安装npm install -g claude-code-workflow"
},
"conflict": {
"badge": "冲突",
"title": "作用域冲突",
"description": "此服务器同时存在于项目和全局作用域。运行时使用{scope}版本。",
"resolution": "建议从其中一个作用域移除该服务器。"
},
"recommended": {
"title": "推荐服务器",
"description": "一键快速安装热门 MCP 服务器",

View File

@@ -121,6 +121,7 @@
"disabled": "已禁用",
"platforms": "平台配置",
"events": "事件触发器",
"selectPlatforms": "选择此事件要通知的平台:",
"noPlatforms": "无平台",
"configured": "已配置",
"save": "保存",
@@ -151,6 +152,36 @@
"method": "HTTP 方法",
"headers": "自定义请求头JSON",
"headersHint": "可选的 JSON 对象,包含自定义请求头"
},
"feishu": {
"webhookUrl": "Webhook URL",
"webhookUrlHint": "从飞书机器人设置中获取",
"useCard": "使用卡片格式",
"useCardHint": "以富交互卡片形式发送",
"title": "卡片标题(可选)"
},
"dingtalk": {
"webhookUrl": "Webhook URL",
"webhookUrlHint": "从钉钉机器人设置中获取",
"keywords": "安全关键词",
"keywordsHint": "逗号分隔的关键词,用于安全校验"
},
"wecom": {
"webhookUrl": "Webhook URL",
"webhookUrlHint": "从企业微信机器人设置中获取",
"mentionedList": "提醒用户",
"mentionedListHint": "要提醒的用户 ID使用 '@all' 提醒所有人"
},
"email": {
"host": "SMTP 服务器",
"hostHint": "例如smtp.gmail.com",
"port": "端口",
"secure": "使用 TLS",
"username": "用户名",
"password": "密码",
"from": "发件人邮箱",
"to": "收件人",
"toHint": "逗号分隔的邮箱地址"
}
},
"versionCheck": {

View File

@@ -78,9 +78,51 @@
"layoutSplitV": "上下分割",
"layoutGrid": "2x2 网格",
"launchCli": "启动 CLI",
"tool": "工具",
"mode": "模式",
"modeDefault": "默认",
"modeYolo": "Yolo",
"quickCreate": "快速创建",
"configure": "配置..."
},
"cliConfig": {
"title": "创建 CLI 会话",
"description": "配置工具、模型、模式、Shell 与工作目录。",
"tool": "工具",
"model": "模型",
"modelAuto": "自动",
"mode": "模式",
"modeDefault": "默认",
"modeYolo": "Yolo",
"shell": "Shell",
"workingDir": "工作目录",
"workingDirPlaceholder": "例如:/path/to/project",
"browse": "浏览",
"errors": {
"workingDirRequired": "工作目录不能为空。",
"createFailed": "创建会话失败。"
}
},
"fileBrowser": {
"title": "文件浏览器",
"open": "打开文件浏览器",
"selected": "已选文件",
"noSelection": "未选择文件",
"copyPath": "复制路径",
"copied": "已复制",
"insertPath": "插入到终端",
"loading": "加载中...",
"loadFailed": "加载文件树失败"
},
"artifacts": {
"types": {
"workflowSession": "工作流",
"liteSession": "Lite",
"claudeMd": "CLAUDE.md",
"ccwConfig": "配置",
"issue": "问题"
}
},
"pane": {
"selectSession": "选择会话",
"selectSessionHint": "从下拉菜单中选择终端会话",

View File

@@ -4,7 +4,7 @@
// Manage MCP servers (Model Context Protocol) with tabbed interface
// Supports Templates, Servers, and Cross-CLI tabs
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
@@ -21,6 +21,7 @@ import {
ChevronDown,
ChevronUp,
BookmarkPlus,
AlertTriangle,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -38,16 +39,20 @@ import { AllProjectsTable } from '@/components/mcp/AllProjectsTable';
import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection';
import { TabsNavigation } from '@/components/ui/TabsNavigation';
import { useMcpServers, useMcpServerMutations, useNotifications } from '@/hooks';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import {
fetchCodexMcpServers,
fetchCcwMcpConfig,
fetchCcwMcpConfigForCodex,
updateCcwConfig,
updateCcwConfigForCodex,
installCcwMcp,
uninstallCcwMcpFromScope,
codexRemoveServer,
codexToggleServer,
saveMcpTemplate,
type McpServer,
type McpServerConflict,
type CcwMcpConfig,
} from '@/lib/api';
import { cn } from '@/lib/utils';
@@ -62,9 +67,10 @@ interface McpServerCardProps {
onEdit: (server: McpServer) => void;
onDelete: (server: McpServer) => void;
onSaveAsTemplate: (server: McpServer) => void;
conflictInfo?: McpServerConflict;
}
function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete, onSaveAsTemplate }: McpServerCardProps) {
function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete, onSaveAsTemplate, conflictInfo }: McpServerCardProps) {
const { formatMessage } = useIntl();
return (
@@ -97,6 +103,12 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o
<><Folder className="w-3 h-3 mr-1" />{formatMessage({ id: 'mcp.scope.project' })}</>
)}
</Badge>
{conflictInfo && (
<Badge variant="outline" className="text-xs text-orange-500 border-orange-300">
<AlertTriangle className="w-3 h-3 mr-1" />
{formatMessage({ id: 'mcp.conflict.badge' })}
</Badge>
)}
{server.enabled && (
<Badge variant="outline" className="text-xs text-green-600">
{formatMessage({ id: 'mcp.status.enabled' })}
@@ -205,6 +217,22 @@ function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, o
</div>
</div>
)}
{/* Conflict warning panel */}
{conflictInfo && (
<div className="p-3 bg-orange-50 dark:bg-orange-950/30 border border-orange-200 dark:border-orange-800 rounded-lg space-y-1">
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400">
<AlertTriangle className="w-4 h-4" />
<span className="text-sm font-medium">{formatMessage({ id: 'mcp.conflict.title' })}</span>
</div>
<p className="text-xs text-orange-600 dark:text-orange-400/80">
{formatMessage({ id: 'mcp.conflict.description' }, { scope: formatMessage({ id: `mcp.scope.${conflictInfo.effectiveScope}` }) })}
</p>
<p className="text-xs text-orange-600 dark:text-orange-400/80">
{formatMessage({ id: 'mcp.conflict.resolution' })}
</p>
</div>
)}
</div>
)}
</Card>
@@ -233,6 +261,7 @@ export function McpManagerPage() {
servers,
projectServers,
globalServers,
conflicts,
totalCount,
enabledCount,
isLoading,
@@ -350,6 +379,7 @@ export function McpManagerPage() {
projectRoot: undefined,
allowedDirs: undefined,
enableSandbox: undefined,
installedScopes: [] as ('global' | 'project')[],
};
const handleToggleCcwTool = async (tool: string, enabled: boolean) => {
@@ -401,13 +431,43 @@ export function McpManagerPage() {
ccwMcpQuery.refetch();
};
const projectPath = useWorkflowStore(selectProjectPath);
// Build conflict map for quick lookup
const conflictMap = useMemo(() => {
const map = new Map<string, McpServerConflict>();
for (const c of conflicts) map.set(c.name, c);
return map;
}, [conflicts]);
// CCW scope-specific handlers
const handleCcwInstallToScope = async (scope: 'global' | 'project') => {
try {
await installCcwMcp(scope, scope === 'project' ? projectPath ?? undefined : undefined);
ccwMcpQuery.refetch();
} catch (error) {
console.error('Failed to install CCW MCP to scope:', error);
}
};
const handleCcwUninstallFromScope = async (scope: 'global' | 'project') => {
try {
await uninstallCcwMcpFromScope(scope, scope === 'project' ? projectPath ?? undefined : undefined);
ccwMcpQuery.refetch();
queryClient.invalidateQueries({ queryKey: ['mcpServers'] });
} catch (error) {
console.error('Failed to uninstall CCW MCP from scope:', error);
}
};
// CCW MCP handlers for Codex mode
const ccwCodexConfig = ccwMcpCodexQuery.data ?? {
isInstalled: false,
enabledTools: [],
enabledTools: [] as string[],
projectRoot: undefined,
allowedDirs: undefined,
enableSandbox: undefined,
installedScopes: [] as ('global' | 'project')[],
};
const handleToggleCcwToolCodex = async (tool: string, enabled: boolean) => {
@@ -725,6 +785,9 @@ export function McpManagerPage() {
onToggleTool={handleToggleCcwTool}
onUpdateConfig={handleUpdateCcwConfig}
onInstall={handleCcwInstall}
installedScopes={ccwConfig.installedScopes}
onInstallToScope={handleCcwInstallToScope}
onUninstallScope={handleCcwUninstallFromScope}
/>
)}
{cliMode === 'codex' && (
@@ -761,7 +824,7 @@ export function McpManagerPage() {
{currentServers.map((server) => (
cliMode === 'codex' ? (
<CodexMcpEditableCard
key={server.name}
key={`${server.name}-${server.scope}`}
server={server as McpServer}
enabled={server.enabled}
isExpanded={currentExpanded.has(server.name)}
@@ -772,14 +835,15 @@ export function McpManagerPage() {
/>
) : (
<McpServerCard
key={server.name}
key={`${server.name}-${server.scope}`}
server={server}
isExpanded={currentExpanded.has(server.name)}
onToggleExpand={() => currentToggleExpand(server.name)}
isExpanded={currentExpanded.has(`${server.name}-${server.scope}`)}
onToggleExpand={() => currentToggleExpand(`${server.name}-${server.scope}`)}
onToggle={handleToggle}
onEdit={handleEdit}
onDelete={handleDelete}
onSaveAsTemplate={handleSaveServerAsTemplate}
conflictInfo={conflictMap.get(server.name)}
/>
)
))}

View File

@@ -7,7 +7,7 @@
/**
* Supported notification platforms
*/
export type NotificationPlatform = 'discord' | 'telegram' | 'webhook';
export type NotificationPlatform = 'discord' | 'telegram' | 'feishu' | 'dingtalk' | 'wecom' | 'email' | 'webhook';
/**
* Event types that can trigger notifications
@@ -39,6 +39,48 @@ export interface TelegramConfig {
parseMode?: 'HTML' | 'Markdown' | 'MarkdownV2';
}
/**
* Feishu (Lark) platform configuration
*/
export interface FeishuConfig {
enabled: boolean;
webhookUrl: string;
useCard?: boolean;
title?: string;
}
/**
* DingTalk platform configuration
*/
export interface DingTalkConfig {
enabled: boolean;
webhookUrl: string;
keywords?: string[];
}
/**
* WeCom (WeChat Work) platform configuration
*/
export interface WeComConfig {
enabled: boolean;
webhookUrl: string;
mentionedList?: string[];
}
/**
* Email SMTP platform configuration
*/
export interface EmailConfig {
enabled: boolean;
host: string;
port: number;
secure?: boolean;
username: string;
password: string;
from: string;
to: string[];
}
/**
* Generic Webhook platform configuration
*/
@@ -67,6 +109,10 @@ export interface RemoteNotificationConfig {
platforms: {
discord?: DiscordConfig;
telegram?: TelegramConfig;
feishu?: FeishuConfig;
dingtalk?: DingTalkConfig;
wecom?: WeComConfig;
email?: EmailConfig;
webhook?: WebhookConfig;
};
events: EventConfig[];
@@ -78,7 +124,7 @@ export interface RemoteNotificationConfig {
*/
export interface TestNotificationRequest {
platform: NotificationPlatform;
config: DiscordConfig | TelegramConfig | WebhookConfig;
config: DiscordConfig | TelegramConfig | FeishuConfig | DingTalkConfig | WeComConfig | EmailConfig | WebhookConfig;
}
/**
@@ -129,6 +175,34 @@ export const PLATFORM_INFO: Record<NotificationPlatform, PlatformInfo> = {
description: 'Send notifications to Telegram chats via bot',
requiredFields: ['botToken', 'chatId'],
},
feishu: {
id: 'feishu',
name: 'Feishu',
icon: 'message-square',
description: 'Send notifications to Feishu (Lark) via webhook with rich card support',
requiredFields: ['webhookUrl'],
},
dingtalk: {
id: 'dingtalk',
name: 'DingTalk',
icon: 'bell',
description: 'Send notifications to DingTalk via webhook',
requiredFields: ['webhookUrl'],
},
wecom: {
id: 'wecom',
name: 'WeCom',
icon: 'users',
description: 'Send notifications to WeCom (WeChat Work) via webhook',
requiredFields: ['webhookUrl'],
},
email: {
id: 'email',
name: 'Email',
icon: 'mail',
description: 'Send notifications via SMTP email',
requiredFields: ['host', 'username', 'password', 'from', 'to'],
},
webhook: {
id: 'webhook',
name: 'Custom Webhook',