mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat(a2ui): Implement A2UI backend with question handling and WebSocket support
- Added A2UITypes for defining question structures and answers. - Created A2UIWebSocketHandler for managing WebSocket connections and message handling. - Developed ask-question tool for interactive user questions via A2UI. - Introduced platformUtils for platform detection and shell command handling. - Centralized TypeScript types in index.ts for better organization. - Implemented compatibility checks for hook templates based on platform requirements.
This commit is contained in:
414
ccw/frontend/src/pages/ExecutionMonitorPage.tsx
Normal file
414
ccw/frontend/src/pages/ExecutionMonitorPage.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
// ========================================
|
||||
// Execution Monitor Page
|
||||
// ========================================
|
||||
// Dashboard for execution monitoring with real-time status, statistics, and history
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Activity,
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
BarChart3,
|
||||
Calendar,
|
||||
Filter,
|
||||
ListTree,
|
||||
History,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { ExecutionMonitor } from './orchestrator/ExecutionMonitor';
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
import type { ExecutionStatus } from '@/types/execution';
|
||||
|
||||
// Mock data - will be replaced with real API calls
|
||||
const mockExecutionHistory = [
|
||||
{
|
||||
execId: 'exec-001',
|
||||
flowId: 'flow-1',
|
||||
flowName: 'Data Processing Pipeline',
|
||||
status: 'completed' as ExecutionStatus,
|
||||
startedAt: '2026-01-31T10:00:00Z',
|
||||
completedAt: '2026-01-31T10:05:30Z',
|
||||
duration: 330000,
|
||||
nodesTotal: 5,
|
||||
nodesCompleted: 5,
|
||||
},
|
||||
{
|
||||
execId: 'exec-002',
|
||||
flowId: 'flow-2',
|
||||
flowName: 'Email Notification Flow',
|
||||
status: 'failed' as ExecutionStatus,
|
||||
startedAt: '2026-01-31T09:30:00Z',
|
||||
completedAt: '2026-01-31T09:32:15Z',
|
||||
duration: 135000,
|
||||
nodesTotal: 3,
|
||||
nodesCompleted: 2,
|
||||
},
|
||||
{
|
||||
execId: 'exec-003',
|
||||
flowId: 'flow-1',
|
||||
flowName: 'Data Processing Pipeline',
|
||||
status: 'completed' as ExecutionStatus,
|
||||
startedAt: '2026-01-31T08:00:00Z',
|
||||
completedAt: '2026-01-31T08:04:45Z',
|
||||
duration: 285000,
|
||||
nodesTotal: 5,
|
||||
nodesCompleted: 5,
|
||||
},
|
||||
];
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function ExecutionMonitorPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const currentExecution = useExecutionStore((state) => state.currentExecution);
|
||||
const [selectedView, setSelectedView] = useState<'workflow' | 'timeline' | 'list'>('workflow');
|
||||
|
||||
// Calculate statistics
|
||||
const stats = useMemo(() => {
|
||||
const total = mockExecutionHistory.length;
|
||||
const completed = mockExecutionHistory.filter((e) => e.status === 'completed').length;
|
||||
const failed = mockExecutionHistory.filter((e) => e.status === 'failed').length;
|
||||
const successRate = total > 0 ? (completed / total) * 100 : 0;
|
||||
const avgDuration =
|
||||
total > 0
|
||||
? mockExecutionHistory.reduce((sum, e) => sum + e.duration, 0) / total
|
||||
: 0;
|
||||
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
failed,
|
||||
successRate,
|
||||
avgDuration,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Group by workflow
|
||||
const workflowGroups = useMemo(() => {
|
||||
const groups = new Map<string, typeof mockExecutionHistory>();
|
||||
mockExecutionHistory.forEach((exec) => {
|
||||
const existing = groups.get(exec.flowId) || [];
|
||||
groups.set(exec.flowId, [...existing, exec]);
|
||||
});
|
||||
return Array.from(groups.entries()).map(([flowId, executions]) => ({
|
||||
flowId,
|
||||
flowName: executions[0].flowName,
|
||||
executions,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<Activity className="w-6 h-6" />
|
||||
{formatMessage({ id: 'executionMonitor.page.title' })}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'executionMonitor.page.subtitle' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Execution Area */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{formatMessage({ id: 'executionMonitor.currentExecution.title' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{currentExecution ? (
|
||||
<ExecutionMonitor />
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{formatMessage({ id: 'executionMonitor.currentExecution.noExecution' })}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Statistics Overview */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">
|
||||
{formatMessage({ id: 'executionMonitor.stats.title' })}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'executionMonitor.stats.totalExecutions' })}
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-foreground mt-1">{stats.total}</p>
|
||||
</div>
|
||||
<BarChart3 className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'executionMonitor.stats.successRate' })}
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-green-600 mt-1">
|
||||
{stats.successRate.toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'executionMonitor.stats.avgDuration' })}
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-foreground mt-1">
|
||||
{formatDuration(stats.avgDuration)}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'executionMonitor.execution.status.failed' })}
|
||||
</p>
|
||||
<p className="text-3xl font-bold text-red-600 mt-1">{stats.failed}</p>
|
||||
</div>
|
||||
<XCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Execution History */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{formatMessage({ id: 'executionMonitor.history.title' })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs value={selectedView} onValueChange={(v) => setSelectedView(v as typeof selectedView)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="workflow">
|
||||
<ListTree className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'executionMonitor.history.tabs.byWorkflow' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="timeline">
|
||||
<History className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'executionMonitor.history.tabs.timeline' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="list">
|
||||
<List className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'executionMonitor.history.tabs.list' })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* By Workflow View */}
|
||||
<TabsContent value="workflow" className="mt-4">
|
||||
{workflowGroups.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{formatMessage({ id: 'executionMonitor.history.empty' })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{workflowGroups.map((group) => (
|
||||
<Card key={group.flowId}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium">{group.flowName}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{group.executions.map((exec) => (
|
||||
<div
|
||||
key={exec.execId}
|
||||
className="flex items-center justify-between p-3 bg-background rounded-lg border border-border hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge
|
||||
variant={
|
||||
exec.status === 'completed'
|
||||
? 'success'
|
||||
: exec.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{formatMessage({ id: `executionMonitor.execution.status.${exec.status}` })}
|
||||
</Badge>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-foreground">{exec.execId}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{formatDateTime(exec.startedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatDuration(exec.duration)}
|
||||
</span>
|
||||
<span>
|
||||
{exec.nodesCompleted}/{exec.nodesTotal} nodes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Timeline View */}
|
||||
<TabsContent value="timeline" className="mt-4">
|
||||
<div className="space-y-3">
|
||||
{mockExecutionHistory.map((exec, index) => (
|
||||
<div key={exec.execId} className="flex gap-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${
|
||||
exec.status === 'completed'
|
||||
? 'bg-green-600'
|
||||
: exec.status === 'failed'
|
||||
? 'bg-red-600'
|
||||
: 'bg-blue-600'
|
||||
}`}
|
||||
/>
|
||||
{index < mockExecutionHistory.length - 1 && (
|
||||
<div className="w-0.5 flex-1 bg-border mt-2" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 pb-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{exec.flowName}</div>
|
||||
<div className="text-sm text-muted-foreground">{exec.execId}</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
exec.status === 'completed'
|
||||
? 'success'
|
||||
: exec.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{formatMessage({ id: `executionMonitor.execution.status.${exec.status}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDateTime(exec.startedAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatDuration(exec.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* List View */}
|
||||
<TabsContent value="list" className="mt-4">
|
||||
<div className="space-y-2">
|
||||
{mockExecutionHistory.map((exec) => (
|
||||
<Card key={exec.execId} className="hover:border-primary/50 transition-colors">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Badge
|
||||
variant={
|
||||
exec.status === 'completed'
|
||||
? 'success'
|
||||
: exec.status === 'failed'
|
||||
? 'destructive'
|
||||
: 'default'
|
||||
}
|
||||
>
|
||||
{formatMessage({ id: `executionMonitor.execution.status.${exec.status}` })}
|
||||
</Badge>
|
||||
<div>
|
||||
<div className="font-medium text-foreground">{exec.flowName}</div>
|
||||
<div className="text-sm text-muted-foreground">{exec.execId}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{formatDateTime(exec.startedAt)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-4 h-4" />
|
||||
{formatDuration(exec.duration)}
|
||||
</span>
|
||||
<span>
|
||||
{exec.nodesCompleted}/{exec.nodesTotal} nodes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExecutionMonitorPage;
|
||||
220
ccw/frontend/src/pages/ExplorerPage.tsx
Normal file
220
ccw/frontend/src/pages/ExplorerPage.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
// ========================================
|
||||
// ExplorerPage Component
|
||||
// ========================================
|
||||
// File Explorer page with tree view and file preview
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useFileExplorer, useFileContent } from '@/hooks/useFileExplorer';
|
||||
import { TreeView } from '@/components/shared/TreeView';
|
||||
import { FilePreview } from '@/components/shared/FilePreview';
|
||||
import { ExplorerToolbar } from '@/components/shared/ExplorerToolbar';
|
||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FileSystemNode } from '@/types/file-explorer';
|
||||
|
||||
const DEFAULT_TREE_WIDTH = 300;
|
||||
const MIN_TREE_WIDTH = 200;
|
||||
const MAX_TREE_WIDTH = 600;
|
||||
|
||||
/**
|
||||
* ExplorerPage component - File Explorer with split pane layout
|
||||
*/
|
||||
export function ExplorerPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Root path state
|
||||
const [rootPath, setRootPath] = React.useState('/');
|
||||
|
||||
// Tree width state for resizable split pane
|
||||
const [treeWidth, setTreeWidth] = React.useState(DEFAULT_TREE_WIDTH);
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const treePanelRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// File explorer hook
|
||||
const {
|
||||
state,
|
||||
rootNodes,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
setSelectedFile,
|
||||
toggleExpanded,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
setViewMode,
|
||||
setSortOrder,
|
||||
toggleShowHidden,
|
||||
setFilter,
|
||||
rootDirectories,
|
||||
isLoadingRoots,
|
||||
} = useFileExplorer({
|
||||
rootPath,
|
||||
maxDepth: 5,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// File content hook
|
||||
const { content: fileContent, isLoading: isContentLoading, error: contentError } = useFileContent(
|
||||
state.selectedFile,
|
||||
{ enabled: !!state.selectedFile }
|
||||
);
|
||||
|
||||
// Handle node click
|
||||
const handleNodeClick = (node: FileSystemNode) => {
|
||||
if (node.type === 'file') {
|
||||
setSelectedFile(node.path);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle root directory change
|
||||
const handleRootChange = (path: string) => {
|
||||
setRootPath(path);
|
||||
setSelectedFile(null);
|
||||
};
|
||||
|
||||
// Handle resize start
|
||||
const handleResizeStart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
document.addEventListener('mousemove', handleResize);
|
||||
document.addEventListener('mouseup', handleResizeEnd);
|
||||
};
|
||||
|
||||
// Handle resize move
|
||||
const handleResize = (e: MouseEvent) => {
|
||||
if (!treePanelRef.current) return;
|
||||
const newWidth = e.clientX;
|
||||
if (newWidth >= MIN_TREE_WIDTH && newWidth <= MAX_TREE_WIDTH) {
|
||||
setTreeWidth(newWidth);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle resize end
|
||||
const handleResizeEnd = () => {
|
||||
setIsResizing(false);
|
||||
document.removeEventListener('mousemove', handleResize);
|
||||
document.removeEventListener('mouseup', handleResizeEnd);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Page header */}
|
||||
<div className="px-6 py-4 border-b border-border">
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{formatMessage({ id: 'explorer.title' })}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'explorer.description' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'explorer.errors.loadFailed' })}
|
||||
</p>
|
||||
<p className="text-xs mt-0.5">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toolbar */}
|
||||
<ExplorerToolbar
|
||||
searchQuery={state.filter}
|
||||
onSearchChange={setFilter}
|
||||
onSearchClear={() => setFilter('')}
|
||||
onRefresh={refetch}
|
||||
rootDirectories={rootDirectories || []}
|
||||
selectedRoot={rootPath}
|
||||
onRootChange={handleRootChange}
|
||||
isLoadingRoots={isLoadingRoots}
|
||||
viewMode={state.viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
sortOrder={state.sortOrder}
|
||||
onSortOrderChange={setSortOrder}
|
||||
showHiddenFiles={state.showHiddenFiles}
|
||||
onToggleShowHidden={toggleShowHidden}
|
||||
onExpandAll={expandAll}
|
||||
onCollapseAll={collapseAll}
|
||||
/>
|
||||
|
||||
{/* Main content - Split pane */}
|
||||
<div className="flex-1 min-h-0 flex overflow-hidden">
|
||||
{/* Tree view panel */}
|
||||
<div
|
||||
ref={treePanelRef}
|
||||
className="h-full flex flex-col bg-background border-r border-border"
|
||||
style={{ width: `${treeWidth}px`, minWidth: `${MIN_TREE_WIDTH}px`, maxWidth: `${MAX_TREE_WIDTH}px` }}
|
||||
>
|
||||
{/* Loading indicator */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'explorer.tree.loading' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tree view */}
|
||||
<div className={cn('flex-1 overflow-auto custom-scrollbar', isLoading && 'opacity-50')}>
|
||||
<TreeView
|
||||
nodes={rootNodes}
|
||||
expandedPaths={state.expandedPaths}
|
||||
selectedPath={state.selectedFile}
|
||||
onNodeClick={handleNodeClick}
|
||||
onToggle={toggleExpanded}
|
||||
showIcons
|
||||
showSizes={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tree footer */}
|
||||
<div className="px-4 py-2 border-t border-border text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>
|
||||
{formatMessage(
|
||||
{ id: 'explorer.tree.stats' },
|
||||
{
|
||||
files: rootNodes.length,
|
||||
loading: isFetching
|
||||
}
|
||||
)}
|
||||
</span>
|
||||
{rootPath !== '/' && (
|
||||
<span className="truncate ml-2" title={rootPath}>
|
||||
{rootPath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resizable handle */}
|
||||
<div
|
||||
className={cn(
|
||||
'w-1 bg-border cursor-col-resize hover:bg-primary/50 transition-colors flex-shrink-0',
|
||||
isResizing && 'bg-primary'
|
||||
)}
|
||||
onMouseDown={handleResizeStart}
|
||||
/>
|
||||
|
||||
{/* File preview panel */}
|
||||
<div className="flex-1 min-w-0 h-full flex flex-col bg-background">
|
||||
<FilePreview
|
||||
fileContent={fileContent}
|
||||
isLoading={isContentLoading}
|
||||
error={contentError?.message}
|
||||
showLineNumbers
|
||||
maxSize={1024 * 1024} // 1MB
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExplorerPage;
|
||||
@@ -192,7 +192,7 @@ export function FixSessionPage() {
|
||||
<Wrench className="h-5 w-5" />
|
||||
{formatMessage({ id: 'fixSession.progress.title' })}
|
||||
</h3>
|
||||
<Badge variant="secondary">{session.phase || 'Execution'}</Badge>
|
||||
<Badge variant="secondary">{session.phase || formatMessage({ id: 'fixSession.phase.execution' })}</Badge>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
|
||||
253
ccw/frontend/src/pages/GraphExplorerPage.tsx
Normal file
253
ccw/frontend/src/pages/GraphExplorerPage.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
// ========================================
|
||||
// Graph Explorer Page
|
||||
// ========================================
|
||||
// Main page for code dependency graph visualization
|
||||
|
||||
import { useCallback, useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useReactFlow,
|
||||
Panel,
|
||||
ReactFlowProvider,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { useGraphData } from '@/hooks/useGraphData';
|
||||
import { GraphToolbar } from '@/components/shared/GraphToolbar';
|
||||
import { GraphSidebar } from '@/components/shared/GraphSidebar';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { GraphNode, GraphFilters, NodeType, EdgeType } from '@/types/graph-explorer';
|
||||
import { nodeTypes } from './graph-explorer/nodes';
|
||||
import { edgeTypes } from './graph-explorer/edges';
|
||||
|
||||
/**
|
||||
* Inner Graph Explorer Page Component (wrapped with ReactFlowProvider)
|
||||
*/
|
||||
function GraphExplorerPageInner() {
|
||||
const { formatMessage } = useIntl();
|
||||
const { fitView } = useReactFlow();
|
||||
|
||||
// State
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [filters, setFilters] = useState<GraphFilters>({
|
||||
nodeTypes: ['component', 'module', 'class', 'function', 'variable', 'interface', 'hook'],
|
||||
edgeTypes: ['imports', 'exports', 'extends', 'implements', 'uses', 'calls', 'depends-on'],
|
||||
showIsolatedNodes: false,
|
||||
});
|
||||
|
||||
// Fetch graph data
|
||||
const { graphData, isLoading, isFetching, error, refetch, applyFilters } = useGraphData({
|
||||
rootPath: '/src',
|
||||
maxDepth: 3,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
// Apply filters to graph data
|
||||
const filteredGraphData = useMemo(() => {
|
||||
return applyFilters(filters) || { nodes: [], edges: [], metadata: undefined };
|
||||
}, [graphData, filters, applyFilters]);
|
||||
|
||||
// Calculate node/edge type counts for badges
|
||||
const nodeTypeCounts = useMemo(() => {
|
||||
const counts: Partial<Record<NodeType, number>> = {};
|
||||
graphData?.nodes.forEach(node => {
|
||||
counts[node.type as NodeType] = (counts[node.type as NodeType] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
}, [graphData]);
|
||||
|
||||
const edgeTypeCounts = useMemo(() => {
|
||||
const counts: Partial<Record<EdgeType, number>> = {};
|
||||
graphData?.edges.forEach(edge => {
|
||||
const type = edge.data?.edgeType as EdgeType;
|
||||
if (type) {
|
||||
counts[type] = (counts[type] || 0) + 1;
|
||||
}
|
||||
});
|
||||
return counts;
|
||||
}, [graphData]);
|
||||
|
||||
// Event handlers
|
||||
const handleNodeClick = useCallback((_event: React.MouseEvent, node: GraphNode) => {
|
||||
setSelectedNode(node);
|
||||
setIsSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const handlePaneClick = useCallback(() => {
|
||||
setSelectedNode(null);
|
||||
}, []);
|
||||
|
||||
const handleFitView = useCallback(() => {
|
||||
fitView({ padding: 0.2, duration: 300 });
|
||||
}, [fitView]);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
await refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleFiltersChange = useCallback((newFilters: GraphFilters) => {
|
||||
setFilters(newFilters);
|
||||
}, []);
|
||||
|
||||
const handleResetFilters = useCallback(() => {
|
||||
setFilters({
|
||||
nodeTypes: ['component', 'module', 'class', 'function', 'variable', 'interface', 'hook'],
|
||||
edgeTypes: ['imports', 'exports', 'extends', 'implements', 'uses', 'calls', 'depends-on'],
|
||||
showIsolatedNodes: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSidebarClose = useCallback(() => {
|
||||
setIsSidebarOpen(false);
|
||||
setSelectedNode(null);
|
||||
}, []);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full p-6">
|
||||
<Card className="p-6 border-red-200 dark:border-red-800">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-500" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">
|
||||
{formatMessage({ id: 'graph.error.loading' }, { message: error.message })}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (!graphData || graphData.nodes.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.empty' })}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<GraphToolbar
|
||||
filters={filters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onFitView={handleFitView}
|
||||
onRefresh={handleRefresh}
|
||||
onResetFilters={handleResetFilters}
|
||||
nodeTypeCounts={nodeTypeCounts}
|
||||
edgeTypeCounts={edgeTypeCounts}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Graph canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<ReactFlow
|
||||
nodes={filteredGraphData.nodes}
|
||||
edges={filteredGraphData.edges}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
defaultViewport={{ x: 0, y: 0, zoom: 0.8 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
className="bg-background"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} />
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
switch (node.type) {
|
||||
case 'component':
|
||||
case 'module':
|
||||
return '#3b82f6';
|
||||
case 'class':
|
||||
return '#22c55e';
|
||||
case 'function':
|
||||
return '#f97316';
|
||||
case 'variable':
|
||||
return '#06b6d4';
|
||||
default:
|
||||
return '#6b7280';
|
||||
}
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
|
||||
{/* Status panel */}
|
||||
<Panel position="top-left" className="bg-card/90 backdrop-blur border border-border rounded-lg p-3 shadow-lg">
|
||||
<div className="text-xs space-y-1">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.status.nodes' })}
|
||||
</span>
|
||||
<span className="font-medium">{filteredGraphData.nodes.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.status.edges' })}
|
||||
</span>
|
||||
<span className="font-medium">{filteredGraphData.edges.length}</span>
|
||||
</div>
|
||||
{isFetching && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'graph.status.updating' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
{isSidebarOpen && (
|
||||
<GraphSidebar
|
||||
selectedNode={selectedNode}
|
||||
showLegend={!selectedNode}
|
||||
onClose={handleSidebarClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Graph Explorer Page Component (with ReactFlowProvider wrapper)
|
||||
*/
|
||||
export function GraphExplorerPage() {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<GraphExplorerPageInner />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default GraphExplorerPage;
|
||||
@@ -19,35 +19,47 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface HelpSection {
|
||||
title: string;
|
||||
description: string;
|
||||
i18nKey: string;
|
||||
descriptionI18nKey: string;
|
||||
headingI18nKey?: string;
|
||||
icon: React.ElementType;
|
||||
link?: string;
|
||||
isExternal?: boolean;
|
||||
}
|
||||
|
||||
const helpSections: HelpSection[] = [
|
||||
interface HelpSectionConfig {
|
||||
i18nKey: string;
|
||||
descriptionKey: string;
|
||||
headingKey?: string;
|
||||
icon: React.ElementType;
|
||||
link?: string;
|
||||
isExternal?: boolean;
|
||||
}
|
||||
|
||||
const helpSectionsConfig: HelpSectionConfig[] = [
|
||||
{
|
||||
title: 'Getting Started',
|
||||
description: 'Learn the basics of CCW Dashboard and workflow management',
|
||||
i18nKey: 'home.help.gettingStarted.title',
|
||||
descriptionKey: 'home.help.gettingStarted.description',
|
||||
headingKey: 'home.help.gettingStarted.heading',
|
||||
icon: Book,
|
||||
link: '#getting-started',
|
||||
},
|
||||
{
|
||||
title: 'Orchestrator Guide',
|
||||
description: 'Master the visual workflow editor with drag-drop flows',
|
||||
i18nKey: 'home.help.orchestratorGuide.title',
|
||||
descriptionKey: 'home.help.orchestratorGuide.description',
|
||||
icon: Workflow,
|
||||
link: '/orchestrator',
|
||||
},
|
||||
{
|
||||
title: 'Sessions Management',
|
||||
description: 'Understanding workflow sessions and task tracking',
|
||||
i18nKey: 'home.help.sessionsManagement.title',
|
||||
descriptionKey: 'home.help.sessionsManagement.description',
|
||||
icon: FolderKanban,
|
||||
link: '/sessions',
|
||||
},
|
||||
{
|
||||
title: 'CLI Integration',
|
||||
description: 'Using CCW commands and CLI tool integration',
|
||||
i18nKey: 'home.help.cliIntegration.title',
|
||||
descriptionKey: 'home.help.cliIntegration.description',
|
||||
headingKey: 'home.help.cliIntegration.heading',
|
||||
icon: Terminal,
|
||||
link: '#cli-integration',
|
||||
},
|
||||
@@ -56,6 +68,13 @@ const helpSections: HelpSection[] = [
|
||||
export function HelpPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Build help sections with i18n
|
||||
const helpSections: HelpSection[] = helpSectionsConfig.map(section => ({
|
||||
...section,
|
||||
descriptionI18nKey: section.descriptionKey,
|
||||
headingI18nKey: section.headingKey,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Page Header */}
|
||||
@@ -81,10 +100,10 @@ export function HelpPage() {
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{section.title}
|
||||
{formatMessage({ id: section.i18nKey })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{section.description}
|
||||
{formatMessage({ id: section.descriptionI18nKey })}
|
||||
</p>
|
||||
</div>
|
||||
{section.isExternal && (
|
||||
@@ -96,14 +115,14 @@ export function HelpPage() {
|
||||
|
||||
if (section.link?.startsWith('/')) {
|
||||
return (
|
||||
<Link key={section.title} to={section.link}>
|
||||
<Link key={section.i18nKey} to={section.link}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a key={section.title} href={section.link}>
|
||||
<a key={section.i18nKey} href={section.link}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
@@ -113,7 +132,7 @@ export function HelpPage() {
|
||||
{/* Getting Started Section */}
|
||||
<Card className="p-6" id="getting-started">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
Getting Started with CCW
|
||||
{formatMessage({ id: 'home.help.gettingStarted.heading' })}
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-muted-foreground">
|
||||
<p>
|
||||
@@ -148,7 +167,7 @@ export function HelpPage() {
|
||||
{/* CLI Integration Section */}
|
||||
<Card className="p-6" id="cli-integration">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
CLI Integration
|
||||
{formatMessage({ id: 'home.help.cliIntegration.heading' })}
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-muted-foreground">
|
||||
<p>
|
||||
|
||||
399
ccw/frontend/src/pages/HookManagerPage.tsx
Normal file
399
ccw/frontend/src/pages/HookManagerPage.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
// ========================================
|
||||
// Hook Manager Page
|
||||
// ========================================
|
||||
// Full CRUD page for managing CLI hooks
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
GitFork,
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Zap,
|
||||
Wrench,
|
||||
CheckCircle,
|
||||
StopCircle,
|
||||
Wand2,
|
||||
Brain,
|
||||
Shield,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EventGroup, HookFormDialog, HookQuickTemplates, HookWizard, type HookCardData, type HookFormData, type HookTriggerType, HOOK_TEMPLATES, type WizardType } from '@/components/hook';
|
||||
import { useHooks, useToggleHook } from '@/hooks';
|
||||
import { installHookTemplate, createHook } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
interface HooksByTrigger {
|
||||
UserPromptSubmit: HookCardData[];
|
||||
PreToolUse: HookCardData[];
|
||||
PostToolUse: HookCardData[];
|
||||
Stop: HookCardData[];
|
||||
}
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function isHookTriggerType(value: string): value is HookTriggerType {
|
||||
return ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'Stop'].includes(value);
|
||||
}
|
||||
|
||||
function toHookCardData(hook: { name: string; description?: string; enabled: boolean; trigger: string; matcher?: string; command?: string; script?: string }): HookCardData | null {
|
||||
if (!isHookTriggerType(hook.trigger)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
name: hook.name,
|
||||
description: hook.description,
|
||||
enabled: hook.enabled,
|
||||
trigger: hook.trigger,
|
||||
matcher: hook.matcher,
|
||||
command: hook.command || hook.script,
|
||||
};
|
||||
}
|
||||
|
||||
function groupHooksByTrigger(hooks: HookCardData[]): HooksByTrigger {
|
||||
return {
|
||||
UserPromptSubmit: hooks.filter((h) => h.trigger === 'UserPromptSubmit'),
|
||||
PreToolUse: hooks.filter((h) => h.trigger === 'PreToolUse'),
|
||||
PostToolUse: hooks.filter((h) => h.trigger === 'PostToolUse'),
|
||||
Stop: hooks.filter((h) => h.trigger === 'Stop'),
|
||||
};
|
||||
}
|
||||
|
||||
function getTriggerStats(hooksByTrigger: HooksByTrigger) {
|
||||
return {
|
||||
UserPromptSubmit: {
|
||||
total: hooksByTrigger.UserPromptSubmit.length,
|
||||
enabled: hooksByTrigger.UserPromptSubmit.filter((h) => h.enabled).length,
|
||||
},
|
||||
PreToolUse: {
|
||||
total: hooksByTrigger.PreToolUse.length,
|
||||
enabled: hooksByTrigger.PreToolUse.filter((h) => h.enabled).length,
|
||||
},
|
||||
PostToolUse: {
|
||||
total: hooksByTrigger.PostToolUse.length,
|
||||
enabled: hooksByTrigger.PostToolUse.filter((h) => h.enabled).length,
|
||||
},
|
||||
Stop: {
|
||||
total: hooksByTrigger.Stop.length,
|
||||
enabled: hooksByTrigger.Stop.filter((h) => h.enabled).length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function HookManagerPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create');
|
||||
const [editingHook, setEditingHook] = useState<HookCardData | undefined>();
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [wizardType, setWizardType] = useState<WizardType>('memory-update');
|
||||
|
||||
const { hooks, enabledCount, totalCount, isLoading, refetch } = useHooks();
|
||||
const { toggleHook } = useToggleHook();
|
||||
|
||||
// Convert hooks to HookCardData and filter by search query
|
||||
const filteredHooks = useMemo(() => {
|
||||
const validHooks = hooks.map(toHookCardData).filter((h): h is HookCardData => h !== null);
|
||||
|
||||
if (!searchQuery.trim()) return validHooks;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return validHooks.filter(
|
||||
(h) =>
|
||||
h.name.toLowerCase().includes(query) ||
|
||||
(h.description && h.description.toLowerCase().includes(query)) ||
|
||||
h.trigger.toLowerCase().includes(query) ||
|
||||
(h.command && h.command.toLowerCase().includes(query))
|
||||
);
|
||||
}, [hooks, searchQuery]);
|
||||
|
||||
// Group hooks by trigger type
|
||||
const hooksByTrigger = useMemo(() => groupHooksByTrigger(filteredHooks), [filteredHooks]);
|
||||
|
||||
// Get stats for each trigger type
|
||||
const triggerStats = useMemo(() => getTriggerStats(hooksByTrigger), [hooksByTrigger]);
|
||||
|
||||
// Handlers
|
||||
const handleAddClick = () => {
|
||||
setDialogMode('create');
|
||||
setEditingHook(undefined);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditClick = (hook: HookCardData) => {
|
||||
setDialogMode('edit');
|
||||
setEditingHook(hook);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = async (hookName: string) => {
|
||||
// This will be implemented when delete API is added
|
||||
console.log('Delete hook:', hookName);
|
||||
};
|
||||
|
||||
const handleSave = async (data: HookFormData) => {
|
||||
// This will be implemented when create/update APIs are added
|
||||
console.log('Save hook:', data);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
// ========== Wizard Handlers ==========
|
||||
|
||||
const wizardTypes: Array<{ type: WizardType; icon: typeof Brain; label: string; description: string }> = [
|
||||
{
|
||||
type: 'memory-update',
|
||||
icon: Brain,
|
||||
label: formatMessage({ id: 'cliHooks.wizards.memoryUpdate.title' }),
|
||||
description: formatMessage({ id: 'cliHooks.wizards.memoryUpdate.shortDescription' }),
|
||||
},
|
||||
{
|
||||
type: 'danger-protection',
|
||||
icon: Shield,
|
||||
label: formatMessage({ id: 'cliHooks.wizards.dangerProtection.title' }),
|
||||
description: formatMessage({ id: 'cliHooks.wizards.dangerProtection.shortDescription' }),
|
||||
},
|
||||
{
|
||||
type: 'skill-context',
|
||||
icon: Sparkles,
|
||||
label: formatMessage({ id: 'cliHooks.wizards.skillContext.title' }),
|
||||
description: formatMessage({ id: 'cliHooks.wizards.skillContext.shortDescription' }),
|
||||
},
|
||||
];
|
||||
|
||||
const handleLaunchWizard = (type: WizardType) => {
|
||||
setWizardType(type);
|
||||
setWizardOpen(true);
|
||||
};
|
||||
|
||||
const handleWizardComplete = async (hookConfig: {
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: string;
|
||||
matcher?: string;
|
||||
command: string;
|
||||
}) => {
|
||||
await createHook(hookConfig);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
// ========== Quick Templates Logic ==========
|
||||
|
||||
// Determine which templates are already installed
|
||||
const installedTemplates = useMemo(() => {
|
||||
return HOOK_TEMPLATES.filter(template => {
|
||||
return hooks.some(hook => {
|
||||
// Check if hook name contains template ID
|
||||
return hook.name.includes(template.id) ||
|
||||
(hook.command && hook.command.includes(template.command));
|
||||
});
|
||||
}).map(t => t.id);
|
||||
}, [hooks]);
|
||||
|
||||
// Mutation for installing templates
|
||||
const installMutation = useMutation({
|
||||
mutationFn: async (templateId: string) => {
|
||||
return await installHookTemplate(templateId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const handleInstallTemplate = async (templateId: string) => {
|
||||
await installMutation.mutateAsync(templateId);
|
||||
};
|
||||
|
||||
const TRIGGER_TYPES: Array<{ type: HookTriggerType; icon: typeof Zap }> = [
|
||||
{ type: 'UserPromptSubmit', icon: Zap },
|
||||
{ type: 'PreToolUse', icon: Wrench },
|
||||
{ type: 'PostToolUse', icon: CheckCircle },
|
||||
{ type: 'Stop', icon: StopCircle },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<GitFork className="w-6 h-6 text-primary" />
|
||||
{formatMessage({ id: 'cliHooks.title' })}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'cliHooks.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-1', isLoading && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleAddClick}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'cliHooks.actions.add' })}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => handleLaunchWizard('memory-update')} variant="secondary">
|
||||
<Wand2 className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'cliHooks.wizards.launch' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{TRIGGER_TYPES.map(({ type, icon: Icon }) => {
|
||||
const stats = triggerStats[type];
|
||||
return (
|
||||
<Card key={type} className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Icon className="w-4 h-4 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: `cliHooks.trigger.${type}` })}
|
||||
</p>
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
{stats.enabled}/{stats.total}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Search and Global Stats */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'cliHooks.filters.searchPlaceholder' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{formatMessage({ id: 'cliHooks.stats.total' }, { count: totalCount })}
|
||||
</Badge>
|
||||
<Badge variant="default" className="text-sm">
|
||||
{formatMessage({ id: 'cliHooks.stats.enabled' }, { count: enabledCount })}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Templates */}
|
||||
<Card className="p-6">
|
||||
<HookQuickTemplates
|
||||
onInstallTemplate={handleInstallTemplate}
|
||||
installedTemplates={installedTemplates}
|
||||
isLoading={installMutation.isPending}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Wizard Launchers */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Wand2 className="w-5 h-5 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.sectionTitle' })}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'cliHooks.wizards.sectionDescription' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{wizardTypes.map(({ type, icon: Icon, label, description }) => (
|
||||
<Card key={type} className="p-4 cursor-pointer hover:bg-muted/50 transition-colors" onClick={() => handleLaunchWizard(type)}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10 shrink-0">
|
||||
<Icon className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||
{label}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Event Groups */}
|
||||
<div className="space-y-4">
|
||||
{TRIGGER_TYPES.map(({ type }) => (
|
||||
<EventGroup
|
||||
key={type}
|
||||
eventType={type}
|
||||
hooks={hooksByTrigger[type]}
|
||||
onHookToggle={(hookName, enabled) => toggleHook(hookName, enabled)}
|
||||
onHookEdit={handleEditClick}
|
||||
onHookDelete={handleDeleteClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && filteredHooks.length === 0 && (
|
||||
<Card className="p-12 text-center">
|
||||
<GitFork className="w-16 h-16 mx-auto text-muted-foreground/30 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{formatMessage({ id: 'cliHooks.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
{formatMessage({ id: 'cliHooks.empty.description' })}
|
||||
</p>
|
||||
<Button onClick={handleAddClick}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'cliHooks.actions.addFirst' })}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Form Dialog */}
|
||||
<HookFormDialog
|
||||
mode={dialogMode}
|
||||
hook={editingHook}
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* Hook Wizard */}
|
||||
<HookWizard
|
||||
wizardType={wizardType}
|
||||
open={wizardOpen}
|
||||
onClose={() => setWizardOpen(false)}
|
||||
onComplete={handleWizardComplete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HookManagerPage;
|
||||
@@ -33,11 +33,12 @@ type LiteTaskTab = 'lite-plan' | 'lite-fix' | 'multi-cli-plan';
|
||||
|
||||
/**
|
||||
* Get i18n text from label object (supports {en, zh} format)
|
||||
* Note: fallback should be provided dynamically from component context
|
||||
*/
|
||||
function getI18nText(label: string | { en?: string; zh?: string } | undefined, fallback: string): string {
|
||||
if (!label) return fallback;
|
||||
function getI18nText(label: string | { en?: string; zh?: string } | undefined): string | undefined {
|
||||
if (!label) return undefined;
|
||||
if (typeof label === 'string') return label;
|
||||
return label.en || label.zh || fallback;
|
||||
return label.en || label.zh;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -147,7 +148,7 @@ export function LiteTasksPage() {
|
||||
</Badge>
|
||||
</div>
|
||||
<h4 className="text-sm font-medium text-foreground">
|
||||
{task.title || 'Untitled Task'}
|
||||
{task.title || formatMessage({ id: 'liteTasks.untitled' })}
|
||||
</h4>
|
||||
{task.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
@@ -172,9 +173,8 @@ export function LiteTasksPage() {
|
||||
const latestSynthesis = session.latestSynthesis || {};
|
||||
const roundCount = (metadata.roundId as number) || session.roundCount || 1;
|
||||
const topicTitle = getI18nText(
|
||||
latestSynthesis.title as string | { en?: string; zh?: string } | undefined,
|
||||
'Discussion Topic'
|
||||
);
|
||||
latestSynthesis.title as string | { en?: string; zh?: string } | undefined
|
||||
) || formatMessage({ id: 'liteTasks.discussionTopic' });
|
||||
const status = latestSynthesis.status || session.status || 'analyzing';
|
||||
const createdAt = (metadata.timestamp as string) || session.createdAt || '';
|
||||
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
// MCP Manager Page
|
||||
// ========================================
|
||||
// Manage MCP servers (Model Context Protocol) with project/global scope switching
|
||||
// Supports both Claude and Codex CLI modes
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
@@ -23,8 +25,19 @@ import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { McpServerDialog } from '@/components/mcp/McpServerDialog';
|
||||
import { CliModeToggle, type CliMode } from '@/components/mcp/CliModeToggle';
|
||||
import { CodexMcpCard } from '@/components/mcp/CodexMcpCard';
|
||||
import { CcwToolsMcpCard } from '@/components/mcp/CcwToolsMcpCard';
|
||||
import { useMcpServers, useMcpServerMutations } from '@/hooks';
|
||||
import type { McpServer } from '@/lib/api';
|
||||
import {
|
||||
fetchCodexMcpServers,
|
||||
fetchCcwMcpConfig,
|
||||
updateCcwConfig,
|
||||
type McpServer,
|
||||
type CodexMcpServer,
|
||||
type CcwMcpConfig,
|
||||
} from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== MCP Server Card Component ==========
|
||||
@@ -180,6 +193,10 @@ export function McpManagerPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'project' | 'global'>('all');
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingServer, setEditingServer] = useState<McpServer | undefined>(undefined);
|
||||
const [cliMode, setCliMode] = useState<CliMode>('claude');
|
||||
const [codexExpandedServers, setCodexExpandedServers] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
servers,
|
||||
@@ -194,6 +211,22 @@ export function McpManagerPage() {
|
||||
scope: scopeFilter === 'all' ? undefined : scopeFilter,
|
||||
});
|
||||
|
||||
// Fetch Codex MCP servers when in codex mode
|
||||
const codexQuery = useQuery({
|
||||
queryKey: ['codexMcpServers'],
|
||||
queryFn: fetchCodexMcpServers,
|
||||
enabled: cliMode === 'codex',
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
});
|
||||
|
||||
// Fetch CCW Tools MCP configuration (Claude mode only)
|
||||
const ccwMcpQuery = useQuery({
|
||||
queryKey: ['ccwMcpConfig'],
|
||||
queryFn: fetchCcwMcpConfig,
|
||||
enabled: cliMode === 'claude',
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const {
|
||||
toggleServer,
|
||||
deleteServer,
|
||||
@@ -211,6 +244,18 @@ export function McpManagerPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCodexExpand = (serverName: string) => {
|
||||
setCodexExpandedServers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(serverName)) {
|
||||
next.delete(serverName);
|
||||
} else {
|
||||
next.add(serverName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggle = (serverName: string, enabled: boolean) => {
|
||||
toggleServer(serverName, enabled);
|
||||
};
|
||||
@@ -222,8 +267,54 @@ export function McpManagerPage() {
|
||||
};
|
||||
|
||||
const handleEdit = (server: McpServer) => {
|
||||
// TODO: Implement edit dialog
|
||||
console.log('Edit server:', server);
|
||||
setEditingServer(server);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAddClick = () => {
|
||||
setEditingServer(undefined);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingServer(undefined);
|
||||
};
|
||||
|
||||
const handleDialogSave = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingServer(undefined);
|
||||
refetch();
|
||||
};
|
||||
|
||||
const handleModeChange = (mode: CliMode) => {
|
||||
setCliMode(mode);
|
||||
};
|
||||
|
||||
// CCW MCP handlers
|
||||
const ccwConfig = ccwMcpQuery.data ?? {
|
||||
isInstalled: false,
|
||||
enabledTools: [],
|
||||
projectRoot: undefined,
|
||||
allowedDirs: undefined,
|
||||
disableSandbox: undefined,
|
||||
};
|
||||
|
||||
const handleToggleCcwTool = async (tool: string, enabled: boolean) => {
|
||||
const updatedTools = enabled
|
||||
? [...ccwConfig.enabledTools, tool]
|
||||
: ccwConfig.enabledTools.filter((t) => t !== tool);
|
||||
await updateCcwConfig({ enabledTools: updatedTools });
|
||||
ccwMcpQuery.refetch();
|
||||
};
|
||||
|
||||
const handleUpdateCcwConfig = async (config: Partial<CcwMcpConfig>) => {
|
||||
await updateCcwConfig(config);
|
||||
ccwMcpQuery.refetch();
|
||||
};
|
||||
|
||||
const handleCcwInstall = () => {
|
||||
ccwMcpQuery.refetch();
|
||||
};
|
||||
|
||||
// Filter servers by search query
|
||||
@@ -232,6 +323,21 @@ export function McpManagerPage() {
|
||||
s.command.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Filter Codex servers by search query
|
||||
const codexServers = codexQuery.data?.servers ?? [];
|
||||
const codexConfigPath = codexQuery.data?.configPath ?? '';
|
||||
const filteredCodexServers = codexServers.filter((s) =>
|
||||
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.command.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const currentServers = cliMode === 'codex' ? filteredCodexServers : filteredServers;
|
||||
const currentExpanded = cliMode === 'codex' ? codexExpandedServers : expandedServers;
|
||||
const currentToggleExpand = cliMode === 'codex' ? toggleCodexExpand : toggleExpand;
|
||||
const currentIsLoading = cliMode === 'codex' ? codexQuery.isLoading : isLoading;
|
||||
const currentIsFetching = cliMode === 'codex' ? codexQuery.isFetching : isFetching;
|
||||
const currentRefetch = cliMode === 'codex' ? (() => codexQuery.refetch()) : refetch;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
@@ -246,51 +352,102 @@ export function McpManagerPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => refetch()} disabled={isFetching}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', isFetching && 'animate-spin')} />
|
||||
<Button variant="outline" onClick={() => currentRefetch()} disabled={currentIsFetching}>
|
||||
<RefreshCw className={cn('w-4 h-4 mr-2', currentIsFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'mcp.actions.add' })}
|
||||
</Button>
|
||||
{cliMode === 'claude' && (
|
||||
<Button onClick={handleAddClick}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'mcp.actions.add' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-primary" />
|
||||
<span className="text-2xl font-bold">{totalCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.total' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Power className="w-5 h-5 text-green-600" />
|
||||
<span className="text-2xl font-bold">{enabledCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.enabled' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-info" />
|
||||
<span className="text-2xl font-bold">{globalServers.length}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.global' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5 text-warning" />
|
||||
<span className="text-2xl font-bold">{projectServers.length}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.project' })}</p>
|
||||
</Card>
|
||||
</div>
|
||||
{/* CLI Mode Toggle */}
|
||||
<CliModeToggle
|
||||
currentMode={cliMode}
|
||||
onModeChange={handleModeChange}
|
||||
codexConfigPath={codexConfigPath}
|
||||
/>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
{/* Stats Cards - Claude mode only */}
|
||||
{cliMode === 'claude' && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-primary" />
|
||||
<span className="text-2xl font-bold">{totalCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.total' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Power className="w-5 h-5 text-green-600" />
|
||||
<span className="text-2xl font-bold">{enabledCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.enabled' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5 text-info" />
|
||||
<span className="text-2xl font-bold">{globalServers.length}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.global' })}</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5 text-warning" />
|
||||
<span className="text-2xl font-bold">{projectServers.length}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">{formatMessage({ id: 'mcp.stats.project' })}</p>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters and Search - Claude mode only */}
|
||||
{cliMode === 'claude' && (
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'mcp.filters.searchPlaceholder' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={scopeFilter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setScopeFilter('all')}
|
||||
>
|
||||
{formatMessage({ id: 'mcp.filters.all' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant={scopeFilter === 'global' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setScopeFilter('global')}
|
||||
>
|
||||
<Globe className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'mcp.scope.global' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant={scopeFilter === 'project' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setScopeFilter('project')}
|
||||
>
|
||||
<Folder className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'mcp.scope.project' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Codex mode search only */}
|
||||
{cliMode === 'codex' && (
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -300,41 +457,30 @@ export function McpManagerPage() {
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={scopeFilter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setScopeFilter('all')}
|
||||
>
|
||||
{formatMessage({ id: 'mcp.filters.all' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant={scopeFilter === 'global' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setScopeFilter('global')}
|
||||
>
|
||||
<Globe className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'mcp.scope.global' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant={scopeFilter === 'project' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setScopeFilter('project')}
|
||||
>
|
||||
<Folder className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'mcp.scope.project' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CCW Tools MCP Card - Claude mode only */}
|
||||
{cliMode === 'claude' && (
|
||||
<CcwToolsMcpCard
|
||||
isInstalled={ccwConfig.isInstalled}
|
||||
enabledTools={ccwConfig.enabledTools}
|
||||
projectRoot={ccwConfig.projectRoot}
|
||||
allowedDirs={ccwConfig.allowedDirs}
|
||||
disableSandbox={ccwConfig.disableSandbox}
|
||||
onToggleTool={handleToggleCcwTool}
|
||||
onUpdateConfig={handleUpdateCcwConfig}
|
||||
onInstall={handleCcwInstall}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Servers List */}
|
||||
{isLoading ? (
|
||||
{currentIsLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-24 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : filteredServers.length === 0 ? (
|
||||
) : currentServers.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Server className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">{formatMessage({ id: 'mcp.emptyState.title' })}</h3>
|
||||
@@ -344,19 +490,40 @@ export function McpManagerPage() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredServers.map((server) => (
|
||||
<McpServerCard
|
||||
key={server.name}
|
||||
server={server}
|
||||
isExpanded={expandedServers.has(server.name)}
|
||||
onToggleExpand={() => toggleExpand(server.name)}
|
||||
onToggle={handleToggle}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
{currentServers.map((server) => (
|
||||
cliMode === 'codex' ? (
|
||||
<CodexMcpCard
|
||||
key={server.name}
|
||||
server={server as CodexMcpServer}
|
||||
enabled={server.enabled}
|
||||
isExpanded={currentExpanded.has(server.name)}
|
||||
onToggleExpand={() => currentToggleExpand(server.name)}
|
||||
/>
|
||||
) : (
|
||||
<McpServerCard
|
||||
key={server.name}
|
||||
server={server}
|
||||
isExpanded={currentExpanded.has(server.name)}
|
||||
onToggleExpand={() => currentToggleExpand(server.name)}
|
||||
onToggle={handleToggle}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Dialog - Claude mode only */}
|
||||
{cliMode === 'claude' && (
|
||||
<McpServerDialog
|
||||
mode={editingServer ? 'edit' : 'add'}
|
||||
server={editingServer}
|
||||
open={dialogOpen}
|
||||
onClose={handleDialogClose}
|
||||
onSave={handleDialogSave}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
|
||||
type DevIndexView = 'category' | 'timeline';
|
||||
|
||||
// Helper function to format date
|
||||
// Helper function to format date (currently unused but kept for future use)
|
||||
// @ts-ignore - kept for potential future use
|
||||
function formatDate(dateString: string | undefined): string {
|
||||
if (!dateString) return '-';
|
||||
try {
|
||||
@@ -62,6 +63,74 @@ export function ProjectOverviewPage() {
|
||||
const { projectOverview, isLoading, error, refetch } = useProjectOverview();
|
||||
const [devIndexView, setDevIndexView] = React.useState<DevIndexView>('category');
|
||||
|
||||
// Helper function to format date
|
||||
function formatDate(dateString: string | undefined): string {
|
||||
if (!dateString) return '-';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
|
||||
// Define dev index categories (before any conditional logic)
|
||||
const devIndexCategories = [
|
||||
{ key: 'feature', i18nKey: 'projectOverview.devIndex.category.features', icon: Sparkles, color: 'primary' },
|
||||
{ key: 'enhancement', i18nKey: 'projectOverview.devIndex.category.enhancements', icon: Zap, color: 'success' },
|
||||
{ key: 'bugfix', i18nKey: 'projectOverview.devIndex.category.bugfixes', icon: Bug, color: 'destructive' },
|
||||
{ key: 'refactor', i18nKey: 'projectOverview.devIndex.category.refactorings', icon: Wrench, color: 'warning' },
|
||||
{ key: 'docs', i18nKey: 'projectOverview.devIndex.category.documentation', icon: BookOpen, color: 'muted' },
|
||||
];
|
||||
|
||||
// Collect all entries for timeline (compute before any conditional logic)
|
||||
const allDevEntries = React.useMemo(() => {
|
||||
if (!projectOverview?.developmentIndex) return [];
|
||||
|
||||
const entries: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
typeLabel: string;
|
||||
typeIcon: React.ElementType;
|
||||
typeColor: string;
|
||||
date: string;
|
||||
sessionId?: string;
|
||||
sub_feature?: string;
|
||||
tags?: string[];
|
||||
}> = [];
|
||||
|
||||
devIndexCategories.forEach((cat) => {
|
||||
(projectOverview.developmentIndex?.[cat.key] || []).forEach((entry: DevelopmentIndexEntry) => {
|
||||
entries.push({
|
||||
...entry,
|
||||
type: cat.key,
|
||||
typeLabel: formatMessage({ id: cat.i18nKey }),
|
||||
typeIcon: cat.icon,
|
||||
typeColor: cat.color,
|
||||
date: entry.archivedAt || entry.date || entry.implemented_at || '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
}, [projectOverview?.developmentIndex]);
|
||||
|
||||
// Calculate totals for development index
|
||||
const devIndexTotals = devIndexCategories.reduce((acc, cat) => {
|
||||
acc[cat.key] = (projectOverview?.developmentIndex?.[cat.key] || []).length;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const totalEntries = Object.values(devIndexTotals).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
// Calculate statistics
|
||||
const totalFeatures = devIndexCategories.reduce((sum, cat) => sum + devIndexTotals[cat.key], 0);
|
||||
|
||||
// Render loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -107,58 +176,7 @@ export function ProjectOverviewPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const { technologyStack, architecture, keyComponents, developmentIndex, guidelines, metadata } =
|
||||
projectOverview;
|
||||
|
||||
// Calculate totals for development index
|
||||
const devIndexCategories = [
|
||||
{ key: 'feature', label: 'Features', icon: Sparkles, color: 'primary' },
|
||||
{ key: 'enhancement', label: 'Enhancements', icon: Zap, color: 'success' },
|
||||
{ key: 'bugfix', label: 'Bug Fixes', icon: Bug, color: 'destructive' },
|
||||
{ key: 'refactor', label: 'Refactorings', icon: Wrench, color: 'warning' },
|
||||
{ key: 'docs', label: 'Documentation', icon: BookOpen, color: 'muted' },
|
||||
];
|
||||
|
||||
const devIndexTotals = devIndexCategories.reduce((acc, cat) => {
|
||||
acc[cat.key] = (developmentIndex?.[cat.key] || []).length;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
const totalEntries = Object.values(devIndexTotals).reduce((sum, count) => sum + count, 0);
|
||||
|
||||
// Collect all entries for timeline
|
||||
const allDevEntries = React.useMemo(() => {
|
||||
const entries: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
typeLabel: string;
|
||||
typeIcon: React.ElementType;
|
||||
typeColor: string;
|
||||
date: string;
|
||||
sessionId?: string;
|
||||
sub_feature?: string;
|
||||
tags?: string[];
|
||||
}> = [];
|
||||
|
||||
devIndexCategories.forEach((cat) => {
|
||||
(developmentIndex?.[cat.key] || []).forEach((entry: DevelopmentIndexEntry) => {
|
||||
entries.push({
|
||||
...entry,
|
||||
type: cat.key,
|
||||
typeLabel: cat.label,
|
||||
typeIcon: cat.icon,
|
||||
typeColor: cat.color,
|
||||
date: entry.archivedAt || entry.date || entry.implemented_at || '',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
}, [developmentIndex, devIndexCategories]);
|
||||
|
||||
// Calculate statistics
|
||||
const totalFeatures = devIndexCategories.reduce((sum, cat) => sum + devIndexTotals[cat.key], 0);
|
||||
const { technologyStack, architecture, keyComponents, developmentIndex, guidelines, metadata } = projectOverview;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -451,7 +469,7 @@ export function ProjectOverviewPage() {
|
||||
<div key={cat.key}>
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center gap-2">
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{cat.label}</span>
|
||||
<span>{formatMessage({ id: cat.i18nKey })}</span>
|
||||
<Badge variant="secondary">{entries.length}</Badge>
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
|
||||
426
ccw/frontend/src/pages/PromptHistoryPage.tsx
Normal file
426
ccw/frontend/src/pages/PromptHistoryPage.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
// ========================================
|
||||
// PromptHistoryPage Component
|
||||
// ========================================
|
||||
// Prompt history page with timeline, stats, and AI insights
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Filter,
|
||||
AlertCircle,
|
||||
History,
|
||||
X,
|
||||
FolderOpen,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
usePromptHistory,
|
||||
usePromptInsights,
|
||||
usePromptHistoryMutations,
|
||||
type PromptHistoryFilter,
|
||||
} from '@/hooks/usePromptHistory';
|
||||
import { PromptStats, PromptStatsSkeleton } from '@/components/shared/PromptStats';
|
||||
import { PromptCard } from '@/components/shared/PromptCard';
|
||||
import { InsightsPanel } from '@/components/shared/InsightsPanel';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type IntentFilter = 'all' | string;
|
||||
|
||||
/**
|
||||
* PromptHistoryPage component - Main page for prompt history management
|
||||
*/
|
||||
export function PromptHistoryPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Filter state
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [intentFilter, setIntentFilter] = React.useState<IntentFilter>('all');
|
||||
const [selectedTool, setSelectedTool] = React.useState<'gemini' | 'qwen' | 'codex'>('gemini');
|
||||
|
||||
// Dialog state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||
const [promptToDelete, setPromptToDelete] = React.useState<string | null>(null);
|
||||
|
||||
// Build filter object
|
||||
const filter: PromptHistoryFilter = React.useMemo(
|
||||
() => ({
|
||||
search: searchQuery,
|
||||
intent: intentFilter === 'all' ? undefined : intentFilter,
|
||||
}),
|
||||
[searchQuery, intentFilter]
|
||||
);
|
||||
|
||||
// Fetch prompts and insights
|
||||
const {
|
||||
prompts,
|
||||
promptsBySession,
|
||||
stats,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = usePromptHistory({ filter });
|
||||
|
||||
const { data: insightsData, isLoading: insightsLoading } = usePromptInsights();
|
||||
|
||||
const { analyzePrompts, deletePrompt, isAnalyzing } = usePromptHistoryMutations();
|
||||
|
||||
const isMutating = isAnalyzing;
|
||||
|
||||
// Handlers
|
||||
const handleAnalyze = async () => {
|
||||
try {
|
||||
await analyzePrompts({ tool: selectedTool });
|
||||
} catch (err) {
|
||||
console.error('Failed to analyze prompts:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (promptId: string) => {
|
||||
setPromptToDelete(promptId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!promptToDelete) return;
|
||||
|
||||
try {
|
||||
await deletePrompt(promptToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setPromptToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete prompt:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const toggleIntentFilter = (intent: string) => {
|
||||
setIntentFilter((prev) => (prev === intent ? 'all' : intent));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery('');
|
||||
setIntentFilter('all');
|
||||
};
|
||||
|
||||
const hasActiveFilters = searchQuery.length > 0 || intentFilter !== 'all';
|
||||
|
||||
// Group prompts for timeline view
|
||||
const timelineGroups = React.useMemo(() => {
|
||||
const groups: Array<{ key: string; label: string; prompts: typeof prompts }> = [];
|
||||
|
||||
// Group by session if available, otherwise by date
|
||||
const sessionKeys = Object.keys(promptsBySession);
|
||||
if (sessionKeys.length > 0 && sessionKeys.some((k) => k !== 'ungrouped')) {
|
||||
// Session-based grouping
|
||||
for (const [sessionKey, sessionPrompts] of Object.entries(promptsBySession)) {
|
||||
const filtered = sessionPrompts.filter((p) =>
|
||||
prompts.some((fp) => fp.id === p.id)
|
||||
);
|
||||
if (filtered.length > 0) {
|
||||
groups.push({
|
||||
key: sessionKey,
|
||||
label: sessionKey === 'ungrouped'
|
||||
? formatMessage({ id: 'prompts.timeline.ungrouped' })
|
||||
: formatMessage({ id: 'prompts.timeline.session' }, { session: sessionKey }),
|
||||
prompts: filtered,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Date-based grouping
|
||||
const dateGroups: Record<string, typeof prompts> = {};
|
||||
for (const prompt of prompts) {
|
||||
const date = new Date(prompt.createdAt).toLocaleDateString();
|
||||
if (!dateGroups[date]) {
|
||||
dateGroups[date] = [];
|
||||
}
|
||||
dateGroups[date].push(prompt);
|
||||
}
|
||||
for (const [date, datePrompts] of Object.entries(dateGroups)) {
|
||||
groups.push({ key: date, label: date, prompts: datePrompts });
|
||||
}
|
||||
}
|
||||
|
||||
return groups.sort((a, b) => {
|
||||
const aDate = a.prompts[0]?.createdAt ? new Date(a.prompts[0].createdAt).getTime() : 0;
|
||||
const bDate = b.prompts[0]?.createdAt ? new Date(b.prompts[0].createdAt).getTime() : 0;
|
||||
return bDate - aDate;
|
||||
});
|
||||
}, [prompts, promptsBySession, formatMessage]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
<History className="h-6 w-6" />
|
||||
{formatMessage({ id: 'prompts.title' })}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'prompts.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
|
||||
<p className="text-xs mt-0.5">{error.message}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{formatMessage({ id: 'home.errors.retry' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{isLoading ? <PromptStatsSkeleton /> : <PromptStats {...stats} />}
|
||||
|
||||
{/* Main content area with timeline and insights */}
|
||||
<div className="grid gap-6 grid-cols-1 lg:grid-cols-3">
|
||||
{/* Timeline section */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* Search input */}
|
||||
<div className="flex-1 max-w-sm relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'prompts.searchPlaceholder' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Intent filter dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
{formatMessage({ id: 'common.actions.filter' })}
|
||||
{intentFilter !== 'all' && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
|
||||
{intentFilter}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>{formatMessage({ id: 'prompts.filterByIntent' })}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => setIntentFilter('all')}
|
||||
className="justify-between"
|
||||
>
|
||||
<span>{formatMessage({ id: 'prompts.intents.all' })}</span>
|
||||
{intentFilter === 'all' && <span className="text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
{['bug-fix', 'feature', 'refactor', 'document', 'analyze'].map((intent) => (
|
||||
<DropdownMenuItem
|
||||
key={intent}
|
||||
onClick={() => toggleIntentFilter(intent)}
|
||||
className="justify-between"
|
||||
>
|
||||
<span>{formatMessage({ id: `prompts.intents.${intent}` })}</span>
|
||||
{intentFilter === intent && <span className="text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={clearFilters} className="text-destructive">
|
||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{formatMessage({ id: 'common.actions.filters' })}:</span>
|
||||
{intentFilter !== 'all' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIntentFilter('all')}
|
||||
>
|
||||
{formatMessage({ id: 'prompts.intents.intent' })}: {intentFilter}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
)}
|
||||
{searchQuery && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-pointer"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.search' })}: {searchQuery}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-6 text-xs">
|
||||
{formatMessage({ id: 'common.actions.clearAll' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="p-4 rounded-lg border border-border animate-pulse">
|
||||
<div className="h-4 w-32 bg-muted rounded mb-2" />
|
||||
<div className="h-20 bg-muted rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : timelineGroups.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 border border-dashed border-border rounded-lg">
|
||||
<FolderOpen className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-1">
|
||||
{hasActiveFilters
|
||||
? formatMessage({ id: 'prompts.emptyState.title' })
|
||||
: formatMessage({ id: 'prompts.emptyState.noPrompts' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">
|
||||
{hasActiveFilters
|
||||
? formatMessage({ id: 'prompts.emptyState.message' })
|
||||
: formatMessage({ id: 'prompts.emptyState.createFirst' })}
|
||||
</p>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{timelineGroups.map((group) => (
|
||||
<div key={group.key} className="space-y-3">
|
||||
{/* Group header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-px bg-border flex-1" />
|
||||
<h3 className="text-sm font-medium text-muted-foreground px-2">
|
||||
{group.label}
|
||||
</h3>
|
||||
<div className="h-px bg-border flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Prompt cards in group */}
|
||||
<div className="space-y-2">
|
||||
{group.prompts.map((prompt) => (
|
||||
<PromptCard
|
||||
key={prompt.id}
|
||||
prompt={prompt}
|
||||
onDelete={handleDeleteClick}
|
||||
actionsDisabled={isMutating}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Insights panel */}
|
||||
<div className="lg:col-span-1">
|
||||
<InsightsPanel
|
||||
insights={insightsData?.insights}
|
||||
patterns={insightsData?.patterns}
|
||||
suggestions={insightsData?.suggestions}
|
||||
selectedTool={selectedTool}
|
||||
onToolChange={setSelectedTool}
|
||||
onAnalyze={handleAnalyze}
|
||||
isAnalyzing={isAnalyzing || insightsLoading}
|
||||
className="sticky top-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'prompts.dialog.deleteTitle' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'prompts.dialog.deleteConfirm' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setPromptToDelete(null);
|
||||
}}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isAnalyzing}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.delete' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PromptHistoryPage;
|
||||
480
ccw/frontend/src/pages/RulesManagerPage.tsx
Normal file
480
ccw/frontend/src/pages/RulesManagerPage.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
// ========================================
|
||||
// RulesManagerPage Component
|
||||
// ========================================
|
||||
// Rules list page with CRUD operations
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Filter,
|
||||
AlertCircle,
|
||||
FileCode,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useRules,
|
||||
useCreateRule,
|
||||
useDeleteRule,
|
||||
useToggleRule,
|
||||
} from '@/hooks';
|
||||
import { RuleCard, RuleCardSkeleton } from '@/components/shared/RuleCard';
|
||||
import { RuleDialog } from '@/components/shared/RuleDialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/Tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Rule } from '@/types/store';
|
||||
|
||||
type StatusFilter = 'all' | 'enabled' | 'disabled';
|
||||
type LocationFilter = 'all' | 'project' | 'user';
|
||||
|
||||
/**
|
||||
* RulesManagerPage component - Rules list with CRUD operations
|
||||
*/
|
||||
export function RulesManagerPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Filter state
|
||||
const [statusFilter, setStatusFilter] = React.useState<StatusFilter>('all');
|
||||
const [locationFilter, setLocationFilter] = React.useState<LocationFilter>('all');
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [categoryFilter, setCategoryFilter] = React.useState<string[]>([]);
|
||||
|
||||
// Dialog state
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = React.useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||
const [selectedRule, setSelectedRule] = React.useState<Rule | null>(null);
|
||||
const [ruleToDelete, setRuleToDelete] = React.useState<string | null>(null);
|
||||
|
||||
// Fetch rules
|
||||
const {
|
||||
rules,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useRules();
|
||||
|
||||
// Mutations
|
||||
const { isCreating } = useCreateRule();
|
||||
const { deleteRule, isDeleting } = useDeleteRule();
|
||||
const { toggleRule, isToggling } = useToggleRule();
|
||||
|
||||
const isMutating = isCreating || isDeleting || isToggling;
|
||||
|
||||
// Filter rules
|
||||
const filteredRules = React.useMemo(() => {
|
||||
let filtered = rules;
|
||||
|
||||
// Status filter
|
||||
if (statusFilter === 'enabled') {
|
||||
filtered = filtered.filter((r) => r.enabled);
|
||||
} else if (statusFilter === 'disabled') {
|
||||
filtered = filtered.filter((r) => !r.enabled);
|
||||
}
|
||||
|
||||
// Location filter
|
||||
if (locationFilter === 'project') {
|
||||
filtered = filtered.filter((r) => r.location === 'project');
|
||||
} else if (locationFilter === 'user') {
|
||||
filtered = filtered.filter((r) => r.location === 'user');
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (categoryFilter.length > 0) {
|
||||
filtered = filtered.filter((r) =>
|
||||
r.category ? categoryFilter.includes(r.category) : false
|
||||
);
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((r) =>
|
||||
r.name.toLowerCase().includes(query) ||
|
||||
r.description?.toLowerCase().includes(query) ||
|
||||
r.category?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [rules, statusFilter, locationFilter, categoryFilter, searchQuery]);
|
||||
|
||||
// Get all unique categories
|
||||
const categories = React.useMemo(() => {
|
||||
const cats = new Set<string>();
|
||||
rules.forEach((r) => {
|
||||
if (r.category) cats.add(r.category);
|
||||
});
|
||||
return Array.from(cats).sort();
|
||||
}, [rules]);
|
||||
|
||||
// Handlers
|
||||
const handleEditClick = (rule: Rule) => {
|
||||
setSelectedRule(rule);
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (ruleId: string) => {
|
||||
setRuleToDelete(ruleId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!ruleToDelete) return;
|
||||
|
||||
try {
|
||||
const rule = rules.find((r) => r.id === ruleToDelete);
|
||||
await deleteRule(ruleToDelete, rule?.location);
|
||||
setDeleteDialogOpen(false);
|
||||
setRuleToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete rule:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (ruleId: string, enabled: boolean) => {
|
||||
try {
|
||||
await toggleRule(ruleId, enabled);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle rule:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const toggleCategoryFilter = (category: string) => {
|
||||
setCategoryFilter((prev) =>
|
||||
prev.includes(category) ? prev.filter((c) => c !== category) : [...prev, category]
|
||||
);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatusFilter('all');
|
||||
setLocationFilter('all');
|
||||
setCategoryFilter([]);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = statusFilter !== 'all' || locationFilter !== 'all' ||
|
||||
categoryFilter.length > 0 || searchQuery.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{formatMessage({ id: 'rules.title' })}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'rules.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
{formatMessage({ id: 'common.actions.refresh' })}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'common.actions.new' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error alert */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 rounded-lg bg-destructive/10 border border-destructive/30 text-destructive">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{formatMessage({ id: 'common.errors.loadFailed' })}</p>
|
||||
<p className="text-xs mt-0.5">{error.message}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
{formatMessage({ id: 'home.errors.retry' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* Status tabs */}
|
||||
<Tabs value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">{formatMessage({ id: 'rules.filters.all' })}</TabsTrigger>
|
||||
<TabsTrigger value="enabled">{formatMessage({ id: 'rules.filters.enabled' })}</TabsTrigger>
|
||||
<TabsTrigger value="disabled">{formatMessage({ id: 'rules.filters.disabled' })}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Search input */}
|
||||
<div className="flex-1 max-w-sm relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={formatMessage({ id: 'rules.searchPlaceholder' })}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={handleClearSearch}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location filter dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
{formatMessage({ id: 'rules.filters.location' })}
|
||||
{locationFilter !== 'all' && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
|
||||
{locationFilter === 'project' ? formatMessage({ id: 'rules.location.project' }) : formatMessage({ id: 'rules.location.user' })}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>{formatMessage({ id: 'rules.filters.location' })}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setLocationFilter('all')}>
|
||||
{formatMessage({ id: 'rules.filters.all' })}
|
||||
{locationFilter === 'all' && <span className="ml-auto text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLocationFilter('project')}>
|
||||
{formatMessage({ id: 'rules.location.project' })}
|
||||
{locationFilter === 'project' && <span className="ml-auto text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setLocationFilter('user')}>
|
||||
{formatMessage({ id: 'rules.location.user' })}
|
||||
{locationFilter === 'user' && <span className="ml-auto text-primary">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Category filter dropdown */}
|
||||
{categories.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
{formatMessage({ id: 'rules.filters.category' })}
|
||||
{categoryFilter.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
|
||||
{categoryFilter.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>{formatMessage({ id: 'rules.dialog.form.category' })}</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{categories.map((cat) => (
|
||||
<DropdownMenuItem
|
||||
key={cat}
|
||||
onClick={() => toggleCategoryFilter(cat)}
|
||||
className="justify-between"
|
||||
>
|
||||
<span>{cat.charAt(0).toUpperCase() + cat.slice(1)}</span>
|
||||
{categoryFilter.includes(cat) && (
|
||||
<span className="text-primary">✓</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{categoryFilter.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setCategoryFilter([])} className="text-destructive">
|
||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{formatMessage({ id: 'common.actions.filters' })}:</span>
|
||||
{statusFilter !== 'all' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setStatusFilter('all')}
|
||||
>
|
||||
{formatMessage({ id: `rules.filters.${statusFilter}` })}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
)}
|
||||
{locationFilter !== 'all' && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setLocationFilter('all')}
|
||||
>
|
||||
{formatMessage({ id: `rules.location.${locationFilter}` })}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
)}
|
||||
{categoryFilter.map((cat) => (
|
||||
<Badge
|
||||
key={cat}
|
||||
variant="secondary"
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleCategoryFilter(cat)}
|
||||
>
|
||||
{cat.charAt(0).toUpperCase() + cat.slice(1)}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
))}
|
||||
{searchQuery && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-pointer"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.search' })}: {searchQuery}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-6 text-xs">
|
||||
{formatMessage({ id: 'common.actions.clearAll' })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rules grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<RuleCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : filteredRules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 border border-dashed border-border rounded-lg">
|
||||
<FileCode className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-1">
|
||||
{hasActiveFilters ? formatMessage({ id: 'rules.emptyState.title' }) : formatMessage({ id: 'rules.emptyState.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">
|
||||
{hasActiveFilters
|
||||
? formatMessage({ id: 'rules.emptyState.message' })
|
||||
: formatMessage({ id: 'rules.emptyState.createFirst' })}
|
||||
</p>
|
||||
{hasActiveFilters ? (
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
{formatMessage({ id: 'common.actions.clearFilters' })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{formatMessage({ id: 'common.actions.new' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredRules.map((rule) => (
|
||||
<RuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteClick}
|
||||
onToggle={handleToggle}
|
||||
actionsDisabled={isMutating}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Rule Dialog */}
|
||||
<RuleDialog
|
||||
mode="add"
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
onSave={() => setCreateDialogOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Edit Rule Dialog */}
|
||||
<RuleDialog
|
||||
mode="edit"
|
||||
rule={selectedRule || undefined}
|
||||
open={editDialogOpen}
|
||||
onClose={() => {
|
||||
setEditDialogOpen(false);
|
||||
setSelectedRule(null);
|
||||
}}
|
||||
onSave={() => {
|
||||
setEditDialogOpen(false);
|
||||
setSelectedRule(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'rules.dialog.deleteTitle' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'rules.dialog.deleteConfirm' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setRuleToDelete(null);
|
||||
}}
|
||||
>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? formatMessage({ id: 'rules.status.deleting' }) : formatMessage({ id: 'common.actions.delete' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RulesManagerPage;
|
||||
@@ -34,6 +34,7 @@ import { useConfigStore, selectCliTools, selectDefaultCliTool, selectUserPrefere
|
||||
import type { CliToolConfig, UserPreferences } from '@/types/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LanguageSwitcher } from '@/components/layout/LanguageSwitcher';
|
||||
import { IndexManager } from '@/components/shared/IndexManager';
|
||||
|
||||
// ========== CLI Tool Card Component ==========
|
||||
|
||||
@@ -626,6 +627,9 @@ export function SettingsPage() {
|
||||
{/* Rules */}
|
||||
<RulesSection />
|
||||
|
||||
{/* Index Manager */}
|
||||
<IndexManager />
|
||||
|
||||
{/* Reset Settings */}
|
||||
<Card className="p-6 border-destructive/50">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
|
||||
73
ccw/frontend/src/pages/graph-explorer/edges/CallsEdge.tsx
Normal file
73
ccw/frontend/src/pages/graph-explorer/edges/CallsEdge.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
// ========================================
|
||||
// Calls Edge Component
|
||||
// ========================================
|
||||
// Custom edge for function/method call visualization in Graph Explorer
|
||||
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
EdgeProps,
|
||||
getBezierPath,
|
||||
EdgeLabelRenderer,
|
||||
BaseEdge,
|
||||
} from '@xyflow/react';
|
||||
import type { GraphEdgeData } from '@/types/graph-explorer';
|
||||
|
||||
/**
|
||||
* Calls edge component - represents function/method call relationships
|
||||
*/
|
||||
export const CallsEdge = memo((props: EdgeProps) => {
|
||||
const {
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
style,
|
||||
markerEnd,
|
||||
} = props;
|
||||
const data = props.data as GraphEdgeData | undefined;
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
const edgeStyle = {
|
||||
...style,
|
||||
stroke: selected ? '#10b981' : '#22c55e',
|
||||
strokeWidth: selected ? 2 : 1.5,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={edgeStyle}
|
||||
markerEnd={markerEnd}
|
||||
/>
|
||||
{data?.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${(sourceX + targetX) / 2}px, ${(sourceY + targetY) / 2}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
className="px-2 py-1 text-xs bg-white dark:bg-gray-800 rounded shadow border border-green-200 dark:border-green-700"
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
CallsEdge.displayName = 'CallsEdge';
|
||||
74
ccw/frontend/src/pages/graph-explorer/edges/ImportsEdge.tsx
Normal file
74
ccw/frontend/src/pages/graph-explorer/edges/ImportsEdge.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
// ========================================
|
||||
// Imports Edge Component
|
||||
// ========================================
|
||||
// Custom edge for import relationship visualization in Graph Explorer
|
||||
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
EdgeProps,
|
||||
getBezierPath,
|
||||
EdgeLabelRenderer,
|
||||
BaseEdge,
|
||||
} from '@xyflow/react';
|
||||
import type { GraphEdgeData } from '@/types/graph-explorer';
|
||||
|
||||
/**
|
||||
* Imports edge component - represents import/requires relationships
|
||||
*/
|
||||
export const ImportsEdge = memo((props: EdgeProps) => {
|
||||
const {
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
style,
|
||||
markerEnd,
|
||||
} = props;
|
||||
const data = props.data as GraphEdgeData | undefined;
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
const edgeStyle = {
|
||||
...style,
|
||||
stroke: selected ? '#3b82f6' : '#64748b',
|
||||
strokeWidth: selected ? 2 : 1.5,
|
||||
strokeDasharray: data?.importType === 'dynamic' ? '5,5' : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={edgeStyle}
|
||||
markerEnd={markerEnd}
|
||||
/>
|
||||
{data?.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${(sourceX + targetX) / 2}px, ${(sourceY + targetY) / 2}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
className="px-2 py-1 text-xs bg-white dark:bg-gray-800 rounded shadow border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ImportsEdge.displayName = 'ImportsEdge';
|
||||
75
ccw/frontend/src/pages/graph-explorer/edges/InheritsEdge.tsx
Normal file
75
ccw/frontend/src/pages/graph-explorer/edges/InheritsEdge.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// ========================================
|
||||
// Inherits Edge Component
|
||||
// ========================================
|
||||
// Custom edge for inheritance relationship visualization in Graph Explorer
|
||||
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
EdgeProps,
|
||||
getSmoothStepPath,
|
||||
EdgeLabelRenderer,
|
||||
BaseEdge,
|
||||
} from '@xyflow/react';
|
||||
import type { GraphEdgeData } from '@/types/graph-explorer';
|
||||
|
||||
/**
|
||||
* Inherits edge component - represents inheritance/implementation relationships
|
||||
*/
|
||||
export const InheritsEdge = memo((props: EdgeProps) => {
|
||||
const {
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
selected,
|
||||
style,
|
||||
markerEnd,
|
||||
} = props;
|
||||
const data = props.data as GraphEdgeData | undefined;
|
||||
const [edgePath] = getSmoothStepPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
borderRadius: 20,
|
||||
});
|
||||
|
||||
const edgeStyle = {
|
||||
...style,
|
||||
stroke: selected ? '#f59e0b' : '#a855f7',
|
||||
strokeWidth: selected ? 2 : 2,
|
||||
strokeDasharray: '4,4',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={edgeStyle}
|
||||
markerEnd={markerEnd}
|
||||
/>
|
||||
{data?.label && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${(sourceX + targetX) / 2}px, ${(sourceY + targetY) / 2}px)`,
|
||||
pointerEvents: 'all',
|
||||
}}
|
||||
className="px-2 py-1 text-xs bg-white dark:bg-gray-800 rounded shadow border border-purple-200 dark:border-purple-700"
|
||||
>
|
||||
{data.label}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
InheritsEdge.displayName = 'InheritsEdge';
|
||||
19
ccw/frontend/src/pages/graph-explorer/edges/index.ts
Normal file
19
ccw/frontend/src/pages/graph-explorer/edges/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// ========================================
|
||||
// Edge Components Barrel Export
|
||||
// ========================================
|
||||
|
||||
export { ImportsEdge } from './ImportsEdge';
|
||||
export { CallsEdge } from './CallsEdge';
|
||||
export { InheritsEdge } from './InheritsEdge';
|
||||
|
||||
import { ImportsEdge } from './ImportsEdge';
|
||||
import { CallsEdge } from './CallsEdge';
|
||||
import { InheritsEdge } from './InheritsEdge';
|
||||
|
||||
// Edge types map for React Flow registration
|
||||
export const edgeTypes = {
|
||||
imports: ImportsEdge,
|
||||
calls: CallsEdge,
|
||||
extends: InheritsEdge,
|
||||
implements: InheritsEdge,
|
||||
};
|
||||
93
ccw/frontend/src/pages/graph-explorer/nodes/ClassNode.tsx
Normal file
93
ccw/frontend/src/pages/graph-explorer/nodes/ClassNode.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// ========================================
|
||||
// Class Node Component
|
||||
// ========================================
|
||||
// Custom node for class visualization in Graph Explorer
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import { Braces } from 'lucide-react';
|
||||
import type { GraphNodeData } from '@/types/graph-explorer';
|
||||
|
||||
/**
|
||||
* Class node component - represents class definitions
|
||||
*/
|
||||
export const ClassNode = memo((props: NodeProps) => {
|
||||
const { data, selected } = props;
|
||||
const nodeData = data as GraphNodeData;
|
||||
const hasIssues = nodeData.hasIssues;
|
||||
const severity = nodeData.severity;
|
||||
|
||||
// Color coding based on severity
|
||||
const getBorderColor = () => {
|
||||
if (severity === 'error') return 'border-red-500';
|
||||
if (severity === 'warning') return 'border-amber-500';
|
||||
if (severity === 'info') return 'border-blue-500';
|
||||
return 'border-green-500 dark:border-green-600';
|
||||
};
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
if (severity === 'error') return 'bg-red-50 dark:bg-red-900/20';
|
||||
if (severity === 'warning') return 'bg-amber-50 dark:bg-amber-900/20';
|
||||
if (severity === 'info') return 'bg-blue-50 dark:bg-blue-900/20';
|
||||
return 'bg-white dark:bg-gray-800';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg shadow-md border-2 min-w-[180px] max-w-[240px]
|
||||
${getBackgroundColor()} ${getBorderColor()}
|
||||
${selected ? 'ring-2 ring-primary ring-offset-2' : ''}
|
||||
transition-all duration-200
|
||||
`}
|
||||
>
|
||||
{/* Input handle */}
|
||||
<Handle type="target" position={Position.Top} className="!bg-green-500" />
|
||||
|
||||
{/* Node content */}
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded flex items-center justify-center bg-green-100 dark:bg-green-900/30">
|
||||
<Braces className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
|
||||
{/* Label and info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-foreground truncate" title={nodeData.label}>
|
||||
{nodeData.label}
|
||||
</div>
|
||||
{nodeData.filePath && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-1" title={nodeData.filePath}>
|
||||
{nodeData.filePath}
|
||||
</div>
|
||||
)}
|
||||
{nodeData.lineNumber && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Line {nodeData.lineNumber}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Issue indicator */}
|
||||
{hasIssues && (
|
||||
<div className={`
|
||||
flex-shrink-0 w-2 h-2 rounded-full
|
||||
${severity === 'error' ? 'bg-red-500' : severity === 'warning' ? 'bg-amber-500' : 'bg-blue-500'}
|
||||
`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Documentation/Tooltip */}
|
||||
{nodeData.documentation && (
|
||||
<div className="mt-2 text-xs text-muted-foreground line-clamp-2" title={nodeData.documentation}>
|
||||
{nodeData.documentation}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output handle */}
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-green-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ClassNode.displayName = 'ClassNode';
|
||||
93
ccw/frontend/src/pages/graph-explorer/nodes/FunctionNode.tsx
Normal file
93
ccw/frontend/src/pages/graph-explorer/nodes/FunctionNode.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// ========================================
|
||||
// Function Node Component
|
||||
// ========================================
|
||||
// Custom node for function/method visualization in Graph Explorer
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import { FunctionSquare } from 'lucide-react';
|
||||
import type { GraphNodeData } from '@/types/graph-explorer';
|
||||
|
||||
/**
|
||||
* Function node component - represents functions and methods
|
||||
*/
|
||||
export const FunctionNode = memo((props: NodeProps) => {
|
||||
const { data, selected } = props;
|
||||
const nodeData = data as GraphNodeData;
|
||||
const hasIssues = nodeData.hasIssues;
|
||||
const severity = nodeData.severity;
|
||||
|
||||
// Color coding based on severity
|
||||
const getBorderColor = () => {
|
||||
if (severity === 'error') return 'border-red-500';
|
||||
if (severity === 'warning') return 'border-amber-500';
|
||||
if (severity === 'info') return 'border-blue-500';
|
||||
return 'border-orange-500 dark:border-orange-600';
|
||||
};
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
if (severity === 'error') return 'bg-red-50 dark:bg-red-900/20';
|
||||
if (severity === 'warning') return 'bg-amber-50 dark:bg-amber-900/20';
|
||||
if (severity === 'info') return 'bg-blue-50 dark:bg-blue-900/20';
|
||||
return 'bg-white dark:bg-gray-800';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg shadow-md border-2 min-w-[180px] max-w-[240px]
|
||||
${getBackgroundColor()} ${getBorderColor()}
|
||||
${selected ? 'ring-2 ring-primary ring-offset-2' : ''}
|
||||
transition-all duration-200
|
||||
`}
|
||||
>
|
||||
{/* Input handle */}
|
||||
<Handle type="target" position={Position.Top} className="!bg-orange-500" />
|
||||
|
||||
{/* Node content */}
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded flex items-center justify-center bg-orange-100 dark:bg-orange-900/30">
|
||||
<FunctionSquare className="w-4 h-4 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
|
||||
{/* Label and info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-foreground truncate" title={nodeData.label}>
|
||||
{nodeData.label}
|
||||
</div>
|
||||
{nodeData.filePath && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-1" title={nodeData.filePath}>
|
||||
{nodeData.filePath}
|
||||
</div>
|
||||
)}
|
||||
{nodeData.lineNumber && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Line {nodeData.lineNumber}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Issue indicator */}
|
||||
{hasIssues && (
|
||||
<div className={`
|
||||
flex-shrink-0 w-2 h-2 rounded-full
|
||||
${severity === 'error' ? 'bg-red-500' : severity === 'warning' ? 'bg-amber-500' : 'bg-blue-500'}
|
||||
`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Documentation/Tooltip */}
|
||||
{nodeData.documentation && (
|
||||
<div className="mt-2 text-xs text-muted-foreground line-clamp-2" title={nodeData.documentation}>
|
||||
{nodeData.documentation}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output handle */}
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-orange-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FunctionNode.displayName = 'FunctionNode';
|
||||
112
ccw/frontend/src/pages/graph-explorer/nodes/ModuleNode.tsx
Normal file
112
ccw/frontend/src/pages/graph-explorer/nodes/ModuleNode.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
// ========================================
|
||||
// Module Node Component
|
||||
// ========================================
|
||||
// Custom node for file/module visualization in Graph Explorer
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import { File, Package } from 'lucide-react';
|
||||
import type { GraphNodeData } from '@/types/graph-explorer';
|
||||
|
||||
/**
|
||||
* Module node component - represents files, modules, or packages
|
||||
*/
|
||||
export const ModuleNode = memo((props: NodeProps) => {
|
||||
const { data, selected } = props;
|
||||
const nodeData = data as GraphNodeData;
|
||||
const hasIssues = nodeData.hasIssues;
|
||||
const severity = nodeData.severity;
|
||||
|
||||
// Color coding based on severity
|
||||
const getBorderColor = () => {
|
||||
if (severity === 'error') return 'border-red-500';
|
||||
if (severity === 'warning') return 'border-amber-500';
|
||||
if (severity === 'info') return 'border-blue-500';
|
||||
return 'border-gray-300 dark:border-gray-600';
|
||||
};
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
if (severity === 'error') return 'bg-red-50 dark:bg-red-900/20';
|
||||
if (severity === 'warning') return 'bg-amber-50 dark:bg-amber-900/20';
|
||||
if (severity === 'info') return 'bg-blue-50 dark:bg-blue-900/20';
|
||||
return 'bg-white dark:bg-gray-800';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg shadow-md border-2 min-w-[180px] max-w-[240px]
|
||||
${getBackgroundColor()} ${getBorderColor()}
|
||||
${selected ? 'ring-2 ring-primary ring-offset-2' : ''}
|
||||
transition-all duration-200
|
||||
`}
|
||||
>
|
||||
{/* Input handle */}
|
||||
<Handle type="target" position={Position.Top} className="!bg-gray-400" />
|
||||
|
||||
{/* Node content */}
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Icon */}
|
||||
<div className={`
|
||||
flex-shrink-0 w-8 h-8 rounded flex items-center justify-center
|
||||
${nodeData.category === 'external' ? 'bg-purple-100 dark:bg-purple-900/30' : 'bg-blue-100 dark:bg-blue-900/30'}
|
||||
`}>
|
||||
{nodeData.category === 'external' ? (
|
||||
<Package className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Label and info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-foreground truncate" title={nodeData.label}>
|
||||
{nodeData.label}
|
||||
</div>
|
||||
{nodeData.filePath && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-1" title={nodeData.filePath}>
|
||||
{nodeData.filePath}
|
||||
</div>
|
||||
)}
|
||||
{nodeData.lineCount && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{nodeData.lineCount} lines
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Issue indicator */}
|
||||
{hasIssues && (
|
||||
<div className={`
|
||||
flex-shrink-0 w-2 h-2 rounded-full
|
||||
${severity === 'error' ? 'bg-red-500' : severity === 'warning' ? 'bg-amber-500' : 'bg-blue-500'}
|
||||
`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{nodeData.tags && nodeData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{nodeData.tags.slice(0, 3).map((tag: string) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-1.5 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{nodeData.tags.length > 3 && (
|
||||
<span className="px-1.5 py-0.5 text-xs text-muted-foreground">
|
||||
+{nodeData.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output handle */}
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-gray-400" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ModuleNode.displayName = 'ModuleNode';
|
||||
93
ccw/frontend/src/pages/graph-explorer/nodes/VariableNode.tsx
Normal file
93
ccw/frontend/src/pages/graph-explorer/nodes/VariableNode.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
// ========================================
|
||||
// Variable Node Component
|
||||
// ========================================
|
||||
// Custom node for variable/constant visualization in Graph Explorer
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position, NodeProps } from '@xyflow/react';
|
||||
import { Variable } from 'lucide-react';
|
||||
import type { GraphNodeData } from '@/types/graph-explorer';
|
||||
|
||||
/**
|
||||
* Variable node component - represents variables and constants
|
||||
*/
|
||||
export const VariableNode = memo((props: NodeProps) => {
|
||||
const { data, selected } = props;
|
||||
const nodeData = data as GraphNodeData;
|
||||
const hasIssues = nodeData.hasIssues;
|
||||
const severity = nodeData.severity;
|
||||
|
||||
// Color coding based on severity
|
||||
const getBorderColor = () => {
|
||||
if (severity === 'error') return 'border-red-500';
|
||||
if (severity === 'warning') return 'border-amber-500';
|
||||
if (severity === 'info') return 'border-blue-500';
|
||||
return 'border-cyan-500 dark:border-cyan-600';
|
||||
};
|
||||
|
||||
const getBackgroundColor = () => {
|
||||
if (severity === 'error') return 'bg-red-50 dark:bg-red-900/20';
|
||||
if (severity === 'warning') return 'bg-amber-50 dark:bg-amber-900/20';
|
||||
if (severity === 'info') return 'bg-blue-50 dark:bg-blue-900/20';
|
||||
return 'bg-white dark:bg-gray-800';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
px-4 py-3 rounded-lg shadow-md border-2 min-w-[180px] max-w-[240px]
|
||||
${getBackgroundColor()} ${getBorderColor()}
|
||||
${selected ? 'ring-2 ring-primary ring-offset-2' : ''}
|
||||
transition-all duration-200
|
||||
`}
|
||||
>
|
||||
{/* Input handle */}
|
||||
<Handle type="target" position={Position.Top} className="!bg-cyan-500" />
|
||||
|
||||
{/* Node content */}
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Icon */}
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded flex items-center justify-center bg-cyan-100 dark:bg-cyan-900/30">
|
||||
<Variable className="w-4 h-4 text-cyan-600 dark:text-cyan-400" />
|
||||
</div>
|
||||
|
||||
{/* Label and info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-foreground truncate" title={nodeData.label}>
|
||||
{nodeData.label}
|
||||
</div>
|
||||
{nodeData.filePath && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-1" title={nodeData.filePath}>
|
||||
{nodeData.filePath}
|
||||
</div>
|
||||
)}
|
||||
{nodeData.lineNumber && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Line {nodeData.lineNumber}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Issue indicator */}
|
||||
{hasIssues && (
|
||||
<div className={`
|
||||
flex-shrink-0 w-2 h-2 rounded-full
|
||||
${severity === 'error' ? 'bg-red-500' : severity === 'warning' ? 'bg-amber-500' : 'bg-blue-500'}
|
||||
`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Type annotation */}
|
||||
{nodeData.documentation && (
|
||||
<div className="mt-2 text-xs text-muted-foreground font-mono" title={nodeData.documentation}>
|
||||
{nodeData.documentation}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Output handle */}
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-cyan-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VariableNode.displayName = 'VariableNode';
|
||||
32
ccw/frontend/src/pages/graph-explorer/nodes/index.ts
Normal file
32
ccw/frontend/src/pages/graph-explorer/nodes/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// ========================================
|
||||
// Node Components Barrel Export
|
||||
// ========================================
|
||||
|
||||
export { ModuleNode } from './ModuleNode';
|
||||
export { ClassNode } from './ClassNode';
|
||||
export { FunctionNode } from './FunctionNode';
|
||||
export { VariableNode } from './VariableNode';
|
||||
|
||||
import { ModuleNode } from './ModuleNode';
|
||||
import { ClassNode } from './ClassNode';
|
||||
import { FunctionNode } from './FunctionNode';
|
||||
import { VariableNode } from './VariableNode';
|
||||
|
||||
// Node types map for React Flow registration
|
||||
export const nodeTypes = {
|
||||
module: ModuleNode,
|
||||
class: ClassNode,
|
||||
function: FunctionNode,
|
||||
variable: VariableNode,
|
||||
component: ModuleNode, // Reuse ModuleNode for components
|
||||
interface: ClassNode, // Reuse ClassNode for interfaces
|
||||
file: ModuleNode, // Reuse ModuleNode for files
|
||||
folder: ModuleNode, // Reuse ModuleNode for folders
|
||||
dependency: ModuleNode, // Reuse ModuleNode for dependencies
|
||||
api: ModuleNode, // Reuse ModuleNode for APIs
|
||||
database: ModuleNode, // Reuse ModuleNode for databases
|
||||
service: ModuleNode, // Reuse ModuleNode for services
|
||||
hook: FunctionNode, // Reuse FunctionNode for hooks
|
||||
utility: FunctionNode, // Reuse FunctionNode for utilities
|
||||
unknown: ModuleNode, // Reuse ModuleNode for unknown types
|
||||
};
|
||||
@@ -17,6 +17,7 @@ export { CommandsManagerPage } from './CommandsManagerPage';
|
||||
export { MemoryPage } from './MemoryPage';
|
||||
export { SettingsPage } from './SettingsPage';
|
||||
export { HelpPage } from './HelpPage';
|
||||
export { HookManagerPage } from './HookManagerPage';
|
||||
export { NotFoundPage } from './NotFoundPage';
|
||||
export { LiteTasksPage } from './LiteTasksPage';
|
||||
export { LiteTaskDetailPage } from './LiteTaskDetailPage';
|
||||
@@ -24,3 +25,8 @@ export { ReviewSessionPage } from './ReviewSessionPage';
|
||||
export { McpManagerPage } from './McpManagerPage';
|
||||
export { EndpointsPage } from './EndpointsPage';
|
||||
export { InstallationsPage } from './InstallationsPage';
|
||||
export { ExecutionMonitorPage } from './ExecutionMonitorPage';
|
||||
export { RulesManagerPage } from './RulesManagerPage';
|
||||
export { PromptHistoryPage } from './PromptHistoryPage';
|
||||
export { ExplorerPage } from './ExplorerPage';
|
||||
export { GraphExplorerPage } from './GraphExplorerPage';
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Toolbar for flow operations: New, Save, Load, Export
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Plus,
|
||||
Save,
|
||||
@@ -28,6 +29,7 @@ interface FlowToolbarProps {
|
||||
}
|
||||
|
||||
export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isFlowListOpen, setIsFlowListOpen] = useState(false);
|
||||
const [flowName, setFlowName] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
@@ -174,7 +176,7 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
placeholder="Flow name"
|
||||
placeholder={formatMessage({ id: 'orchestrator.toolbar.placeholder' })}
|
||||
className="max-w-[200px] h-8 text-sm"
|
||||
/>
|
||||
{isModified && (
|
||||
@@ -265,7 +267,7 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => handleDuplicate(flow, e)}
|
||||
title="Duplicate"
|
||||
title={formatMessage({ id: 'orchestrator.toolbar.duplicate' })}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
@@ -274,7 +276,7 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDelete(flow, e)}
|
||||
title="Delete"
|
||||
title={formatMessage({ id: 'orchestrator.toolbar.delete' })}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Draggable node palette for creating new nodes
|
||||
|
||||
import { DragEvent, useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Terminal, FileText, GitBranch, GitMerge, ChevronDown, ChevronRight, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -75,6 +76,7 @@ function NodeTypeCard({ type }: NodeTypeCardProps) {
|
||||
}
|
||||
|
||||
export function NodePalette({ className }: NodePaletteProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen);
|
||||
const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen);
|
||||
@@ -86,7 +88,7 @@ export function NodePalette({ className }: NodePaletteProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsPaletteOpen(true)}
|
||||
title="Open node palette"
|
||||
title={formatMessage({ id: 'orchestrator.palette.open' })}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -98,13 +100,13 @@ export function NodePalette({ className }: NodePaletteProps) {
|
||||
<div className={cn('w-64 bg-card border-r border-border flex flex-col', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 className="font-semibold text-foreground">Node Palette</h3>
|
||||
<h3 className="font-semibold text-foreground">{formatMessage({ id: 'orchestrator.palette.title' })}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsPaletteOpen(false)}
|
||||
title="Collapse palette"
|
||||
title={formatMessage({ id: 'orchestrator.palette.collapse' })}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -112,7 +114,7 @@ export function NodePalette({ className }: NodePaletteProps) {
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="px-4 py-2 text-xs text-muted-foreground bg-muted/50 border-b border-border">
|
||||
Drag nodes onto the canvas to add them to your workflow
|
||||
{formatMessage({ id: 'orchestrator.palette.instructions' })}
|
||||
</div>
|
||||
|
||||
{/* Node Type Categories */}
|
||||
@@ -128,7 +130,7 @@ export function NodePalette({ className }: NodePaletteProps) {
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
Node Types
|
||||
{formatMessage({ id: 'orchestrator.palette.nodeTypes' })}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
@@ -144,7 +146,7 @@ export function NodePalette({ className }: NodePaletteProps) {
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-3 border-t border-border bg-muted/30">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="font-medium">Tip:</span> Connect nodes by dragging from output to input handles
|
||||
<span className="font-medium">{formatMessage({ id: 'orchestrator.palette.tipLabel' })}</span> {formatMessage({ id: 'orchestrator.palette.tip' })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { FlowCanvas } from './FlowCanvas';
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { PropertyPanel } from './PropertyPanel';
|
||||
import { FlowToolbar } from './FlowToolbar';
|
||||
import { ExecutionMonitor } from './ExecutionMonitor';
|
||||
import { TemplateLibrary } from './TemplateLibrary';
|
||||
|
||||
export function OrchestratorPage() {
|
||||
@@ -27,7 +26,7 @@ export function OrchestratorPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="h-full flex flex-col -m-4 md:-m-6">
|
||||
{/* Toolbar */}
|
||||
<FlowToolbar onOpenTemplateLibrary={handleOpenTemplateLibrary} />
|
||||
|
||||
@@ -45,9 +44,6 @@ export function OrchestratorPage() {
|
||||
<PropertyPanel />
|
||||
</div>
|
||||
|
||||
{/* Execution Monitor (Bottom) */}
|
||||
<ExecutionMonitor />
|
||||
|
||||
{/* Template Library Dialog */}
|
||||
<TemplateLibrary
|
||||
open={isTemplateLibraryOpen}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Dynamic property editor for selected nodes
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Settings, X, Terminal, FileText, GitBranch, GitMerge, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -38,38 +39,40 @@ function SlashCommandProperties({
|
||||
data: SlashCommandNodeData;
|
||||
onChange: (updates: Partial<SlashCommandNodeData>) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder="Node label"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Command</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.command' })}</label>
|
||||
<Input
|
||||
value={data.command || ''}
|
||||
onChange={(e) => onChange({ command: e.target.value })}
|
||||
placeholder="/command-name"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.commandName' })}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Arguments</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.arguments' })}</label>
|
||||
<Input
|
||||
value={data.args || ''}
|
||||
onChange={(e) => onChange({ args: e.target.value })}
|
||||
placeholder="Command arguments"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.commandArgs' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Execution Mode</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.executionMode' })}</label>
|
||||
<select
|
||||
value={data.execution?.mode || 'analysis'}
|
||||
onChange={(e) =>
|
||||
@@ -79,26 +82,26 @@ function SlashCommandProperties({
|
||||
}
|
||||
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="analysis">Analysis (Read-only)</option>
|
||||
<option value="write">Write (Modify files)</option>
|
||||
<option value="analysis">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeAnalysis' })}</option>
|
||||
<option value="write">{formatMessage({ id: 'orchestrator.propertyPanel.options.modeWrite' })}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">On Error</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.onError' })}</label>
|
||||
<select
|
||||
value={data.onError || 'stop'}
|
||||
onChange={(e) => onChange({ onError: e.target.value as 'continue' | 'stop' | 'retry' })}
|
||||
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="stop">Stop execution</option>
|
||||
<option value="continue">Continue</option>
|
||||
<option value="retry">Retry</option>
|
||||
<option value="stop">{formatMessage({ id: 'orchestrator.propertyPanel.options.errorStop' })}</option>
|
||||
<option value="continue">{formatMessage({ id: 'orchestrator.propertyPanel.options.errorContinue' })}</option>
|
||||
<option value="retry">{formatMessage({ id: 'orchestrator.propertyPanel.options.errorRetry' })}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Timeout (ms)</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.timeout' })}</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.execution?.timeout || ''}
|
||||
@@ -111,7 +114,7 @@ function SlashCommandProperties({
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="60000"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.timeout' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -126,19 +129,21 @@ function FileOperationProperties({
|
||||
data: FileOperationNodeData;
|
||||
onChange: (updates: Partial<FileOperationNodeData>) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder="Node label"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Operation</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.operation' })}</label>
|
||||
<select
|
||||
value={data.operation || 'read'}
|
||||
onChange={(e) =>
|
||||
@@ -148,32 +153,32 @@ function FileOperationProperties({
|
||||
}
|
||||
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="read">Read</option>
|
||||
<option value="write">Write</option>
|
||||
<option value="append">Append</option>
|
||||
<option value="delete">Delete</option>
|
||||
<option value="copy">Copy</option>
|
||||
<option value="move">Move</option>
|
||||
<option value="read">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationRead' })}</option>
|
||||
<option value="write">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationWrite' })}</option>
|
||||
<option value="append">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationAppend' })}</option>
|
||||
<option value="delete">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationDelete' })}</option>
|
||||
<option value="copy">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationCopy' })}</option>
|
||||
<option value="move">{formatMessage({ id: 'orchestrator.propertyPanel.options.operationMove' })}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Path</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.path' })}</label>
|
||||
<Input
|
||||
value={data.path || ''}
|
||||
onChange={(e) => onChange({ path: e.target.value })}
|
||||
placeholder="/path/to/file"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.path' })}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(data.operation === 'write' || data.operation === 'append') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Content</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.content' })}</label>
|
||||
<textarea
|
||||
value={data.content || ''}
|
||||
onChange={(e) => onChange({ content: e.target.value })}
|
||||
placeholder="File content..."
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.content' })}
|
||||
className="w-full h-24 px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
@@ -181,22 +186,22 @@ function FileOperationProperties({
|
||||
|
||||
{(data.operation === 'copy' || data.operation === 'move') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Destination Path</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.destinationPath' })}</label>
|
||||
<Input
|
||||
value={data.destinationPath || ''}
|
||||
onChange={(e) => onChange({ destinationPath: e.target.value })}
|
||||
placeholder="/path/to/destination"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.destinationPath' })}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Output Variable</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.outputVariable' })}</label>
|
||||
<Input
|
||||
value={data.outputVariable || ''}
|
||||
onChange={(e) => onChange({ outputVariable: e.target.value })}
|
||||
placeholder="variableName"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.variableName' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -209,7 +214,7 @@ function FileOperationProperties({
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<label htmlFor="addToContext" className="text-sm text-foreground">
|
||||
Add to context
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.labels.addToContext' })}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -224,42 +229,44 @@ function ConditionalProperties({
|
||||
data: ConditionalNodeData;
|
||||
onChange: (updates: Partial<ConditionalNodeData>) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder="Node label"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Condition</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.condition' })}</label>
|
||||
<textarea
|
||||
value={data.condition || ''}
|
||||
onChange={(e) => onChange({ condition: e.target.value })}
|
||||
placeholder="e.g., result.success === true"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.condition' })}
|
||||
className="w-full h-20 px-3 py-2 rounded-md border border-border bg-background text-foreground text-sm resize-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">True Label</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.trueLabel' })}</label>
|
||||
<Input
|
||||
value={data.trueLabel || ''}
|
||||
onChange={(e) => onChange({ trueLabel: e.target.value })}
|
||||
placeholder="True"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.trueLabel' })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">False Label</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.falseLabel' })}</label>
|
||||
<Input
|
||||
value={data.falseLabel || ''}
|
||||
onChange={(e) => onChange({ falseLabel: e.target.value })}
|
||||
placeholder="False"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.falseLabel' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,19 +282,21 @@ function ParallelProperties({
|
||||
data: ParallelNodeData;
|
||||
onChange: (updates: Partial<ParallelNodeData>) => void;
|
||||
}) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.label' })}</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder="Node label"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.nodeLabel' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Join Mode</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.joinMode' })}</label>
|
||||
<select
|
||||
value={data.joinMode || 'all'}
|
||||
onChange={(e) =>
|
||||
@@ -295,21 +304,21 @@ function ParallelProperties({
|
||||
}
|
||||
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="all">Wait for all branches</option>
|
||||
<option value="any">Complete when any branch finishes</option>
|
||||
<option value="none">No synchronization</option>
|
||||
<option value="all">{formatMessage({ id: 'orchestrator.propertyPanel.options.joinModeAll' })}</option>
|
||||
<option value="any">{formatMessage({ id: 'orchestrator.propertyPanel.options.joinModeAny' })}</option>
|
||||
<option value="none">{formatMessage({ id: 'orchestrator.propertyPanel.options.joinModeNone' })}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Timeout (ms)</label>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">{formatMessage({ id: 'orchestrator.propertyPanel.labels.timeout' })}</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.timeout || ''}
|
||||
onChange={(e) =>
|
||||
onChange({ timeout: e.target.value ? parseInt(e.target.value) : undefined })
|
||||
}
|
||||
placeholder="30000"
|
||||
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.timeout' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -322,7 +331,7 @@ function ParallelProperties({
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<label htmlFor="failFast" className="text-sm text-foreground">
|
||||
Fail fast (stop all branches on first error)
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.labels.failFast' })}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,6 +339,7 @@ function ParallelProperties({
|
||||
}
|
||||
|
||||
export function PropertyPanel({ className }: PropertyPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const selectedNodeId = useFlowStore((state) => state.selectedNodeId);
|
||||
const nodes = useFlowStore((state) => state.nodes);
|
||||
const updateNode = useFlowStore((state) => state.updateNode);
|
||||
@@ -361,7 +371,7 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsPropertyPanelOpen(true)}
|
||||
title="Open properties panel"
|
||||
title={formatMessage({ id: 'orchestrator.propertyPanel.open' })}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -374,13 +384,13 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
|
||||
<div className={cn('w-72 bg-card border-l border-border flex flex-col', className)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h3 className="font-semibold text-foreground">Properties</h3>
|
||||
<h3 className="font-semibold text-foreground">{formatMessage({ id: 'orchestrator.propertyPanel.title' })}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsPropertyPanelOpen(false)}
|
||||
title="Close panel"
|
||||
title={formatMessage({ id: 'orchestrator.propertyPanel.close' })}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -390,7 +400,7 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
|
||||
<div className="flex-1 flex items-center justify-center p-4">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Settings className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">Select a node to edit its properties</p>
|
||||
<p className="text-sm">{formatMessage({ id: 'orchestrator.propertyPanel.selectNode' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,14 +416,14 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="w-4 h-4 text-primary" />}
|
||||
<h3 className="font-semibold text-foreground">Properties</h3>
|
||||
<h3 className="font-semibold text-foreground">{formatMessage({ id: 'orchestrator.propertyPanel.title' })}</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsPropertyPanelOpen(false)}
|
||||
title="Close panel"
|
||||
title={formatMessage({ id: 'orchestrator.propertyPanel.close' })}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -462,7 +472,7 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Node
|
||||
{formatMessage({ id: 'orchestrator.propertyPanel.deleteNode' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// Template browser with import/export functionality
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Library,
|
||||
Search,
|
||||
@@ -64,6 +65,7 @@ function TemplateCard({
|
||||
isInstalling,
|
||||
isDeleting,
|
||||
}: TemplateCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const isGrid = viewMode === 'grid';
|
||||
|
||||
return (
|
||||
@@ -98,7 +100,7 @@ function TemplateCard({
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{template.nodeCount} nodes
|
||||
{template.nodeCount} {formatMessage({ id: 'orchestrator.templateLibrary.card.nodes' })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
@@ -233,6 +235,7 @@ function ExportDialog({
|
||||
isExporting,
|
||||
flowName,
|
||||
}: ExportDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [name, setName] = useState(flowName);
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
@@ -250,46 +253,46 @@ function ExportDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export as Template</DialogTitle>
|
||||
<DialogTitle>{formatMessage({ id: 'orchestrator.templateLibrary.exportDialog.title' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save this flow as a reusable template in your library.
|
||||
{formatMessage({ id: 'orchestrator.templateLibrary.exportDialog.description' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<label className="text-sm font-medium">{formatMessage({ id: 'orchestrator.templateLibrary.exportDialog.fields.name' })}</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Template name"
|
||||
placeholder={formatMessage({ id: 'orchestrator.templateLibrary.exportDialog.fields.namePlaceholder' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<label className="text-sm font-medium">{formatMessage({ id: 'orchestrator.templateLibrary.exportDialog.fields.description' })}</label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this template"
|
||||
placeholder={formatMessage({ id: 'orchestrator.templateLibrary.exportDialog.fields.descriptionPlaceholder' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Category</label>
|
||||
<label className="text-sm font-medium">{formatMessage({ id: 'orchestrator.templateLibrary.exportDialog.fields.category' })}</label>
|
||||
<Input
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="e.g., Development, Testing, Deployment"
|
||||
placeholder={formatMessage({ id: 'orchestrator.templateLibrary.exportDialog.fields.categoryPlaceholder' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tags (comma-separated)</label>
|
||||
<label className="text-sm font-medium">{formatMessage({ id: 'orchestrator.templateLibrary.exportDialog.fields.tags' })}</label>
|
||||
<Input
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder="e.g., react, testing, ci/cd"
|
||||
placeholder={formatMessage({ id: 'orchestrator.templateLibrary.exportDialog.fields.tagsPlaceholder' })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -320,6 +323,7 @@ interface TemplateLibraryProps {
|
||||
}
|
||||
|
||||
export function TemplateLibrary({ open, onOpenChange }: TemplateLibraryProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
@@ -419,7 +423,7 @@ export function TemplateLibrary({ open, onOpenChange }: TemplateLibraryProps) {
|
||||
Template Library
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Browse and import workflow templates, or export your current flow as a template.
|
||||
{formatMessage({ id: 'orchestrator.templateLibrary.description' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -431,7 +435,7 @@ export function TemplateLibrary({ open, onOpenChange }: TemplateLibraryProps) {
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search templates..."
|
||||
placeholder={formatMessage({ id: 'orchestrator.templateLibrary.searchPlaceholder' })}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
@@ -501,15 +505,15 @@ export function TemplateLibrary({ open, onOpenChange }: TemplateLibraryProps) {
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mb-2" />
|
||||
<p>Failed to load templates</p>
|
||||
<p>{formatMessage({ id: 'orchestrator.templateLibrary.errors.loadFailed' })}</p>
|
||||
<p className="text-sm">{(error as Error).message}</p>
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-muted-foreground">
|
||||
<Library className="h-12 w-12 mb-2" />
|
||||
<p>No templates found</p>
|
||||
<p>{formatMessage({ id: 'orchestrator.templateLibrary.emptyState.title' })}</p>
|
||||
{searchQuery && (
|
||||
<p className="text-sm">Try a different search query</p>
|
||||
<p className="text-sm">{formatMessage({ id: 'orchestrator.templateLibrary.emptyState.searchSuggestion' })}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -539,7 +543,10 @@ export function TemplateLibrary({ open, onOpenChange }: TemplateLibraryProps) {
|
||||
<DialogFooter className="border-t border-border pt-4">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{filteredTemplates.length} template{filteredTemplates.length !== 1 ? 's' : ''}
|
||||
{formatMessage(
|
||||
{ id: 'orchestrator.templateLibrary.footer.templateCount' },
|
||||
{ count: filteredTemplates.length }
|
||||
)}
|
||||
</span>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
|
||||
Reference in New Issue
Block a user