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:
catlog22
2026-01-31 15:27:12 +08:00
parent 4e009bb03a
commit 715ef12c92
163 changed files with 19495 additions and 715 deletions

View 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;

View 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;

View File

@@ -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 */}

View 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;

View File

@@ -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>

View 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;

View File

@@ -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 || '';

View File

@@ -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>
);
}

View File

@@ -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">

View 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">&#10003;</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">&#10003;</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;

View 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">&#10003;</span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLocationFilter('project')}>
{formatMessage({ id: 'rules.location.project' })}
{locationFilter === 'project' && <span className="ml-auto text-primary">&#10003;</span>}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setLocationFilter('user')}>
{formatMessage({ id: 'rules.location.user' })}
{locationFilter === 'user' && <span className="ml-auto text-primary">&#10003;</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">&#10003;</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;

View File

@@ -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">

View 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';

View 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';

View 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';

View 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,
};

View 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';

View 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';

View 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';

View 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';

View 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
};

View File

@@ -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';

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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