feat: implement FlowExecutor for executing flow definitions with DAG traversal and node execution

This commit is contained in:
catlog22
2026-01-30 16:59:18 +08:00
parent 0a7c1454d9
commit a5c3dff8d3
92 changed files with 23875 additions and 542 deletions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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