mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
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:
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user