mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-28 09:23:08 +08:00
feat: implement FlowExecutor for executing flow definitions with DAG traversal and node execution
This commit is contained in:
351
ccw/frontend/src/pages/CommandsManagerPage.tsx
Normal file
351
ccw/frontend/src/pages/CommandsManagerPage.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
// ========================================
|
||||
// Commands Manager Page
|
||||
// ========================================
|
||||
// Manage custom slash commands with search/filter
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Terminal,
|
||||
Search,
|
||||
Plus,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
Copy,
|
||||
Play,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Code,
|
||||
BookOpen,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { useCommands } from '@/hooks';
|
||||
import type { Command } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Command Card Component ==========
|
||||
|
||||
interface CommandCardProps {
|
||||
command: Command;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onCopy: (text: string) => void;
|
||||
}
|
||||
|
||||
function CommandCard({ command, isExpanded, onToggleExpand, onCopy }: CommandCardProps) {
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Terminal className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono font-medium text-foreground">
|
||||
/{command.name}
|
||||
</code>
|
||||
{command.source && (
|
||||
<Badge variant={command.source === 'builtin' ? 'default' : 'secondary'} className="text-xs">
|
||||
{command.source}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{command.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy(`/${command.name}`);
|
||||
}}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category and Aliases */}
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{command.category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Tag className="w-3 h-3 mr-1" />
|
||||
{command.category}
|
||||
</Badge>
|
||||
)}
|
||||
{command.aliases?.map((alias) => (
|
||||
<Badge key={alias} variant="secondary" className="text-xs font-mono">
|
||||
/{alias}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
|
||||
{/* Usage */}
|
||||
{command.usage && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
||||
<Code className="w-4 h-4" />
|
||||
Usage
|
||||
</div>
|
||||
<div className="p-3 bg-background rounded-md font-mono text-sm overflow-x-auto">
|
||||
<code>{command.usage}</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Examples */}
|
||||
{command.examples && command.examples.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground mb-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Examples
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{command.examples.map((example, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3 bg-background rounded-md font-mono text-sm flex items-center justify-between gap-2 group"
|
||||
>
|
||||
<code className="overflow-x-auto flex-1">{example}</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => onCopy(example)}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function CommandsManagerPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
||||
const [expandedCommands, setExpandedCommands] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
commands,
|
||||
categories,
|
||||
commandsByCategory,
|
||||
totalCount,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useCommands({
|
||||
filter: {
|
||||
search: searchQuery || undefined,
|
||||
category: categoryFilter !== 'all' ? categoryFilter : undefined,
|
||||
source: sourceFilter !== 'all' ? sourceFilter as Command['source'] : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const toggleExpand = (commandName: string) => {
|
||||
setExpandedCommands((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(commandName)) {
|
||||
next.delete(commandName);
|
||||
} else {
|
||||
next.add(commandName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const expandAll = () => {
|
||||
setExpandedCommands(new Set(commands.map((c) => c.name)));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedCommands(new Set());
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
// TODO: Show toast notification
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Count by source
|
||||
const builtinCount = useMemo(
|
||||
() => commands.filter((c) => c.source === 'builtin').length,
|
||||
[commands]
|
||||
);
|
||||
const customCount = useMemo(
|
||||
() => commands.filter((c) => c.source === 'custom').length,
|
||||
[commands]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Terminal className="w-6 h-6 text-primary" />
|
||||
Commands Manager
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage custom slash commands for Claude Code
|
||||
</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')} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Command
|
||||
</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">
|
||||
<Terminal 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">Total Commands</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-5 h-5 text-info" />
|
||||
<span className="text-2xl font-bold">{builtinCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Built-in</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="w-5 h-5 text-success" />
|
||||
<span className="text-2xl font-bold">{customCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Custom</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-5 h-5 text-warning" />
|
||||
<span className="text-2xl font-bold">{categories.length}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Categories</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<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="Search commands by name, description, or alias..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sourceFilter} onValueChange={setSourceFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Sources</SelectItem>
|
||||
<SelectItem value="builtin">Built-in</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse All */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={expandAll}>
|
||||
Expand All
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={collapseAll}>
|
||||
Collapse All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Commands List */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-24 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : commands.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Terminal className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">No commands found</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Try adjusting your search or filters.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{commands.map((command) => (
|
||||
<CommandCard
|
||||
key={command.name}
|
||||
command={command}
|
||||
isExpanded={expandedCommands.has(command.name)}
|
||||
onToggleExpand={() => toggleExpand(command.name)}
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CommandsManagerPage;
|
||||
207
ccw/frontend/src/pages/HelpPage.tsx
Normal file
207
ccw/frontend/src/pages/HelpPage.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
// ========================================
|
||||
// Help Page
|
||||
// ========================================
|
||||
// Help documentation and guides
|
||||
|
||||
import {
|
||||
HelpCircle,
|
||||
Book,
|
||||
Video,
|
||||
MessageCircle,
|
||||
ExternalLink,
|
||||
Workflow,
|
||||
FolderKanban,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
|
||||
interface HelpSection {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ElementType;
|
||||
link?: string;
|
||||
isExternal?: boolean;
|
||||
}
|
||||
|
||||
const helpSections: HelpSection[] = [
|
||||
{
|
||||
title: 'Getting Started',
|
||||
description: 'Learn the basics of CCW Dashboard and workflow management',
|
||||
icon: Book,
|
||||
link: '#getting-started',
|
||||
},
|
||||
{
|
||||
title: 'Orchestrator Guide',
|
||||
description: 'Master the visual workflow editor with drag-drop flows',
|
||||
icon: Workflow,
|
||||
link: '/orchestrator',
|
||||
},
|
||||
{
|
||||
title: 'Sessions Management',
|
||||
description: 'Understanding workflow sessions and task tracking',
|
||||
icon: FolderKanban,
|
||||
link: '/sessions',
|
||||
},
|
||||
{
|
||||
title: 'CLI Integration',
|
||||
description: 'Using CCW commands and CLI tool integration',
|
||||
icon: Terminal,
|
||||
link: '#cli-integration',
|
||||
},
|
||||
];
|
||||
|
||||
export function HelpPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<HelpCircle className="w-6 h-6 text-primary" />
|
||||
Help & Documentation
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Learn how to use CCW Dashboard and get the most out of your workflows
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{helpSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const content = (
|
||||
<Card className="p-4 h-full hover:shadow-md hover:border-primary/50 transition-all cursor-pointer group">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-foreground group-hover:text-primary transition-colors">
|
||||
{section.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{section.description}
|
||||
</p>
|
||||
</div>
|
||||
{section.isExternal && (
|
||||
<ExternalLink className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
if (section.link?.startsWith('/')) {
|
||||
return (
|
||||
<Link key={section.title} to={section.link}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<a key={section.title} href={section.link}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Getting Started Section */}
|
||||
<Card className="p-6" id="getting-started">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
Getting Started with CCW
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-muted-foreground">
|
||||
<p>
|
||||
CCW (Claude Code Workflow) Dashboard is your central hub for managing
|
||||
AI-powered development workflows. Here are the key concepts:
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2">
|
||||
<li>
|
||||
<strong className="text-foreground">Sessions</strong> - Track the
|
||||
progress of multi-step development tasks
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Orchestrator</strong> - Visual
|
||||
workflow builder for creating automation flows
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Loops</strong> - Monitor
|
||||
iterative development cycles in real-time
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Skills</strong> - Extend Claude
|
||||
Code with custom capabilities
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Memory</strong> - Store context
|
||||
and knowledge for better AI assistance
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* CLI Integration Section */}
|
||||
<Card className="p-6" id="cli-integration">
|
||||
<h2 className="text-xl font-semibold text-foreground mb-4">
|
||||
CLI Integration
|
||||
</h2>
|
||||
<div className="prose prose-sm max-w-none text-muted-foreground">
|
||||
<p>
|
||||
CCW integrates with multiple CLI tools for AI-assisted development:
|
||||
</p>
|
||||
<ul className="mt-4 space-y-2">
|
||||
<li>
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-foreground">
|
||||
ccw cli -p "prompt" --tool gemini
|
||||
</code>
|
||||
- Execute with Gemini
|
||||
</li>
|
||||
<li>
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-foreground">
|
||||
ccw cli -p "prompt" --tool qwen
|
||||
</code>
|
||||
- Execute with Qwen
|
||||
</li>
|
||||
<li>
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-foreground">
|
||||
ccw cli -p "prompt" --tool codex
|
||||
</code>
|
||||
- Execute with Codex
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Support Section */}
|
||||
<Card className="p-6 bg-primary/5 border-primary/20">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 rounded-lg bg-primary/10">
|
||||
<MessageCircle className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Need more help?
|
||||
</h3>
|
||||
<p className="text-muted-foreground mt-1 mb-4">
|
||||
Check the project documentation or reach out for support.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button variant="outline" size="sm">
|
||||
<Book className="w-4 h-4 mr-2" />
|
||||
Documentation
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Video className="w-4 h-4 mr-2" />
|
||||
Tutorials
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HelpPage;
|
||||
226
ccw/frontend/src/pages/HomePage.tsx
Normal file
226
ccw/frontend/src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
// ========================================
|
||||
// HomePage Component
|
||||
// ========================================
|
||||
// Dashboard home page with stat cards and recent sessions
|
||||
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
FolderKanban,
|
||||
ListChecks,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
XCircle,
|
||||
Activity,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { useDashboardStats } from '@/hooks/useDashboardStats';
|
||||
import { useSessions } from '@/hooks/useSessions';
|
||||
import { StatCard, StatCardSkeleton } from '@/components/shared/StatCard';
|
||||
import { SessionCard, SessionCardSkeleton } from '@/components/shared/SessionCard';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Stat card configuration
|
||||
const statCards = [
|
||||
{
|
||||
key: 'activeSessions',
|
||||
title: 'Active Sessions',
|
||||
icon: FolderKanban,
|
||||
variant: 'primary' as const,
|
||||
getValue: (stats: { activeSessions: number }) => stats.activeSessions,
|
||||
},
|
||||
{
|
||||
key: 'totalTasks',
|
||||
title: 'Total Tasks',
|
||||
icon: ListChecks,
|
||||
variant: 'info' as const,
|
||||
getValue: (stats: { totalTasks: number }) => stats.totalTasks,
|
||||
},
|
||||
{
|
||||
key: 'completedTasks',
|
||||
title: 'Completed',
|
||||
icon: CheckCircle2,
|
||||
variant: 'success' as const,
|
||||
getValue: (stats: { completedTasks: number }) => stats.completedTasks,
|
||||
},
|
||||
{
|
||||
key: 'pendingTasks',
|
||||
title: 'Pending',
|
||||
icon: Clock,
|
||||
variant: 'warning' as const,
|
||||
getValue: (stats: { pendingTasks: number }) => stats.pendingTasks,
|
||||
},
|
||||
{
|
||||
key: 'failedTasks',
|
||||
title: 'Failed',
|
||||
icon: XCircle,
|
||||
variant: 'danger' as const,
|
||||
getValue: (stats: { failedTasks: number }) => stats.failedTasks,
|
||||
},
|
||||
{
|
||||
key: 'todayActivity',
|
||||
title: "Today's Activity",
|
||||
icon: Activity,
|
||||
variant: 'default' as const,
|
||||
getValue: (stats: { todayActivity: number }) => stats.todayActivity,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* HomePage component - Dashboard overview with statistics and recent sessions
|
||||
*/
|
||||
export function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Fetch dashboard stats
|
||||
const {
|
||||
stats,
|
||||
isLoading: statsLoading,
|
||||
isFetching: statsFetching,
|
||||
error: statsError,
|
||||
refetch: refetchStats,
|
||||
} = useDashboardStats({
|
||||
refetchInterval: 60000, // Refetch every minute
|
||||
});
|
||||
|
||||
// Fetch recent sessions (active only, limited)
|
||||
const {
|
||||
activeSessions,
|
||||
isLoading: sessionsLoading,
|
||||
isFetching: sessionsFetching,
|
||||
error: sessionsError,
|
||||
refetch: refetchSessions,
|
||||
} = useSessions({
|
||||
filter: { location: 'active' },
|
||||
});
|
||||
|
||||
// Get recent sessions (max 6)
|
||||
const recentSessions = React.useMemo(
|
||||
() =>
|
||||
[...activeSessions]
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||
.slice(0, 6),
|
||||
[activeSessions]
|
||||
);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
await Promise.all([refetchStats(), refetchSessions()]);
|
||||
};
|
||||
|
||||
const handleSessionClick = (sessionId: string) => {
|
||||
navigate(`/sessions/${sessionId}`);
|
||||
};
|
||||
|
||||
const handleViewAllSessions = () => {
|
||||
navigate('/sessions');
|
||||
};
|
||||
|
||||
const isLoading = statsLoading || sessionsLoading;
|
||||
const isFetching = statsFetching || sessionsFetching;
|
||||
const hasError = statsError || sessionsError;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Overview of your workflow sessions and tasks
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isFetching}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4 mr-2', isFetching && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error alert */}
|
||||
{hasError && (
|
||||
<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">Failed to load dashboard data</p>
|
||||
<p className="text-xs mt-0.5">
|
||||
{(statsError || sessionsError)?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Grid */}
|
||||
<section>
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">Statistics</h2>
|
||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-6">
|
||||
{isLoading
|
||||
? // Loading skeletons
|
||||
Array.from({ length: 6 }).map((_, i) => <StatCardSkeleton key={i} />)
|
||||
: // Actual stat cards
|
||||
statCards.map((card) => (
|
||||
<StatCard
|
||||
key={card.key}
|
||||
title={card.title}
|
||||
value={stats ? card.getValue(stats as any) : 0}
|
||||
icon={card.icon}
|
||||
variant={card.variant}
|
||||
isLoading={isFetching && !stats}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Sessions */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-medium text-foreground">Recent Sessions</h2>
|
||||
<Button variant="link" size="sm" onClick={handleViewAllSessions}>
|
||||
View All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sessionsLoading ? (
|
||||
// Loading skeletons
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<SessionCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : recentSessions.length === 0 ? (
|
||||
// Empty state
|
||||
<div className="flex flex-col items-center justify-center py-12 px-4 border border-dashed border-border rounded-lg">
|
||||
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-1">No sessions yet</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||
Start a new workflow session to track your development tasks and progress.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
// Session cards grid
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{recentSessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
onClick={handleSessionClick}
|
||||
onView={handleSessionClick}
|
||||
showActions={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
401
ccw/frontend/src/pages/IssueManagerPage.tsx
Normal file
401
ccw/frontend/src/pages/IssueManagerPage.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
// ========================================
|
||||
// Issue Manager Page
|
||||
// ========================================
|
||||
// Track and manage project issues with drag-drop queue
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Filter,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
Github,
|
||||
ListFilter,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { IssueCard } from '@/components/shared/IssueCard';
|
||||
import { useIssues, useIssueMutations } from '@/hooks';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
type ViewMode = 'issues' | 'queue';
|
||||
type StatusFilter = 'all' | Issue['status'];
|
||||
type PriorityFilter = 'all' | Issue['priority'];
|
||||
|
||||
// ========== New Issue Dialog ==========
|
||||
|
||||
interface NewIssueDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: { title: string; context?: string; priority?: Issue['priority'] }) => void;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
function NewIssueDialog({ open, onOpenChange, onSubmit, isCreating }: NewIssueDialogProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [context, setContext] = useState('');
|
||||
const [priority, setPriority] = useState<Issue['priority']>('medium');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (title.trim()) {
|
||||
onSubmit({ title: title.trim(), context: context.trim() || undefined, priority });
|
||||
setTitle('');
|
||||
setContext('');
|
||||
setPriority('medium');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Issue</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Title</label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Issue title..."
|
||||
className="mt-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Context (optional)</label>
|
||||
<textarea
|
||||
value={context}
|
||||
onChange={(e) => setContext(e.target.value)}
|
||||
placeholder="Describe the issue..."
|
||||
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Priority</label>
|
||||
<Select value={priority} onValueChange={(v) => setPriority(v as Issue['priority'])}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || !title.trim()}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Issue
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Issue List Component ==========
|
||||
|
||||
interface IssueListProps {
|
||||
issues: Issue[];
|
||||
isLoading: boolean;
|
||||
onIssueClick: (issue: Issue) => void;
|
||||
onIssueEdit: (issue: Issue) => void;
|
||||
onIssueDelete: (issue: Issue) => void;
|
||||
onStatusChange: (issue: Issue, status: Issue['status']) => void;
|
||||
}
|
||||
|
||||
function IssueList({
|
||||
issues,
|
||||
isLoading,
|
||||
onIssueClick,
|
||||
onIssueEdit,
|
||||
onIssueDelete,
|
||||
onStatusChange,
|
||||
}: IssueListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<AlertCircle className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">No issues found</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Create a new issue or adjust your filters.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{issues.map((issue) => (
|
||||
<IssueCard
|
||||
key={issue.id}
|
||||
issue={issue}
|
||||
onClick={onIssueClick}
|
||||
onEdit={onIssueEdit}
|
||||
onDelete={onIssueDelete}
|
||||
onStatusChange={onStatusChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function IssueManagerPage() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('issues');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState<PriorityFilter>('all');
|
||||
const [isNewIssueOpen, setIsNewIssueOpen] = useState(false);
|
||||
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null);
|
||||
|
||||
const {
|
||||
issues,
|
||||
issuesByStatus,
|
||||
issuesByPriority,
|
||||
openCount,
|
||||
criticalCount,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useIssues({
|
||||
filter: {
|
||||
search: searchQuery || undefined,
|
||||
status: statusFilter !== 'all' ? [statusFilter] : undefined,
|
||||
priority: priorityFilter !== 'all' ? [priorityFilter] : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { createIssue, updateIssue, deleteIssue, isCreating, isUpdating } = useIssueMutations();
|
||||
|
||||
// Filter counts
|
||||
const statusCounts = useMemo(() => ({
|
||||
all: issues.length,
|
||||
open: issuesByStatus.open?.length || 0,
|
||||
in_progress: issuesByStatus.in_progress?.length || 0,
|
||||
resolved: issuesByStatus.resolved?.length || 0,
|
||||
closed: issuesByStatus.closed?.length || 0,
|
||||
completed: issuesByStatus.completed?.length || 0,
|
||||
}), [issues, issuesByStatus]);
|
||||
|
||||
const handleCreateIssue = async (data: { title: string; context?: string; priority?: Issue['priority'] }) => {
|
||||
await createIssue(data);
|
||||
setIsNewIssueOpen(false);
|
||||
};
|
||||
|
||||
const handleEditIssue = (issue: Issue) => {
|
||||
setSelectedIssue(issue);
|
||||
// TODO: Open edit dialog
|
||||
};
|
||||
|
||||
const handleDeleteIssue = async (issue: Issue) => {
|
||||
if (confirm(`Delete issue "${issue.title}"?`)) {
|
||||
await deleteIssue(issue.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (issue: Issue, status: Issue['status']) => {
|
||||
await updateIssue(issue.id, { status });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<AlertCircle className="w-6 h-6 text-primary" />
|
||||
Issue Manager
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Track and manage project issues and bugs
|
||||
</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')} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="outline">
|
||||
<Github className="w-4 h-4 mr-2" />
|
||||
Pull from GitHub
|
||||
</Button>
|
||||
<Button onClick={() => setIsNewIssueOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Issue
|
||||
</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">
|
||||
<AlertCircle className="w-5 h-5 text-info" />
|
||||
<span className="text-2xl font-bold">{openCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Open Issues</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-warning" />
|
||||
<span className="text-2xl font-bold">{issuesByStatus.in_progress?.length || 0}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">In Progress</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-destructive" />
|
||||
<span className="text-2xl font-bold">{criticalCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Critical</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-success" />
|
||||
<span className="text-2xl font-bold">{issuesByStatus.resolved?.length || 0}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Resolved</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<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="Search issues..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as StatusFilter)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={priorityFilter} onValueChange={(v) => setPriorityFilter(v as PriorityFilter)}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Priority" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Priority</SelectItem>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
<SelectItem value="high">High</SelectItem>
|
||||
<SelectItem value="medium">Medium</SelectItem>
|
||||
<SelectItem value="low">Low</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={statusFilter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter('all')}
|
||||
>
|
||||
All ({statusCounts.all})
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === 'open' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter('open')}
|
||||
>
|
||||
<Badge variant="info" className="mr-2">{statusCounts.open}</Badge>
|
||||
Open
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === 'in_progress' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setStatusFilter('in_progress')}
|
||||
>
|
||||
<Badge variant="warning" className="mr-2">{statusCounts.in_progress}</Badge>
|
||||
In Progress
|
||||
</Button>
|
||||
<Button
|
||||
variant={priorityFilter === 'critical' ? 'destructive' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPriorityFilter(priorityFilter === 'critical' ? 'all' : 'critical');
|
||||
setStatusFilter('all');
|
||||
}}
|
||||
>
|
||||
<Badge variant="destructive" className="mr-2">{criticalCount}</Badge>
|
||||
Critical
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Issue List */}
|
||||
<IssueList
|
||||
issues={issues}
|
||||
isLoading={isLoading}
|
||||
onIssueClick={setSelectedIssue}
|
||||
onIssueEdit={handleEditIssue}
|
||||
onIssueDelete={handleDeleteIssue}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
|
||||
{/* New Issue Dialog */}
|
||||
<NewIssueDialog
|
||||
open={isNewIssueOpen}
|
||||
onOpenChange={setIsNewIssueOpen}
|
||||
onSubmit={handleCreateIssue}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default IssueManagerPage;
|
||||
438
ccw/frontend/src/pages/LoopMonitorPage.tsx
Normal file
438
ccw/frontend/src/pages/LoopMonitorPage.tsx
Normal file
@@ -0,0 +1,438 @@
|
||||
// ========================================
|
||||
// Loop Monitor Page
|
||||
// ========================================
|
||||
// Monitor running development loops with Kanban board
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
RefreshCw,
|
||||
Play,
|
||||
Pause,
|
||||
StopCircle,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import type { DropResult, DraggableProvided } from '@hello-pangea/dnd';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/Dialog';
|
||||
import { KanbanBoard, useLoopKanbanColumns, type LoopKanbanItem } from '@/components/shared/KanbanBoard';
|
||||
import { useLoops, useLoopMutations } from '@/hooks';
|
||||
import type { Loop } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Loop Card Component ==========
|
||||
|
||||
interface LoopCardProps {
|
||||
loop: Loop;
|
||||
provided: DraggableProvided;
|
||||
onPause?: (loop: Loop) => void;
|
||||
onResume?: (loop: Loop) => void;
|
||||
onStop?: (loop: Loop) => void;
|
||||
onClick?: (loop: Loop) => void;
|
||||
}
|
||||
|
||||
function LoopCard({ loop, provided, onPause, onResume, onStop, onClick }: LoopCardProps) {
|
||||
const statusIcons: Record<Loop['status'], React.ReactNode> = {
|
||||
created: <Clock className="w-4 h-4 text-muted-foreground" />,
|
||||
running: <Loader2 className="w-4 h-4 text-primary animate-spin" />,
|
||||
paused: <Pause className="w-4 h-4 text-warning" />,
|
||||
completed: <CheckCircle className="w-4 h-4 text-success" />,
|
||||
failed: <XCircle className="w-4 h-4 text-destructive" />,
|
||||
};
|
||||
|
||||
const progress = loop.totalSteps > 0
|
||||
? Math.round((loop.currentStep / loop.totalSteps) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
onClick={() => onClick?.(loop)}
|
||||
className={cn(
|
||||
'p-3 bg-card border border-border rounded-lg cursor-pointer',
|
||||
'hover:shadow-md hover:border-primary/50 transition-all',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/50'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{statusIcons[loop.status]}
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{loop.name || loop.id}
|
||||
</span>
|
||||
</div>
|
||||
{loop.tool && (
|
||||
<Badge variant="outline" className="text-xs flex-shrink-0">
|
||||
{loop.tool}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt Preview */}
|
||||
{loop.prompt && (
|
||||
<p className="text-xs text-muted-foreground mt-2 line-clamp-2">
|
||||
{loop.prompt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
{loop.status === 'running' && loop.totalSteps > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
|
||||
<span>Step {loop.currentStep}/{loop.totalSteps}</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{(loop.status === 'running' || loop.status === 'paused') && (
|
||||
<div className="flex items-center gap-1 mt-3 pt-2 border-t border-border">
|
||||
{loop.status === 'running' ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => { e.stopPropagation(); onPause?.(loop); }}
|
||||
>
|
||||
<Pause className="w-3 h-3 mr-1" />
|
||||
Pause
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => { e.stopPropagation(); onResume?.(loop); }}
|
||||
>
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); onStop?.(loop); }}
|
||||
>
|
||||
<StopCircle className="w-3 h-3 mr-1" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{loop.status === 'failed' && loop.error && (
|
||||
<div className="mt-2 p-2 bg-destructive/10 rounded text-xs text-destructive">
|
||||
{loop.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== New Loop Dialog ==========
|
||||
|
||||
interface NewLoopDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: { prompt: string; tool?: string; mode?: string }) => void;
|
||||
isCreating: boolean;
|
||||
}
|
||||
|
||||
function NewLoopDialog({ open, onOpenChange, onSubmit, isCreating }: NewLoopDialogProps) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [tool, setTool] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (prompt.trim()) {
|
||||
onSubmit({ prompt: prompt.trim(), tool: tool || undefined });
|
||||
setPrompt('');
|
||||
setTool('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Start New Loop</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Prompt</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Enter your development loop prompt..."
|
||||
className="mt-1 w-full min-h-[100px] p-3 bg-background border border-input rounded-md text-sm resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">CLI Tool (optional)</label>
|
||||
<Input
|
||||
value={tool}
|
||||
onChange={(e) => setTool(e.target.value)}
|
||||
placeholder="e.g., gemini, qwen, codex"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || !prompt.trim()}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Start Loop
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function LoopMonitorPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isNewLoopOpen, setIsNewLoopOpen] = useState(false);
|
||||
const [selectedLoop, setSelectedLoop] = useState<Loop | null>(null);
|
||||
|
||||
const {
|
||||
loops,
|
||||
loopsByStatus,
|
||||
runningCount,
|
||||
completedCount,
|
||||
failedCount,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useLoops({
|
||||
filter: searchQuery ? { search: searchQuery } : undefined,
|
||||
refetchInterval: 5000, // Refresh every 5 seconds for real-time updates
|
||||
});
|
||||
|
||||
const { createLoop, updateStatus, isCreating, isUpdating } = useLoopMutations();
|
||||
|
||||
// Kanban columns
|
||||
const columns = useLoopKanbanColumns(loopsByStatus as unknown as Record<string, LoopKanbanItem[]>);
|
||||
|
||||
// Handle drag and drop status change
|
||||
const handleDragEnd = useCallback(
|
||||
async (result: DropResult, _source: string, destination: string) => {
|
||||
const loopId = result.draggableId;
|
||||
const newStatus = destination as Loop['status'];
|
||||
|
||||
// Only allow certain transitions
|
||||
const allowedTransitions: Record<Loop['status'], Loop['status'][]> = {
|
||||
created: ['running'],
|
||||
running: ['paused', 'completed', 'failed'],
|
||||
paused: ['running', 'completed'],
|
||||
completed: [],
|
||||
failed: ['created'], // Retry
|
||||
};
|
||||
|
||||
const loop = loops.find((l) => l.id === loopId);
|
||||
if (!loop) return;
|
||||
|
||||
if (!allowedTransitions[loop.status]?.includes(newStatus)) {
|
||||
return; // Invalid transition
|
||||
}
|
||||
|
||||
// Map status to action
|
||||
const actionMap: Record<Loop['status'], 'pause' | 'resume' | 'stop' | null> = {
|
||||
paused: 'pause',
|
||||
running: 'resume',
|
||||
completed: 'stop',
|
||||
failed: 'stop',
|
||||
created: null,
|
||||
};
|
||||
|
||||
const action = actionMap[newStatus];
|
||||
if (action) {
|
||||
await updateStatus(loopId, action);
|
||||
}
|
||||
},
|
||||
[loops, updateStatus]
|
||||
);
|
||||
|
||||
const handlePause = async (loop: Loop) => {
|
||||
await updateStatus(loop.id, 'pause');
|
||||
};
|
||||
|
||||
const handleResume = async (loop: Loop) => {
|
||||
await updateStatus(loop.id, 'resume');
|
||||
};
|
||||
|
||||
const handleStop = async (loop: Loop) => {
|
||||
await updateStatus(loop.id, 'stop');
|
||||
};
|
||||
|
||||
const handleCreateLoop = async (data: { prompt: string; tool?: string; mode?: string }) => {
|
||||
await createLoop(data);
|
||||
setIsNewLoopOpen(false);
|
||||
};
|
||||
|
||||
// Custom item renderer for loops
|
||||
const renderLoopItem = useCallback(
|
||||
(item: LoopKanbanItem, provided: DraggableProvided) => (
|
||||
<LoopCard
|
||||
loop={item as unknown as Loop}
|
||||
provided={provided}
|
||||
onPause={handlePause}
|
||||
onResume={handleResume}
|
||||
onStop={handleStop}
|
||||
onClick={setSelectedLoop}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<RefreshCw className="w-6 h-6 text-primary" />
|
||||
Loop Monitor
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Monitor and control running development loops
|
||||
</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')} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => setIsNewLoopOpen(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Loop
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-5 h-5 text-primary" />
|
||||
<span className="text-2xl font-bold">{runningCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Running</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pause className="w-5 h-5 text-warning" />
|
||||
<span className="text-2xl font-bold">{loopsByStatus.paused?.length || 0}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Paused</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-5 h-5 text-success" />
|
||||
<span className="text-2xl font-bold">{completedCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Completed</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="w-5 h-5 text-destructive" />
|
||||
<span className="text-2xl font-bold">{failedCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Failed</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search loops..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Kanban Board */}
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<Card key={i} className="p-4">
|
||||
<div className="h-6 w-20 bg-muted animate-pulse rounded mb-4" />
|
||||
<div className="space-y-2">
|
||||
{[1, 2].map((j) => (
|
||||
<div key={j} className="h-24 bg-muted animate-pulse rounded" />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : loops.length === 0 && !searchQuery ? (
|
||||
<Card className="p-8 text-center">
|
||||
<RefreshCw className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
No active loops
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Start a new development loop to begin monitoring progress.
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => setIsNewLoopOpen(true)}>
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Start New Loop
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<KanbanBoard
|
||||
columns={columns}
|
||||
onDragEnd={handleDragEnd}
|
||||
renderItem={renderLoopItem}
|
||||
emptyColumnMessage="No loops"
|
||||
className="min-h-[400px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New Loop Dialog */}
|
||||
<NewLoopDialog
|
||||
open={isNewLoopOpen}
|
||||
onOpenChange={setIsNewLoopOpen}
|
||||
onSubmit={handleCreateLoop}
|
||||
isCreating={isCreating}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoopMonitorPage;
|
||||
480
ccw/frontend/src/pages/MemoryPage.tsx
Normal file
480
ccw/frontend/src/pages/MemoryPage.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
// ========================================
|
||||
// Memory Page
|
||||
// ========================================
|
||||
// View and manage core memory and context with CRUD operations
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Brain,
|
||||
Search,
|
||||
Plus,
|
||||
Database,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Edit,
|
||||
Eye,
|
||||
Tag,
|
||||
Loader2,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
|
||||
import { useMemory, useMemoryMutations } from '@/hooks';
|
||||
import type { CoreMemory } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Memory Card Component ==========
|
||||
|
||||
interface MemoryCardProps {
|
||||
memory: CoreMemory;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onEdit: (memory: CoreMemory) => void;
|
||||
onDelete: (memory: CoreMemory) => void;
|
||||
onCopy: (content: string) => void;
|
||||
}
|
||||
|
||||
function MemoryCard({ memory, isExpanded, onToggleExpand, onEdit, onDelete, onCopy }: MemoryCardProps) {
|
||||
const formattedDate = new Date(memory.createdAt).toLocaleDateString();
|
||||
const formattedSize = memory.size
|
||||
? memory.size < 1024
|
||||
? `${memory.size} B`
|
||||
: `${(memory.size / 1024).toFixed(1)} KB`
|
||||
: 'Unknown';
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Brain className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{memory.id}
|
||||
</span>
|
||||
{memory.source && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{memory.source}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formattedDate} - {formattedSize}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy(memory.content);
|
||||
}}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(memory);
|
||||
}}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(memory);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{!isExpanded && (
|
||||
<p className="text-sm text-muted-foreground mt-2 line-clamp-2">
|
||||
{memory.content}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{memory.tags && memory.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{memory.tags.slice(0, 5).map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
<Tag className="w-3 h-3 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{memory.tags.length > 5 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{memory.tags.length - 5}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-4 bg-muted/30">
|
||||
<pre className="text-sm text-foreground whitespace-pre-wrap font-mono bg-background p-4 rounded-lg overflow-x-auto max-h-96">
|
||||
{memory.content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== New Memory Dialog ==========
|
||||
|
||||
interface NewMemoryDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: { content: string; tags?: string[] }) => void;
|
||||
isCreating: boolean;
|
||||
editingMemory?: CoreMemory | null;
|
||||
}
|
||||
|
||||
function NewMemoryDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isCreating,
|
||||
editingMemory,
|
||||
}: NewMemoryDialogProps) {
|
||||
const [content, setContent] = useState(editingMemory?.content || '');
|
||||
const [tagsInput, setTagsInput] = useState(editingMemory?.tags?.join(', ') || '');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (content.trim()) {
|
||||
const tags = tagsInput
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
onSubmit({ content: content.trim(), tags: tags.length > 0 ? tags : undefined });
|
||||
setContent('');
|
||||
setTagsInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingMemory ? 'Edit Memory' : 'Add New Memory'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Content</label>
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Enter memory content..."
|
||||
className="mt-1 w-full min-h-[200px] p-3 bg-background border border-input rounded-md text-sm font-mono resize-none focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Tags (comma-separated)</label>
|
||||
<Input
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder="e.g., project, config, api"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isCreating || !content.trim()}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
{editingMemory ? 'Updating...' : 'Creating...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{editingMemory ? 'Update Memory' : 'Add Memory'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function MemoryPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [isNewMemoryOpen, setIsNewMemoryOpen] = useState(false);
|
||||
const [editingMemory, setEditingMemory] = useState<CoreMemory | null>(null);
|
||||
const [expandedMemories, setExpandedMemories] = useState<Set<string>>(new Set());
|
||||
|
||||
const {
|
||||
memories,
|
||||
totalSize,
|
||||
claudeMdCount,
|
||||
allTags,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useMemory({
|
||||
filter: {
|
||||
search: searchQuery || undefined,
|
||||
tags: selectedTags.length > 0 ? selectedTags : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { createMemory, updateMemory, deleteMemory, isCreating, isUpdating, isDeleting } =
|
||||
useMemoryMutations();
|
||||
|
||||
const toggleExpand = (memoryId: string) => {
|
||||
setExpandedMemories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(memoryId)) {
|
||||
next.delete(memoryId);
|
||||
} else {
|
||||
next.add(memoryId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateMemory = async (data: { content: string; tags?: string[] }) => {
|
||||
if (editingMemory) {
|
||||
await updateMemory(editingMemory.id, data);
|
||||
setEditingMemory(null);
|
||||
} else {
|
||||
await createMemory(data);
|
||||
}
|
||||
setIsNewMemoryOpen(false);
|
||||
};
|
||||
|
||||
const handleEdit = (memory: CoreMemory) => {
|
||||
setEditingMemory(memory);
|
||||
setIsNewMemoryOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (memory: CoreMemory) => {
|
||||
if (confirm(`Delete memory "${memory.id}"?`)) {
|
||||
await deleteMemory(memory.id);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (content: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
// TODO: Show toast notification
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||
);
|
||||
};
|
||||
|
||||
const formattedTotalSize = totalSize < 1024
|
||||
? `${totalSize} B`
|
||||
: totalSize < 1024 * 1024
|
||||
? `${(totalSize / 1024).toFixed(1)} KB`
|
||||
: `${(totalSize / (1024 * 1024)).toFixed(1)} MB`;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Brain className="w-6 h-6 text-primary" />
|
||||
Memory
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage core memory, context, and knowledge base
|
||||
</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')} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button onClick={() => { setEditingMemory(null); setIsNewMemoryOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add Memory
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10">
|
||||
<Database className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{memories.length}</div>
|
||||
<p className="text-sm text-muted-foreground">Core Memories</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-info/10">
|
||||
<FileText className="w-5 h-5 text-info" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{claudeMdCount}</div>
|
||||
<p className="text-sm text-muted-foreground">CLAUDE.md Files</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-success/10">
|
||||
<Brain className="w-5 h-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">{formattedTotalSize}</div>
|
||||
<p className="text-sm text-muted-foreground">Total Size</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search memories..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags Filter */}
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm text-muted-foreground py-1">Tags:</span>
|
||||
{allTags.map((tag) => (
|
||||
<Button
|
||||
key={tag}
|
||||
variant={selectedTags.includes(tag) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => toggleTag(tag)}
|
||||
>
|
||||
<Tag className="w-3 h-3 mr-1" />
|
||||
{tag}
|
||||
</Button>
|
||||
))}
|
||||
{selectedTags.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={() => setSelectedTags([])}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Memory List */}
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-32 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
) : memories.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<Brain className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
No memories stored
|
||||
</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Add context and knowledge to help Claude understand your project better.
|
||||
</p>
|
||||
<Button className="mt-4" onClick={() => { setEditingMemory(null); setIsNewMemoryOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Add First Memory
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{memories.map((memory) => (
|
||||
<MemoryCard
|
||||
key={memory.id}
|
||||
memory={memory}
|
||||
isExpanded={expandedMemories.has(memory.id)}
|
||||
onToggleExpand={() => toggleExpand(memory.id)}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCopy={copyToClipboard}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New/Edit Memory Dialog */}
|
||||
<NewMemoryDialog
|
||||
open={isNewMemoryOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsNewMemoryOpen(open);
|
||||
if (!open) setEditingMemory(null);
|
||||
}}
|
||||
onSubmit={handleCreateMemory}
|
||||
isCreating={isCreating || isUpdating}
|
||||
editingMemory={editingMemory}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MemoryPage;
|
||||
450
ccw/frontend/src/pages/SessionsPage.tsx
Normal file
450
ccw/frontend/src/pages/SessionsPage.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
// ========================================
|
||||
// SessionsPage Component
|
||||
// ========================================
|
||||
// Sessions list page with CRUD operations
|
||||
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Filter,
|
||||
AlertCircle,
|
||||
FolderKanban,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
useSessions,
|
||||
useCreateSession,
|
||||
useArchiveSession,
|
||||
useDeleteSession,
|
||||
type SessionsFilter,
|
||||
} from '@/hooks/useSessions';
|
||||
import { SessionCard, SessionCardSkeleton } from '@/components/shared/SessionCard';
|
||||
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 { SessionMetadata } from '@/types/store';
|
||||
|
||||
type LocationFilter = 'all' | 'active' | 'archived';
|
||||
|
||||
/**
|
||||
* SessionsPage component - Sessions list with CRUD operations
|
||||
*/
|
||||
export function SessionsPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Filter state
|
||||
const [locationFilter, setLocationFilter] = React.useState<LocationFilter>('active');
|
||||
const [searchQuery, setSearchQuery] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<SessionMetadata['status'][]>([]);
|
||||
|
||||
// Dialog state
|
||||
const [createDialogOpen, setCreateDialogOpen] = React.useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false);
|
||||
const [sessionToDelete, setSessionToDelete] = React.useState<string | null>(null);
|
||||
|
||||
// Create session form state
|
||||
const [newSessionId, setNewSessionId] = React.useState('');
|
||||
const [newSessionTitle, setNewSessionTitle] = React.useState('');
|
||||
const [newSessionDescription, setNewSessionDescription] = React.useState('');
|
||||
|
||||
// Build filter object
|
||||
const filter: SessionsFilter = React.useMemo(
|
||||
() => ({
|
||||
location: locationFilter,
|
||||
search: searchQuery,
|
||||
status: statusFilter.length > 0 ? statusFilter : undefined,
|
||||
}),
|
||||
[locationFilter, searchQuery, statusFilter]
|
||||
);
|
||||
|
||||
// Fetch sessions with filter
|
||||
const {
|
||||
filteredSessions,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
refetch,
|
||||
} = useSessions({ filter });
|
||||
|
||||
// Mutations
|
||||
const { createSession, isCreating } = useCreateSession();
|
||||
const { archiveSession, isArchiving } = useArchiveSession();
|
||||
const { deleteSession, isDeleting } = useDeleteSession();
|
||||
|
||||
const isMutating = isCreating || isArchiving || isDeleting;
|
||||
|
||||
// Handlers
|
||||
const handleSessionClick = (sessionId: string) => {
|
||||
navigate(`/sessions/${sessionId}`);
|
||||
};
|
||||
|
||||
const handleCreateSession = async () => {
|
||||
if (!newSessionId.trim()) return;
|
||||
|
||||
try {
|
||||
await createSession({
|
||||
session_id: newSessionId.trim(),
|
||||
title: newSessionTitle.trim() || undefined,
|
||||
description: newSessionDescription.trim() || undefined,
|
||||
});
|
||||
setCreateDialogOpen(false);
|
||||
resetCreateForm();
|
||||
} catch (err) {
|
||||
console.error('Failed to create session:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
setNewSessionId('');
|
||||
setNewSessionTitle('');
|
||||
setNewSessionDescription('');
|
||||
};
|
||||
|
||||
const handleArchive = async (sessionId: string) => {
|
||||
try {
|
||||
await archiveSession(sessionId);
|
||||
} catch (err) {
|
||||
console.error('Failed to archive session:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (sessionId: string) => {
|
||||
setSessionToDelete(sessionId);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!sessionToDelete) return;
|
||||
|
||||
try {
|
||||
await deleteSession(sessionToDelete);
|
||||
setDeleteDialogOpen(false);
|
||||
setSessionToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete session:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const toggleStatusFilter = (status: SessionMetadata['status']) => {
|
||||
setStatusFilter((prev) =>
|
||||
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
||||
);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setStatusFilter([]);
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const hasActiveFilters = statusFilter.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">Sessions</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your workflow sessions
|
||||
</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')} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Session
|
||||
</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">Failed to load sessions</p>
|
||||
<p className="text-xs mt-0.5">{error.message}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
{/* Location tabs */}
|
||||
<Tabs value={locationFilter} onValueChange={(v) => setLocationFilter(v as LocationFilter)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="active">Active</TabsTrigger>
|
||||
<TabsTrigger value="archived">Archived</TabsTrigger>
|
||||
<TabsTrigger value="all">All</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="Search sessions..."
|
||||
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>
|
||||
|
||||
{/* Status filter dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filter
|
||||
{statusFilter.length > 0 && (
|
||||
<Badge variant="secondary" className="ml-1 h-5 min-w-5 px-1">
|
||||
{statusFilter.length}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuLabel>Status</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{(['planning', 'in_progress', 'completed', 'paused'] as const).map((status) => (
|
||||
<DropdownMenuItem
|
||||
key={status}
|
||||
onClick={() => toggleStatusFilter(status)}
|
||||
className="justify-between"
|
||||
>
|
||||
<span className="capitalize">{status.replace('_', ' ')}</span>
|
||||
{statusFilter.includes(status) && (
|
||||
<span className="text-primary">✓</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{hasActiveFilters && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={clearFilters} className="text-destructive">
|
||||
Clear filters
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Active filters display */}
|
||||
{hasActiveFilters && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Filters:</span>
|
||||
{statusFilter.map((status) => (
|
||||
<Badge
|
||||
key={status}
|
||||
variant="secondary"
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleStatusFilter(status)}
|
||||
>
|
||||
{status.replace('_', ' ')}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
))}
|
||||
{searchQuery && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-pointer"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
Search: {searchQuery}
|
||||
<X className="ml-1 h-3 w-3" />
|
||||
</Badge>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={clearFilters} className="h-6 text-xs">
|
||||
Clear all
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sessions grid */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<SessionCardSkeleton key={i} />
|
||||
))}
|
||||
</div>
|
||||
) : filteredSessions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 px-4 border border-dashed border-border rounded-lg">
|
||||
<FolderKanban className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-medium text-foreground mb-1">
|
||||
{hasActiveFilters ? 'No sessions match your filters' : 'No sessions found'}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">
|
||||
{hasActiveFilters
|
||||
? 'Try adjusting your filters or search query.'
|
||||
: 'Create a new session to get started with your workflow.'}
|
||||
</p>
|
||||
{hasActiveFilters ? (
|
||||
<Button variant="outline" onClick={clearFilters}>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Session
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredSessions.map((session) => (
|
||||
<SessionCard
|
||||
key={session.session_id}
|
||||
session={session}
|
||||
onClick={handleSessionClick}
|
||||
onView={handleSessionClick}
|
||||
onArchive={handleArchive}
|
||||
onDelete={handleDeleteClick}
|
||||
actionsDisabled={isMutating}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Session Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Session</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new workflow session to track your development tasks.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="sessionId" className="text-sm font-medium">
|
||||
Session ID <span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="sessionId"
|
||||
placeholder="e.g., WFS-feature-auth"
|
||||
value={newSessionId}
|
||||
onChange={(e) => setNewSessionId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="sessionTitle" className="text-sm font-medium">
|
||||
Title (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="sessionTitle"
|
||||
placeholder="e.g., Authentication System"
|
||||
value={newSessionTitle}
|
||||
onChange={(e) => setNewSessionTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="sessionDescription" className="text-sm font-medium">
|
||||
Description (optional)
|
||||
</label>
|
||||
<Input
|
||||
id="sessionDescription"
|
||||
placeholder="Brief description of the session"
|
||||
value={newSessionDescription}
|
||||
onChange={(e) => setNewSessionDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCreateDialogOpen(false);
|
||||
resetCreateForm();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreateSession}
|
||||
disabled={!newSessionId.trim() || isCreating}
|
||||
>
|
||||
{isCreating ? 'Creating...' : 'Create Session'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Session</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete this session? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setSessionToDelete(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionsPage;
|
||||
440
ccw/frontend/src/pages/SettingsPage.tsx
Normal file
440
ccw/frontend/src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,440 @@
|
||||
// ========================================
|
||||
// Settings Page
|
||||
// ========================================
|
||||
// Application settings and configuration with CLI tools management
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Settings,
|
||||
Moon,
|
||||
Sun,
|
||||
Globe,
|
||||
Bell,
|
||||
Shield,
|
||||
Cpu,
|
||||
RefreshCw,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Check,
|
||||
X,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useTheme, useConfig } from '@/hooks';
|
||||
import { useConfigStore, selectCliTools, selectDefaultCliTool, selectUserPreferences } from '@/stores/configStore';
|
||||
import type { CliToolConfig, UserPreferences } from '@/types/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== CLI Tool Card Component ==========
|
||||
|
||||
interface CliToolCardProps {
|
||||
toolId: string;
|
||||
config: CliToolConfig;
|
||||
isDefault: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onToggleEnabled: () => void;
|
||||
onSetDefault: () => void;
|
||||
onUpdateModel: (field: 'primaryModel' | 'secondaryModel', value: string) => void;
|
||||
}
|
||||
|
||||
function CliToolCard({
|
||||
toolId,
|
||||
config,
|
||||
isDefault,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onToggleEnabled,
|
||||
onSetDefault,
|
||||
onUpdateModel,
|
||||
}: CliToolCardProps) {
|
||||
return (
|
||||
<Card className={cn('overflow-hidden', !config.enabled && 'opacity-60')}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
'p-2 rounded-lg',
|
||||
config.enabled ? 'bg-primary/10' : 'bg-muted'
|
||||
)}>
|
||||
<Cpu className={cn(
|
||||
'w-5 h-5',
|
||||
config.enabled ? 'text-primary' : 'text-muted-foreground'
|
||||
)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground capitalize">
|
||||
{toolId}
|
||||
</span>
|
||||
{isDefault && (
|
||||
<Badge variant="default" className="text-xs">Default</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">{config.type}</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{config.primaryModel}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={config.enabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleEnabled();
|
||||
}}
|
||||
>
|
||||
{config.enabled ? (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-1" />
|
||||
Enabled
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<X className="w-4 h-4 mr-1" />
|
||||
Disabled
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{config.tags && config.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{config.tags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border p-4 space-y-4 bg-muted/30">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Primary Model</label>
|
||||
<Input
|
||||
value={config.primaryModel}
|
||||
onChange={(e) => onUpdateModel('primaryModel', e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">Secondary Model</label>
|
||||
<Input
|
||||
value={config.secondaryModel}
|
||||
onChange={(e) => onUpdateModel('secondaryModel', e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isDefault && config.enabled && (
|
||||
<Button variant="outline" size="sm" onClick={onSetDefault}>
|
||||
Set as Default
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function SettingsPage() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const cliTools = useConfigStore(selectCliTools);
|
||||
const defaultCliTool = useConfigStore(selectDefaultCliTool);
|
||||
const userPreferences = useConfigStore(selectUserPreferences);
|
||||
const { updateCliTool, setDefaultCliTool, setUserPreferences, resetUserPreferences } = useConfigStore();
|
||||
|
||||
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const toggleToolExpand = (toolId: string) => {
|
||||
setExpandedTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(toolId)) {
|
||||
next.delete(toolId);
|
||||
} else {
|
||||
next.add(toolId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleToolEnabled = (toolId: string) => {
|
||||
updateCliTool(toolId, { enabled: !cliTools[toolId].enabled });
|
||||
};
|
||||
|
||||
const handleSetDefaultTool = (toolId: string) => {
|
||||
setDefaultCliTool(toolId);
|
||||
};
|
||||
|
||||
const handleUpdateModel = (toolId: string, field: 'primaryModel' | 'secondaryModel', value: string) => {
|
||||
updateCliTool(toolId, { [field]: value });
|
||||
};
|
||||
|
||||
const handlePreferenceChange = (key: keyof UserPreferences, value: unknown) => {
|
||||
setUserPreferences({ [key]: value });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Settings className="w-6 h-6 text-primary" />
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Configure your dashboard preferences and CLI tools
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Appearance Settings */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
<Moon className="w-5 h-5" />
|
||||
Appearance
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Theme</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose your preferred color theme
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={theme === 'light' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTheme('light')}
|
||||
>
|
||||
<Sun className="w-4 h-4 mr-2" />
|
||||
Light
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === 'dark' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTheme('dark')}
|
||||
>
|
||||
<Moon className="w-4 h-4 mr-2" />
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
variant={theme === 'system' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setTheme('system')}
|
||||
>
|
||||
System
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Compact View</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use a more compact layout for lists
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={userPreferences.compactView ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handlePreferenceChange('compactView', !userPreferences.compactView)}
|
||||
>
|
||||
{userPreferences.compactView ? 'On' : 'Off'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* CLI Tools Configuration */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
<Cpu className="w-5 h-5" />
|
||||
CLI Tools
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure available CLI tools and their models. Default tool: <strong className="text-foreground">{defaultCliTool}</strong>
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(cliTools).map(([toolId, config]) => (
|
||||
<CliToolCard
|
||||
key={toolId}
|
||||
toolId={toolId}
|
||||
config={config}
|
||||
isDefault={toolId === defaultCliTool}
|
||||
isExpanded={expandedTools.has(toolId)}
|
||||
onToggleExpand={() => toggleToolExpand(toolId)}
|
||||
onToggleEnabled={() => handleToggleToolEnabled(toolId)}
|
||||
onSetDefault={() => handleSetDefaultTool(toolId)}
|
||||
onUpdateModel={(field, value) => handleUpdateModel(toolId, field, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Data Refresh Settings */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Data Refresh
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Auto Refresh</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically refresh data periodically
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={userPreferences.autoRefresh ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handlePreferenceChange('autoRefresh', !userPreferences.autoRefresh)}
|
||||
>
|
||||
{userPreferences.autoRefresh ? 'Enabled' : 'Disabled'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{userPreferences.autoRefresh && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Refresh Interval</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How often to refresh data
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{[15000, 30000, 60000, 120000].map((interval) => (
|
||||
<Button
|
||||
key={interval}
|
||||
variant={userPreferences.refreshInterval === interval ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handlePreferenceChange('refreshInterval', interval)}
|
||||
>
|
||||
{interval / 1000}s
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Notifications */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
<Bell className="w-5 h-5" />
|
||||
Notifications
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Enable Notifications</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Show notifications for workflow events
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={userPreferences.notificationsEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handlePreferenceChange('notificationsEnabled', !userPreferences.notificationsEnabled)}
|
||||
>
|
||||
{userPreferences.notificationsEnabled ? 'Enabled' : 'Disabled'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Sound Effects</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Play sound for notifications
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={userPreferences.soundEnabled ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handlePreferenceChange('soundEnabled', !userPreferences.soundEnabled)}
|
||||
>
|
||||
{userPreferences.soundEnabled ? 'On' : 'Off'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Display Settings */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
<Settings className="w-5 h-5" />
|
||||
Display Settings
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Show Completed Tasks</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display completed tasks in task lists
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={userPreferences.showCompletedTasks ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handlePreferenceChange('showCompletedTasks', !userPreferences.showCompletedTasks)}
|
||||
>
|
||||
{userPreferences.showCompletedTasks ? 'Show' : 'Hide'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Reset Settings */}
|
||||
<Card className="p-6 border-destructive/50">
|
||||
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2 mb-4">
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
Reset Settings
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Reset all user preferences to their default values. This cannot be undone.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (confirm('Reset all settings to defaults?')) {
|
||||
resetUserPreferences();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsPage;
|
||||
279
ccw/frontend/src/pages/SkillsManagerPage.tsx
Normal file
279
ccw/frontend/src/pages/SkillsManagerPage.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
// ========================================
|
||||
// Skills Manager Page
|
||||
// ========================================
|
||||
// Browse and manage skills library with search/filter
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import {
|
||||
Sparkles,
|
||||
Search,
|
||||
Plus,
|
||||
Filter,
|
||||
RefreshCw,
|
||||
Power,
|
||||
PowerOff,
|
||||
Tag,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/Select';
|
||||
import { SkillCard } from '@/components/shared/SkillCard';
|
||||
import { useSkills, useSkillMutations } from '@/hooks';
|
||||
import type { Skill } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Skill Grid Component ==========
|
||||
|
||||
interface SkillGridProps {
|
||||
skills: Skill[];
|
||||
isLoading: boolean;
|
||||
onToggle: (skill: Skill, enabled: boolean) => void;
|
||||
onClick: (skill: Skill) => void;
|
||||
isToggling: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function SkillGrid({ skills, isLoading, onToggle, onClick, isToggling, compact }: SkillGridProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'grid gap-4',
|
||||
compact ? 'grid-cols-1' : 'md:grid-cols-2 lg:grid-cols-3'
|
||||
)}>
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="h-48 bg-muted animate-pulse rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
return (
|
||||
<Card className="p-8 text-center">
|
||||
<Sparkles className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">No skills found</h3>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Try adjusting your search or filters.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'grid gap-4',
|
||||
compact ? 'grid-cols-1' : 'md:grid-cols-2 lg:grid-cols-3'
|
||||
)}>
|
||||
{skills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
onToggle={onToggle}
|
||||
onClick={onClick}
|
||||
isToggling={isToggling}
|
||||
compact={compact}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function SkillsManagerPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [sourceFilter, setSourceFilter] = useState<string>('all');
|
||||
const [enabledFilter, setEnabledFilter] = useState<'all' | 'enabled' | 'disabled'>('all');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'compact'>('grid');
|
||||
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
||||
|
||||
const {
|
||||
skills,
|
||||
enabledSkills,
|
||||
categories,
|
||||
skillsByCategory,
|
||||
totalCount,
|
||||
enabledCount,
|
||||
isLoading,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useSkills({
|
||||
filter: {
|
||||
search: searchQuery || undefined,
|
||||
category: categoryFilter !== 'all' ? categoryFilter : undefined,
|
||||
source: sourceFilter !== 'all' ? sourceFilter as Skill['source'] : undefined,
|
||||
enabledOnly: enabledFilter === 'enabled',
|
||||
},
|
||||
});
|
||||
|
||||
const { toggleSkill, isToggling } = useSkillMutations();
|
||||
|
||||
// Filter skills based on enabled filter
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (enabledFilter === 'disabled') {
|
||||
return skills.filter((s) => !s.enabled);
|
||||
}
|
||||
return skills;
|
||||
}, [skills, enabledFilter]);
|
||||
|
||||
const handleToggle = async (skill: Skill, enabled: boolean) => {
|
||||
await toggleSkill(skill.name, enabled);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-primary" />
|
||||
Skills Manager
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Browse, install, and manage Claude Code skills
|
||||
</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')} />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Install Skill
|
||||
</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">
|
||||
<Sparkles 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">Total Skills</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Power className="w-5 h-5 text-success" />
|
||||
<span className="text-2xl font-bold">{enabledCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Enabled</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<PowerOff className="w-5 h-5 text-muted-foreground" />
|
||||
<span className="text-2xl font-bold">{totalCount - enabledCount}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Disabled</p>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-5 h-5 text-info" />
|
||||
<span className="text-2xl font-bold">{categories.length}</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">Categories</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<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="Search skills by name, description, or trigger..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={sourceFilter} onValueChange={setSourceFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Source" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Sources</SelectItem>
|
||||
<SelectItem value="builtin">Built-in</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
<SelectItem value="community">Community</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={enabledFilter} onValueChange={(v) => setEnabledFilter(v as 'all' | 'enabled' | 'disabled')}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="enabled">Enabled Only</SelectItem>
|
||||
<SelectItem value="disabled">Disabled Only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={enabledFilter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setEnabledFilter('all')}
|
||||
>
|
||||
All ({totalCount})
|
||||
</Button>
|
||||
<Button
|
||||
variant={enabledFilter === 'enabled' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setEnabledFilter('enabled')}
|
||||
>
|
||||
<Power className="w-4 h-4 mr-1" />
|
||||
Enabled ({enabledCount})
|
||||
</Button>
|
||||
<Button
|
||||
variant={enabledFilter === 'disabled' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setEnabledFilter('disabled')}
|
||||
>
|
||||
<PowerOff className="w-4 h-4 mr-1" />
|
||||
Disabled ({totalCount - enabledCount})
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'compact' : 'grid')}
|
||||
>
|
||||
{viewMode === 'grid' ? 'Compact View' : 'Grid View'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Skills Grid */}
|
||||
<SkillGrid
|
||||
skills={filteredSkills}
|
||||
isLoading={isLoading}
|
||||
onToggle={handleToggle}
|
||||
onClick={setSelectedSkill}
|
||||
isToggling={isToggling}
|
||||
compact={viewMode === 'compact'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillsManagerPage;
|
||||
15
ccw/frontend/src/pages/index.ts
Normal file
15
ccw/frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// ========================================
|
||||
// Pages Barrel Export
|
||||
// ========================================
|
||||
// Re-export all page components for convenient imports
|
||||
|
||||
export { HomePage } from './HomePage';
|
||||
export { SessionsPage } from './SessionsPage';
|
||||
export { OrchestratorPage } from './orchestrator';
|
||||
export { LoopMonitorPage } from './LoopMonitorPage';
|
||||
export { IssueManagerPage } from './IssueManagerPage';
|
||||
export { SkillsManagerPage } from './SkillsManagerPage';
|
||||
export { CommandsManagerPage } from './CommandsManagerPage';
|
||||
export { MemoryPage } from './MemoryPage';
|
||||
export { SettingsPage } from './SettingsPage';
|
||||
export { HelpPage } from './HelpPage';
|
||||
462
ccw/frontend/src/pages/orchestrator/ExecutionMonitor.tsx
Normal file
462
ccw/frontend/src/pages/orchestrator/ExecutionMonitor.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
// ========================================
|
||||
// Execution Monitor
|
||||
// ========================================
|
||||
// Real-time execution monitoring panel with logs and controls
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Square,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Terminal,
|
||||
ArrowDownToLine,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useExecutionStore } from '@/stores/executionStore';
|
||||
import {
|
||||
useExecuteFlow,
|
||||
usePauseExecution,
|
||||
useResumeExecution,
|
||||
useStopExecution,
|
||||
} from '@/hooks/useFlows';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import type { ExecutionStatus, LogLevel } from '@/types/execution';
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function formatElapsedTime(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}:${String(minutes % 60).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutes}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getStatusBadgeVariant(status: ExecutionStatus): 'default' | 'secondary' | 'destructive' | 'success' | 'warning' {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'default';
|
||||
case 'paused':
|
||||
return 'warning';
|
||||
case 'completed':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'secondary';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusIcon(status: ExecutionStatus) {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return <Loader2 className="h-3 w-3 animate-spin" />;
|
||||
case 'paused':
|
||||
return <Pause className="h-3 w-3" />;
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="h-3 w-3" />;
|
||||
case 'failed':
|
||||
return <AlertCircle className="h-3 w-3" />;
|
||||
default:
|
||||
return <Clock className="h-3 w-3" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getLogLevelColor(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'text-red-500';
|
||||
case 'warn':
|
||||
return 'text-yellow-500';
|
||||
case 'info':
|
||||
return 'text-blue-500';
|
||||
case 'debug':
|
||||
return 'text-gray-400';
|
||||
default:
|
||||
return 'text-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
interface ExecutionMonitorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
|
||||
const logsEndRef = useRef<HTMLDivElement>(null);
|
||||
const logsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
||||
|
||||
// Execution store state
|
||||
const currentExecution = useExecutionStore((state) => state.currentExecution);
|
||||
const logs = useExecutionStore((state) => state.logs);
|
||||
const nodeStates = useExecutionStore((state) => state.nodeStates);
|
||||
const isMonitorExpanded = useExecutionStore((state) => state.isMonitorExpanded);
|
||||
const autoScrollLogs = useExecutionStore((state) => state.autoScrollLogs);
|
||||
const setMonitorExpanded = useExecutionStore((state) => state.setMonitorExpanded);
|
||||
const setAutoScrollLogs = useExecutionStore((state) => state.setAutoScrollLogs);
|
||||
const startExecution = useExecutionStore((state) => state.startExecution);
|
||||
|
||||
// Local state for elapsed time (calculated from startedAt)
|
||||
const [elapsedMs, setElapsedMs] = useState(0);
|
||||
|
||||
// Flow store state
|
||||
const currentFlow = useFlowStore((state) => state.currentFlow);
|
||||
const nodes = useFlowStore((state) => state.nodes);
|
||||
|
||||
// Mutations
|
||||
const executeFlow = useExecuteFlow();
|
||||
const pauseExecution = usePauseExecution();
|
||||
const resumeExecution = useResumeExecution();
|
||||
const stopExecution = useStopExecution();
|
||||
|
||||
// Update elapsed time every second while running (calculated from startedAt)
|
||||
useEffect(() => {
|
||||
if (currentExecution?.status === 'running' && currentExecution.startedAt) {
|
||||
const calculateElapsed = () => {
|
||||
const startTime = new Date(currentExecution.startedAt).getTime();
|
||||
setElapsedMs(Date.now() - startTime);
|
||||
};
|
||||
|
||||
// Calculate immediately
|
||||
calculateElapsed();
|
||||
|
||||
// Update every second
|
||||
const interval = setInterval(calculateElapsed, 1000);
|
||||
return () => clearInterval(interval);
|
||||
} else if (currentExecution?.completedAt) {
|
||||
// Use final elapsed time from store when completed
|
||||
setElapsedMs(currentExecution.elapsedMs);
|
||||
} else if (!currentExecution) {
|
||||
setElapsedMs(0);
|
||||
}
|
||||
}, [currentExecution?.status, currentExecution?.startedAt, currentExecution?.completedAt, currentExecution?.elapsedMs]);
|
||||
|
||||
// Auto-scroll logs
|
||||
useEffect(() => {
|
||||
if (autoScrollLogs && !isUserScrolling && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [logs, autoScrollLogs, isUserScrolling]);
|
||||
|
||||
// Handle scroll to detect user scrolling
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!logsContainerRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = logsContainerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
|
||||
setIsUserScrolling(!isAtBottom);
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom handler
|
||||
const scrollToBottom = useCallback(() => {
|
||||
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
setIsUserScrolling(false);
|
||||
}, []);
|
||||
|
||||
// Handle execute
|
||||
const handleExecute = useCallback(async () => {
|
||||
if (!currentFlow) return;
|
||||
|
||||
try {
|
||||
const result = await executeFlow.mutateAsync(currentFlow.id);
|
||||
startExecution(result.execId, currentFlow.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to execute flow:', error);
|
||||
}
|
||||
}, [currentFlow, executeFlow, startExecution]);
|
||||
|
||||
// Handle pause
|
||||
const handlePause = useCallback(async () => {
|
||||
if (!currentExecution) return;
|
||||
try {
|
||||
await pauseExecution.mutateAsync(currentExecution.execId);
|
||||
} catch (error) {
|
||||
console.error('Failed to pause execution:', error);
|
||||
}
|
||||
}, [currentExecution, pauseExecution]);
|
||||
|
||||
// Handle resume
|
||||
const handleResume = useCallback(async () => {
|
||||
if (!currentExecution) return;
|
||||
try {
|
||||
await resumeExecution.mutateAsync(currentExecution.execId);
|
||||
} catch (error) {
|
||||
console.error('Failed to resume execution:', error);
|
||||
}
|
||||
}, [currentExecution, resumeExecution]);
|
||||
|
||||
// Handle stop
|
||||
const handleStop = useCallback(async () => {
|
||||
if (!currentExecution) return;
|
||||
try {
|
||||
await stopExecution.mutateAsync(currentExecution.execId);
|
||||
} catch (error) {
|
||||
console.error('Failed to stop execution:', error);
|
||||
}
|
||||
}, [currentExecution, stopExecution]);
|
||||
|
||||
// Calculate node progress
|
||||
const completedNodes = Object.values(nodeStates).filter(
|
||||
(state) => state.status === 'completed'
|
||||
).length;
|
||||
const totalNodes = nodes.length;
|
||||
const progressPercent = totalNodes > 0 ? (completedNodes / totalNodes) * 100 : 0;
|
||||
|
||||
const isExecuting = currentExecution?.status === 'running';
|
||||
const isPaused = currentExecution?.status === 'paused';
|
||||
const canExecute = currentFlow && !isExecuting && !isPaused;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-border bg-card transition-all duration-300',
|
||||
isMonitorExpanded ? 'h-64' : 'h-12',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 h-12 border-b border-border cursor-pointer"
|
||||
onClick={() => setMonitorExpanded(!isMonitorExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Execution Monitor</span>
|
||||
|
||||
{currentExecution && (
|
||||
<>
|
||||
<Badge variant={getStatusBadgeVariant(currentExecution.status)}>
|
||||
<span className="flex items-center gap-1">
|
||||
{getStatusIcon(currentExecution.status)}
|
||||
{currentExecution.status}
|
||||
</span>
|
||||
</Badge>
|
||||
|
||||
<span className="text-sm text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatElapsedTime(elapsedMs)}
|
||||
</span>
|
||||
|
||||
{totalNodes > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{completedNodes}/{totalNodes} nodes
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Control buttons */}
|
||||
{canExecute && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleExecute();
|
||||
}}
|
||||
disabled={executeFlow.isPending}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Execute
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isExecuting && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePause();
|
||||
}}
|
||||
disabled={pauseExecution.isPending}
|
||||
>
|
||||
<Pause className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStop();
|
||||
}}
|
||||
disabled={stopExecution.isPending}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isPaused && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleResume();
|
||||
}}
|
||||
disabled={resumeExecution.isPending}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Resume
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStop();
|
||||
}}
|
||||
disabled={stopExecution.isPending}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Expand/collapse button */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setMonitorExpanded(!isMonitorExpanded);
|
||||
}}
|
||||
>
|
||||
{isMonitorExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isMonitorExpanded && (
|
||||
<div className="flex h-[calc(100%-3rem)]">
|
||||
{/* Progress bar */}
|
||||
{currentExecution && (
|
||||
<div className="absolute top-12 left-0 right-0 h-1 bg-muted">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs panel */}
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Logs container */}
|
||||
<div
|
||||
ref={logsContainerRef}
|
||||
className="flex-1 overflow-y-auto p-3 font-mono text-xs"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{currentExecution
|
||||
? 'Waiting for logs...'
|
||||
: 'Select a flow and click Execute to start'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'uppercase w-12 shrink-0',
|
||||
getLogLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
[{log.level}]
|
||||
</span>
|
||||
{log.nodeId && (
|
||||
<span className="text-purple-500 shrink-0">
|
||||
[{log.nodeId}]
|
||||
</span>
|
||||
)}
|
||||
<span className="text-foreground break-all">
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{isUserScrolling && logs.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="absolute bottom-3 right-3"
|
||||
onClick={scrollToBottom}
|
||||
>
|
||||
<ArrowDownToLine className="h-4 w-4 mr-1" />
|
||||
Scroll to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Node states panel (collapsed by default) */}
|
||||
{currentExecution && Object.keys(nodeStates).length > 0 && (
|
||||
<div className="w-48 border-l border-border p-2 overflow-y-auto">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Node Status
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{Object.entries(nodeStates).map(([nodeId, state]) => (
|
||||
<div
|
||||
key={nodeId}
|
||||
className="flex items-center gap-2 text-xs p-1 rounded hover:bg-muted"
|
||||
>
|
||||
{state.status === 'running' && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-blue-500" />
|
||||
)}
|
||||
{state.status === 'completed' && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||
)}
|
||||
{state.status === 'failed' && (
|
||||
<AlertCircle className="h-3 w-3 text-red-500" />
|
||||
)}
|
||||
{state.status === 'pending' && (
|
||||
<Clock className="h-3 w-3 text-gray-400" />
|
||||
)}
|
||||
<span className="truncate" title={nodeId}>
|
||||
{nodeId.slice(0, 20)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExecutionMonitor;
|
||||
199
ccw/frontend/src/pages/orchestrator/FlowCanvas.tsx
Normal file
199
ccw/frontend/src/pages/orchestrator/FlowCanvas.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
// ========================================
|
||||
// Flow Canvas Component
|
||||
// ========================================
|
||||
// React Flow canvas with minimap, controls, and background
|
||||
|
||||
import { useCallback, useRef, DragEvent } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
MiniMap,
|
||||
Controls,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Connection,
|
||||
NodeChange,
|
||||
EdgeChange,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
Node,
|
||||
Edge,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import { useFlowStore } from '@/stores';
|
||||
import type { FlowNodeType, FlowNode, FlowEdge } from '@/types/flow';
|
||||
import { NODE_TYPE_CONFIGS } from '@/types/flow';
|
||||
|
||||
// Custom node types (enhanced with execution status in IMPL-A8)
|
||||
import { nodeTypes } from './nodes';
|
||||
|
||||
interface FlowCanvasProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function FlowCanvasInner({ className }: FlowCanvasProps) {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
// Get state and actions from store
|
||||
const nodes = useFlowStore((state) => state.nodes);
|
||||
const edges = useFlowStore((state) => state.edges);
|
||||
const setNodes = useFlowStore((state) => state.setNodes);
|
||||
const setEdges = useFlowStore((state) => state.setEdges);
|
||||
const addNode = useFlowStore((state) => state.addNode);
|
||||
const setSelectedNodeId = useFlowStore((state) => state.setSelectedNodeId);
|
||||
const setSelectedEdgeId = useFlowStore((state) => state.setSelectedEdgeId);
|
||||
const markModified = useFlowStore((state) => state.markModified);
|
||||
|
||||
// Handle node changes (position, selection, etc.)
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
const updatedNodes = applyNodeChanges(changes, nodes as Node[]);
|
||||
setNodes(updatedNodes as FlowNode[]);
|
||||
},
|
||||
[nodes, setNodes]
|
||||
);
|
||||
|
||||
// Handle edge changes
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange[]) => {
|
||||
const updatedEdges = applyEdgeChanges(changes, edges as Edge[]);
|
||||
setEdges(updatedEdges as FlowEdge[]);
|
||||
},
|
||||
[edges, setEdges]
|
||||
);
|
||||
|
||||
// Handle new edge connections
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (connection.source && connection.target) {
|
||||
const newEdge: FlowEdge = {
|
||||
id: `edge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: connection.sourceHandle ?? undefined,
|
||||
targetHandle: connection.targetHandle ?? undefined,
|
||||
};
|
||||
setEdges([...edges, newEdge]);
|
||||
markModified();
|
||||
}
|
||||
},
|
||||
[edges, setEdges, markModified]
|
||||
);
|
||||
|
||||
// Handle node selection
|
||||
const onNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node) => {
|
||||
setSelectedNodeId(node.id);
|
||||
},
|
||||
[setSelectedNodeId]
|
||||
);
|
||||
|
||||
// Handle edge selection
|
||||
const onEdgeClick = useCallback(
|
||||
(_event: React.MouseEvent, edge: Edge) => {
|
||||
setSelectedEdgeId(edge.id);
|
||||
},
|
||||
[setSelectedEdgeId]
|
||||
);
|
||||
|
||||
// Handle canvas click (deselect)
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
}, [setSelectedNodeId, setSelectedEdgeId]);
|
||||
|
||||
// Handle drag over for node palette drop
|
||||
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
}, []);
|
||||
|
||||
// Handle drop from node palette
|
||||
const onDrop = useCallback(
|
||||
(event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const nodeType = event.dataTransfer.getData('application/reactflow-node-type') as FlowNodeType;
|
||||
if (!nodeType || !NODE_TYPE_CONFIGS[nodeType]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get drop position in flow coordinates
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
// Add node at drop position
|
||||
addNode(nodeType, position);
|
||||
},
|
||||
[screenToFlowPosition, addNode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={reactFlowWrapper} className={`w-full h-full ${className || ''}`}>
|
||||
<ReactFlow
|
||||
nodes={nodes as Node[]}
|
||||
edges={edges as Edge[]}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onEdgeClick={onEdgeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
snapToGrid
|
||||
snapGrid={[15, 15]}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
className="bg-background"
|
||||
>
|
||||
<Controls
|
||||
className="bg-card border border-border rounded-md shadow-sm"
|
||||
showZoom={true}
|
||||
showFitView={true}
|
||||
showInteractive={true}
|
||||
/>
|
||||
<MiniMap
|
||||
className="bg-card border border-border rounded-md shadow-sm"
|
||||
nodeColor={(node) => {
|
||||
switch (node.type) {
|
||||
case 'slash-command':
|
||||
return '#3b82f6'; // blue-500
|
||||
case 'file-operation':
|
||||
return '#22c55e'; // green-500
|
||||
case 'conditional':
|
||||
return '#f59e0b'; // amber-500
|
||||
case 'parallel':
|
||||
return '#a855f7'; // purple-500
|
||||
default:
|
||||
return '#6b7280'; // gray-500
|
||||
}
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
size={1}
|
||||
className="bg-muted/20"
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlowCanvas(props: FlowCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<FlowCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowCanvas;
|
||||
308
ccw/frontend/src/pages/orchestrator/FlowToolbar.tsx
Normal file
308
ccw/frontend/src/pages/orchestrator/FlowToolbar.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
// ========================================
|
||||
// Flow Toolbar Component
|
||||
// ========================================
|
||||
// Toolbar for flow operations: New, Save, Load, Export
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Save,
|
||||
FolderOpen,
|
||||
Download,
|
||||
Play,
|
||||
Trash2,
|
||||
Copy,
|
||||
Workflow,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Library,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useFlowStore, toast } from '@/stores';
|
||||
import type { Flow } from '@/types/flow';
|
||||
|
||||
interface FlowToolbarProps {
|
||||
className?: string;
|
||||
onOpenTemplateLibrary?: () => void;
|
||||
}
|
||||
|
||||
export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarProps) {
|
||||
const [isFlowListOpen, setIsFlowListOpen] = useState(false);
|
||||
const [flowName, setFlowName] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Flow store
|
||||
const currentFlow = useFlowStore((state) => state.currentFlow);
|
||||
const isModified = useFlowStore((state) => state.isModified);
|
||||
const flows = useFlowStore((state) => state.flows);
|
||||
const isLoadingFlows = useFlowStore((state) => state.isLoadingFlows);
|
||||
const createFlow = useFlowStore((state) => state.createFlow);
|
||||
const saveFlow = useFlowStore((state) => state.saveFlow);
|
||||
const loadFlow = useFlowStore((state) => state.loadFlow);
|
||||
const deleteFlow = useFlowStore((state) => state.deleteFlow);
|
||||
const duplicateFlow = useFlowStore((state) => state.duplicateFlow);
|
||||
const fetchFlows = useFlowStore((state) => state.fetchFlows);
|
||||
|
||||
// Load flows on mount
|
||||
useEffect(() => {
|
||||
fetchFlows();
|
||||
}, [fetchFlows]);
|
||||
|
||||
// Sync flow name with current flow
|
||||
useEffect(() => {
|
||||
setFlowName(currentFlow?.name || '');
|
||||
}, [currentFlow?.name]);
|
||||
|
||||
// Handle new flow
|
||||
const handleNew = useCallback(() => {
|
||||
const newFlow = createFlow('Untitled Flow', 'A new workflow');
|
||||
setFlowName(newFlow.name);
|
||||
toast.success('Flow Created', 'New flow created successfully');
|
||||
}, [createFlow]);
|
||||
|
||||
// Handle save
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!currentFlow) {
|
||||
toast.error('No Flow', 'Create a flow first before saving');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Update flow name if changed
|
||||
if (flowName && flowName !== currentFlow.name) {
|
||||
useFlowStore.setState((state) => ({
|
||||
currentFlow: state.currentFlow
|
||||
? { ...state.currentFlow, name: flowName }
|
||||
: null,
|
||||
}));
|
||||
}
|
||||
|
||||
const saved = await saveFlow();
|
||||
if (saved) {
|
||||
toast.success('Flow Saved', `"${flowName || currentFlow.name}" saved successfully`);
|
||||
} else {
|
||||
toast.error('Save Failed', 'Could not save the flow');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Save Error', 'An error occurred while saving');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [currentFlow, flowName, saveFlow]);
|
||||
|
||||
// Handle load
|
||||
const handleLoad = useCallback(
|
||||
async (flow: Flow) => {
|
||||
const loaded = await loadFlow(flow.id);
|
||||
if (loaded) {
|
||||
setIsFlowListOpen(false);
|
||||
toast.success('Flow Loaded', `"${flow.name}" loaded successfully`);
|
||||
} else {
|
||||
toast.error('Load Failed', 'Could not load the flow');
|
||||
}
|
||||
},
|
||||
[loadFlow]
|
||||
);
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = useCallback(
|
||||
async (flow: Flow, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Delete "${flow.name}"? This cannot be undone.`)) return;
|
||||
|
||||
const deleted = await deleteFlow(flow.id);
|
||||
if (deleted) {
|
||||
toast.success('Flow Deleted', `"${flow.name}" deleted successfully`);
|
||||
} else {
|
||||
toast.error('Delete Failed', 'Could not delete the flow');
|
||||
}
|
||||
},
|
||||
[deleteFlow]
|
||||
);
|
||||
|
||||
// Handle duplicate
|
||||
const handleDuplicate = useCallback(
|
||||
async (flow: Flow, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const duplicated = await duplicateFlow(flow.id);
|
||||
if (duplicated) {
|
||||
toast.success('Flow Duplicated', `"${duplicated.name}" created`);
|
||||
} else {
|
||||
toast.error('Duplicate Failed', 'Could not duplicate the flow');
|
||||
}
|
||||
},
|
||||
[duplicateFlow]
|
||||
);
|
||||
|
||||
// Handle export
|
||||
const handleExport = useCallback(() => {
|
||||
if (!currentFlow) {
|
||||
toast.error('No Flow', 'Create or load a flow first');
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = useFlowStore.getState().nodes;
|
||||
const edges = useFlowStore.getState().edges;
|
||||
const exportData = {
|
||||
...currentFlow,
|
||||
nodes,
|
||||
edges,
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${currentFlow.name.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success('Flow Exported', 'Flow exported as JSON file');
|
||||
}, [currentFlow]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-3 p-3 bg-card border-b border-border', className)}>
|
||||
{/* Flow Icon and Name */}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<Workflow className="w-5 h-5 text-primary flex-shrink-0" />
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
placeholder="Flow name"
|
||||
className="max-w-[200px] h-8 text-sm"
|
||||
/>
|
||||
{isModified && (
|
||||
<span className="text-xs text-amber-500 flex-shrink-0">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleNew}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
New
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !currentFlow}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
|
||||
{/* Flow List Dropdown */}
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsFlowListOpen(!isFlowListOpen)}
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 mr-1" />
|
||||
Load
|
||||
<ChevronDown className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
|
||||
{isFlowListOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsFlowListOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
<div className="absolute top-full right-0 mt-1 w-72 bg-card border border-border rounded-lg shadow-lg z-50 overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-border bg-muted/50">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Saved Flows ({flows.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{isLoadingFlows ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
No saved flows
|
||||
</div>
|
||||
) : (
|
||||
flows.map((flow) => (
|
||||
<div
|
||||
key={flow.id}
|
||||
onClick={() => handleLoad(flow)}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors',
|
||||
currentFlow?.id === flow.id && 'bg-primary/10'
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-foreground truncate">
|
||||
{flow.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{new Date(flow.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={(e) => handleDuplicate(flow, e)}
|
||||
title="Duplicate"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDelete(flow, e)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={handleExport} disabled={!currentFlow}>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Export
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={onOpenTemplateLibrary}>
|
||||
<Library className="w-4 h-4 mr-1" />
|
||||
Templates
|
||||
</Button>
|
||||
|
||||
<div className="w-px h-6 bg-border" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FlowToolbar;
|
||||
154
ccw/frontend/src/pages/orchestrator/NodePalette.tsx
Normal file
154
ccw/frontend/src/pages/orchestrator/NodePalette.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
// ========================================
|
||||
// Node Palette Component
|
||||
// ========================================
|
||||
// Draggable node palette for creating new nodes
|
||||
|
||||
import { DragEvent, useState } from 'react';
|
||||
import { Terminal, FileText, GitBranch, GitMerge, ChevronDown, ChevronRight, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import type { FlowNodeType } from '@/types/flow';
|
||||
import { NODE_TYPE_CONFIGS } from '@/types/flow';
|
||||
|
||||
// Icon mapping for node types
|
||||
const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
|
||||
'slash-command': Terminal,
|
||||
'file-operation': FileText,
|
||||
conditional: GitBranch,
|
||||
parallel: GitMerge,
|
||||
};
|
||||
|
||||
// Color mapping for node types
|
||||
const nodeColors: Record<FlowNodeType, string> = {
|
||||
'slash-command': 'bg-blue-500 hover:bg-blue-600',
|
||||
'file-operation': 'bg-green-500 hover:bg-green-600',
|
||||
conditional: 'bg-amber-500 hover:bg-amber-600',
|
||||
parallel: 'bg-purple-500 hover:bg-purple-600',
|
||||
};
|
||||
|
||||
const nodeBorderColors: Record<FlowNodeType, string> = {
|
||||
'slash-command': 'border-blue-500',
|
||||
'file-operation': 'border-green-500',
|
||||
conditional: 'border-amber-500',
|
||||
parallel: 'border-purple-500',
|
||||
};
|
||||
|
||||
interface NodePaletteProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface NodeTypeCardProps {
|
||||
type: FlowNodeType;
|
||||
}
|
||||
|
||||
function NodeTypeCard({ type }: NodeTypeCardProps) {
|
||||
const config = NODE_TYPE_CONFIGS[type];
|
||||
const Icon = nodeIcons[type];
|
||||
|
||||
// Handle drag start
|
||||
const onDragStart = (event: DragEvent<HTMLDivElement>) => {
|
||||
event.dataTransfer.setData('application/reactflow-node-type', type);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
className={cn(
|
||||
'group flex items-center gap-3 p-3 rounded-lg border-2 bg-card cursor-grab transition-all',
|
||||
'hover:shadow-md hover:scale-[1.02] active:cursor-grabbing active:scale-[0.98]',
|
||||
nodeBorderColors[type]
|
||||
)}
|
||||
>
|
||||
<div className={cn('p-2 rounded-md text-white', nodeColors[type])}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{config.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{config.description}</div>
|
||||
</div>
|
||||
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodePalette({ className }: NodePaletteProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const isPaletteOpen = useFlowStore((state) => state.isPaletteOpen);
|
||||
const setIsPaletteOpen = useFlowStore((state) => state.setIsPaletteOpen);
|
||||
|
||||
if (!isPaletteOpen) {
|
||||
return (
|
||||
<div className={cn('w-10 bg-card border-r border-border flex flex-col items-center py-4', className)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsPaletteOpen(true)}
|
||||
title="Open node palette"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsPaletteOpen(false)}
|
||||
title="Collapse palette"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
</div>
|
||||
|
||||
{/* Node Type Categories */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* Execution Nodes */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 w-full text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
)}
|
||||
Node Types
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="space-y-2">
|
||||
{(Object.keys(NODE_TYPE_CONFIGS) as FlowNodeType[]).map((type) => (
|
||||
<NodeTypeCard key={type} type={type} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NodePalette;
|
||||
70
ccw/frontend/src/pages/orchestrator/OrchestratorPage.tsx
Normal file
70
ccw/frontend/src/pages/orchestrator/OrchestratorPage.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
// ========================================
|
||||
// Orchestrator Page
|
||||
// ========================================
|
||||
// Visual workflow editor with React Flow, drag-drop node palette, and property panel
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import { FlowCanvas } from './FlowCanvas';
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { PropertyPanel } from './PropertyPanel';
|
||||
import { FlowToolbar } from './FlowToolbar';
|
||||
import { ExecutionMonitor } from './ExecutionMonitor';
|
||||
import { TemplateLibrary } from './TemplateLibrary';
|
||||
import { useWebSocket } from '@/hooks/useWebSocket';
|
||||
|
||||
export function OrchestratorPage() {
|
||||
const fetchFlows = useFlowStore((state) => state.fetchFlows);
|
||||
const [isTemplateLibraryOpen, setIsTemplateLibraryOpen] = useState(false);
|
||||
|
||||
// Initialize WebSocket connection for real-time updates
|
||||
const { isConnected, reconnect } = useWebSocket({
|
||||
enabled: true,
|
||||
onMessage: (message) => {
|
||||
// Additional message handling can be added here if needed
|
||||
console.log('[Orchestrator] WebSocket message:', message.type);
|
||||
},
|
||||
});
|
||||
|
||||
// Load flows on mount
|
||||
useEffect(() => {
|
||||
fetchFlows();
|
||||
}, [fetchFlows]);
|
||||
|
||||
// Handle open template library
|
||||
const handleOpenTemplateLibrary = useCallback(() => {
|
||||
setIsTemplateLibraryOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Toolbar */}
|
||||
<FlowToolbar onOpenTemplateLibrary={handleOpenTemplateLibrary} />
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Node Palette (Left) */}
|
||||
<NodePalette />
|
||||
|
||||
{/* Flow Canvas (Center) */}
|
||||
<div className="flex-1 relative">
|
||||
<FlowCanvas className="absolute inset-0" />
|
||||
</div>
|
||||
|
||||
{/* Property Panel (Right) */}
|
||||
<PropertyPanel />
|
||||
</div>
|
||||
|
||||
{/* Execution Monitor (Bottom) */}
|
||||
<ExecutionMonitor />
|
||||
|
||||
{/* Template Library Dialog */}
|
||||
<TemplateLibrary
|
||||
open={isTemplateLibraryOpen}
|
||||
onOpenChange={setIsTemplateLibraryOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrchestratorPage;
|
||||
472
ccw/frontend/src/pages/orchestrator/PropertyPanel.tsx
Normal file
472
ccw/frontend/src/pages/orchestrator/PropertyPanel.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
// ========================================
|
||||
// Property Panel Component
|
||||
// ========================================
|
||||
// Dynamic property editor for selected nodes
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Settings, X, Terminal, FileText, GitBranch, GitMerge, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import type {
|
||||
FlowNodeType,
|
||||
SlashCommandNodeData,
|
||||
FileOperationNodeData,
|
||||
ConditionalNodeData,
|
||||
ParallelNodeData,
|
||||
NodeData,
|
||||
} from '@/types/flow';
|
||||
|
||||
interface PropertyPanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Icon mapping for node types
|
||||
const nodeIcons: Record<FlowNodeType, React.FC<{ className?: string }>> = {
|
||||
'slash-command': Terminal,
|
||||
'file-operation': FileText,
|
||||
conditional: GitBranch,
|
||||
parallel: GitMerge,
|
||||
};
|
||||
|
||||
// Slash Command Property Editor
|
||||
function SlashCommandProperties({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: SlashCommandNodeData;
|
||||
onChange: (updates: Partial<SlashCommandNodeData>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder="Node label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Command</label>
|
||||
<Input
|
||||
value={data.command || ''}
|
||||
onChange={(e) => onChange({ command: e.target.value })}
|
||||
placeholder="/command-name"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Arguments</label>
|
||||
<Input
|
||||
value={data.args || ''}
|
||||
onChange={(e) => onChange({ args: e.target.value })}
|
||||
placeholder="Command arguments"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Execution Mode</label>
|
||||
<select
|
||||
value={data.execution?.mode || 'analysis'}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
execution: { ...data.execution, mode: e.target.value as 'analysis' | 'write' },
|
||||
})
|
||||
}
|
||||
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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">On Error</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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Timeout (ms)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.execution?.timeout || ''}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
execution: {
|
||||
...data.execution,
|
||||
mode: data.execution?.mode || 'analysis',
|
||||
timeout: e.target.value ? parseInt(e.target.value) : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="60000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File Operation Property Editor
|
||||
function FileOperationProperties({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: FileOperationNodeData;
|
||||
onChange: (updates: Partial<FileOperationNodeData>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder="Node label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Operation</label>
|
||||
<select
|
||||
value={data.operation || 'read'}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
operation: e.target.value as FileOperationNodeData['operation'],
|
||||
})
|
||||
}
|
||||
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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Path</label>
|
||||
<Input
|
||||
value={data.path || ''}
|
||||
onChange={(e) => onChange({ path: e.target.value })}
|
||||
placeholder="/path/to/file"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(data.operation === 'write' || data.operation === 'append') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Content</label>
|
||||
<textarea
|
||||
value={data.content || ''}
|
||||
onChange={(e) => onChange({ content: e.target.value })}
|
||||
placeholder="File 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>
|
||||
)}
|
||||
|
||||
{(data.operation === 'copy' || data.operation === 'move') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Destination Path</label>
|
||||
<Input
|
||||
value={data.destinationPath || ''}
|
||||
onChange={(e) => onChange({ destinationPath: e.target.value })}
|
||||
placeholder="/path/to/destination"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Output Variable</label>
|
||||
<Input
|
||||
value={data.outputVariable || ''}
|
||||
onChange={(e) => onChange({ outputVariable: e.target.value })}
|
||||
placeholder="variableName"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="addToContext"
|
||||
checked={data.addToContext || false}
|
||||
onChange={(e) => onChange({ addToContext: e.target.checked })}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<label htmlFor="addToContext" className="text-sm text-foreground">
|
||||
Add to context
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Conditional Property Editor
|
||||
function ConditionalProperties({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: ConditionalNodeData;
|
||||
onChange: (updates: Partial<ConditionalNodeData>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder="Node label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Condition</label>
|
||||
<textarea
|
||||
value={data.condition || ''}
|
||||
onChange={(e) => onChange({ condition: e.target.value })}
|
||||
placeholder="e.g., result.success === true"
|
||||
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>
|
||||
<Input
|
||||
value={data.trueLabel || ''}
|
||||
onChange={(e) => onChange({ trueLabel: e.target.value })}
|
||||
placeholder="True"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">False Label</label>
|
||||
<Input
|
||||
value={data.falseLabel || ''}
|
||||
onChange={(e) => onChange({ falseLabel: e.target.value })}
|
||||
placeholder="False"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Parallel Property Editor
|
||||
function ParallelProperties({
|
||||
data,
|
||||
onChange,
|
||||
}: {
|
||||
data: ParallelNodeData;
|
||||
onChange: (updates: Partial<ParallelNodeData>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Label</label>
|
||||
<Input
|
||||
value={data.label || ''}
|
||||
onChange={(e) => onChange({ label: e.target.value })}
|
||||
placeholder="Node label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Join Mode</label>
|
||||
<select
|
||||
value={data.joinMode || 'all'}
|
||||
onChange={(e) =>
|
||||
onChange({ joinMode: e.target.value as ParallelNodeData['joinMode'] })
|
||||
}
|
||||
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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Timeout (ms)</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={data.timeout || ''}
|
||||
onChange={(e) =>
|
||||
onChange({ timeout: e.target.value ? parseInt(e.target.value) : undefined })
|
||||
}
|
||||
placeholder="30000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="failFast"
|
||||
checked={data.failFast || false}
|
||||
onChange={(e) => onChange({ failFast: e.target.checked })}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<label htmlFor="failFast" className="text-sm text-foreground">
|
||||
Fail fast (stop all branches on first error)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertyPanel({ className }: PropertyPanelProps) {
|
||||
const selectedNodeId = useFlowStore((state) => state.selectedNodeId);
|
||||
const nodes = useFlowStore((state) => state.nodes);
|
||||
const updateNode = useFlowStore((state) => state.updateNode);
|
||||
const removeNode = useFlowStore((state) => state.removeNode);
|
||||
const isPropertyPanelOpen = useFlowStore((state) => state.isPropertyPanelOpen);
|
||||
const setIsPropertyPanelOpen = useFlowStore((state) => state.setIsPropertyPanelOpen);
|
||||
|
||||
const selectedNode = nodes.find((n) => n.id === selectedNodeId);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(updates: Partial<NodeData>) => {
|
||||
if (selectedNodeId) {
|
||||
updateNode(selectedNodeId, updates);
|
||||
}
|
||||
},
|
||||
[selectedNodeId, updateNode]
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (selectedNodeId) {
|
||||
removeNode(selectedNodeId);
|
||||
}
|
||||
}, [selectedNodeId, removeNode]);
|
||||
|
||||
if (!isPropertyPanelOpen) {
|
||||
return (
|
||||
<div className={cn('w-10 bg-card border-l border-border flex flex-col items-center py-4', className)}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsPropertyPanelOpen(true)}
|
||||
title="Open properties panel"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedNode) {
|
||||
return (
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsPropertyPanelOpen(false)}
|
||||
title="Close panel"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeType = selectedNode.type as FlowNodeType;
|
||||
const Icon = nodeIcons[nodeType];
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
{Icon && <Icon className="w-4 h-4 text-primary" />}
|
||||
<h3 className="font-semibold text-foreground">Properties</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setIsPropertyPanelOpen(false)}
|
||||
title="Close panel"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Node Type Badge */}
|
||||
<div className="px-4 py-2 border-b border-border bg-muted/30">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{nodeType.replace('-', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Properties Form */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{nodeType === 'slash-command' && (
|
||||
<SlashCommandProperties
|
||||
data={selectedNode.data as SlashCommandNodeData}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{nodeType === 'file-operation' && (
|
||||
<FileOperationProperties
|
||||
data={selectedNode.data as FileOperationNodeData}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{nodeType === 'conditional' && (
|
||||
<ConditionalProperties
|
||||
data={selectedNode.data as ConditionalNodeData}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
{nodeType === 'parallel' && (
|
||||
<ParallelProperties
|
||||
data={selectedNode.data as ParallelNodeData}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
<div className="px-4 py-3 border-t border-border">
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete Node
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PropertyPanel;
|
||||
567
ccw/frontend/src/pages/orchestrator/TemplateLibrary.tsx
Normal file
567
ccw/frontend/src/pages/orchestrator/TemplateLibrary.tsx
Normal file
@@ -0,0 +1,567 @@
|
||||
// ========================================
|
||||
// Template Library
|
||||
// ========================================
|
||||
// Template browser with import/export functionality
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Library,
|
||||
Search,
|
||||
Download,
|
||||
Upload,
|
||||
Grid,
|
||||
List,
|
||||
Tag,
|
||||
Calendar,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { useTemplates, useInstallTemplate, useExportTemplate, useDeleteTemplate } from '@/hooks/useTemplates';
|
||||
import { useFlowStore } from '@/stores';
|
||||
import type { FlowTemplate } from '@/types/execution';
|
||||
|
||||
// ========== Helper Functions ==========
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Template Card Component ==========
|
||||
|
||||
interface TemplateCardProps {
|
||||
template: FlowTemplate;
|
||||
viewMode: 'grid' | 'list';
|
||||
onInstall: (template: FlowTemplate) => void;
|
||||
onDelete: (template: FlowTemplate) => void;
|
||||
isInstalling: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
function TemplateCard({
|
||||
template,
|
||||
viewMode,
|
||||
onInstall,
|
||||
onDelete,
|
||||
isInstalling,
|
||||
isDeleting,
|
||||
}: TemplateCardProps) {
|
||||
const isGrid = viewMode === 'grid';
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'hover:border-primary/50 transition-colors',
|
||||
isGrid ? '' : 'flex items-center'
|
||||
)}
|
||||
>
|
||||
{isGrid ? (
|
||||
<>
|
||||
{/* Grid view */}
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-base truncate" title={template.name}>
|
||||
{template.name}
|
||||
</CardTitle>
|
||||
{template.category && (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{template.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{template.description && (
|
||||
<p className="text-sm text-muted-foreground line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<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
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDate(template.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{template.tags && template.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.tags.slice(0, 3).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
<Tag className="h-2 w-2 mr-1" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{template.tags.length > 3 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{template.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
onClick={() => onInstall(template)}
|
||||
disabled={isInstalling}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(template)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* List view */}
|
||||
<div className="flex-1 flex items-center gap-4 p-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{template.name}</span>
|
||||
{template.category && (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{template.category}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{template.description && (
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground shrink-0">
|
||||
<span className="flex items-center gap-1">
|
||||
<GitBranch className="h-3 w-3" />
|
||||
{template.nodeCount}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDate(template.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={() => onInstall(template)}
|
||||
disabled={isInstalling}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDelete(template)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Export Dialog Component ==========
|
||||
|
||||
interface ExportDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onExport: (name: string, description: string, category: string, tags: string[]) => void;
|
||||
isExporting: boolean;
|
||||
flowName: string;
|
||||
}
|
||||
|
||||
function ExportDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onExport,
|
||||
isExporting,
|
||||
flowName,
|
||||
}: ExportDialogProps) {
|
||||
const [name, setName] = useState(flowName);
|
||||
const [description, setDescription] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [tagsInput, setTagsInput] = useState('');
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
const tags = tagsInput
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
onExport(name, description, category, tags);
|
||||
}, [name, description, category, tagsInput, onExport]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export as Template</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save this flow as a reusable template in your library.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Template name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Brief description of this template"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Category</label>
|
||||
<Input
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="e.g., Development, Testing, Deployment"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Tags (comma-separated)</label>
|
||||
<Input
|
||||
value={tagsInput}
|
||||
onChange={(e) => setTagsInput(e.target.value)}
|
||||
placeholder="e.g., react, testing, ci/cd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleExport} disabled={!name.trim() || isExporting}>
|
||||
{isExporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
Export
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
interface TemplateLibraryProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function TemplateLibrary({ open, onOpenChange }: TemplateLibraryProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
||||
const [installingId, setInstallingId] = useState<string | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
// Flow store
|
||||
const currentFlow = useFlowStore((state) => state.currentFlow);
|
||||
const setCurrentFlow = useFlowStore((state) => state.setCurrentFlow);
|
||||
|
||||
// Query hooks
|
||||
const { data, isLoading, error } = useTemplates(selectedCategory ?? undefined);
|
||||
|
||||
// Mutation hooks
|
||||
const installTemplate = useInstallTemplate();
|
||||
const exportTemplate = useExportTemplate();
|
||||
const deleteTemplate = useDeleteTemplate();
|
||||
|
||||
// Filter templates by search query
|
||||
const filteredTemplates = useMemo(() => {
|
||||
if (!data?.templates) return [];
|
||||
if (!searchQuery.trim()) return data.templates;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return data.templates.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(query) ||
|
||||
t.description?.toLowerCase().includes(query) ||
|
||||
t.tags?.some((tag) => tag.toLowerCase().includes(query))
|
||||
);
|
||||
}, [data?.templates, searchQuery]);
|
||||
|
||||
// Handle install
|
||||
const handleInstall = useCallback(
|
||||
async (template: FlowTemplate) => {
|
||||
setInstallingId(template.id);
|
||||
try {
|
||||
const result = await installTemplate.mutateAsync({
|
||||
templateId: template.id,
|
||||
});
|
||||
// Set the installed flow as current
|
||||
setCurrentFlow(result.flow);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to install template:', error);
|
||||
} finally {
|
||||
setInstallingId(null);
|
||||
}
|
||||
},
|
||||
[installTemplate, setCurrentFlow, onOpenChange]
|
||||
);
|
||||
|
||||
// Handle export
|
||||
const handleExport = useCallback(
|
||||
async (name: string, description: string, category: string, tags: string[]) => {
|
||||
if (!currentFlow) return;
|
||||
|
||||
try {
|
||||
await exportTemplate.mutateAsync({
|
||||
flowId: currentFlow.id,
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
tags,
|
||||
});
|
||||
setExportDialogOpen(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to export template:', error);
|
||||
}
|
||||
},
|
||||
[currentFlow, exportTemplate]
|
||||
);
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = useCallback(
|
||||
async (template: FlowTemplate) => {
|
||||
setDeletingId(template.id);
|
||||
try {
|
||||
await deleteTemplate.mutateAsync(template.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete template:', error);
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
},
|
||||
[deleteTemplate]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Library className="h-5 w-5" />
|
||||
Template Library
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Browse and import workflow templates, or export your current flow as a template.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-4 py-2">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search templates..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category filter */}
|
||||
{data?.categories && data.categories.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={selectedCategory === null ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(null)}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
{data.categories.slice(0, 4).map((cat) => (
|
||||
<Button
|
||||
key={cat}
|
||||
variant={selectedCategory === cat ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setSelectedCategory(cat)}
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div className="flex items-center border border-border rounded-md">
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-r-none"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
className="rounded-l-none"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Export button */}
|
||||
{currentFlow && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setExportDialogOpen(true)}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-1" />
|
||||
Export Current
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : 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 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>
|
||||
{searchQuery && (
|
||||
<p className="text-sm">Try a different search query</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'space-y-2'
|
||||
)}
|
||||
>
|
||||
{filteredTemplates.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
viewMode={viewMode}
|
||||
onInstall={handleInstall}
|
||||
onDelete={handleDelete}
|
||||
isInstalling={installingId === template.id}
|
||||
isDeleting={deletingId === template.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<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' : ''}
|
||||
</span>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Export Dialog */}
|
||||
{currentFlow && (
|
||||
<ExportDialog
|
||||
open={exportDialogOpen}
|
||||
onOpenChange={setExportDialogOpen}
|
||||
onExport={handleExport}
|
||||
isExporting={exportTemplate.isPending}
|
||||
flowName={currentFlow.name}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TemplateLibrary;
|
||||
15
ccw/frontend/src/pages/orchestrator/index.ts
Normal file
15
ccw/frontend/src/pages/orchestrator/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// ========================================
|
||||
// Orchestrator Page Barrel Export
|
||||
// ========================================
|
||||
|
||||
export { OrchestratorPage } from './OrchestratorPage';
|
||||
export { FlowCanvas } from './FlowCanvas';
|
||||
export { NodePalette } from './NodePalette';
|
||||
export { PropertyPanel } from './PropertyPanel';
|
||||
export { FlowToolbar } from './FlowToolbar';
|
||||
|
||||
// Node components
|
||||
export { SlashCommandNode } from './nodes/SlashCommandNode';
|
||||
export { FileOperationNode } from './nodes/FileOperationNode';
|
||||
export { ConditionalNode } from './nodes/ConditionalNode';
|
||||
export { ParallelNode } from './nodes/ParallelNode';
|
||||
118
ccw/frontend/src/pages/orchestrator/nodes/ConditionalNode.tsx
Normal file
118
ccw/frontend/src/pages/orchestrator/nodes/ConditionalNode.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
// ========================================
|
||||
// Conditional Node Component
|
||||
// ========================================
|
||||
// Custom node for conditional branching with true/false outputs
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { GitBranch, Check, X } from 'lucide-react';
|
||||
import type { ConditionalNodeData } from '@/types/flow';
|
||||
import { NodeWrapper } from './NodeWrapper';
|
||||
|
||||
interface ConditionalNodeProps {
|
||||
data: ConditionalNodeData;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
export const ConditionalNode = memo(({ data, selected }: ConditionalNodeProps) => {
|
||||
// Truncate condition for display
|
||||
const displayCondition = data.condition
|
||||
? data.condition.length > 30
|
||||
? data.condition.slice(0, 27) + '...'
|
||||
: data.condition
|
||||
: 'No condition';
|
||||
|
||||
return (
|
||||
<NodeWrapper
|
||||
status={data.executionStatus}
|
||||
selected={selected}
|
||||
accentColor="amber"
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-3 !h-3 !bg-amber-500 !border-2 !border-background"
|
||||
/>
|
||||
|
||||
{/* Node Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-amber-500 text-white rounded-t-md">
|
||||
<GitBranch className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{data.label || 'Condition'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Node Content */}
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
{/* Condition expression */}
|
||||
<div
|
||||
className="font-mono text-xs bg-muted px-2 py-1 rounded text-foreground/90 truncate"
|
||||
title={data.condition}
|
||||
>
|
||||
{displayCondition}
|
||||
</div>
|
||||
|
||||
{/* Branch labels */}
|
||||
<div className="flex justify-between items-center pt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Check className="w-3 h-3 text-green-500" />
|
||||
<span className="text-xs text-green-600 dark:text-green-400 font-medium">
|
||||
{data.trueLabel || 'True'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<X className="w-3 h-3 text-red-500" />
|
||||
<span className="text-xs text-red-600 dark:text-red-400 font-medium">
|
||||
{data.falseLabel || 'False'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Execution result indicator */}
|
||||
{data.executionStatus === 'completed' && data.executionResult !== undefined && (
|
||||
<div className="text-[10px] text-muted-foreground text-center">
|
||||
Result:{' '}
|
||||
<span
|
||||
className={
|
||||
data.executionResult
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
>
|
||||
{data.executionResult ? 'true' : 'false'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution error message */}
|
||||
{data.executionStatus === 'failed' && data.executionError && (
|
||||
<div
|
||||
className="text-[10px] text-destructive truncate"
|
||||
title={data.executionError}
|
||||
>
|
||||
{data.executionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output Handles (True and False) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="true"
|
||||
className="!w-3 !h-3 !bg-green-500 !border-2 !border-background"
|
||||
style={{ left: '30%' }}
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="false"
|
||||
className="!w-3 !h-3 !bg-red-500 !border-2 !border-background"
|
||||
style={{ left: '70%' }}
|
||||
/>
|
||||
</NodeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
ConditionalNode.displayName = 'ConditionalNode';
|
||||
145
ccw/frontend/src/pages/orchestrator/nodes/FileOperationNode.tsx
Normal file
145
ccw/frontend/src/pages/orchestrator/nodes/FileOperationNode.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
// ========================================
|
||||
// File Operation Node Component
|
||||
// ========================================
|
||||
// Custom node for file read/write operations
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import {
|
||||
FileText,
|
||||
FileInput,
|
||||
FileOutput,
|
||||
FilePlus,
|
||||
FileX,
|
||||
Copy,
|
||||
Move,
|
||||
} from 'lucide-react';
|
||||
import type { FileOperationNodeData } from '@/types/flow';
|
||||
import { NodeWrapper } from './NodeWrapper';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FileOperationNodeProps {
|
||||
data: FileOperationNodeData;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
// Operation icons and colors
|
||||
const OPERATION_CONFIG: Record<
|
||||
string,
|
||||
{ icon: React.ElementType; label: string; color: string }
|
||||
> = {
|
||||
read: { icon: FileInput, label: 'Read', color: 'text-blue-500' },
|
||||
write: { icon: FileOutput, label: 'Write', color: 'text-amber-500' },
|
||||
append: { icon: FilePlus, label: 'Append', color: 'text-green-500' },
|
||||
delete: { icon: FileX, label: 'Delete', color: 'text-red-500' },
|
||||
copy: { icon: Copy, label: 'Copy', color: 'text-purple-500' },
|
||||
move: { icon: Move, label: 'Move', color: 'text-indigo-500' },
|
||||
};
|
||||
|
||||
export const FileOperationNode = memo(({ data, selected }: FileOperationNodeProps) => {
|
||||
const operation = data.operation || 'read';
|
||||
const config = OPERATION_CONFIG[operation] || OPERATION_CONFIG.read;
|
||||
const IconComponent = config.icon;
|
||||
|
||||
// Truncate path for display
|
||||
const displayPath = data.path
|
||||
? data.path.length > 25
|
||||
? '...' + data.path.slice(-22)
|
||||
: data.path
|
||||
: '';
|
||||
|
||||
return (
|
||||
<NodeWrapper
|
||||
status={data.executionStatus}
|
||||
selected={selected}
|
||||
accentColor="green"
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-3 !h-3 !bg-green-500 !border-2 !border-background"
|
||||
/>
|
||||
|
||||
{/* Node Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-green-500 text-white rounded-t-md">
|
||||
<FileText className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{data.label || 'File Operation'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Node Content */}
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
{/* Operation type with icon */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<IconComponent className={cn('w-3.5 h-3.5', config.color)} />
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* File path */}
|
||||
{data.path && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground font-mono truncate max-w-[160px]"
|
||||
title={data.path}
|
||||
>
|
||||
{displayPath}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Badges row */}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{/* Add to context badge */}
|
||||
{data.addToContext && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
+ context
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Output variable badge */}
|
||||
{data.outputVariable && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400 truncate max-w-[80px]"
|
||||
title={data.outputVariable}
|
||||
>
|
||||
${data.outputVariable}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Destination path for copy/move */}
|
||||
{(operation === 'copy' || operation === 'move') && data.destinationPath && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
To:{' '}
|
||||
<span className="font-mono text-foreground/70" title={data.destinationPath}>
|
||||
{data.destinationPath.length > 20
|
||||
? '...' + data.destinationPath.slice(-17)
|
||||
: data.destinationPath}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution error message */}
|
||||
{data.executionStatus === 'failed' && data.executionError && (
|
||||
<div
|
||||
className="text-[10px] text-destructive truncate max-w-[160px]"
|
||||
title={data.executionError}
|
||||
>
|
||||
{data.executionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!w-3 !h-3 !bg-green-500 !border-2 !border-background"
|
||||
/>
|
||||
</NodeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
FileOperationNode.displayName = 'FileOperationNode';
|
||||
81
ccw/frontend/src/pages/orchestrator/nodes/NodeWrapper.tsx
Normal file
81
ccw/frontend/src/pages/orchestrator/nodes/NodeWrapper.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// ========================================
|
||||
// Node Wrapper Component
|
||||
// ========================================
|
||||
// Shared wrapper for all custom nodes with execution status styling
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import {
|
||||
Circle,
|
||||
Loader2,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import type { ExecutionStatus } from '@/types/flow';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface NodeWrapperProps {
|
||||
children: ReactNode;
|
||||
status?: ExecutionStatus;
|
||||
selected?: boolean;
|
||||
accentColor: 'blue' | 'green' | 'amber' | 'purple';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Status styling configuration
|
||||
const STATUS_STYLES: Record<ExecutionStatus, string> = {
|
||||
pending: 'border-muted bg-card',
|
||||
running: 'border-primary bg-primary/10 animate-pulse',
|
||||
completed: 'border-green-500 bg-green-500/10',
|
||||
failed: 'border-destructive bg-destructive/10',
|
||||
};
|
||||
|
||||
// Selection ring styles per accent color
|
||||
const SELECTION_STYLES: Record<string, string> = {
|
||||
blue: 'ring-2 ring-blue-500/20 border-blue-500',
|
||||
green: 'ring-2 ring-green-500/20 border-green-500',
|
||||
amber: 'ring-2 ring-amber-500/20 border-amber-500',
|
||||
purple: 'ring-2 ring-purple-500/20 border-purple-500',
|
||||
};
|
||||
|
||||
// Status icons
|
||||
function StatusIcon({ status }: { status: ExecutionStatus }) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Circle className="w-3 h-3 text-muted-foreground" />;
|
||||
case 'running':
|
||||
return <Loader2 className="w-3 h-3 text-primary animate-spin" />;
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="w-3 h-3 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="w-3 h-3 text-destructive" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function NodeWrapper({
|
||||
children,
|
||||
status = 'pending',
|
||||
selected = false,
|
||||
accentColor,
|
||||
className,
|
||||
}: NodeWrapperProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative min-w-[180px] rounded-lg border-2 shadow-md transition-all',
|
||||
STATUS_STYLES[status],
|
||||
selected && SELECTION_STYLES[accentColor],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Status indicator */}
|
||||
<div className="absolute -top-2 -right-2 z-10 bg-background rounded-full p-0.5 shadow-sm border border-border">
|
||||
<StatusIcon status={status} />
|
||||
</div>
|
||||
|
||||
{/* Node content (includes handles, header, body) */}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
NodeWrapper.displayName = 'NodeWrapper';
|
||||
129
ccw/frontend/src/pages/orchestrator/nodes/ParallelNode.tsx
Normal file
129
ccw/frontend/src/pages/orchestrator/nodes/ParallelNode.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
// ========================================
|
||||
// Parallel Node Component
|
||||
// ========================================
|
||||
// Custom node for parallel execution with multiple branch outputs
|
||||
|
||||
import { memo, useMemo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { GitMerge, Layers, Timer, AlertTriangle } from 'lucide-react';
|
||||
import type { ParallelNodeData } from '@/types/flow';
|
||||
import { NodeWrapper } from './NodeWrapper';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ParallelNodeProps {
|
||||
data: ParallelNodeData;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
// Join mode configuration
|
||||
const JOIN_MODE_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
all: { label: 'Wait All', color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' },
|
||||
any: { label: 'Wait Any', color: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
none: { label: 'Fire & Forget', color: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400' },
|
||||
};
|
||||
|
||||
export const ParallelNode = memo(({ data, selected }: ParallelNodeProps) => {
|
||||
const joinMode = data.joinMode || 'all';
|
||||
const branchCount = Math.max(2, Math.min(data.branchCount || 2, 5)); // Clamp between 2-5
|
||||
const joinConfig = JOIN_MODE_CONFIG[joinMode] || JOIN_MODE_CONFIG.all;
|
||||
|
||||
// Calculate branch handle positions
|
||||
const branchPositions = useMemo(() => {
|
||||
const positions: number[] = [];
|
||||
const step = 100 / (branchCount + 1);
|
||||
for (let i = 1; i <= branchCount; i++) {
|
||||
positions.push(step * i);
|
||||
}
|
||||
return positions;
|
||||
}, [branchCount]);
|
||||
|
||||
return (
|
||||
<NodeWrapper
|
||||
status={data.executionStatus}
|
||||
selected={selected}
|
||||
accentColor="purple"
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-background"
|
||||
/>
|
||||
|
||||
{/* Node Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-purple-500 text-white rounded-t-md">
|
||||
<GitMerge className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{data.label || 'Parallel'}
|
||||
</span>
|
||||
{/* Branch count indicator */}
|
||||
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded">
|
||||
{branchCount}x
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Node Content */}
|
||||
<div className="px-3 py-2 space-y-2">
|
||||
{/* Join mode badge */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className={cn('text-[10px] font-medium px-1.5 py-0.5 rounded', joinConfig.color)}>
|
||||
{joinConfig.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Additional settings row */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* Timeout indicator */}
|
||||
{data.timeout && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<Timer className="w-3 h-3" />
|
||||
<span>{data.timeout}ms</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fail fast indicator */}
|
||||
{data.failFast && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span>Fail Fast</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Branch labels */}
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground pt-1">
|
||||
{branchPositions.map((_, index) => (
|
||||
<span key={index} className="text-purple-600 dark:text-purple-400">
|
||||
B{index + 1}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Execution error message */}
|
||||
{data.executionStatus === 'failed' && data.executionError && (
|
||||
<div
|
||||
className="text-[10px] text-destructive truncate"
|
||||
title={data.executionError}
|
||||
>
|
||||
{data.executionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dynamic Branch Output Handles */}
|
||||
{branchPositions.map((position, index) => (
|
||||
<Handle
|
||||
key={`branch-${index + 1}`}
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id={`branch-${index + 1}`}
|
||||
className="!w-3 !h-3 !bg-purple-500 !border-2 !border-background"
|
||||
style={{ left: `${position}%` }}
|
||||
/>
|
||||
))}
|
||||
</NodeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
ParallelNode.displayName = 'ParallelNode';
|
||||
100
ccw/frontend/src/pages/orchestrator/nodes/SlashCommandNode.tsx
Normal file
100
ccw/frontend/src/pages/orchestrator/nodes/SlashCommandNode.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
// ========================================
|
||||
// Slash Command Node Component
|
||||
// ========================================
|
||||
// Custom node for executing CCW slash commands
|
||||
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { Terminal } from 'lucide-react';
|
||||
import type { SlashCommandNodeData } from '@/types/flow';
|
||||
import { NodeWrapper } from './NodeWrapper';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SlashCommandNodeProps {
|
||||
data: SlashCommandNodeData;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
// Mode badge styling
|
||||
const MODE_STYLES = {
|
||||
analysis: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
write: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
|
||||
};
|
||||
|
||||
export const SlashCommandNode = memo(({ data, selected }: SlashCommandNodeProps) => {
|
||||
const executionMode = data.execution?.mode || 'analysis';
|
||||
|
||||
return (
|
||||
<NodeWrapper
|
||||
status={data.executionStatus}
|
||||
selected={selected}
|
||||
accentColor="blue"
|
||||
>
|
||||
{/* Input Handle */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-background"
|
||||
/>
|
||||
|
||||
{/* Node Header */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-blue-500 text-white rounded-t-md">
|
||||
<Terminal className="w-4 h-4 shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{data.label || 'Command'}
|
||||
</span>
|
||||
{/* Execution mode badge */}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium px-1.5 py-0.5 rounded',
|
||||
MODE_STYLES[executionMode]
|
||||
)}
|
||||
>
|
||||
{executionMode}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Node Content */}
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
{/* Command name */}
|
||||
{data.command && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded text-foreground">
|
||||
/{data.command}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arguments (truncated) */}
|
||||
{data.args && (
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[160px]">
|
||||
<span className="text-foreground/70 font-mono">{data.args}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error handling indicator */}
|
||||
{data.onError && data.onError !== 'stop' && (
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
On error: <span className="text-foreground">{data.onError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Execution error message */}
|
||||
{data.executionStatus === 'failed' && data.executionError && (
|
||||
<div className="text-[10px] text-destructive truncate max-w-[160px]" title={data.executionError}>
|
||||
{data.executionError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Output Handle */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!w-3 !h-3 !bg-blue-500 !border-2 !border-background"
|
||||
/>
|
||||
</NodeWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
SlashCommandNode.displayName = 'SlashCommandNode';
|
||||
26
ccw/frontend/src/pages/orchestrator/nodes/index.ts
Normal file
26
ccw/frontend/src/pages/orchestrator/nodes/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// ========================================
|
||||
// Node Components Barrel Export
|
||||
// ========================================
|
||||
|
||||
// Shared wrapper component
|
||||
export { NodeWrapper } from './NodeWrapper';
|
||||
|
||||
// Custom node components
|
||||
export { SlashCommandNode } from './SlashCommandNode';
|
||||
export { FileOperationNode } from './FileOperationNode';
|
||||
export { ConditionalNode } from './ConditionalNode';
|
||||
export { ParallelNode } from './ParallelNode';
|
||||
|
||||
// Node types map for React Flow registration
|
||||
import { SlashCommandNode } from './SlashCommandNode';
|
||||
import { FileOperationNode } from './FileOperationNode';
|
||||
import { ConditionalNode } from './ConditionalNode';
|
||||
import { ParallelNode } from './ParallelNode';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const nodeTypes: Record<string, any> = {
|
||||
'slash-command': SlashCommandNode,
|
||||
'file-operation': FileOperationNode,
|
||||
conditional: ConditionalNode,
|
||||
parallel: ParallelNode,
|
||||
};
|
||||
Reference in New Issue
Block a user