Add phases for issue resolution: From Brainstorm and Form Execution Queue

- Implement Phase 3: From Brainstorm to convert brainstorm session output into executable issues and solutions.
- Implement Phase 4: Form Execution Queue to analyze bound solutions, resolve conflicts, and create an ordered execution queue.
- Introduce new data structures for Issue and Solution schemas.
- Enhance CLI commands for issue creation and queue management.
- Add error handling and quality checklist for queue formation.
This commit is contained in:
catlog22
2026-02-06 14:23:13 +08:00
parent 248daa1d00
commit 9b1655be9b
42 changed files with 2845 additions and 4644 deletions

View File

@@ -1,192 +0,0 @@
// ========================================
// CoordinatorEmptyState Component
// ========================================
// Modern empty state with tech-inspired design for coordinator start page
import { useIntl } from 'react-intl';
import { Play, Rocket, Zap, GitBranch } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/utils';
export interface CoordinatorEmptyStateProps {
onStart: () => void;
disabled?: boolean;
className?: string;
}
/**
* Empty state component with modern tech-inspired design
* Displays when no coordinator execution is active
*/
export function CoordinatorEmptyState({
onStart,
disabled = false,
className,
}: CoordinatorEmptyStateProps) {
const { formatMessage } = useIntl();
return (
<div
className={cn(
'relative flex items-center justify-center min-h-[600px] overflow-hidden',
className
)}
>
{/* Animated Background - Using theme colors with gradient utilities */}
<div className="absolute inset-0 bg-gradient-to-br from-background via-card to-background animate-slow-gradient">
{/* Grid Pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `
linear-gradient(var(--primary) 1px, transparent 1px),
linear-gradient(90deg, var(--primary) 1px, transparent 1px)
`,
backgroundSize: '50px 50px',
}}
/>
{/* Animated Gradient Orbs - Using gradient utility classes */}
<div className="absolute top-20 left-20 w-72 h-72 rounded-full blur-3xl animate-pulse bg-gradient-primary opacity-15" />
<div
className="absolute bottom-20 right-20 w-96 h-96 rounded-full blur-3xl animate-pulse opacity-15 bg-gradient-secondary"
style={{ animationDelay: '1s' }}
/>
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full blur-3xl animate-pulse bg-gradient-primary opacity-10" style={{ animationDelay: '2s' }} />
</div>
{/* Main Content */}
<div className="relative z-10 max-w-2xl mx-auto px-8 text-center">
{/* Hero Icon - Using gradient brand background */}
<div className="relative mb-8 inline-block">
<div className="absolute inset-0 rounded-full blur-2xl opacity-40 animate-pulse bg-gradient-brand" />
<div className="relative p-6 rounded-full shadow-2xl text-white bg-primary hover-glow-primary">
<Rocket className="w-16 h-16" strokeWidth={2} />
</div>
</div>
{/* Title */}
<h1 className="text-4xl font-bold mb-4 text-foreground">
{formatMessage({ id: 'coordinator.emptyState.title' })}
</h1>
{/* Subtitle */}
<p className="text-lg text-muted-foreground mb-12 max-w-lg mx-auto">
{formatMessage({ id: 'coordinator.emptyState.subtitle' })}
</p>
{/* Start Button - Using gradient and glow utilities */}
<Button
size="lg"
onClick={onStart}
disabled={disabled}
className="group relative px-8 py-6 text-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-300 bg-primary text-primary-foreground hover-glow-primary"
>
<Play className="w-6 h-6 mr-2 group-hover:scale-110 transition-transform" />
{formatMessage({ id: 'coordinator.emptyState.startButton' })}
</Button>
{/* Feature Cards */}
<div className="mt-16 grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Feature 1 */}
<div className="group relative bg-card/80 backdrop-blur-sm rounded-xl p-6 border border-border hover:border-primary/50 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div
className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: 'hsl(var(--primary) / 0.05)' }}
/>
<div className="relative">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
style={{ background: 'hsl(var(--primary) / 0.1)', color: 'hsl(var(--primary))' }}
>
<Zap className="w-6 h-6" />
</div>
<h3 className="font-semibold mb-2 text-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature1.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature1.description' })}
</p>
</div>
</div>
{/* Feature 2 */}
<div className="group relative bg-card/80 backdrop-blur-sm rounded-xl p-6 border border-border hover:border-primary/50 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div
className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: 'hsl(var(--secondary) / 0.05)' }}
/>
<div className="relative">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
style={{ background: 'hsl(var(--secondary) / 0.1)', color: 'hsl(var(--secondary))' }}
>
<GitBranch className="w-6 h-6" />
</div>
<h3 className="font-semibold mb-2 text-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature2.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature2.description' })}
</p>
</div>
</div>
{/* Feature 3 */}
<div className="group relative bg-card/80 backdrop-blur-sm rounded-xl p-6 border border-border hover:border-primary/50 transition-all duration-300 hover:shadow-lg hover:-translate-y-1">
<div
className="absolute inset-0 rounded-xl opacity-0 group-hover:opacity-100 transition-opacity"
style={{ background: 'hsl(var(--accent) / 0.05)' }}
/>
<div className="relative">
<div
className="w-12 h-12 rounded-lg flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
style={{ background: 'hsl(var(--accent) / 0.1)', color: 'hsl(var(--accent))' }}
>
<Play className="w-6 h-6" />
</div>
<h3 className="font-semibold mb-2 text-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature3.title' })}
</h3>
<p className="text-sm text-muted-foreground">
{formatMessage({ id: 'coordinator.emptyState.feature3.description' })}
</p>
</div>
</div>
</div>
{/* Quick Start Guide */}
<div className="mt-12 text-left bg-card/50 backdrop-blur-sm rounded-xl p-6 border border-border">
<h3 className="font-semibold mb-4 text-foreground flex items-center gap-2">
<span className="w-6 h-6 rounded-full flex items-center justify-center text-primary-foreground text-xs font-semibold bg-primary">
ok
</span>
{formatMessage({ id: 'coordinator.emptyState.quickStart.title' })}
</h3>
<div className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-start gap-3">
<span className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5 text-white bg-primary">
1
</span>
<p>{formatMessage({ id: 'coordinator.emptyState.quickStart.step1' })}</p>
</div>
<div className="flex items-start gap-3">
<span className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5 text-white bg-secondary">
2
</span>
<p>{formatMessage({ id: 'coordinator.emptyState.quickStart.step2' })}</p>
</div>
<div className="flex items-start gap-3">
<span className="w-5 h-5 rounded-full flex items-center justify-center text-xs font-semibold shrink-0 mt-0.5 text-white bg-accent">
3
</span>
<p>{formatMessage({ id: 'coordinator.emptyState.quickStart.step3' })}</p>
</div>
</div>
</div>
</div>
</div>
);
}
export default CoordinatorEmptyState;

View File

@@ -1,136 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { CoordinatorInputModal } from './CoordinatorInputModal';
// Mock zustand stores
vi.mock('@/stores/coordinatorStore', () => ({
useCoordinatorStore: () => ({
startCoordinator: vi.fn(),
}),
}));
vi.mock('@/hooks/useNotifications', () => ({
useNotifications: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}));
// Mock fetch
global.fetch = vi.fn();
const mockMessages = {
'coordinator.modal.title': 'Start Coordinator',
'coordinator.modal.description': 'Describe the task',
'coordinator.form.taskDescription': 'Task Description',
'coordinator.form.taskDescriptionPlaceholder': 'Enter task description',
'coordinator.form.parameters': 'Parameters',
'coordinator.form.parametersPlaceholder': '{"key": "value"}',
'coordinator.form.parametersHelp': 'Optional JSON parameters',
'coordinator.form.characterCount': '{current} / {max} characters (min: {min})',
'coordinator.form.start': 'Start',
'coordinator.form.starting': 'Starting...',
'coordinator.validation.taskDescriptionRequired': 'Task description is required',
'coordinator.validation.taskDescriptionTooShort': 'Too short',
'coordinator.validation.taskDescriptionTooLong': 'Too long',
'coordinator.validation.parametersInvalidJson': 'Invalid JSON',
'common.actions.cancel': 'Cancel',
};
const renderWithIntl = (ui: React.ReactElement) => {
return render(
<IntlProvider locale="en" messages={mockMessages}>
{ui}
</IntlProvider>
);
};
describe('CoordinatorInputModal', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('should render when open', () => {
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
expect(screen.getByText('Start Coordinator')).toBeInTheDocument();
expect(screen.getByText('Describe the task')).toBeInTheDocument();
});
it('should not render when closed', () => {
renderWithIntl(<CoordinatorInputModal open={false} onClose={mockOnClose} />);
expect(screen.queryByText('Start Coordinator')).not.toBeInTheDocument();
});
it('should show validation error for empty task description', async () => {
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
const startButton = screen.getByText('Start');
fireEvent.click(startButton);
await waitFor(() => {
expect(screen.getByText('Task description is required')).toBeInTheDocument();
});
});
it('should show validation error for short task description', async () => {
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Enter task description');
fireEvent.change(textarea, { target: { value: 'Short' } });
const startButton = screen.getByText('Start');
fireEvent.click(startButton);
await waitFor(() => {
expect(screen.getByText('Too short')).toBeInTheDocument();
});
});
it('should show validation error for invalid JSON parameters', async () => {
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Enter task description');
fireEvent.change(textarea, { target: { value: 'Valid task description here' } });
const paramsInput = screen.getByPlaceholderText('{"key": "value"}');
fireEvent.change(paramsInput, { target: { value: 'invalid json' } });
const startButton = screen.getByText('Start');
fireEvent.click(startButton);
await waitFor(() => {
expect(screen.getByText('Invalid JSON')).toBeInTheDocument();
});
});
it('should submit with valid task description', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
global.fetch = mockFetch;
renderWithIntl(<CoordinatorInputModal open={true} onClose={mockOnClose} />);
const textarea = screen.getByPlaceholderText('Enter task description');
fireEvent.change(textarea, { target: { value: 'Valid task description with more than 10 characters' } });
const startButton = screen.getByText('Start');
fireEvent.click(startButton);
await waitFor(() => {
expect(mockFetch).toHaveBeenCalledWith(
'/api/coordinator/start',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
);
});
});
});

View File

@@ -1,441 +0,0 @@
// ========================================
// Coordinator Input Modal Component (Multi-Step)
// ========================================
// Two-step modal: Welcome page -> Template & Parameters
import { useState, useEffect } from 'react';
import { useIntl } from 'react-intl';
import { Loader2, Rocket, Zap, GitBranch, Eye, ChevronRight, ChevronLeft } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Textarea } from '@/components/ui/Textarea';
import { Label } from '@/components/ui/Label';
import { useCoordinatorStore } from '@/stores/coordinatorStore';
import { useNotifications } from '@/hooks/useNotifications';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface CoordinatorInputModalProps {
open: boolean;
onClose: () => void;
}
interface FormErrors {
taskDescription?: string;
parameters?: string;
}
// ========== Constants ==========
const TEMPLATES = [
{ id: 'feature-dev', nameKey: 'coordinator.multiStep.step2.templates.featureDev', description: 'Complete feature development workflow' },
{ id: 'api-integration', nameKey: 'coordinator.multiStep.step2.templates.apiIntegration', description: 'Third-party API integration' },
{ id: 'performance', nameKey: 'coordinator.multiStep.step2.templates.performanceOptimization', description: 'System performance analysis' },
{ id: 'documentation', nameKey: 'coordinator.multiStep.step2.templates.documentGeneration', description: 'Auto-generate documentation' },
] as const;
const TOTAL_STEPS = 2;
// ========== Validation Helper ==========
function validateForm(taskDescription: string, parameters: string): FormErrors {
const errors: FormErrors = {};
if (!taskDescription.trim()) {
errors.taskDescription = 'coordinator.validation.taskDescriptionRequired';
} else {
const length = taskDescription.trim().length;
if (length < 10) {
errors.taskDescription = 'coordinator.validation.taskDescriptionTooShort';
} else if (length > 2000) {
errors.taskDescription = 'coordinator.validation.taskDescriptionTooLong';
}
}
if (parameters.trim()) {
try {
JSON.parse(parameters.trim());
} catch {
errors.parameters = 'coordinator.validation.parametersInvalidJson';
}
}
return errors;
}
// ========== Feature Card Data ==========
const FEATURES = [
{
icon: Zap,
titleKey: 'coordinator.multiStep.step1.feature1.title',
descriptionKey: 'coordinator.multiStep.step1.feature1.description',
bgClass: 'bg-primary/10',
iconClass: 'text-primary',
},
{
icon: GitBranch,
titleKey: 'coordinator.multiStep.step1.feature2.title',
descriptionKey: 'coordinator.multiStep.step1.feature2.description',
bgClass: 'bg-secondary/10',
iconClass: 'text-secondary-foreground',
},
{
icon: Eye,
titleKey: 'coordinator.multiStep.step1.feature3.title',
descriptionKey: 'coordinator.multiStep.step1.feature3.description',
bgClass: 'bg-accent/10',
iconClass: 'text-accent-foreground',
},
] as const;
// ========== Component ==========
export function CoordinatorInputModal({ open, onClose }: CoordinatorInputModalProps) {
const { formatMessage } = useIntl();
const { success, error: showError } = useNotifications();
const { startCoordinator } = useCoordinatorStore();
// Step state
const [step, setStep] = useState(1);
// Form state
const [taskDescription, setTaskDescription] = useState('');
const [parameters, setParameters] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset all state when modal opens/closes
useEffect(() => {
if (open) {
setStep(1);
setTaskDescription('');
setParameters('');
setSelectedTemplate(null);
setErrors({});
setIsSubmitting(false);
}
}, [open]);
// Handle field change
const handleFieldChange = (
field: 'taskDescription' | 'parameters',
value: string
) => {
if (field === 'taskDescription') {
setTaskDescription(value);
} else {
setParameters(value);
}
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
// Handle template selection
const handleTemplateSelect = (templateId: string) => {
setSelectedTemplate(templateId);
const template = TEMPLATES.find((t) => t.id === templateId);
if (template) {
setTaskDescription(template.description);
if (errors.taskDescription) {
setErrors((prev) => ({ ...prev, taskDescription: undefined }));
}
}
};
// Handle submit - preserved exactly from original
const handleSubmit = async () => {
const validationErrors = validateForm(taskDescription, parameters);
if (Object.keys(validationErrors).length > 0) {
setErrors(validationErrors);
return;
}
setIsSubmitting(true);
try {
const parsedParams = parameters.trim() ? JSON.parse(parameters.trim()) : undefined;
const executionId = `exec-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const response = await fetch('/api/coordinator/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
executionId,
taskDescription: taskDescription.trim(),
parameters: parsedParams,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Unknown error' }));
throw new Error(error.message || 'Failed to start coordinator');
}
await startCoordinator(executionId, taskDescription.trim(), parsedParams);
success(formatMessage({ id: 'coordinator.success.started' }));
onClose();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
showError('Error', errorMessage);
console.error('Failed to start coordinator:', error);
} finally {
setIsSubmitting(false);
}
};
// Navigation
const handleNext = () => setStep(2);
const handleBack = () => setStep(1);
// ========== Step 1: Welcome ==========
const renderStep1 = () => (
<div className="flex flex-col items-center px-6 py-8">
{/* Hero Icon */}
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground mb-6">
<Rocket className="h-8 w-8" />
</div>
{/* Title & Subtitle */}
<h2 className="text-2xl font-bold text-foreground mb-2">
{formatMessage({ id: 'coordinator.multiStep.step1.title' })}
</h2>
<p className="text-sm text-muted-foreground mb-8 text-center max-w-md">
{formatMessage({ id: 'coordinator.multiStep.step1.subtitle' })}
</p>
{/* Feature Cards */}
<div className="grid grid-cols-3 gap-4 w-full">
{FEATURES.map((feature) => {
const Icon = feature.icon;
return (
<div
key={feature.titleKey}
className={cn(
'flex flex-col items-center rounded-xl p-5 text-center',
feature.bgClass
)}
>
<Icon className={cn('h-6 w-6 mb-3', feature.iconClass)} />
<span className="text-sm font-medium text-foreground mb-1">
{formatMessage({ id: feature.titleKey })}
</span>
<span className="text-xs text-muted-foreground">
{formatMessage({ id: feature.descriptionKey })}
</span>
</div>
);
})}
</div>
</div>
);
// ========== Step 2: Template + Parameters ==========
const renderStep2 = () => (
<div className="flex min-h-[380px]">
{/* Left Column: Template Selection */}
<div className="w-2/5 border-r border-border p-5">
<h3 className="text-sm font-semibold text-foreground mb-3">
{formatMessage({ id: 'coordinator.multiStep.step2.templateLabel' })}
</h3>
<div className="space-y-2">
{TEMPLATES.map((template) => {
const isSelected = selectedTemplate === template.id;
return (
<button
key={template.id}
type="button"
onClick={() => handleTemplateSelect(template.id)}
className={cn(
'flex w-full items-center gap-3 rounded-lg border px-3 py-3 text-left transition-colors',
isSelected
? 'border-primary bg-primary/5'
: 'border-border bg-card hover:bg-muted/50'
)}
>
{/* Radio dot */}
<span
className={cn(
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full border',
isSelected
? 'border-primary'
: 'border-muted-foreground/40'
)}
>
{isSelected && (
<span className="h-2 w-2 rounded-full bg-primary" />
)}
</span>
<span className={cn(
'text-sm',
isSelected ? 'font-medium text-foreground' : 'text-muted-foreground'
)}>
{formatMessage({ id: template.nameKey })}
</span>
</button>
);
})}
</div>
</div>
{/* Right Column: Parameter Form */}
<div className="w-3/5 p-5 space-y-4">
{/* Task Description */}
<div className="space-y-2">
<Label htmlFor="task-description" className="text-sm font-medium">
{formatMessage({ id: 'coordinator.form.taskDescription' })}
<span className="text-destructive ml-0.5">*</span>
</Label>
<Textarea
id="task-description"
value={taskDescription}
onChange={(e) => handleFieldChange('taskDescription', e.target.value)}
placeholder={formatMessage({ id: 'coordinator.form.taskDescriptionPlaceholder' })}
rows={5}
className={cn(
'resize-none',
errors.taskDescription && 'border-destructive'
)}
disabled={isSubmitting}
/>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{formatMessage(
{ id: 'coordinator.form.characterCount' },
{ current: taskDescription.length, min: 10, max: 2000 }
)}
</span>
{taskDescription.length >= 10 && taskDescription.length <= 2000 && (
<span className="text-primary">Valid</span>
)}
</div>
{errors.taskDescription && (
<p className="text-xs text-destructive">
{formatMessage({ id: errors.taskDescription })}
</p>
)}
</div>
{/* Custom Parameters */}
<div className="space-y-2">
<Label htmlFor="parameters" className="text-sm font-medium">
{formatMessage({ id: 'coordinator.form.parameters' })}
</Label>
<Textarea
id="parameters"
value={parameters}
onChange={(e) => handleFieldChange('parameters', e.target.value)}
placeholder={formatMessage({ id: 'coordinator.form.parametersPlaceholder' })}
rows={3}
className={cn(
'resize-none font-mono text-sm',
errors.parameters && 'border-destructive'
)}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
{formatMessage({ id: 'coordinator.form.parametersHelp' })}
</p>
{errors.parameters && (
<p className="text-xs text-destructive">
{formatMessage({ id: errors.parameters })}
</p>
)}
</div>
</div>
</div>
);
// ========== Footer ==========
const renderFooter = () => (
<div className="flex items-center justify-between border-t border-border px-6 py-4">
{/* Left: Step indicator + Back */}
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">
{formatMessage(
{ id: 'coordinator.multiStep.progress.step' },
{ current: step, total: TOTAL_STEPS }
)}
</span>
{step === 2 && (
<Button
variant="ghost"
size="sm"
onClick={handleBack}
disabled={isSubmitting}
>
<ChevronLeft className="mr-1 h-4 w-4" />
{formatMessage({ id: 'coordinator.multiStep.actions.back' })}
</Button>
)}
</div>
{/* Right: Cancel + Next/Submit */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onClose}
disabled={isSubmitting}
>
{formatMessage({ id: 'common.actions.cancel' })}
</Button>
{step === 1 ? (
<Button size="sm" onClick={handleNext}>
{formatMessage({ id: 'coordinator.multiStep.actions.next' })}
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
) : (
<Button
size="sm"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{formatMessage({ id: 'coordinator.form.starting' })}
</>
) : (
formatMessage({ id: 'coordinator.multiStep.actions.submit' })
)}
</Button>
)}
</div>
</div>
);
// ========== Render ==========
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-4xl gap-0 p-0 overflow-hidden">
{/* Visually hidden title for accessibility */}
<DialogTitle className="sr-only">
{formatMessage({ id: 'coordinator.modal.title' })}
</DialogTitle>
{/* Step Content */}
{step === 1 ? renderStep1() : renderStep2()}
{/* Footer */}
{renderFooter()}
</DialogContent>
</Dialog>
);
}
export default CoordinatorInputModal;

View File

@@ -1,196 +0,0 @@
// ========================================
// Coordinator Log Stream Component
// ========================================
// Real-time log display with level filtering and auto-scroll
import { useEffect, useRef, useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { FileText } from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/Card';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
import { Label } from '@/components/ui/Label';
import { useCoordinatorStore, type LogLevel, type CoordinatorLog } from '@/stores/coordinatorStore';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface CoordinatorLogStreamProps {
maxHeight?: number;
autoScroll?: boolean;
showFilter?: boolean;
}
type LogLevelFilter = LogLevel | 'all';
// ========== Component ==========
export function CoordinatorLogStream({
maxHeight = 400,
autoScroll = true,
showFilter = true,
}: CoordinatorLogStreamProps) {
const { formatMessage } = useIntl();
const { logs } = useCoordinatorStore();
const [levelFilter, setLevelFilter] = useState<LogLevelFilter>('all');
const logContainerRef = useRef<HTMLPreElement>(null);
// Filter logs by level
const filteredLogs = useMemo(() => {
if (levelFilter === 'all') {
return logs;
}
return logs.filter((log) => log.level === levelFilter);
}, [logs, levelFilter]);
// Auto-scroll to latest log
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
// Get log level color
const getLogLevelColor = (level: LogLevel): string => {
switch (level) {
case 'error':
return 'text-red-600';
case 'warn':
return 'text-yellow-600';
case 'success':
return 'text-green-600';
case 'debug':
return 'text-blue-600';
case 'info':
default:
return 'text-gray-600';
}
};
// Get log level background color
const getLogLevelBgColor = (level: LogLevel): string => {
switch (level) {
case 'error':
return 'bg-red-50';
case 'warn':
return 'bg-yellow-50';
case 'success':
return 'bg-green-50';
case 'debug':
return 'bg-blue-50';
case 'info':
default:
return 'bg-gray-50';
}
};
// Format log entry
const formatLogEntry = (log: CoordinatorLog): string => {
const timestamp = new Date(log.timestamp).toLocaleTimeString();
const levelLabel = `[${log.level.toUpperCase()}]`;
const source = log.source ? `[${log.source}]` : '';
return `${timestamp} ${levelLabel} ${source} ${log.message}`;
};
return (
<Card className="w-full">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4" />
<CardTitle className="text-base">
{formatMessage({ id: 'coordinator.logs' })}
</CardTitle>
<span className="text-xs text-muted-foreground">
({filteredLogs.length} {formatMessage({ id: 'coordinator.entries' })})
</span>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Level Filter */}
{showFilter && (
<div className="space-y-2">
<Label className="text-sm font-medium">
{formatMessage({ id: 'coordinator.logLevel' })}
</Label>
<RadioGroup
value={levelFilter}
onValueChange={(value) => setLevelFilter(value as LogLevelFilter)}
className="flex flex-wrap gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="all" id="level-all" />
<Label htmlFor="level-all" className="cursor-pointer">
{formatMessage({ id: 'coordinator.level.all' })}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="info" id="level-info" />
<Label htmlFor="level-info" className="cursor-pointer text-gray-600">
{formatMessage({ id: 'coordinator.level.info' })}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="warn" id="level-warn" />
<Label htmlFor="level-warn" className="cursor-pointer text-yellow-600">
{formatMessage({ id: 'coordinator.level.warn' })}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="error" id="level-error" />
<Label htmlFor="level-error" className="cursor-pointer text-red-600">
{formatMessage({ id: 'coordinator.level.error' })}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="debug" id="level-debug" />
<Label htmlFor="level-debug" className="cursor-pointer text-blue-600">
{formatMessage({ id: 'coordinator.level.debug' })}
</Label>
</div>
</RadioGroup>
</div>
)}
{/* Log Display */}
<div className="space-y-2">
{filteredLogs.length === 0 ? (
<div className="flex items-center justify-center p-8 text-muted-foreground text-sm">
{formatMessage({ id: 'coordinator.noLogs' })}
</div>
) : (
<pre
ref={logContainerRef}
className={cn(
'w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto whitespace-pre-wrap break-words font-mono'
)}
style={{ maxHeight: `${maxHeight}px` }}
>
{filteredLogs.map((log) => (
<div
key={log.id}
className={cn(
'py-1 px-2 mb-1 rounded',
getLogLevelBgColor(log.level)
)}
>
<span className={getLogLevelColor(log.level)}>
{formatLogEntry(log)}
</span>
</div>
))}
</pre>
)}
</div>
</CardContent>
</Card>
);
}
export default CoordinatorLogStream;

View File

@@ -1,289 +0,0 @@
// ========================================
// Coordinator Question Modal Component
// ========================================
// Interactive question dialog for coordinator execution
import { useState, useEffect, useRef } from 'react';
import { useIntl } from 'react-intl';
import { Loader2, AlertCircle } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Label } from '@/components/ui/Label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/RadioGroup';
import { useCoordinatorStore, type CoordinatorQuestion } from '@/stores/coordinatorStore';
// ========== Types ==========
export interface CoordinatorQuestionModalProps {
question: CoordinatorQuestion | null;
onSubmit?: (questionId: string, answer: string | string[]) => void;
}
// ========== Component ==========
export function CoordinatorQuestionModal({
question,
onSubmit,
}: CoordinatorQuestionModalProps) {
const { formatMessage } = useIntl();
const { submitAnswer } = useCoordinatorStore();
const [answer, setAnswer] = useState<string | string[]>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Reset state when question changes
useEffect(() => {
if (question) {
setAnswer(question.type === 'multi' ? [] : '');
setError(null);
// Auto-focus on input when modal opens
setTimeout(() => {
inputRef.current?.focus();
}, 100);
}
}, [question]);
// Validate answer
const validateAnswer = (): boolean => {
if (!question) return false;
if (question.required) {
if (question.type === 'multi') {
if (!Array.isArray(answer) || answer.length === 0) {
setError(formatMessage({ id: 'coordinator.validation.answerRequired' }));
return false;
}
} else {
if (!answer || (typeof answer === 'string' && !answer.trim())) {
setError(formatMessage({ id: 'coordinator.validation.answerRequired' }));
return false;
}
}
}
return true;
};
// Handle submit
const handleSubmit = async () => {
if (!question) return;
if (!validateAnswer()) return;
setIsSubmitting(true);
try {
const finalAnswer = typeof answer === 'string' ? answer.trim() : answer;
// Call store action
await submitAnswer(question.id, finalAnswer);
// Call optional callback
onSubmit?.(question.id, finalAnswer);
setError(null);
} catch (error) {
console.error('Failed to submit answer:', error);
setError(
error instanceof Error
? error.message
: formatMessage({ id: 'coordinator.error.submitFailed' })
);
} finally {
setIsSubmitting(false);
}
};
// Handle Enter key
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && question?.type === 'text') {
e.preventDefault();
handleSubmit();
}
};
// Handle multi-select change
const handleMultiSelectChange = (option: string, checked: boolean) => {
if (!Array.isArray(answer)) {
setAnswer([]);
return;
}
if (checked) {
setAnswer([...answer, option]);
} else {
setAnswer(answer.filter((a) => a !== option));
}
setError(null);
};
if (!question) {
return null;
}
return (
<Dialog open={!!question} onOpenChange={() => {/* Prevent manual close */}}>
<DialogContent
className="sm:max-w-[500px]"
onPointerDownOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{question.title}</DialogTitle>
{question.description && (
<DialogDescription>{question.description}</DialogDescription>
)}
</DialogHeader>
<div className="space-y-4 py-4">
{/* Text Input */}
{question.type === 'text' && (
<div className="space-y-2">
<Label htmlFor="text-answer">
{formatMessage({ id: 'coordinator.question.answer' })}
{question.required && <span className="text-destructive">*</span>}
</Label>
<Input
id="text-answer"
ref={inputRef}
value={typeof answer === 'string' ? answer : ''}
onChange={(e) => {
setAnswer(e.target.value);
setError(null);
}}
onKeyDown={handleKeyDown}
placeholder={formatMessage({ id: 'coordinator.question.textPlaceholder' })}
disabled={isSubmitting}
/>
</div>
)}
{/* Single Select (RadioGroup) */}
{question.type === 'single' && question.options && (
<div className="space-y-2">
<Label>
{formatMessage({ id: 'coordinator.question.selectOne' })}
{question.required && <span className="text-destructive">*</span>}
</Label>
<RadioGroup
value={typeof answer === 'string' ? answer : ''}
onValueChange={(value) => {
setAnswer(value);
setError(null);
}}
disabled={isSubmitting}
>
{question.options.map((option) => (
<div key={option} className="flex items-center space-x-2">
<RadioGroupItem value={option} id={`option-${option}`} />
<Label htmlFor={`option-${option}`} className="cursor-pointer">
{option}
</Label>
</div>
))}
</RadioGroup>
</div>
)}
{/* Multi Select (Checkboxes) */}
{question.type === 'multi' && question.options && (
<div className="space-y-2">
<Label>
{formatMessage({ id: 'coordinator.question.selectMultiple' })}
{question.required && <span className="text-destructive">*</span>}
</Label>
<div className="space-y-2">
{question.options.map((option) => {
const isChecked = Array.isArray(answer) && answer.includes(option);
return (
<div key={option} className="flex items-center space-x-2">
<input
type="checkbox"
id={`multi-${option}`}
checked={isChecked}
onChange={(e) => handleMultiSelectChange(option, e.target.checked)}
disabled={isSubmitting}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
/>
<Label htmlFor={`multi-${option}`} className="cursor-pointer">
{option}
</Label>
</div>
);
})}
</div>
</div>
)}
{/* Yes/No Buttons */}
{question.type === 'yes_no' && (
<div className="space-y-2">
<Label>
{formatMessage({ id: 'coordinator.question.confirm' })}
{question.required && <span className="text-destructive">*</span>}
</Label>
<div className="flex gap-2">
<Button
variant={answer === 'yes' ? 'default' : 'outline'}
onClick={() => {
setAnswer('yes');
setError(null);
}}
disabled={isSubmitting}
className="flex-1"
>
{formatMessage({ id: 'coordinator.question.yes' })}
</Button>
<Button
variant={answer === 'no' ? 'default' : 'outline'}
onClick={() => {
setAnswer('no');
setError(null);
}}
disabled={isSubmitting}
className="flex-1"
>
{formatMessage({ id: 'coordinator.question.no' })}
</Button>
</div>
</div>
)}
{/* Error Display */}
{error && (
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
</div>
<DialogFooter>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{formatMessage({ id: 'coordinator.question.submitting' })}
</>
) : (
formatMessage({ id: 'coordinator.question.submit' })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CoordinatorQuestionModal;

View File

@@ -1,137 +0,0 @@
// ========================================
// CoordinatorTaskCard Component
// ========================================
// Task card component for displaying task overview in horizontal list
import { useIntl } from 'react-intl';
import { Clock, CheckCircle, XCircle, Loader2, CircleDashed } from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import { cn } from '@/lib/utils';
export interface TaskStatus {
id: string;
name: string;
description?: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
progress: { completed: number; total: number };
startedAt?: string;
completedAt?: string;
}
export interface CoordinatorTaskCardProps {
task: TaskStatus;
isSelected: boolean;
onClick: () => void;
className?: string;
}
/**
* Task card component displaying task status and progress
* Used in horizontal scrolling task list
*/
export function CoordinatorTaskCard({
task,
isSelected,
onClick,
className,
}: CoordinatorTaskCardProps) {
const { formatMessage } = useIntl();
// Map status to badge variant
const getStatusVariant = (status: TaskStatus['status']) => {
switch (status) {
case 'pending':
return 'secondary';
case 'running':
return 'warning';
case 'completed':
return 'success';
case 'failed':
return 'destructive';
case 'cancelled':
return 'outline';
default:
return 'default';
}
};
// Get status icon
const getStatusIcon = (status: TaskStatus['status']) => {
switch (status) {
case 'pending':
return <CircleDashed className="w-3 h-3" />;
case 'running':
return <Loader2 className="w-3 h-3 animate-spin" />;
case 'completed':
return <CheckCircle className="w-3 h-3" />;
case 'failed':
return <XCircle className="w-3 h-3" />;
case 'cancelled':
return <XCircle className="w-3 h-3" />;
default:
return null;
}
};
// Format time display
const formatTime = (dateString?: string) => {
if (!dateString) return null;
try {
const date = new Date(dateString);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} catch {
return null;
}
};
const displayTime = task.startedAt ? formatTime(task.startedAt) : null;
return (
<Card
className={cn(
'min-w-[180px] max-w-[220px] p-4 cursor-pointer transition-all duration-200',
'hover:border-primary/50 hover:shadow-md',
isSelected && 'border-primary ring-1 ring-primary/20',
className
)}
onClick={onClick}
>
{/* Task Name */}
<h3 className="font-medium text-sm text-foreground truncate mb-2" title={task.name}>
{task.name}
</h3>
{/* Status Badge */}
<div className="mb-3">
<Badge variant={getStatusVariant(task.status)} className="gap-1">
{getStatusIcon(task.status)}
{formatMessage({ id: `coordinator.status.${task.status}` })}
</Badge>
</div>
{/* Progress */}
<div className="text-xs text-muted-foreground mb-2">
<span className="font-medium">{task.progress.completed}</span>
<span>/</span>
<span>{task.progress.total}</span>
<span className="ml-1">
{formatMessage({ id: 'coordinator.taskCard.nodes' })}
</span>
</div>
{/* Time */}
{displayTime && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Clock className="w-3 h-3" />
<span>{displayTime}</span>
<span className="ml-1">
{formatMessage({ id: 'coordinator.taskCard.started' })}
</span>
</div>
)}
</Card>
);
}
export default CoordinatorTaskCard;

View File

@@ -1,140 +0,0 @@
// ========================================
// CoordinatorTaskList Component
// ========================================
// Horizontal scrolling task list with filter and sort controls
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Filter, ArrowUpDown, Inbox } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/Select';
import { CoordinatorTaskCard, TaskStatus } from './CoordinatorTaskCard';
import { cn } from '@/lib/utils';
export type FilterOption = 'all' | 'running' | 'completed' | 'failed';
export type SortOption = 'time' | 'name';
export interface CoordinatorTaskListProps {
tasks: TaskStatus[];
selectedTaskId: string | null;
onTaskSelect: (taskId: string) => void;
className?: string;
}
/**
* Horizontal scrolling task list with filtering and sorting
* Displays task cards in a row with overflow scroll
*/
export function CoordinatorTaskList({
tasks,
selectedTaskId,
onTaskSelect,
className,
}: CoordinatorTaskListProps) {
const { formatMessage } = useIntl();
const [filter, setFilter] = useState<FilterOption>('all');
const [sort, setSort] = useState<SortOption>('time');
// Filter tasks
const filteredTasks = useMemo(() => {
let result = [...tasks];
// Apply filter
if (filter !== 'all') {
result = result.filter((task) => task.status === filter);
}
// Apply sort
result.sort((a, b) => {
if (sort === 'time') {
// Sort by start time (newest first), pending tasks last
const timeA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
const timeB = b.startedAt ? new Date(b.startedAt).getTime() : 0;
return timeB - timeA;
} else {
// Sort by name alphabetically
return a.name.localeCompare(b.name);
}
});
return result;
}, [tasks, filter, sort]);
return (
<div className={cn('space-y-4', className)}>
{/* Controls Row */}
<div className="flex items-center gap-3">
{/* Filter Select */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
<Select value={filter} onValueChange={(v) => setFilter(v as FilterOption)}>
<SelectTrigger className="w-[140px] h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{formatMessage({ id: 'coordinator.taskList.filter.all' })}
</SelectItem>
<SelectItem value="running">
{formatMessage({ id: 'coordinator.taskList.filter.running' })}
</SelectItem>
<SelectItem value="completed">
{formatMessage({ id: 'coordinator.taskList.filter.completed' })}
</SelectItem>
<SelectItem value="failed">
{formatMessage({ id: 'coordinator.taskList.filter.failed' })}
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Sort Select */}
<div className="flex items-center gap-2">
<ArrowUpDown className="w-4 h-4 text-muted-foreground" />
<Select value={sort} onValueChange={(v) => setSort(v as SortOption)}>
<SelectTrigger className="w-[120px] h-9">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="time">
{formatMessage({ id: 'coordinator.taskList.sort.time' })}
</SelectItem>
<SelectItem value="name">
{formatMessage({ id: 'coordinator.taskList.sort.name' })}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Task Cards - Horizontal Scroll */}
{filteredTasks.length > 0 ? (
<div className="flex gap-4 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent">
{filteredTasks.map((task) => (
<CoordinatorTaskCard
key={task.id}
task={task}
isSelected={task.id === selectedTaskId}
onClick={() => onTaskSelect(task.id)}
/>
))}
</div>
) : (
/* Empty State */
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Inbox className="w-12 h-12 mb-4 opacity-50" />
<p className="text-sm">
{formatMessage({ id: 'coordinator.taskList.empty' })}
</p>
</div>
)}
</div>
);
}
export default CoordinatorTaskList;

View File

@@ -1,116 +0,0 @@
// ========================================
// CoordinatorTimeline Component
// ========================================
// Main horizontal timeline container for coordinator pipeline visualization
import { useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { useCoordinatorStore, selectCommandChain, selectCurrentNode } from '@/stores/coordinatorStore';
import { TimelineNode } from './TimelineNode';
import { NodeConnector } from './NodeConnector';
export interface CoordinatorTimelineProps {
className?: string;
autoScroll?: boolean;
onNodeClick?: (nodeId: string) => void;
}
/**
* Horizontal scrolling timeline displaying the coordinator command chain
* with connectors between nodes
*/
export function CoordinatorTimeline({
className,
autoScroll = true,
onNodeClick,
}: CoordinatorTimelineProps) {
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Store selectors
const commandChain = useCoordinatorStore(selectCommandChain);
const currentNode = useCoordinatorStore(selectCurrentNode);
// Auto-scroll to the current/latest node
useEffect(() => {
if (!autoScroll || !scrollContainerRef.current) return;
// Find the active or latest node
const activeNodeIndex = commandChain.findIndex(
(node) => node.status === 'running' || node.id === currentNode?.id
);
// If no active node, scroll to the end
if (activeNodeIndex === -1) {
scrollContainerRef.current.scrollTo({
left: scrollContainerRef.current.scrollWidth,
behavior: 'smooth',
});
return;
}
// Scroll the active node into view
const nodeElements = scrollContainerRef.current.querySelectorAll('[data-node-id]');
const activeElement = nodeElements[activeNodeIndex] as HTMLElement;
if (activeElement) {
activeElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
inline: 'center',
});
}
}, [commandChain, currentNode?.id, autoScroll]);
// Handle node click
const handleNodeClick = (nodeId: string) => {
if (onNodeClick) {
onNodeClick(nodeId);
}
};
// Render empty state
if (commandChain.length === 0) {
return (
<div className={cn('flex items-center justify-center p-8 text-muted-foreground', className)}>
<div className="text-center">
<p className="text-sm">No pipeline nodes to display</p>
<p className="text-xs mt-1">Start a coordinator execution to see the pipeline</p>
</div>
</div>
);
}
return (
<div
ref={scrollContainerRef}
className={cn(
'flex items-center gap-0 p-4 overflow-x-auto overflow-y-hidden',
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent',
className
)}
role="region"
aria-label="Coordinator pipeline timeline"
>
{commandChain.map((node, index) => (
<div key={node.id} className="flex items-center" data-node-id={node.id}>
{/* Timeline node */}
<TimelineNode
node={node}
isActive={currentNode?.id === node.id}
onClick={() => handleNodeClick(node.id)}
/>
{/* Connector to next node (if not last) */}
{index < commandChain.length - 1 && (
<NodeConnector
status={commandChain[index + 1].status}
className="mx-2"
/>
)}
</div>
))}
</div>
);
}
export default CoordinatorTimeline;

View File

@@ -1,49 +0,0 @@
// ========================================
// NodeConnector Component
// ========================================
// Visual connector line between pipeline nodes with status-based styling
import { cn } from '@/lib/utils';
import type { NodeExecutionStatus } from '@/stores/coordinatorStore';
export interface NodeConnectorProps {
status: NodeExecutionStatus;
className?: string;
}
/**
* Connector line between timeline nodes
* Changes color based on the status of the connected node
*/
export function NodeConnector({ status, className }: NodeConnectorProps) {
// Determine connector color and animation based on status
const getConnectorStyle = () => {
switch (status) {
case 'completed':
return 'bg-gradient-to-r from-green-500 to-green-400';
case 'failed':
return 'bg-gradient-to-r from-red-500 to-red-400';
case 'running':
return 'bg-gradient-to-r from-blue-500 to-blue-400 animate-pulse';
case 'pending':
return 'bg-gradient-to-r from-gray-300 to-gray-200 dark:from-gray-700 dark:to-gray-600';
case 'skipped':
return 'bg-gradient-to-r from-yellow-400 to-yellow-300';
default:
return 'bg-gradient-to-r from-gray-300 to-gray-200 dark:from-gray-700 dark:to-gray-600';
}
};
return (
<div
className={cn(
'w-16 h-1 shrink-0 transition-all duration-300',
getConnectorStyle(),
className
)}
aria-hidden="true"
/>
);
}
export default NodeConnector;

View File

@@ -1,254 +0,0 @@
// ========================================
// Node Details Panel Component
// ========================================
// Expandable panel showing node logs, error information, and retry/skip actions
import { useEffect, useRef, useState } from 'react';
import { useIntl } from 'react-intl';
import { Loader2, RotateCcw, SkipForward, ChevronDown, ChevronUp } from 'lucide-react';
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { useCoordinatorStore, type CommandNode } from '@/stores/coordinatorStore';
import { cn } from '@/lib/utils';
// ========== Types ==========
export interface NodeDetailsPanelProps {
node: CommandNode;
isExpanded?: boolean;
onToggle?: (expanded: boolean) => void;
}
// ========== Component ==========
export function NodeDetailsPanel({
node,
isExpanded = true,
onToggle,
}: NodeDetailsPanelProps) {
const { formatMessage } = useIntl();
const { retryNode, skipNode, logs } = useCoordinatorStore();
const [expanded, setExpanded] = useState(isExpanded);
const [isLoading, setIsLoading] = useState(false);
const logScrollRef = useRef<HTMLPreElement>(null);
// Filter logs for this node
const nodeLogs = logs.filter((log) => log.nodeId === node.id);
// Auto-scroll to latest log
useEffect(() => {
if (expanded && logScrollRef.current) {
logScrollRef.current.scrollTop = logScrollRef.current.scrollHeight;
}
}, [expanded, nodeLogs]);
// Handle expand/collapse
const handleToggle = () => {
const newExpanded = !expanded;
setExpanded(newExpanded);
onToggle?.(newExpanded);
};
// Handle retry
const handleRetry = async () => {
setIsLoading(true);
try {
await retryNode(node.id);
} catch (error) {
console.error('Failed to retry node:', error);
} finally {
setIsLoading(false);
}
};
// Handle skip
const handleSkip = async () => {
setIsLoading(true);
try {
await skipNode(node.id);
} catch (error) {
console.error('Failed to skip node:', error);
} finally {
setIsLoading(false);
}
};
// Get status color
const getStatusColor = (status: string): string => {
switch (status) {
case 'completed':
return 'text-green-600';
case 'failed':
return 'text-red-600';
case 'running':
return 'text-blue-600';
case 'skipped':
return 'text-yellow-600';
default:
return 'text-gray-600';
}
};
// Get status label
const getStatusLabel = (status: string): string => {
const labels: Record<string, string> = {
pending: 'coordinator.status.pending',
running: 'coordinator.status.running',
completed: 'coordinator.status.completed',
failed: 'coordinator.status.failed',
skipped: 'coordinator.status.skipped',
};
return labels[status] || status;
};
return (
<Card className="w-full">
<CardHeader className="cursor-pointer" onClick={handleToggle}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 flex-1">
<button
className="inline-flex items-center justify-center"
onClick={handleToggle}
aria-expanded={expanded}
>
{expanded ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
<CardTitle className="text-lg">
{node.name}
</CardTitle>
<span className={cn('text-sm font-medium', getStatusColor(node.status))}>
{formatMessage({ id: getStatusLabel(node.status) })}
</span>
</div>
</div>
{node.description && (
<p className="text-sm text-muted-foreground mt-2">{node.description}</p>
)}
</CardHeader>
{expanded && (
<CardContent className="space-y-4">
{/* Logs Section */}
{nodeLogs.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold">
{formatMessage({ id: 'coordinator.logs' })}
</h4>
<pre
ref={logScrollRef}
className="w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto max-h-[200px] whitespace-pre-wrap break-words font-mono"
>
{nodeLogs
.map((log) => {
const timestamp = new Date(log.timestamp).toLocaleTimeString();
const levelLabel = `[${log.level.toUpperCase()}]`;
return `${timestamp} ${levelLabel} ${log.message}`;
})
.join('\n')}
</pre>
</div>
)}
{/* Error Information */}
{node.error && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-red-600">
{formatMessage({ id: 'coordinator.error' })}
</h4>
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-xs text-red-800">
{node.error}
</div>
</div>
)}
{/* Output Section */}
{node.output && (
<div className="space-y-2">
<h4 className="text-sm font-semibold">
{formatMessage({ id: 'coordinator.output' })}
</h4>
<pre className="w-full p-3 bg-muted rounded-lg text-xs overflow-y-auto max-h-[150px] whitespace-pre-wrap break-words font-mono">
{node.output}
</pre>
</div>
)}
{/* Node Information */}
<div className="grid grid-cols-2 gap-2 text-xs">
{node.startedAt && (
<div>
<span className="font-semibold text-muted-foreground">
{formatMessage({ id: 'coordinator.startedAt' })}:
</span>
<p>{new Date(node.startedAt).toLocaleString()}</p>
</div>
)}
{node.completedAt && (
<div>
<span className="font-semibold text-muted-foreground">
{formatMessage({ id: 'coordinator.completedAt' })}:
</span>
<p>{new Date(node.completedAt).toLocaleString()}</p>
</div>
)}
</div>
{/* Action Buttons */}
{node.status === 'failed' && (
<div className="flex gap-2 pt-4 border-t">
<Button
size="sm"
variant="outline"
onClick={handleRetry}
disabled={isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
{formatMessage({ id: 'coordinator.retrying' })}
</>
) : (
<>
<RotateCcw className="w-3 h-3 mr-2" />
{formatMessage({ id: 'coordinator.retry' })}
</>
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleSkip}
disabled={isLoading}
className="flex-1"
>
{isLoading ? (
<>
<Loader2 className="w-3 h-3 mr-2 animate-spin" />
{formatMessage({ id: 'coordinator.skipping' })}
</>
) : (
<>
<SkipForward className="w-3 h-3 mr-2" />
{formatMessage({ id: 'coordinator.skip' })}
</>
)}
</Button>
</div>
)}
</CardContent>
)}
</Card>
);
}
export default NodeDetailsPanel;

View File

@@ -1,279 +0,0 @@
# Coordinator Components
## CoordinatorInputModal
Modal dialog for starting coordinator execution with task description and optional JSON parameters.
### Usage
```tsx
import { CoordinatorInputModal } from '@/components/coordinator';
import { useState } from 'react';
function MyComponent() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<Button onClick={() => setIsModalOpen(true)}>
Start Coordinator
</Button>
<CoordinatorInputModal
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</>
);
}
```
### Features
- **Task Description**: Required text area (10-2000 characters)
- **Parameters**: Optional JSON input
- **Validation**: Real-time validation for description length and JSON format
- **Loading State**: Displays loading indicator during submission
- **Error Handling**: Shows appropriate error messages
- **Internationalization**: Full i18n support (English/Chinese)
- **Notifications**: Success/error toasts via useNotifications hook
### API Integration
The component integrates with:
- **POST /api/coordinator/start**: Starts coordinator execution
- **coordinatorStore**: Updates Zustand store state
- **notificationStore**: Shows success/error notifications
### Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `open` | `boolean` | Yes | Controls modal visibility |
| `onClose` | `() => void` | Yes | Callback when modal closes |
### Validation Rules
- **Task Description**:
- Minimum length: 10 characters
- Maximum length: 2000 characters
- Required field
- **Parameters**:
- Optional field
- Must be valid JSON if provided
### Example Payload
```json
{
"executionId": "exec-1738477200000-abc123def",
"taskDescription": "Implement user authentication with JWT tokens",
"parameters": {
"timeout": 3600,
"priority": "high"
}
}
```
---
## Pipeline Timeline View Components
Horizontal scrolling timeline visualization for coordinator command pipeline execution.
### CoordinatorTimeline
Main timeline container that displays the command chain with auto-scrolling to active nodes.
#### Usage
```tsx
import { CoordinatorTimeline } from '@/components/coordinator';
function MyComponent() {
const handleNodeClick = (nodeId: string) => {
console.log('Node clicked:', nodeId);
};
return (
<CoordinatorTimeline
autoScroll={true}
onNodeClick={handleNodeClick}
/>
);
}
```
#### Props
| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `className` | `string` | No | - | Additional CSS classes |
| `autoScroll` | `boolean` | No | `true` | Auto-scroll to active/latest node |
| `onNodeClick` | `(nodeId: string) => void` | No | - | Callback when node is clicked |
#### Features
- **Horizontal Scrolling**: Smooth horizontal scroll with mouse wheel
- **Auto-scroll**: Automatically scrolls to the active or latest node
- **Empty State**: Shows helpful message when no nodes are present
- **Store Integration**: Uses `useCoordinatorStore` for state management
---
### TimelineNode
Individual node card displaying node status, timing, and expandable details.
#### Usage
```tsx
import { TimelineNode } from '@/components/coordinator';
import type { CommandNode } from '@/stores/coordinatorStore';
function MyComponent({ node }: { node: CommandNode }) {
return (
<TimelineNode
node={node}
isActive={true}
onClick={() => console.log('Clicked:', node.id)}
/>
);
}
```
#### Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `node` | `CommandNode` | Yes | Node data from coordinator store |
| `isActive` | `boolean` | No | Whether this node is currently active |
| `onClick` | `() => void` | No | Callback when node is clicked |
| `className` | `string` | No | Additional CSS classes |
#### Features
- **Status Indicators**:
- `completed`: Green checkmark icon
- `failed`: Red X icon
- `running`: Blue spinning loader
- `pending`: Gray clock icon
- `skipped`: Yellow X icon
- **Status Badges**:
- Success (green)
- Failed (red)
- Running (blue)
- Pending (gray outline)
- Skipped (yellow)
- **Expandable Details**:
- Error messages (red background)
- Output text (scrollable pre)
- Result JSON (formatted and scrollable)
- **Timing Information**:
- Start time
- Completion time
- Duration calculation
- **Animations**:
- Hover scale effect (scale-105)
- Smooth transitions (300ms)
- Active ring (ring-2 ring-primary)
---
### NodeConnector
Visual connector line between timeline nodes with status-based styling.
#### Usage
```tsx
import { NodeConnector } from '@/components/coordinator';
function MyComponent() {
return (
<div className="flex items-center">
<TimelineNode node={node1} />
<NodeConnector status="completed" />
<TimelineNode node={node2} />
</div>
);
}
```
#### Props
| Prop | Type | Required | Description |
|------|------|----------|-------------|
| `status` | `NodeExecutionStatus` | Yes | Status of the connected node |
| `className` | `string` | No | Additional CSS classes |
#### Status Colors
| Status | Color | Animation |
|--------|-------|-----------|
| `completed` | Green gradient | None |
| `failed` | Red gradient | None |
| `running` | Blue gradient | Pulse animation |
| `pending` | Gray gradient | None |
| `skipped` | Yellow gradient | None |
---
## Complete Example
```tsx
import { useState } from 'react';
import {
CoordinatorInputModal,
CoordinatorTimeline,
} from '@/components/coordinator';
import { Button } from '@/components/ui/Button';
function CoordinatorDashboard() {
const [isModalOpen, setIsModalOpen] = useState(false);
const handleNodeClick = (nodeId: string) => {
console.log('Node clicked:', nodeId);
// Show node details panel, etc.
};
return (
<div className="flex flex-col h-screen">
{/* Header */}
<header className="border-b border-border p-4">
<Button onClick={() => setIsModalOpen(true)}>
New Execution
</Button>
</header>
{/* Timeline */}
<div className="flex-1 overflow-hidden">
<CoordinatorTimeline
autoScroll={true}
onNodeClick={handleNodeClick}
/>
</div>
{/* Input Modal */}
<CoordinatorInputModal
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
</div>
);
}
```
## Design Principles
- **Responsive**: Works on mobile and desktop
- **Dark Mode**: Full dark mode support via Tailwind CSS
- **Accessibility**: Proper ARIA labels and keyboard navigation
- **Performance**: Smooth 60fps animations
- **Mobile-first**: Touch-friendly interactions

View File

@@ -1,213 +0,0 @@
// ========================================
// TimelineNode Component
// ========================================
// Individual node card in the coordinator pipeline timeline
import { useState } from 'react';
import { CheckCircle, XCircle, Loader2, ChevronDown, ChevronUp, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card, CardHeader, CardContent } from '@/components/ui/Card';
import { Badge } from '@/components/ui/Badge';
import type { CommandNode } from '@/stores/coordinatorStore';
export interface TimelineNodeProps {
node: CommandNode;
isActive?: boolean;
onClick?: () => void;
className?: string;
}
/**
* Individual timeline node card with status indicator and expandable details
*/
export function TimelineNode({ node, isActive = false, onClick, className }: TimelineNodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Get status icon
const getStatusIcon = () => {
const iconClassName = 'h-5 w-5 shrink-0';
switch (node.status) {
case 'completed':
return <CheckCircle className={cn(iconClassName, 'text-green-500')} />;
case 'failed':
return <XCircle className={cn(iconClassName, 'text-red-500')} />;
case 'running':
return <Loader2 className={cn(iconClassName, 'text-blue-500 animate-spin')} />;
case 'skipped':
return <XCircle className={cn(iconClassName, 'text-yellow-500')} />;
case 'pending':
default:
return <Clock className={cn(iconClassName, 'text-gray-400')} />;
}
};
// Get status badge variant
const getStatusBadge = () => {
switch (node.status) {
case 'completed':
return <Badge variant="success">Success</Badge>;
case 'failed':
return <Badge variant="destructive">Failed</Badge>;
case 'running':
return <Badge variant="info">Running</Badge>;
case 'skipped':
return <Badge variant="warning">Skipped</Badge>;
case 'pending':
default:
return <Badge variant="outline">Pending</Badge>;
}
};
// Format timestamp
const formatTime = (timestamp?: string) => {
if (!timestamp) return 'N/A';
return new Date(timestamp).toLocaleTimeString();
};
// Calculate duration
const getDuration = () => {
if (!node.startedAt || !node.completedAt) return null;
const start = new Date(node.startedAt).getTime();
const end = new Date(node.completedAt).getTime();
const durationMs = end - start;
if (durationMs < 1000) return `${durationMs}ms`;
const seconds = Math.floor(durationMs / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
const handleToggleExpand = (e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
};
const hasDetails = Boolean(node.output || node.error || node.result);
return (
<Card
className={cn(
'w-64 shrink-0 cursor-pointer transition-all duration-300',
'hover:shadow-lg hover:scale-105',
isActive && 'ring-2 ring-primary',
isExpanded && 'w-80',
className
)}
onClick={onClick}
>
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
{getStatusIcon()}
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold text-foreground truncate" title={node.name}>
{node.name}
</h4>
{node.description && (
<p className="text-xs text-muted-foreground truncate mt-0.5" title={node.description}>
{node.description}
</p>
)}
</div>
</div>
<div className="shrink-0">
{getStatusBadge()}
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
{/* Timing information */}
{(node.startedAt || node.completedAt) && (
<div className="text-xs text-muted-foreground space-y-0.5 mb-2">
{node.startedAt && (
<div>Started: {formatTime(node.startedAt)}</div>
)}
{node.completedAt && (
<div>Completed: {formatTime(node.completedAt)}</div>
)}
{getDuration() && (
<div className="font-medium">Duration: {getDuration()}</div>
)}
</div>
)}
{/* Expand/collapse toggle for details */}
{hasDetails && (
<>
<button
onClick={handleToggleExpand}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors w-full"
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3" />
Hide details
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
Show details
</>
)}
</button>
{/* Expanded details panel */}
{isExpanded && (
<div className="mt-2 space-y-2">
{/* Error message */}
{node.error && (
<div className="p-2 rounded-md bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800">
<div className="text-xs font-semibold text-red-700 dark:text-red-400 mb-1">
Error:
</div>
<div className="text-xs text-red-600 dark:text-red-300 break-words">
{node.error}
</div>
</div>
)}
{/* Output */}
{Boolean(node.output) && (
<div className="p-2 rounded-md bg-muted/50 border border-border">
<div className="text-xs font-semibold text-foreground mb-1">
Output:
</div>
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{String(node.output)}
</pre>
</div>
)}
{/* Result */}
{Boolean(node.result) && (
<div className="p-2 rounded-md bg-muted/50 border border-border">
<div className="text-xs font-semibold text-foreground mb-1">
Result:
</div>
<pre className="text-xs text-muted-foreground overflow-x-auto whitespace-pre-wrap break-words max-h-32 overflow-y-auto">
{typeof node.result === 'object' && node.result !== null
? JSON.stringify(node.result, null, 2)
: String(node.result)}
</pre>
</div>
)}
</div>
)}
</>
)}
{/* Command information */}
{node.command && !isExpanded && (
<div className="mt-2 text-xs text-muted-foreground truncate" title={node.command}>
<span className="font-mono">{node.command}</span>
</div>
)}
</CardContent>
</Card>
);
}
export default TimelineNode;

View File

@@ -1,32 +0,0 @@
// Coordinator components
export { CoordinatorInputModal } from './CoordinatorInputModal';
export type { CoordinatorInputModalProps } from './CoordinatorInputModal';
// Timeline visualization components
export { CoordinatorTimeline } from './CoordinatorTimeline';
export type { CoordinatorTimelineProps } from './CoordinatorTimeline';
export { TimelineNode } from './TimelineNode';
export type { TimelineNodeProps } from './TimelineNode';
export { NodeConnector } from './NodeConnector';
export type { NodeConnectorProps } from './NodeConnector';
// Node interaction components
export { NodeDetailsPanel } from './NodeDetailsPanel';
export type { NodeDetailsPanelProps } from './NodeDetailsPanel';
export { CoordinatorLogStream } from './CoordinatorLogStream';
export type { CoordinatorLogStreamProps } from './CoordinatorLogStream';
export { CoordinatorQuestionModal } from './CoordinatorQuestionModal';
export type { CoordinatorQuestionModalProps } from './CoordinatorQuestionModal';
export { CoordinatorEmptyState } from './CoordinatorEmptyState';
export type { CoordinatorEmptyStateProps } from './CoordinatorEmptyState';
export { CoordinatorTaskCard } from './CoordinatorTaskCard';
export type { CoordinatorTaskCardProps, TaskStatus } from './CoordinatorTaskCard';
export { CoordinatorTaskList } from './CoordinatorTaskList';
export type { CoordinatorTaskListProps, FilterOption, SortOption } from './CoordinatorTaskList';

View File

@@ -10,7 +10,6 @@ import {
FolderKanban,
Workflow,
Zap,
Play,
Clock,
CheckCircle2,
XCircle,
@@ -27,7 +26,6 @@ import { Button } from '@/components/ui/Button';
import { Progress } from '@/components/ui/Progress';
import { useSessions } from '@/hooks/useSessions';
import { useLiteTasks } from '@/hooks/useLiteTasks';
import { useCoordinatorStore } from '@/stores/coordinatorStore';
import { cn } from '@/lib/utils';
export interface RecentSessionsWidgetProps {
@@ -36,7 +34,7 @@ export interface RecentSessionsWidgetProps {
}
// Task type definitions
type TaskType = 'all' | 'workflow' | 'lite' | 'orchestrator';
type TaskType = 'all' | 'workflow' | 'lite';
// Unified task item for display
interface UnifiedTaskItem {
@@ -57,7 +55,6 @@ const TABS: { key: TaskType; label: string; icon: React.ElementType }[] = [
{ key: 'all', label: 'home.tabs.allTasks', icon: FolderKanban },
{ key: 'workflow', label: 'home.tabs.workflow', icon: Workflow },
{ key: 'lite', label: 'home.tabs.liteTasks', icon: Zap },
{ key: 'orchestrator', label: 'home.tabs.orchestrator', icon: Play },
];
// Status icon mapping
@@ -114,15 +111,13 @@ const typeColors: Record<TaskType, string> = {
all: 'bg-muted text-muted-foreground',
workflow: 'bg-primary/20 text-primary',
lite: 'bg-amber-500/20 text-amber-600',
orchestrator: 'bg-violet-500/20 text-violet-600',
};
function TaskItemCard({ item, onClick }: { item: UnifiedTaskItem; onClick: () => void }) {
const { formatMessage } = useIntl();
const StatusIcon = statusIcons[item.status] || Clock;
const TypeIcon = item.subType ? (liteTypeIcons[item.subType] || Zap) :
item.type === 'workflow' ? Workflow :
item.type === 'orchestrator' ? Play : Zap;
item.type === 'workflow' ? Workflow : Zap;
const isAnimated = item.status === 'in_progress' || item.status === 'running' || item.status === 'initializing';
@@ -226,9 +221,6 @@ function RecentSessionsWidgetComponent({
// Fetch lite tasks
const { allSessions: liteSessions, isLoading: liteLoading } = useLiteTasks();
// Get coordinator state
const coordinatorState = useCoordinatorStore();
// Format relative time with fallback
const formatRelativeTime = React.useCallback((dateStr: string | undefined): string => {
if (!dateStr) return formatMessage({ id: 'common.time.justNow' });
@@ -286,28 +278,9 @@ function RecentSessionsWidgetComponent({
});
});
// Add current coordinator execution if exists
if (coordinatorState.currentExecutionId && coordinatorState.status !== 'idle') {
const status = coordinatorState.status;
const completedSteps = coordinatorState.commandChain.filter(n => n.status === 'completed').length;
const totalSteps = coordinatorState.commandChain.length;
const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
items.push({
id: coordinatorState.currentExecutionId,
name: coordinatorState.pipelineDetails?.nodes[0]?.name || 'Orchestrator Task',
type: 'orchestrator',
status,
statusKey: statusI18nKeys[status] || status,
createdAt: formatRelativeTime(coordinatorState.startedAt),
description: `${completedSteps}/${totalSteps} steps completed`,
progress,
});
}
// Sort by most recent (use original date for sorting, not formatted string)
return items;
}, [activeSessions, liteSessions, coordinatorState, formatRelativeTime]);
}, [activeSessions, liteSessions, formatRelativeTime]);
// Filter items by tab
const filteredItems = React.useMemo(() => {
@@ -324,9 +297,6 @@ function RecentSessionsWidgetComponent({
case 'lite':
navigate(`/lite-tasks/${item.subType}/${item.id}`);
break;
case 'orchestrator':
navigate(`/orchestrator`);
break;
}
};

View File

@@ -78,7 +78,6 @@ const navGroupDefinitions: NavGroupDef[] = [
{ path: '/sessions', labelKey: 'navigation.main.sessions', icon: FolderKanban },
{ path: '/lite-tasks', labelKey: 'navigation.main.liteTasks', icon: Zap },
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
{ path: '/coordinator', labelKey: 'navigation.main.coordinator', icon: GitFork },
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
],

View File

@@ -8,7 +8,6 @@ import { useNotificationStore } from '@/stores';
import { useExecutionStore } from '@/stores/executionStore';
import { useFlowStore } from '@/stores';
import { useCliStreamStore } from '@/stores/cliStreamStore';
import { useCoordinatorStore } from '@/stores/coordinatorStore';
import {
OrchestratorMessageSchema,
type OrchestratorWebSocketMessage,
@@ -28,7 +27,6 @@ function getStoreState() {
const execution = useExecutionStore.getState();
const flow = useFlowStore.getState();
const cliStream = useCliStreamStore.getState();
const coordinator = useCoordinatorStore.getState();
return {
// Notification store
setWsStatus: notification.setWsStatus,
@@ -48,12 +46,6 @@ function getStoreState() {
updateNode: flow.updateNode,
// CLI stream store
addOutput: cliStream.addOutput,
// Coordinator store
updateNodeStatus: coordinator.updateNodeStatus,
addCoordinatorLog: coordinator.addLog,
setActiveQuestion: coordinator.setActiveQuestion,
markExecutionComplete: coordinator.markExecutionComplete,
coordinatorExecutionId: coordinator.currentExecutionId,
};
}
@@ -165,57 +157,6 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
return;
}
// Handle Coordinator messages
if (data.type?.startsWith('COORDINATOR_')) {
const { coordinatorExecutionId } = stores;
// Only process messages for current coordinator execution
if (coordinatorExecutionId && data.executionId !== coordinatorExecutionId) {
return;
}
// Dispatch to coordinator store based on message type
switch (data.type) {
case 'COORDINATOR_STATE_UPDATE':
// Check for completion
if (data.status === 'completed') {
stores.markExecutionComplete(true);
} else if (data.status === 'failed') {
stores.markExecutionComplete(false);
}
break;
case 'COORDINATOR_COMMAND_STARTED':
stores.updateNodeStatus(data.nodeId, 'running');
break;
case 'COORDINATOR_COMMAND_COMPLETED':
stores.updateNodeStatus(data.nodeId, 'completed', data.result);
break;
case 'COORDINATOR_COMMAND_FAILED':
stores.updateNodeStatus(data.nodeId, 'failed', undefined, data.error);
break;
case 'COORDINATOR_LOG_ENTRY':
stores.addCoordinatorLog(
data.log.message,
data.log.level,
data.log.nodeId,
data.log.source
);
break;
case 'COORDINATOR_QUESTION_ASKED':
stores.setActiveQuestion(data.question);
break;
case 'COORDINATOR_ANSWER_RECEIVED':
// Answer received - handled by submitAnswer in the store
break;
}
return;
}
// Check if this is an orchestrator message
if (!data.type?.startsWith('ORCHESTRATOR_')) {
return;

View File

@@ -1,141 +0,0 @@
{
"page": {
"title": "Coordinator",
"status": "Status: {status}",
"startButton": "Start Coordinator",
"noNodeSelected": "Select a node to view details"
},
"taskDetail": {
"title": "Task Details",
"noSelection": "Select a task to view execution details"
},
"emptyState": {
"title": "Workflow Coordinator",
"subtitle": "Intelligent task orchestration with real-time monitoring for complex workflows",
"startButton": "Launch Coordinator",
"feature1": {
"title": "Intelligent Execution",
"description": "Smart task orchestration with dependency management and parallel execution"
},
"feature2": {
"title": "Real-time Monitoring",
"description": "Pipeline visualization with detailed logs and execution metrics"
},
"feature3": {
"title": "Flexible Control",
"description": "Interactive control with retry, skip, and pause capabilities"
},
"quickStart": {
"title": "Quick Start",
"step1": "Click the 'Launch Coordinator' button to begin",
"step2": "Describe your workflow task in natural language",
"step3": "Monitor execution pipeline and interact with running tasks"
}
},
"multiStep": {
"step1": {
"title": "Welcome to Coordinator",
"subtitle": "Intelligent workflow orchestration for automated task execution",
"feature1": { "title": "Intelligent Execution", "description": "Smart task orchestration with dependency management and parallel execution" },
"feature2": { "title": "Real-time Monitoring", "description": "Pipeline visualization with detailed logs and execution metrics" },
"feature3": { "title": "Flexible Control", "description": "Interactive control with retry, skip, and pause capabilities" }
},
"step2": {
"title": "Configure Parameters",
"subtitle": "Select a template or customize parameters",
"templateLabel": "Select Template",
"templates": {
"featureDev": "Feature Development",
"apiIntegration": "API Integration",
"performanceOptimization": "Performance Optimization",
"documentGeneration": "Document Generation"
},
"taskName": "Task Name",
"taskNamePlaceholder": "Enter task name...",
"taskDescription": "Task Description",
"taskDescriptionPlaceholder": "Describe your task requirements in detail...",
"customParameters": "Custom Parameters"
},
"progress": { "step": "Step {current} / {total}" },
"actions": { "next": "Next", "back": "Back", "cancel": "Cancel", "submit": "Submit" }
},
"modal": {
"title": "Start Coordinator",
"description": "Describe the task you want the coordinator to execute"
},
"form": {
"taskDescription": "Task Description",
"taskDescriptionPlaceholder": "Describe what you want the coordinator to do (min 10 characters)...",
"parameters": "Parameters (Optional)",
"parametersPlaceholder": "{\"key\": \"value\"}",
"parametersHelp": "Optional JSON parameters for coordinator execution",
"characterCount": "{current} / {max} characters (min: {min})",
"start": "Start Coordinator",
"starting": "Starting..."
},
"validation": {
"taskDescriptionRequired": "Task description is required",
"taskDescriptionTooShort": "Task description must be at least 10 characters",
"taskDescriptionTooLong": "Task description must not exceed 2000 characters",
"parametersInvalidJson": "Parameters must be valid JSON",
"answerRequired": "An answer is required"
},
"success": {
"started": "Coordinator started successfully"
},
"status": {
"pending": "Pending",
"running": "Running",
"completed": "Completed",
"failed": "Failed",
"skipped": "Skipped"
},
"logs": "Logs",
"entries": "entries",
"error": "Error",
"output": "Output",
"startedAt": "Started At",
"completedAt": "Completed At",
"retrying": "Retrying...",
"retry": "Retry",
"skipping": "Skipping...",
"skip": "Skip",
"logLevel": "Log Level",
"level": {
"all": "All",
"info": "Info",
"warn": "Warning",
"error": "Error",
"debug": "Debug"
},
"noLogs": "No logs available",
"question": {
"answer": "Answer",
"textPlaceholder": "Enter your answer...",
"selectOne": "Select One",
"selectMultiple": "Select Multiple",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
"submitting": "Submitting...",
"submit": "Submit"
},
"taskList": {
"filter": {
"all": "All Tasks",
"running": "Running",
"completed": "Completed",
"failed": "Failed"
},
"sort": {
"time": "By Time",
"name": "By Name"
},
"empty": "No tasks found"
},
"taskCard": {
"nodes": "nodes",
"started": "started"
},
"steps": "steps"
}

View File

@@ -9,7 +9,6 @@ import sessions from './sessions.json';
import issues from './issues.json';
import home from './home.json';
import orchestrator from './orchestrator.json';
import coordinator from './coordinator.json';
import loops from './loops.json';
import commands from './commands.json';
import memory from './memory.json';
@@ -71,7 +70,6 @@ export default {
...flattenMessages(issues, 'issues'),
...flattenMessages(home, 'home'),
...flattenMessages(orchestrator, 'orchestrator'),
...flattenMessages(coordinator, 'coordinator'),
...flattenMessages(loops, 'loops'),
...flattenMessages(commands, 'commands'),
...flattenMessages(memory, 'memory'),

View File

@@ -1,157 +0,0 @@
{
"page": {
"title": "协调器",
"status": "状态:{status}",
"startButton": "启动协调器",
"noNodeSelected": "选择节点以查看详细信息"
},
"taskDetail": {
"title": "任务详情",
"noSelection": "选择任务以查看执行详情"
},
"emptyState": {
"title": "欢迎使用工作流协调器",
"subtitle": "智能任务编排,实时执行监控,一站式管理复杂工作流",
"startButton": "启动协调器",
"feature1": {
"title": "智能执行",
"description": "依赖管理与并行执行的智能任务编排"
},
"feature2": {
"title": "实时监控",
"description": "流水线可视化,详细日志与执行指标"
},
"feature3": {
"title": "灵活控制",
"description": "支持重试、跳过和暂停的交互式控制"
},
"quickStart": {
"title": "快速开始",
"step1": "点击「启动协调器」按钮开始",
"step2": "用自然语言描述您的工作流任务",
"step3": "监控执行流水线,与运行中的任务交互"
}
},
"modal": {
"title": "启动协调器",
"description": "描述您希望协调器执行的任务"
},
"multiStep": {
"step1": {
"title": "欢迎使用协调器",
"subtitle": "智能工作流编排,助力任务自动化执行",
"feature1": {
"title": "智能执行",
"description": "依赖管理与并行执行的智能任务编排"
},
"feature2": {
"title": "实时监控",
"description": "流水线可视化,详细日志与执行指标"
},
"feature3": {
"title": "灵活控制",
"description": "支持重试、跳过和暂停的交互式控制"
}
},
"step2": {
"title": "配置参数",
"subtitle": "选择模板或自定义参数",
"templateLabel": "选择模板",
"templates": {
"featureDev": "功能开发",
"apiIntegration": "API 集成",
"performanceOptimization": "性能优化",
"documentGeneration": "文档生成"
},
"taskName": "任务名称",
"taskNamePlaceholder": "输入任务名称...",
"taskDescription": "任务描述",
"taskDescriptionPlaceholder": "详细描述您的任务需求...",
"customParameters": "自定义参数"
},
"progress": {
"step": "步骤 {current} / {total}"
},
"actions": {
"next": "下一步",
"back": "返回",
"cancel": "取消",
"submit": "提交"
}
},
"form": {
"taskDescription": "任务描述",
"taskDescriptionPlaceholder": "描述协调器需要执行的任务至少10个字符...",
"parameters": "参数(可选)",
"parametersPlaceholder": "{\"key\": \"value\"}",
"parametersHelp": "协调器执行的可选JSON参数",
"characterCount": "{current} / {max} 字符(最少:{min}",
"start": "启动协调器",
"starting": "启动中..."
},
"validation": {
"taskDescriptionRequired": "任务描述为必填项",
"taskDescriptionTooShort": "任务描述至少需要10个字符",
"taskDescriptionTooLong": "任务描述不能超过2000个字符",
"parametersInvalidJson": "参数必须是有效的JSON格式",
"answerRequired": "答案为必填项"
},
"success": {
"started": "协调器启动成功"
},
"status": {
"pending": "待执行",
"running": "运行中",
"completed": "已完成",
"failed": "失败",
"skipped": "已跳过"
},
"logs": "日志",
"entries": "条日志",
"error": "错误",
"output": "输出",
"startedAt": "开始时间",
"completedAt": "完成时间",
"retrying": "重试中...",
"retry": "重试",
"skipping": "跳过中...",
"skip": "跳过",
"logLevel": "日志级别",
"level": {
"all": "全部",
"info": "信息",
"warn": "警告",
"error": "错误",
"debug": "调试"
},
"noLogs": "暂无日志",
"question": {
"answer": "回答",
"textPlaceholder": "输入您的回答...",
"selectOne": "单选",
"selectMultiple": "多选",
"confirm": "确认",
"yes": "是",
"no": "否",
"submitting": "提交中...",
"submit": "提交"
},
"taskList": {
"filter": {
"all": "全部任务",
"running": "运行中",
"completed": "已完成",
"failed": "失败"
},
"sort": {
"time": "按时间",
"name": "按名称"
},
"empty": "暂无任务"
},
"taskCard": {
"nodes": "节点",
"started": "开始"
},
"steps": "步"
}

View File

@@ -9,7 +9,6 @@ import sessions from './sessions.json';
import issues from './issues.json';
import home from './home.json';
import orchestrator from './orchestrator.json';
import coordinator from './coordinator.json';
import loops from './loops.json';
import commands from './commands.json';
import memory from './memory.json';
@@ -71,7 +70,6 @@ export default {
...flattenMessages(issues, 'issues'),
...flattenMessages(home, 'home'),
...flattenMessages(orchestrator, 'orchestrator'),
...flattenMessages(coordinator, 'coordinator'),
...flattenMessages(loops, 'loops'),
...flattenMessages(commands, 'commands'),
...flattenMessages(memory, 'memory'),

View File

@@ -1,354 +0,0 @@
// ========================================
// Coordinator Page - Merged Layout
// ========================================
// Unified page for task list overview and execution details with timeline, logs, and node details
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { Play, CheckCircle2, XCircle, Clock, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import {
CoordinatorInputModal,
CoordinatorTimeline,
CoordinatorLogStream,
NodeDetailsPanel,
CoordinatorEmptyState,
} from '@/components/coordinator';
import {
useCoordinatorStore,
selectCommandChain,
selectCurrentNode,
selectCoordinatorStatus,
selectIsPipelineLoaded,
} from '@/stores/coordinatorStore';
import { cn } from '@/lib/utils';
// ========================================
// Types
// ========================================
interface CoordinatorTask {
id: string;
name: string;
status: 'pending' | 'running' | 'completed' | 'failed';
progress: {
completed: number;
total: number;
};
startedAt: string;
completedAt?: string;
}
// ========================================
// Mock Data (temporary - will be replaced by store)
// ========================================
const MOCK_TASKS: CoordinatorTask[] = [
{
id: 'task-1',
name: 'Feature Auth',
status: 'running',
progress: { completed: 3, total: 5 },
startedAt: '2026-02-03T14:23:00Z',
},
{
id: 'task-2',
name: 'API Integration',
status: 'completed',
progress: { completed: 8, total: 8 },
startedAt: '2026-02-03T10:00:00Z',
completedAt: '2026-02-03T10:15:00Z',
},
{
id: 'task-3',
name: 'Performance Test',
status: 'failed',
progress: { completed: 2, total: 6 },
startedAt: '2026-02-03T09:00:00Z',
},
];
// ========================================
// Task Card Component (inline)
// ========================================
interface TaskCardProps {
task: CoordinatorTask;
isSelected: boolean;
onClick: () => void;
}
function TaskCard({ task, isSelected, onClick }: TaskCardProps) {
const { formatMessage } = useIntl();
const statusConfig = {
pending: {
icon: Clock,
color: 'text-muted-foreground',
bg: 'bg-muted/50',
},
running: {
icon: Loader2,
color: 'text-blue-500',
bg: 'bg-blue-500/10',
},
completed: {
icon: CheckCircle2,
color: 'text-green-500',
bg: 'bg-green-500/10',
},
failed: {
icon: XCircle,
color: 'text-red-500',
bg: 'bg-red-500/10',
},
};
const config = statusConfig[task.status];
const StatusIcon = config.icon;
const progressPercent = Math.round((task.progress.completed / task.progress.total) * 100);
return (
<button
type="button"
onClick={onClick}
className={cn(
'flex flex-col p-3 rounded-lg border transition-all text-left w-full min-w-[160px] max-w-[200px]',
'hover:border-primary/50 hover:shadow-sm',
isSelected
? 'border-primary bg-primary/5 shadow-sm'
: 'border-border bg-card'
)}
>
{/* Task Name */}
<div className="flex items-center gap-2 mb-2">
<StatusIcon
className={cn(
'w-4 h-4 flex-shrink-0',
config.color,
task.status === 'running' && 'animate-spin'
)}
/>
<span className="text-sm font-medium text-foreground truncate">
{task.name}
</span>
</div>
{/* Status Badge */}
<div
className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium mb-2 w-fit',
config.bg,
config.color
)}
>
{formatMessage({ id: `coordinator.status.${task.status}` })}
</div>
{/* Progress */}
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
{task.progress.completed}/{task.progress.total}
</span>
<span>{progressPercent}%</span>
</div>
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full transition-all',
task.status === 'completed' && 'bg-green-500',
task.status === 'running' && 'bg-blue-500',
task.status === 'failed' && 'bg-red-500',
task.status === 'pending' && 'bg-muted-foreground'
)}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
</button>
);
}
// ========================================
// Main Component
// ========================================
export function CoordinatorPage() {
const { formatMessage } = useIntl();
const [isInputModalOpen, setIsInputModalOpen] = useState(false);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
// Store selectors
const commandChain = useCoordinatorStore(selectCommandChain);
const currentNode = useCoordinatorStore(selectCurrentNode);
const status = useCoordinatorStore(selectCoordinatorStatus);
const isPipelineLoaded = useCoordinatorStore(selectIsPipelineLoaded);
const syncStateFromServer = useCoordinatorStore((state) => state.syncStateFromServer);
// Mock tasks (temporary - will be replaced by store)
const tasks = useMemo(() => MOCK_TASKS, []);
const hasTasks = tasks.length > 0;
const selectedTask = tasks.find((t) => t.id === selectedTaskId);
// Sync state on mount (for page refresh scenarios)
useEffect(() => {
if (status === 'running' || status === 'paused' || status === 'initializing') {
syncStateFromServer();
}
}, []);
// Handle open input modal
const handleOpenInputModal = useCallback(() => {
setIsInputModalOpen(true);
}, []);
// Handle node click from timeline
const handleNodeClick = useCallback((nodeId: string) => {
setSelectedNode(nodeId);
}, []);
// Handle task selection
const handleTaskClick = useCallback((taskId: string) => {
setSelectedTaskId((prev) => (prev === taskId ? null : taskId));
setSelectedNode(null);
}, []);
// Get selected node object
const selectedNodeObject =
commandChain.find((node) => node.id === selectedNode) || currentNode || null;
return (
<div className="h-full flex flex-col -m-4 md:-m-6">
{/* ======================================== */}
{/* Toolbar */}
{/* ======================================== */}
<div className="flex items-center gap-3 p-3 bg-card border-b border-border">
{/* Page Title and Status */}
<div className="flex items-center gap-2 min-w-0 flex-1">
<Play className="w-5 h-5 text-primary flex-shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-foreground">
{formatMessage({ id: 'coordinator.page.title' })}
</span>
{isPipelineLoaded && (
<span className="text-xs text-muted-foreground">
{formatMessage(
{ id: 'coordinator.page.status' },
{
status: formatMessage({ id: `coordinator.status.${status}` }),
}
)}
</span>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
<Button
variant="default"
size="sm"
onClick={handleOpenInputModal}
disabled={status === 'running' || status === 'initializing'}
>
<Play className="w-4 h-4 mr-1" />
{formatMessage({ id: 'coordinator.page.startButton' })}
</Button>
</div>
</div>
{/* ======================================== */}
{/* Main Content Area */}
{/* ======================================== */}
{!hasTasks ? (
/* Empty State - No tasks */
<div className="flex-1 flex overflow-hidden">
<CoordinatorEmptyState
onStart={handleOpenInputModal}
disabled={status === 'running' || status === 'initializing'}
className="w-full"
/>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
{/* ======================================== */}
{/* Task List Area */}
{/* ======================================== */}
<div className="p-4 border-b border-border bg-background">
<div className="flex gap-3 overflow-x-auto pb-2">
{tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onClick={() => handleTaskClick(task.id)}
/>
))}
</div>
</div>
{/* ======================================== */}
{/* Task Detail Area (shown when task is selected) */}
{/* ======================================== */}
{selectedTask ? (
<div className="flex-1 flex overflow-hidden">
{/* Left Panel: Timeline */}
<div className="w-1/3 min-w-[300px] border-r border-border bg-card">
<CoordinatorTimeline
autoScroll={true}
onNodeClick={handleNodeClick}
className="h-full"
/>
</div>
{/* Center Panel: Log Stream */}
<div className="flex-1 min-w-0 flex flex-col bg-card">
<div className="flex-1 min-h-0">
<CoordinatorLogStream />
</div>
</div>
{/* Right Panel: Node Details */}
<div className="w-80 min-w-[320px] max-w-[400px] border-l border-border bg-card overflow-y-auto">
{selectedNodeObject ? (
<NodeDetailsPanel
node={selectedNodeObject}
isExpanded={true}
onToggle={(expanded) => {
if (!expanded) {
setSelectedNode(null);
}
}}
/>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm p-4 text-center">
{formatMessage({ id: 'coordinator.page.noNodeSelected' })}
</div>
)}
</div>
</div>
) : (
/* No task selected - show selection prompt */
<div className="flex-1 flex items-center justify-center bg-muted/30">
<div className="text-sm text-muted-foreground">
{formatMessage({ id: 'coordinator.taskDetail.noSelection' })}
</div>
</div>
)}
</div>
)}
{/* ======================================== */}
{/* Coordinator Input Modal */}
{/* ======================================== */}
<CoordinatorInputModal
open={isInputModalOpen}
onClose={() => setIsInputModalOpen(false)}
/>
</div>
);
}
export default CoordinatorPage;

View File

@@ -1,6 +0,0 @@
// ========================================
// Coordinator Page Export
// ========================================
// Barrel export for CoordinatorPage component
export { CoordinatorPage } from './CoordinatorPage';

View File

@@ -10,7 +10,6 @@ export { ProjectOverviewPage } from './ProjectOverviewPage';
export { SessionDetailPage } from './SessionDetailPage';
export { HistoryPage } from './HistoryPage';
export { OrchestratorPage } from './orchestrator';
export { CoordinatorPage } from './coordinator';
export { LoopMonitorPage } from './LoopMonitorPage';
export { IssueHubPage } from './IssueHubPage';
export { QueuePage } from './QueuePage';

View File

@@ -1,21 +1,20 @@
// ========================================
// Execution Monitor
// ========================================
// Real-time execution monitoring panel with logs and controls
// Right-side slide-out panel for real-time execution monitoring
import { useEffect, useRef, useCallback, useState } from 'react';
import {
Play,
Pause,
Square,
ChevronDown,
ChevronUp,
Clock,
AlertCircle,
CheckCircle2,
Loader2,
Terminal,
ArrowDownToLine,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
@@ -103,12 +102,12 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
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 isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen);
const autoScrollLogs = useExecutionStore((state) => state.autoScrollLogs);
const setMonitorExpanded = useExecutionStore((state) => state.setMonitorExpanded);
const setMonitorPanelOpen = useExecutionStore((state) => state.setMonitorPanelOpen);
const startExecution = useExecutionStore((state) => state.startExecution);
// Local state for elapsed time (calculated from startedAt)
// Local state for elapsed time
const [elapsedMs, setElapsedMs] = useState(0);
// Flow store state
@@ -121,22 +120,17 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
const resumeExecution = useResumeExecution();
const stopExecution = useStopExecution();
// Update elapsed time every second while running (calculated from startedAt)
// Update elapsed time every second while running
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);
@@ -153,10 +147,8 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
// 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);
}, []);
@@ -169,7 +161,6 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
// Handle execute
const handleExecute = useCallback(async () => {
if (!currentFlow) return;
try {
const result = await executeFlow.mutateAsync(currentFlow.id);
startExecution(result.execId, currentFlow.id);
@@ -219,241 +210,200 @@ export function ExecutionMonitor({ className }: ExecutionMonitorProps) {
const isPaused = currentExecution?.status === 'paused';
const canExecute = currentFlow && !isExecuting && !isPaused;
if (!isMonitorPanelOpen) return null;
return (
<div
className={cn(
'border-t border-border bg-card transition-all duration-300',
isMonitorExpanded ? 'h-64' : 'h-12',
'w-80 border-l border-border bg-card flex flex-col h-full',
'animate-in slide-in-from-right duration-300',
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>
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
<div className="flex items-center gap-2 min-w-0">
<Terminal className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm font-medium truncate">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)}
<Badge variant={getStatusBadgeVariant(currentExecution.status)} className="shrink-0">
<span className="flex items-center gap-1">
{getStatusIcon(currentExecution.status)}
{currentExecution.status}
</span>
{totalNodes > 0 && (
<span className="text-sm text-muted-foreground">
{completedNodes}/{totalNodes} nodes
</span>
)}
</>
</Badge>
)}
</div>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 shrink-0"
onClick={() => setMonitorPanelOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
{/* Control buttons */}
{canExecute && (
{/* Controls */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border shrink-0">
{canExecute && (
<Button
size="sm"
variant="default"
onClick={handleExecute}
disabled={executeFlow.isPending}
className="flex-1"
>
<Play className="h-4 w-4 mr-1" />
Execute
</Button>
)}
{isExecuting && (
<>
<Button
size="sm"
variant="outline"
onClick={handlePause}
disabled={pauseExecution.isPending}
>
<Pause className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={handleStop}
disabled={stopExecution.isPending}
>
<Square className="h-4 w-4" />
</Button>
</>
)}
{isPaused && (
<>
<Button
size="sm"
variant="default"
onClick={(e) => {
e.stopPropagation();
handleExecute();
}}
disabled={executeFlow.isPending}
onClick={handleResume}
disabled={resumeExecution.isPending}
className="flex-1"
>
<Play className="h-4 w-4 mr-1" />
Execute
Resume
</Button>
)}
<Button
size="sm"
variant="destructive"
onClick={handleStop}
disabled={stopExecution.isPending}
>
<Square className="h-4 w-4" />
</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>
{currentExecution && (
<span className="text-xs text-muted-foreground flex items-center gap-1 ml-auto">
<Clock className="h-3 w-3" />
{formatElapsedTime(elapsedMs)}
</span>
)}
</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>
)}
{/* Progress bar */}
{currentExecution && (
<div className="h-1 bg-muted shrink-0">
<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>
)}
{/* Node status */}
{currentExecution && Object.keys(nodeStates).length > 0 && (
<div className="px-3 py-2 border-b border-border shrink-0">
<div className="text-xs font-medium text-muted-foreground mb-1.5">
Node Status ({completedNodes}/{totalNodes})
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{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 shrink-0" />
)}
{state.status === 'completed' && (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
)}
{state.status === 'failed' && (
<AlertCircle className="h-3 w-3 text-red-500 shrink-0" />
)}
{state.status === 'pending' && (
<Clock className="h-3 w-3 text-gray-400 shrink-0" />
)}
<span className="truncate" title={nodeId}>
{nodeId.slice(0, 24)}
</span>
</div>
))}
</div>
</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"
{/* Logs */}
<div className="flex-1 flex flex-col min-h-0 relative">
<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 text-center">
{currentExecution
? 'Waiting for logs...'
: 'Click Execute to start'}
</div>
) : (
<div className="space-y-1">
{logs.map((log, index) => (
<div key={index} className="flex gap-1.5">
<span className="text-muted-foreground shrink-0 text-[10px]">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span
className={cn(
'uppercase w-10 shrink-0 text-[10px]',
getLogLevelColor(log.level)
)}
>
{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>
[{log.level}]
</span>
<span className="text-foreground break-all text-[11px]">
{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-3 w-3" />
</Button>
)}
</div>
</div>
);
}

View File

@@ -23,6 +23,7 @@ import {
import '@xyflow/react/dist/style.css';
import { useFlowStore } from '@/stores';
import { useExecutionStore, selectIsExecuting } from '@/stores/executionStore';
import type { FlowNode, FlowEdge } from '@/types/flow';
// Custom node types (enhanced with execution status in IMPL-A8)
@@ -36,6 +37,9 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const { screenToFlowPosition } = useReactFlow();
// Execution state - lock canvas during execution
const isExecuting = useExecutionStore(selectIsExecuting);
// Get state and actions from store
const nodes = useFlowStore((state) => state.nodes);
const edges = useFlowStore((state) => state.edges);
@@ -68,6 +72,7 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
// Handle new edge connections
const onConnect = useCallback(
(connection: Connection) => {
if (isExecuting) return;
if (connection.source && connection.target) {
const newEdge: FlowEdge = {
id: `edge-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
@@ -80,7 +85,7 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
markModified();
}
},
[edges, setEdges, markModified]
[edges, setEdges, markModified, isExecuting]
);
// Handle node selection
@@ -115,6 +120,7 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
const onDrop = useCallback(
(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
if (isExecuting) return;
// Verify the drop is from node palette
const nodeType = event.dataTransfer.getData('application/reactflow-node-type');
@@ -138,7 +144,7 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
addNode(position);
}
},
[screenToFlowPosition, addNode, addNodeFromTemplate]
[screenToFlowPosition, addNode, addNodeFromTemplate, isExecuting]
);
return (
@@ -155,10 +161,13 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
onDragOver={onDragOver}
onDrop={onDrop}
nodeTypes={nodeTypes}
nodesDraggable={!isExecuting}
nodesConnectable={!isExecuting}
elementsSelectable={!isExecuting}
deleteKeyCode={isExecuting ? null : ['Backspace', 'Delete']}
fitView
snapToGrid
snapGrid={[15, 15]}
deleteKeyCode={['Backspace', 'Delete']}
className="bg-background"
>
<Controls
@@ -179,6 +188,14 @@ function FlowCanvasInner({ className }: FlowCanvasProps) {
className="bg-muted/20"
/>
</ReactFlow>
{/* Execution lock overlay */}
{isExecuting && (
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 px-3 py-1.5 bg-primary/90 text-primary-foreground rounded-full text-xs font-medium shadow-lg flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-primary-foreground animate-pulse" />
Execution in progress
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
// ========================================
// Flow Toolbar Component
// ========================================
// Toolbar for flow operations: Save, Load, Import Template, Export, Simulate, Run
// Toolbar for flow operations: Save, Load, Import Template, Export, Run, Monitor
import { useState, useCallback, useEffect } from 'react';
import { useIntl } from 'react-intl';
@@ -16,12 +16,14 @@ import {
ChevronDown,
Library,
Play,
FlaskConical,
Activity,
} 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 { useExecutionStore } from '@/stores/executionStore';
import { useExecuteFlow } from '@/hooks/useFlows';
import type { Flow } from '@/types/flow';
interface FlowToolbarProps {
@@ -46,6 +48,18 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
const duplicateFlow = useFlowStore((state) => state.duplicateFlow);
const fetchFlows = useFlowStore((state) => state.fetchFlows);
// Execution store
const currentExecution = useExecutionStore((state) => state.currentExecution);
const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen);
const setMonitorPanelOpen = useExecutionStore((state) => state.setMonitorPanelOpen);
const startExecution = useExecutionStore((state) => state.startExecution);
// Mutations
const executeFlow = useExecuteFlow();
const isExecuting = currentExecution?.status === 'running';
const isPaused = currentExecution?.status === 'paused';
// Load flows on mount
useEffect(() => {
fetchFlows();
@@ -161,6 +175,25 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
toast.success('Flow Exported', 'Flow exported as JSON file');
}, [currentFlow]);
// Handle run workflow
const handleRun = useCallback(async () => {
if (!currentFlow) return;
try {
// Open monitor panel automatically
setMonitorPanelOpen(true);
const result = await executeFlow.mutateAsync(currentFlow.id);
startExecution(result.execId, currentFlow.id);
} catch (error) {
console.error('Failed to execute flow:', error);
toast.error('Execution Failed', 'Could not start flow execution');
}
}, [currentFlow, executeFlow, startExecution, setMonitorPanelOpen]);
// Handle monitor toggle
const handleToggleMonitor = useCallback(() => {
setMonitorPanelOpen(!isMonitorPanelOpen);
}, [isMonitorPanelOpen, setMonitorPanelOpen]);
return (
<div className={cn('flex items-center gap-3 p-3 bg-card border-b border-border', className)}>
{/* Flow Icon and Name */}
@@ -294,14 +327,28 @@ export function FlowToolbar({ className, onOpenTemplateLibrary }: FlowToolbarPro
<div className="w-px h-6 bg-border" />
{/* Run Group */}
<Button variant="outline" size="sm" disabled title="Coming soon">
<FlaskConical className="w-4 h-4 mr-1" />
Simulate
{/* Run & Monitor Group */}
<Button
variant={isMonitorPanelOpen ? 'secondary' : 'outline'}
size="sm"
onClick={handleToggleMonitor}
title="Toggle execution monitor"
>
<Activity className={cn('w-4 h-4 mr-1', (isExecuting || isPaused) && 'text-primary animate-pulse')} />
Monitor
</Button>
<Button variant="default" size="sm" disabled title="Coming soon">
<Play className="w-4 h-4 mr-1" />
<Button
variant="default"
size="sm"
onClick={handleRun}
disabled={!currentFlow || isExecuting || isPaused || executeFlow.isPending}
>
{executeFlow.isPending ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Play className="w-4 h-4 mr-1" />
)}
Run Workflow
</Button>
</div>

View File

@@ -1,15 +1,13 @@
// ========================================
// Node Library Component
// ========================================
// Displays quick templates organized by category (phase / tool / command)
// Extracted from NodePalette for use inside LeftSidebar
// Displays built-in and custom node templates
// Supports creating, saving, and deleting custom templates with color selection
import { DragEvent, useState } from 'react';
import {
MessageSquare, ChevronDown, ChevronRight, GripVertical,
Search, Code, Terminal, Plus,
FolderOpen, Database, ListTodo, Play, CheckCircle,
FolderSearch, GitMerge, ListChecks,
Terminal, Plus, Trash2, X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useFlowStore } from '@/stores';
@@ -19,62 +17,59 @@ import type { QuickTemplate } from '@/types/flow';
// ========== Icon Mapping ==========
const TEMPLATE_ICONS: Record<string, React.ElementType> = {
// Command templates
'slash-command-main': Terminal,
'slash-command-async': Terminal,
analysis: Search,
implementation: Code,
// Phase templates
'phase-session': FolderOpen,
'phase-context': Database,
'phase-plan': ListTodo,
'phase-execute': Play,
'phase-review': CheckCircle,
// Tool templates
'tool-context-gather': FolderSearch,
'tool-conflict-resolution': GitMerge,
'tool-task-generate': ListChecks,
};
// ========== Category Configuration ==========
// ========== Color Palette for custom templates ==========
const CATEGORY_CONFIG: Record<QuickTemplate['category'], { title: string; defaultExpanded: boolean }> = {
phase: { title: '\u9636\u6BB5\u8282\u70B9', defaultExpanded: true },
tool: { title: '\u5DE5\u5177\u8282\u70B9', defaultExpanded: true },
command: { title: '\u547D\u4EE4', defaultExpanded: false },
};
const CATEGORY_ORDER: QuickTemplate['category'][] = ['phase', 'tool', 'command'];
const COLOR_OPTIONS = [
{ value: 'bg-blue-500', label: 'Blue' },
{ value: 'bg-green-500', label: 'Green' },
{ value: 'bg-purple-500', label: 'Purple' },
{ value: 'bg-rose-500', label: 'Rose' },
{ value: 'bg-amber-500', label: 'Amber' },
{ value: 'bg-cyan-500', label: 'Cyan' },
{ value: 'bg-teal-500', label: 'Teal' },
{ value: 'bg-orange-500', label: 'Orange' },
{ value: 'bg-indigo-500', label: 'Indigo' },
{ value: 'bg-pink-500', label: 'Pink' },
];
// ========== Sub-Components ==========
/**
* Collapsible category section
* Collapsible category section with optional action button
*/
function TemplateCategory({
title,
children,
defaultExpanded = true,
action,
}: {
title: string;
children: React.ReactNode;
defaultExpanded?: boolean;
action?: React.ReactNode;
}) {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<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 hover:text-foreground transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
{title}
</button>
<div className="flex items-center gap-1 mb-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 flex-1 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider hover:text-foreground transition-colors"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
{title}
</button>
{action}
</div>
{isExpanded && <div className="space-y-2">{children}</div>}
</div>
@@ -86,8 +81,10 @@ function TemplateCategory({
*/
function QuickTemplateCard({
template,
onDelete,
}: {
template: QuickTemplate;
onDelete?: () => void;
}) {
const Icon = TEMPLATE_ICONS[template.id] || MessageSquare;
@@ -110,17 +107,26 @@ function QuickTemplateCard({
className={cn(
'group flex items-center gap-3 p-3 rounded-lg border bg-card cursor-grab transition-all',
'hover:shadow-md hover:scale-[1.02] active:cursor-grabbing active:scale-[0.98]',
`border-${template.color.replace('bg-', '')}`
)}
>
<div className={cn('p-2 rounded-md text-white', template.color)}>
<div className={cn('p-2 rounded-md text-white shrink-0', template.color)}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground">{template.label}</div>
<div className="text-xs text-muted-foreground truncate">{template.description}</div>
</div>
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
{onDelete ? (
<button
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity hover:text-destructive"
title="Delete template"
>
<Trash2 className="w-4 h-4" />
</button>
) : (
<GripVertical className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
)}
</div>
);
}
@@ -164,6 +170,108 @@ function BasicTemplateCard() {
);
}
/**
* Inline form for creating a new custom template
*/
function CreateTemplateForm({ onClose }: { onClose: () => void }) {
const [label, setLabel] = useState('');
const [description, setDescription] = useState('');
const [instruction, setInstruction] = useState('');
const [color, setColor] = useState('bg-blue-500');
const addCustomTemplate = useFlowStore((s) => s.addCustomTemplate);
const handleSubmit = () => {
if (!label.trim()) return;
const template: QuickTemplate = {
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
label: label.trim(),
description: description.trim() || label.trim(),
icon: 'MessageSquare',
color,
category: 'command',
data: {
label: label.trim(),
instruction: instruction.trim(),
contextRefs: [],
},
};
addCustomTemplate(template);
onClose();
};
return (
<div className="p-3 rounded-lg border border-primary/50 bg-muted/50 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-foreground">New Custom Node</span>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
placeholder="Node name"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="w-full text-sm px-2 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
autoFocus
/>
<input
type="text"
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full text-sm px-2 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
/>
<textarea
placeholder="Default instruction (optional)"
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
rows={2}
className="w-full text-sm px-2 py-1.5 rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary resize-none"
/>
{/* Color picker */}
<div>
<div className="text-xs text-muted-foreground mb-1.5">Color</div>
<div className="flex flex-wrap gap-1.5">
{COLOR_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setColor(opt.value)}
className={cn(
'w-6 h-6 rounded-full transition-all',
opt.value,
color === opt.value
? 'ring-2 ring-offset-2 ring-offset-background ring-primary scale-110'
: 'hover:scale-110',
)}
title={opt.label}
/>
))}
</div>
</div>
<button
onClick={handleSubmit}
disabled={!label.trim()}
className={cn(
'w-full text-sm font-medium py-1.5 rounded transition-colors',
label.trim()
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-muted text-muted-foreground cursor-not-allowed',
)}
>
Save
</button>
</div>
);
}
// ========== Main Component ==========
interface NodeLibraryProps {
@@ -171,36 +279,53 @@ interface NodeLibraryProps {
}
/**
* Node library panel displaying quick templates grouped by category.
* Renders a scrollable list of template cards organized into collapsible sections.
* Used inside LeftSidebar - does not manage its own header/footer/collapse state.
* Node library panel displaying built-in and custom node templates.
* Built-in: Slash Command, Slash Command (Async), Prompt Template
* Custom: User-created templates persisted to localStorage
*/
export function NodeLibrary({ className }: NodeLibraryProps) {
const [isCreating, setIsCreating] = useState(false);
const customTemplates = useFlowStore((s) => s.customTemplates);
const removeCustomTemplate = useFlowStore((s) => s.removeCustomTemplate);
return (
<div className={cn('flex-1 overflow-y-auto p-4 space-y-4', className)}>
{/* Basic / Empty Template */}
<TemplateCategory title="Basic" defaultExpanded={false}>
{/* Built-in templates */}
<TemplateCategory title="Built-in" defaultExpanded>
<BasicTemplateCard />
{QUICK_TEMPLATES.map((template) => (
<QuickTemplateCard key={template.id} template={template} />
))}
</TemplateCategory>
{/* Category groups in order: phase -> tool -> command */}
{CATEGORY_ORDER.map((category) => {
const config = CATEGORY_CONFIG[category];
const templates = QUICK_TEMPLATES.filter((t) => t.category === category);
if (templates.length === 0) return null;
return (
<TemplateCategory
key={category}
title={config.title}
defaultExpanded={config.defaultExpanded}
{/* Custom templates */}
<TemplateCategory
title={`Custom (${customTemplates.length})`}
defaultExpanded
action={
<button
onClick={() => setIsCreating(true)}
className="p-0.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="Create custom node"
>
{templates.map((template) => (
<QuickTemplateCard key={template.id} template={template} />
))}
</TemplateCategory>
);
})}
<Plus className="w-3.5 h-3.5" />
</button>
}
>
{isCreating && <CreateTemplateForm onClose={() => setIsCreating(false)} />}
{customTemplates.map((template) => (
<QuickTemplateCard
key={template.id}
template={template}
onDelete={() => removeCustomTemplate(template.id)}
/>
))}
{customTemplates.length === 0 && !isCreating && (
<div className="text-xs text-muted-foreground text-center py-3">
No custom nodes yet. Click + to create.
</div>
)}
</TemplateCategory>
</div>
);
}

View File

@@ -5,14 +5,17 @@
import { useEffect, useState, useCallback } from 'react';
import { useFlowStore } from '@/stores';
import { useExecutionStore } from '@/stores/executionStore';
import { FlowCanvas } from './FlowCanvas';
import { LeftSidebar } from './LeftSidebar';
import { PropertyPanel } from './PropertyPanel';
import { FlowToolbar } from './FlowToolbar';
import { TemplateLibrary } from './TemplateLibrary';
import { ExecutionMonitor } from './ExecutionMonitor';
export function OrchestratorPage() {
const fetchFlows = useFlowStore((state) => state.fetchFlows);
const isMonitorPanelOpen = useExecutionStore((state) => state.isMonitorPanelOpen);
const [isTemplateLibraryOpen, setIsTemplateLibraryOpen] = useState(false);
// Load flows on mount
@@ -26,7 +29,7 @@ export function OrchestratorPage() {
}, []);
return (
<div className="h-full flex flex-col -m-4 md:-m-6">
<div className="h-[calc(100%+2rem)] md:h-[calc(100%+3rem)] flex flex-col -m-4 md:-m-6">
{/* Toolbar */}
<FlowToolbar onOpenTemplateLibrary={handleOpenTemplateLibrary} />
@@ -40,8 +43,11 @@ export function OrchestratorPage() {
<FlowCanvas className="absolute inset-0" />
</div>
{/* Property Panel (Right) */}
<PropertyPanel />
{/* Property Panel (Right) - hidden when monitor is open */}
{!isMonitorPanelOpen && <PropertyPanel />}
{/* Execution Monitor Panel (Right) */}
<ExecutionMonitor />
</div>
{/* Template Library Dialog */}
@@ -52,5 +58,3 @@ export function OrchestratorPage() {
</div>
);
}
export default OrchestratorPage;

View File

@@ -5,7 +5,7 @@
import { useCallback, useMemo, useState, useEffect, useRef, KeyboardEvent } from 'react';
import { useIntl } from 'react-intl';
import { Settings, X, MessageSquare, Trash2, AlertCircle, CheckCircle2, Plus, Save, Copy, ChevronDown, ChevronRight } from 'lucide-react';
import { Settings, X, MessageSquare, Trash2, AlertCircle, CheckCircle2, Plus, Save, ChevronDown, ChevronRight, BookmarkPlus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
@@ -338,7 +338,7 @@ interface TagEditorProps {
/**
* Token types for the editor
*/
type TokenType = 'text' | 'variable';
type TokenType = 'text' | 'variable' | 'artifact';
interface Token {
type: TokenType;
@@ -347,21 +347,27 @@ interface Token {
}
/**
* Parse text into tokens (text segments and variables)
* Parse text into tokens (text segments, {{variables}}, and [[artifacts]])
*/
function tokenize(text: string): Token[] {
const tokens: Token[] = [];
const regex = /\{\{([^}]+)\}\}/g;
// Match both {{variable}} and [[artifact]] patterns
const regex = /\{\{([^}]+)\}\}|\[\[([^\]]+)\]\]/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
// Add text before variable
// Add text before token
if (match.index > lastIndex) {
tokens.push({ type: 'text', value: text.slice(lastIndex, match.index) });
}
// Add variable token
tokens.push({ type: 'variable', value: match[1].trim() });
if (match[1] !== undefined) {
// {{variable}} match
tokens.push({ type: 'variable', value: match[1].trim() });
} else if (match[2] !== undefined) {
// [[artifact]] match
tokens.push({ type: 'artifact', value: match[2].trim() });
}
lastIndex = match.index + match[0].length;
}
@@ -381,6 +387,14 @@ function extractVariables(text: string): string[] {
return [...new Set(matches.map(m => m.slice(2, -2).trim()))];
}
/**
* Extract unique artifact names from text
*/
function extractArtifacts(text: string): string[] {
const matches = text.match(/\[\[([^\]]+)\]\]/g) || [];
return [...new Set(matches.map(m => m.slice(2, -2).trim()))];
}
/**
* Tag-based instruction editor with inline variable tags
*/
@@ -388,11 +402,13 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
const editorRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
const [newVarName, setNewVarName] = useState('');
const [newArtifactName, setNewArtifactName] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [customTemplates, setCustomTemplates] = useState<TemplateItem[]>(() => loadCustomTemplates());
const tokens = useMemo(() => tokenize(value || ''), [value]);
const detectedVars = useMemo(() => extractVariables(value || ''), [value]);
const detectedArtifacts = useMemo(() => extractArtifacts(value || ''), [value]);
const hasContent = (value || '').length > 0;
// All templates (builtin + custom)
@@ -413,7 +429,7 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
}, [customTemplates]);
// Handle content changes from contenteditable
// Convert tag elements back to {{variable}} format for storage
// Convert tag elements back to {{variable}} / [[artifact]] format for storage
const handleInput = useCallback(() => {
if (editorRef.current) {
// Clone the content to avoid modifying the actual DOM
@@ -428,6 +444,15 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
}
});
// Convert artifact tags back to [[artifact]] format
const artTags = clone.querySelectorAll('[data-artifact]');
artTags.forEach((tag) => {
const artName = tag.getAttribute('data-artifact');
if (artName) {
tag.replaceWith(`[[${artName}]]`);
}
});
// Convert <br> to newlines
clone.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
@@ -453,6 +478,15 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
}
}, []);
// Insert artifact at cursor position
const insertArtifact = useCallback((artName: string) => {
if (editorRef.current) {
editorRef.current.focus();
const artText = `[[${artName}]]`;
document.execCommand('insertText', false, artText);
}
}, []);
// Insert text at cursor position (or append if no focus)
const insertText = useCallback((text: string) => {
if (editorRef.current) {
@@ -469,6 +503,14 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
}
}, [newVarName, insertVariable]);
// Add new artifact
const handleAddArtifact = useCallback(() => {
if (newArtifactName.trim()) {
insertArtifact(newArtifactName.trim());
setNewArtifactName('');
}
}, [newArtifactName, insertArtifact]);
// Handle key press in new variable input
const handleVarInputKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
@@ -477,20 +519,22 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
}
}, [handleAddVariable]);
// Render tokens as HTML - variables show as tags without {{}}
// Render tokens as HTML - variables show as green tags, artifacts as blue tags
const renderContent = useMemo(() => {
if (!hasContent) return '';
return tokens.map((token) => {
if (token.type === 'variable') {
const isValid = availableVariables.includes(token.value) || token.value.includes('.');
// Show only variable name in tag, no {{}}
return `<span class="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded text-xs font-semibold align-baseline cursor-default select-none ${
isValid
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300'
}" contenteditable="false" data-var="${token.value}">${token.value}</span>`;
}
if (token.type === 'artifact') {
return `<span class="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded text-xs font-semibold align-baseline cursor-default select-none bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300" contenteditable="false" data-artifact="${token.value}">\u2192 ${token.value}</span>`;
}
// Escape HTML in text and preserve whitespace
return token.value
.replace(/&/g, '&amp;')
@@ -535,7 +579,7 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
/>
</div>
{/* Variable toolbar */}
{/* Variable & Artifact toolbar */}
<div className="flex flex-wrap items-center gap-2 p-2 rounded-md bg-muted/30 border border-border">
{/* Add new variable input */}
<div className="flex items-center gap-1">
@@ -543,8 +587,8 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
value={newVarName}
onChange={(e) => setNewVarName(e.target.value)}
onKeyDown={handleVarInputKeyDown}
placeholder="变量名"
className="h-7 w-24 text-xs font-mono"
placeholder="{{变量}}"
className="h-7 w-20 text-xs font-mono"
/>
<Button
type="button"
@@ -560,9 +604,31 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
<div className="w-px h-5 bg-border" />
{/* Add new artifact input */}
<div className="flex items-center gap-1">
<Input
value={newArtifactName}
onChange={(e) => setNewArtifactName(e.target.value)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => { if (e.key === 'Enter') { e.preventDefault(); handleAddArtifact(); } }}
placeholder="[[产物]]"
className="h-7 w-20 text-xs font-mono"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAddArtifact}
disabled={!newArtifactName.trim()}
className="h-7 px-2"
>
<Plus className="w-3.5 h-3.5" />
</Button>
</div>
{/* Quick insert available variables */}
{availableVariables.length > 0 && (
<>
<div className="w-px h-5 bg-border" />
<span className="text-xs text-muted-foreground">:</span>
{availableVariables.slice(0, 5).map((varName) => (
<button
@@ -581,7 +647,7 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
{detectedVars.length > 0 && (
<>
<div className="w-px h-5 bg-border" />
<span className="text-xs text-muted-foreground">:</span>
<span className="text-xs text-muted-foreground">:</span>
{detectedVars.map((varName) => {
const isValid = availableVariables.includes(varName) || varName.includes('.');
return (
@@ -601,6 +667,22 @@ function TagEditor({ value, onChange, placeholder, availableVariables, minHeight
})}
</>
)}
{/* Detected artifacts summary */}
{detectedArtifacts.length > 0 && (
<>
<div className="w-px h-5 bg-border" />
<span className="text-xs text-muted-foreground">:</span>
{detectedArtifacts.map((artName) => (
<span
key={artName}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-mono bg-sky-100 text-sky-700 dark:bg-sky-900/50 dark:text-sky-300"
>
{'\u2192'} {artName}
</span>
))}
</>
)}
</div>
{/* Templates - categorized */}
@@ -906,56 +988,6 @@ function ArtifactsList({ artifacts, onChange }: { artifacts: string[]; onChange:
);
}
// ========== Script Preview ==========
function ScriptPreview({ data }: { data: PromptTemplateNodeData }) {
const script = useMemo(() => {
// Slash command mode
if (data.slashCommand) {
const args = data.slashArgs ? ` ${data.slashArgs}` : '';
return `/${data.slashCommand}${args}`;
}
// CLI tool mode
if (data.tool && (data.mode === 'analysis' || data.mode === 'write')) {
const parts = ['ccw cli'];
parts.push(`--tool ${data.tool}`);
parts.push(`--mode ${data.mode}`);
if (data.instruction) {
const snippet = data.instruction.slice(0, 80).replace(/\n/g, ' ');
parts.push(`-p "${snippet}..."`);
}
return parts.join(' \\\n ');
}
// Plain instruction
if (data.instruction) {
return `# ${data.instruction.slice(0, 100)}`;
}
return '# 未配置命令';
}, [data.slashCommand, data.slashArgs, data.tool, data.mode, data.instruction]);
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(script);
}, [script]);
return (
<div className="relative">
<pre className="p-3 rounded-md bg-muted/50 font-mono text-xs text-foreground/80 overflow-x-auto whitespace-pre-wrap border border-border">
{script}
</pre>
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
title="复制"
>
<Copy className="w-3 h-3" />
</button>
</div>
);
}
// ========== Unified PromptTemplate Property Editor ==========
interface PromptTemplatePropertiesProps {
@@ -1030,7 +1062,11 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
</label>
<TagEditor
value={data.instruction || ''}
onChange={(value) => onChange({ instruction: value })}
onChange={(value) => {
// Auto-extract [[artifact]] names and sync to artifacts field
const arts = extractArtifacts(value);
onChange({ instruction: value, artifacts: arts.length > 0 ? arts : undefined });
}}
placeholder={formatMessage({ id: 'orchestrator.propertyPanel.placeholders.instruction' })}
minHeight={120}
availableVariables={availableVariables}
@@ -1052,23 +1088,6 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
/>
</div>
{/* Phase */}
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
<select
value={data.phase || ''}
onChange={(e) => onChange({ phase: (e.target.value || undefined) as PromptTemplateNodeData['phase'] })}
className="w-full h-10 px-3 rounded-md border border-border bg-background text-foreground text-sm"
>
<option value=""></option>
<option value="session">Session</option>
<option value="context">Context</option>
<option value="plan">Plan</option>
<option value="execute">Execute</option>
<option value="review">Review</option>
</select>
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-foreground mb-1"></label>
@@ -1101,11 +1120,104 @@ function PromptTemplateProperties({ data, onChange }: PromptTemplatePropertiesPr
/>
</div>
</CollapsibleSection>
</div>
);
}
{/* Script Preview Section */}
<CollapsibleSection title="脚本预览" defaultExpanded={true}>
<ScriptPreview data={data} />
</CollapsibleSection>
// ========== Save As Template Button ==========
const SAVE_COLOR_OPTIONS = [
{ value: 'bg-blue-500', label: 'Blue' },
{ value: 'bg-green-500', label: 'Green' },
{ value: 'bg-purple-500', label: 'Purple' },
{ value: 'bg-rose-500', label: 'Rose' },
{ value: 'bg-amber-500', label: 'Amber' },
{ value: 'bg-cyan-500', label: 'Cyan' },
{ value: 'bg-teal-500', label: 'Teal' },
{ value: 'bg-orange-500', label: 'Orange' },
];
function SaveAsTemplateButton({ nodeId, nodeLabel }: { nodeId: string; nodeLabel: string }) {
const [isOpen, setIsOpen] = useState(false);
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
const [color, setColor] = useState('bg-blue-500');
const saveNodeAsTemplate = useFlowStore((s) => s.saveNodeAsTemplate);
const addCustomTemplate = useFlowStore((s) => s.addCustomTemplate);
const nodes = useFlowStore((s) => s.nodes);
const handleSave = () => {
const node = nodes.find((n) => n.id === nodeId);
if (!node || !name.trim()) return;
const { executionStatus, executionError, executionResult, ...templateData } = node.data;
addCustomTemplate({
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
label: name.trim(),
description: desc.trim() || name.trim(),
icon: 'MessageSquare',
color,
category: 'command',
data: { ...templateData, label: name.trim() },
});
setIsOpen(false);
setName('');
setDesc('');
setColor('bg-blue-500');
};
if (!isOpen) {
return (
<Button
variant="outline"
className="w-full"
onClick={() => { setName(nodeLabel); setIsOpen(true); }}
>
<BookmarkPlus className="w-4 h-4 mr-2" />
Save to Node Library
</Button>
);
}
return (
<div className="p-2 rounded-md border border-primary/50 bg-muted/50 space-y-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Template name"
className="h-8 text-sm"
autoFocus
/>
<Input
value={desc}
onChange={(e) => setDesc(e.target.value)}
placeholder="Description (optional)"
className="h-8 text-sm"
/>
<div className="flex flex-wrap gap-1">
{SAVE_COLOR_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setColor(opt.value)}
className={cn(
'w-5 h-5 rounded-full transition-all',
opt.value,
color === opt.value ? 'ring-2 ring-offset-1 ring-offset-background ring-primary' : '',
)}
title={opt.label}
/>
))}
</div>
<div className="flex gap-1">
<Button variant="outline" size="sm" className="flex-1" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button size="sm" className="flex-1" onClick={handleSave} disabled={!name.trim()}>
<Save className="w-3.5 h-3.5 mr-1" />
Save
</Button>
</div>
</div>
);
}
@@ -1218,8 +1330,9 @@ export function PropertyPanel({ className }: PropertyPanelProps) {
/>
</div>
{/* Delete Button */}
<div className="px-4 py-3 border-t border-border">
{/* Footer Actions */}
<div className="px-4 py-3 border-t border-border space-y-2">
<SaveAsTemplateButton nodeId={selectedNodeId!} nodeLabel={selectedNode.data.label} />
<Button variant="destructive" className="w-full" onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-2" />
{formatMessage({ id: 'orchestrator.propertyPanel.deleteNode' })}

View File

@@ -13,7 +13,6 @@ import {
SessionDetailPage,
HistoryPage,
OrchestratorPage,
CoordinatorPage,
LoopMonitorPage,
IssueHubPage,
IssueManagerPage,
@@ -87,10 +86,6 @@ const routes: RouteObject[] = [
path: 'orchestrator',
element: <OrchestratorPage />,
},
{
path: 'coordinator',
element: <CoordinatorPage />,
},
{
path: 'loops',
element: <LoopMonitorPage />,
@@ -205,7 +200,6 @@ export const ROUTES = {
PROJECT: '/project',
HISTORY: '/history',
ORCHESTRATOR: '/orchestrator',
COORDINATOR: '/coordinator',
LOOPS: '/loops',
CLI_VIEWER: '/cli-viewer',
ISSUES: '/issues',

View File

@@ -1,772 +0,0 @@
// ========================================
// Coordinator Store
// ========================================
// Zustand store for managing coordinator execution state and command chains
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
// ========== Types ==========
/**
* Execution status of a coordinator
*/
export type CoordinatorStatus = 'idle' | 'initializing' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
/**
* Node execution status within a command chain
*/
export type NodeExecutionStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
/**
* Log level for coordinator logs
*/
export type LogLevel = 'info' | 'warn' | 'error' | 'debug' | 'success';
/**
* Command node representing a step in the coordinator pipeline
*/
export interface CommandNode {
id: string;
name: string;
description?: string;
command: string;
status: NodeExecutionStatus;
startedAt?: string;
completedAt?: string;
result?: unknown;
error?: string;
output?: string;
parentId?: string; // For hierarchical structure
children?: CommandNode[];
}
/**
* Log entry for coordinator execution
*/
export interface CoordinatorLog {
id: string;
timestamp: string;
level: LogLevel;
message: string;
nodeId?: string;
source?: 'system' | 'node' | 'user';
}
/**
* Question to be answered during coordinator execution
*/
export interface CoordinatorQuestion {
id: string;
nodeId: string;
title: string;
description?: string;
type: 'text' | 'single' | 'multi' | 'yes_no';
options?: string[];
required: boolean;
answer?: string | string[];
}
/**
* Pipeline details fetched from backend
*/
export interface PipelineDetails {
id: string;
name: string;
description?: string;
nodes: CommandNode[];
totalSteps: number;
estimatedDuration?: number;
}
/**
* Coordinator state
*/
export interface CoordinatorState {
// Current execution
currentExecutionId: string | null;
status: CoordinatorStatus;
startedAt?: string;
completedAt?: string;
totalElapsedMs: number;
// Command chain
commandChain: CommandNode[];
currentNodeIndex: number;
currentNode: CommandNode | null;
// Pipeline details
pipelineDetails: PipelineDetails | null;
isPipelineLoaded: boolean;
// Logs
logs: CoordinatorLog[];
maxLogs: number;
// Interactive questions
activeQuestion: CoordinatorQuestion | null;
pendingQuestions: CoordinatorQuestion[];
// Execution metadata
metadata: Record<string, unknown>;
// Error tracking
lastError?: string;
errorDetails?: unknown;
// UI state
isLogPanelExpanded: boolean;
autoScrollLogs: boolean;
// Actions
startCoordinator: (executionId: string, taskDescription: string, parameters?: Record<string, unknown>) => Promise<void>;
pauseCoordinator: () => Promise<void>;
resumeCoordinator: () => Promise<void>;
cancelCoordinator: (reason?: string) => Promise<void>;
updateNodeStatus: (nodeId: string, status: NodeExecutionStatus, result?: unknown, error?: string) => void;
submitAnswer: (questionId: string, answer: string | string[]) => Promise<void>;
retryNode: (nodeId: string) => Promise<void>;
skipNode: (nodeId: string) => Promise<void>;
fetchPipelineDetails: (executionId: string) => Promise<void>;
syncStateFromServer: () => Promise<void>;
addLog: (message: string, level?: LogLevel, nodeId?: string, source?: 'system' | 'node' | 'user') => void;
clearLogs: () => void;
setActiveQuestion: (question: CoordinatorQuestion | null) => void;
markExecutionComplete: (success: boolean, finalResult?: unknown) => void;
setLogPanelExpanded: (expanded: boolean) => void;
setAutoScrollLogs: (autoScroll: boolean) => void;
reset: () => void;
}
// ========== Constants ==========
const MAX_LOGS = 1000;
const LOG_STORAGE_KEY = 'coordinator-storage';
const COORDINATOR_STORAGE_VERSION = 1;
// ========== Helper Functions ==========
/**
* Generate unique ID for logs and questions
*/
const generateId = (): string => {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
};
/**
* Find node by ID in command chain (handles hierarchical structure)
*/
const findNodeById = (nodes: CommandNode[], nodeId: string): CommandNode | null => {
for (const node of nodes) {
if (node.id === nodeId) {
return node;
}
if (node.children) {
const found = findNodeById(node.children, nodeId);
if (found) return found;
}
}
return null;
};
// ========== Initial State ==========
const initialState: CoordinatorState = {
currentExecutionId: null,
status: 'idle',
totalElapsedMs: 0,
commandChain: [],
currentNodeIndex: -1,
currentNode: null,
pipelineDetails: null,
isPipelineLoaded: false,
logs: [],
maxLogs: MAX_LOGS,
activeQuestion: null,
pendingQuestions: [],
metadata: {},
isLogPanelExpanded: true,
autoScrollLogs: true,
// Actions are added in the create callback
startCoordinator: async () => {},
pauseCoordinator: async () => {},
resumeCoordinator: async () => {},
cancelCoordinator: async () => {},
updateNodeStatus: () => {},
submitAnswer: async () => {},
retryNode: async () => {},
skipNode: async () => {},
fetchPipelineDetails: async () => {},
syncStateFromServer: async () => {},
addLog: () => {},
clearLogs: () => {},
setActiveQuestion: () => {},
markExecutionComplete: () => {},
setLogPanelExpanded: () => {},
setAutoScrollLogs: () => {},
reset: () => {},
};
// ========== Store ==========
/**
* Coordinator store for managing orchestrator execution state
*
* @remarks
* Uses Zustand with persist middleware to save execution metadata to localStorage.
* The store manages command chains, logs, interactive questions, and execution status.
*
* @example
* ```tsx
* const { startCoordinator, status, logs } = useCoordinatorStore();
* await startCoordinator('exec-123', 'Build and deploy application');
* ```
*/
export const useCoordinatorStore = create<CoordinatorState>()(
persist(
devtools(
(set, get) => ({
...initialState,
// ========== Coordinator Lifecycle Actions ==========
startCoordinator: async (
executionId: string,
taskDescription: string,
parameters?: Record<string, unknown>
) => {
set({
currentExecutionId: executionId,
status: 'initializing',
startedAt: new Date().toISOString(),
totalElapsedMs: 0,
lastError: undefined,
errorDetails: undefined,
metadata: parameters || {},
}, false, 'coordinator/startCoordinator');
get().addLog(`Starting coordinator execution: ${taskDescription}`, 'info', undefined, 'system');
try {
// Fetch pipeline details from backend
await get().fetchPipelineDetails(executionId);
const state = get();
set({
status: 'running',
currentNodeIndex: 0,
currentNode: state.commandChain.length > 0 ? state.commandChain[0] : null,
}, false, 'coordinator/startCoordinator-running');
get().addLog('Coordinator running', 'success', undefined, 'system');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
set({
status: 'failed',
lastError: errorMessage,
errorDetails: error,
}, false, 'coordinator/startCoordinator-error');
get().addLog(`Failed to start coordinator: ${errorMessage}`, 'error', undefined, 'system');
}
},
pauseCoordinator: async () => {
const state = get();
if (state.status !== 'running') {
get().addLog('Cannot pause - coordinator is not running', 'warn', undefined, 'system');
return;
}
set({ status: 'paused' }, false, 'coordinator/pauseCoordinator');
get().addLog('Coordinator paused', 'info', undefined, 'system');
},
resumeCoordinator: async () => {
const state = get();
if (state.status !== 'paused') {
get().addLog('Cannot resume - coordinator is not paused', 'warn', undefined, 'system');
return;
}
set({ status: 'running' }, false, 'coordinator/resumeCoordinator');
get().addLog('Coordinator resumed', 'info', undefined, 'system');
},
cancelCoordinator: async (reason?: string) => {
set({
status: 'cancelled',
completedAt: new Date().toISOString(),
}, false, 'coordinator/cancelCoordinator');
const message = reason ? `Coordinator cancelled: ${reason}` : 'Coordinator cancelled';
get().addLog(message, 'warn', undefined, 'system');
},
// ========== Node Status Management ==========
updateNodeStatus: (nodeId: string, status: NodeExecutionStatus, result?: unknown, error?: string) => {
const state = get();
const node = findNodeById(state.commandChain, nodeId);
if (!node) {
console.warn(`[CoordinatorStore] Node not found: ${nodeId}`);
return;
}
// Create a deep copy of the command chain with updated node
const updateNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
return nodes.map((n) => {
if (n.id === nodeId) {
const updated: CommandNode = { ...n, status };
if (status === 'running') {
updated.startedAt = new Date().toISOString();
} else if (status === 'completed') {
updated.completedAt = new Date().toISOString();
updated.result = result;
} else if (status === 'failed') {
updated.completedAt = new Date().toISOString();
updated.error = error;
} else if (status === 'skipped') {
updated.completedAt = new Date().toISOString();
}
return updated;
}
if (n.children && n.children.length > 0) {
return { ...n, children: updateNodeInTree(n.children) };
}
return n;
});
};
const updatedCommandChain = updateNodeInTree(state.commandChain);
set({ commandChain: updatedCommandChain }, false, 'coordinator/updateNodeStatus');
// Add logs after state update
if (status === 'running') {
get().addLog(`Node started: ${node.name}`, 'debug', nodeId, 'system');
} else if (status === 'completed') {
get().addLog(`Node completed: ${node.name}`, 'success', nodeId, 'system');
} else if (status === 'failed') {
get().addLog(`Node failed: ${node.name} - ${error || 'Unknown error'}`, 'error', nodeId, 'system');
} else if (status === 'skipped') {
get().addLog(`Node skipped: ${node.name}`, 'info', nodeId, 'system');
}
},
// ========== Interactive Question Handling ==========
submitAnswer: async (questionId: string, answer: string | string[]) => {
const state = get();
const question = state.activeQuestion || state.pendingQuestions.find((q) => q.id === questionId);
if (!question) {
get().addLog(`Question not found: ${questionId}`, 'warn', undefined, 'system');
return;
}
// Update question with answer
const updatedActiveQuestion =
state.activeQuestion && state.activeQuestion.id === questionId
? { ...state.activeQuestion, answer }
: state.activeQuestion;
const updatedPendingQuestions = state.pendingQuestions.map((q) =>
q.id === questionId ? { ...q, answer } : q
);
set(
{
activeQuestion: updatedActiveQuestion,
pendingQuestions: updatedPendingQuestions,
},
false,
'coordinator/submitAnswer'
);
get().addLog(
`Answer submitted for question: ${question.title}`,
'info',
question.nodeId,
'user'
);
// Clear active question
set({ activeQuestion: null }, false, 'coordinator/submitAnswer-clear');
},
// ========== Node Control Actions ==========
retryNode: async (nodeId: string) => {
const state = get();
const node = findNodeById(state.commandChain, nodeId);
if (!node) {
get().addLog(`Cannot retry - node not found: ${nodeId}`, 'warn', undefined, 'system');
return;
}
get().addLog(`Retrying node: ${node.name}`, 'info', nodeId, 'system');
// Recursively update node status to pending
const resetNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
return nodes.map((n) => {
if (n.id === nodeId) {
return { ...n, status: 'pending', result: undefined, error: undefined };
}
if (n.children && n.children.length > 0) {
return { ...n, children: resetNodeInTree(n.children) };
}
return n;
});
};
const updatedCommandChain = resetNodeInTree(state.commandChain);
set({ commandChain: updatedCommandChain }, false, 'coordinator/retryNode');
},
skipNode: async (nodeId: string) => {
const state = get();
const node = findNodeById(state.commandChain, nodeId);
if (!node) {
get().addLog(`Cannot skip - node not found: ${nodeId}`, 'warn', undefined, 'system');
return;
}
get().addLog(`Skipping node: ${node.name}`, 'info', nodeId, 'system');
// Recursively update node status to skipped
const skipNodeInTree = (nodes: CommandNode[]): CommandNode[] => {
return nodes.map((n) => {
if (n.id === nodeId) {
return { ...n, status: 'skipped', completedAt: new Date().toISOString() };
}
if (n.children && n.children.length > 0) {
return { ...n, children: skipNodeInTree(n.children) };
}
return n;
});
};
const updatedCommandChain = skipNodeInTree(state.commandChain);
set({ commandChain: updatedCommandChain }, false, 'coordinator/skipNode');
},
// ========== Pipeline Details ==========
fetchPipelineDetails: async (executionId: string) => {
try {
get().addLog('Fetching pipeline details', 'info', undefined, 'system');
// Import API function dynamically to avoid circular deps
const { fetchCoordinatorPipeline } = await import('../lib/api');
const response = await fetchCoordinatorPipeline(executionId);
if (!response.success || !response.data) {
throw new Error('Failed to fetch pipeline details');
}
const apiData = response.data;
// Transform API response to PipelineDetails
const pipelineDetails: PipelineDetails = {
id: apiData.id,
name: apiData.name,
description: apiData.description,
nodes: apiData.nodes,
totalSteps: apiData.totalSteps,
estimatedDuration: apiData.estimatedDuration,
};
set({
pipelineDetails,
isPipelineLoaded: true,
commandChain: apiData.nodes,
status: apiData.status || get().status,
}, false, 'coordinator/fetchPipelineDetails');
// Load logs if available
if (apiData.logs && apiData.logs.length > 0) {
set({ logs: apiData.logs }, false, 'coordinator/fetchPipelineDetails-logs');
}
get().addLog('Pipeline details loaded', 'success', undefined, 'system');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
set({
isPipelineLoaded: false,
lastError: errorMessage,
}, false, 'coordinator/fetchPipelineDetails-error');
get().addLog(`Failed to fetch pipeline details: ${errorMessage}`, 'error', undefined, 'system');
throw error;
}
},
// ========== State Synchronization (for WebSocket reconnection) ==========
syncStateFromServer: async () => {
const state = get();
// Only sync if we have an active execution
if (!state.currentExecutionId) {
get().addLog('No active execution to sync', 'debug', undefined, 'system');
return;
}
try {
get().addLog('Syncing state from server', 'info', undefined, 'system');
// Fetch current execution state from server
const { fetchExecutionState } = await import('../lib/api');
const response = await fetchExecutionState(state.currentExecutionId);
if (!response.success || !response.data) {
throw new Error('Failed to sync execution state');
}
const serverState = response.data;
// Update local state with server state
set({
status: serverState.status as CoordinatorStatus,
totalElapsedMs: serverState.elapsedMs,
}, false, 'coordinator/syncStateFromServer');
// Fetch full pipeline details if status indicates running/paused
if (serverState.status === 'running' || serverState.status === 'paused') {
await get().fetchPipelineDetails(state.currentExecutionId);
}
get().addLog('State synchronized with server', 'success', undefined, 'system');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[CoordinatorStore] Failed to sync state:', error);
get().addLog(`Failed to sync state from server: ${errorMessage}`, 'warn', undefined, 'system');
}
},
addLog: (
message: string,
level: LogLevel = 'info',
nodeId?: string,
source: 'system' | 'node' | 'user' = 'system'
) => {
const state = get();
const log: CoordinatorLog = {
id: generateId(),
timestamp: new Date().toISOString(),
level,
message,
nodeId,
source,
};
let updatedLogs = [...state.logs, log];
// Keep only the last maxLogs entries
if (updatedLogs.length > state.maxLogs) {
updatedLogs = updatedLogs.slice(-state.maxLogs);
}
set({ logs: updatedLogs }, false, 'coordinator/addLog');
},
clearLogs: () => {
set({ logs: [] }, false, 'coordinator/clearLogs');
},
// ========== Question Management ==========
setActiveQuestion: (question: CoordinatorQuestion | null) => {
const state = get();
const updatedPendingQuestions =
question && !state.pendingQuestions.find((q) => q.id === question.id)
? [...state.pendingQuestions, question]
: state.pendingQuestions;
set({
activeQuestion: question,
pendingQuestions: updatedPendingQuestions,
}, false, 'coordinator/setActiveQuestion');
},
// ========== Execution Completion ==========
markExecutionComplete: (success: boolean, finalResult?: unknown) => {
const state = get();
set({
status: success ? 'completed' : 'failed',
completedAt: new Date().toISOString(),
metadata: { ...state.metadata, finalResult },
}, false, 'coordinator/markExecutionComplete');
const message = success
? 'Coordinator execution completed successfully'
: 'Coordinator execution failed';
get().addLog(message, success ? 'success' : 'error', undefined, 'system');
},
// ========== UI State ==========
setLogPanelExpanded: (expanded: boolean) => {
set({ isLogPanelExpanded: expanded }, false, 'coordinator/setLogPanelExpanded');
},
setAutoScrollLogs: (autoScroll: boolean) => {
set({ autoScrollLogs: autoScroll }, false, 'coordinator/setAutoScrollLogs');
},
// ========== Reset ==========
reset: () => {
set({
currentExecutionId: null,
status: 'idle',
startedAt: undefined,
completedAt: undefined,
totalElapsedMs: 0,
commandChain: [],
currentNodeIndex: -1,
currentNode: null,
pipelineDetails: null,
isPipelineLoaded: false,
logs: [],
activeQuestion: null,
pendingQuestions: [],
metadata: {},
lastError: undefined,
errorDetails: undefined,
}, false, 'coordinator/reset');
get().addLog('Coordinator state reset', 'info', undefined, 'system');
},
}),
{ name: 'CoordinatorStore' }
),
{
name: LOG_STORAGE_KEY,
version: COORDINATOR_STORAGE_VERSION,
// Only persist basic pipeline info (not full nodes/logs or metadata which may contain sensitive data)
partialize: (state) => ({
currentExecutionId: state.currentExecutionId,
status: state.status,
startedAt: state.startedAt,
completedAt: state.completedAt,
totalElapsedMs: state.totalElapsedMs,
// Exclude metadata from persistence - it may contain sensitive data (Record<string, unknown>)
isLogPanelExpanded: state.isLogPanelExpanded,
autoScrollLogs: state.autoScrollLogs,
// Only persist basic pipeline info, not full nodes
pipelineDetails: state.pipelineDetails ? {
id: state.pipelineDetails.id,
name: state.pipelineDetails.name,
description: state.pipelineDetails.description,
nodes: [], // Don't persist nodes - will be fetched from API
totalSteps: state.pipelineDetails.totalSteps,
estimatedDuration: state.pipelineDetails.estimatedDuration,
} : null,
}),
// Rehydration callback to restore state on page load
onRehydrateStorage: () => (state) => {
if (!state) return;
// Check if we have an active execution that needs hydration
const needsHydration =
state.currentExecutionId &&
(state.status === 'running' || state.status === 'paused' || state.status === 'initializing') &&
(!state.pipelineDetails || state.pipelineDetails.nodes.length === 0);
if (needsHydration && state.currentExecutionId) {
// Log restoration
state.addLog('Restoring coordinator state from localStorage', 'info', undefined, 'system');
// Fetch full pipeline details from API
state.fetchPipelineDetails(state.currentExecutionId).catch((error) => {
console.error('[CoordinatorStore] Failed to hydrate pipeline details:', error);
state.addLog('Failed to restore pipeline data - session may be incomplete', 'warn', undefined, 'system');
});
} else if (state.currentExecutionId) {
// Just log that we restored the session
state.addLog('Session state restored', 'info', undefined, 'system');
}
},
}
)
);
// ========== Helper Hooks ==========
/**
* Hook to get coordinator actions
* Useful for components that only need actions, not the full state
*/
export const useCoordinatorActions = () => {
return useCoordinatorStore((state) => ({
startCoordinator: state.startCoordinator,
pauseCoordinator: state.pauseCoordinator,
resumeCoordinator: state.resumeCoordinator,
cancelCoordinator: state.cancelCoordinator,
updateNodeStatus: state.updateNodeStatus,
submitAnswer: state.submitAnswer,
retryNode: state.retryNode,
skipNode: state.skipNode,
fetchPipelineDetails: state.fetchPipelineDetails,
syncStateFromServer: state.syncStateFromServer,
addLog: state.addLog,
clearLogs: state.clearLogs,
setActiveQuestion: state.setActiveQuestion,
markExecutionComplete: state.markExecutionComplete,
setLogPanelExpanded: state.setLogPanelExpanded,
setAutoScrollLogs: state.setAutoScrollLogs,
reset: state.reset,
}));
};
// ========== Selectors ==========
/**
* Select current execution status
*/
export const selectCoordinatorStatus = (state: CoordinatorState) => state.status;
/**
* Select current execution ID
*/
export const selectCurrentExecutionId = (state: CoordinatorState) => state.currentExecutionId;
/**
* Select all logs
*/
export const selectCoordinatorLogs = (state: CoordinatorState) => state.logs;
/**
* Select active question
*/
export const selectActiveQuestion = (state: CoordinatorState) => state.activeQuestion;
/**
* Select command chain
*/
export const selectCommandChain = (state: CoordinatorState) => state.commandChain;
/**
* Select current node
*/
export const selectCurrentNode = (state: CoordinatorState) => state.currentNode;
/**
* Select pipeline details
*/
export const selectPipelineDetails = (state: CoordinatorState) => state.pipelineDetails;
/**
* Select is pipeline loaded
*/
export const selectIsPipelineLoaded = (state: CoordinatorState) => state.isPipelineLoaded;

View File

@@ -29,7 +29,7 @@ const initialState = {
maxLogs: MAX_LOGS,
// UI state
isMonitorExpanded: true,
isMonitorPanelOpen: false,
autoScrollLogs: true,
};
@@ -197,8 +197,8 @@ export const useExecutionStore = create<ExecutionStore>()(
// ========== UI State ==========
setMonitorExpanded: (expanded: boolean) => {
set({ isMonitorExpanded: expanded }, false, 'setMonitorExpanded');
setMonitorPanelOpen: (open: boolean) => {
set({ isMonitorPanelOpen: open }, false, 'setMonitorPanelOpen');
},
setAutoScrollLogs: (autoScroll: boolean) => {
@@ -213,7 +213,7 @@ export const useExecutionStore = create<ExecutionStore>()(
export const selectCurrentExecution = (state: ExecutionStore) => state.currentExecution;
export const selectNodeStates = (state: ExecutionStore) => state.nodeStates;
export const selectLogs = (state: ExecutionStore) => state.logs;
export const selectIsMonitorExpanded = (state: ExecutionStore) => state.isMonitorExpanded;
export const selectIsMonitorPanelOpen = (state: ExecutionStore) => state.isMonitorPanelOpen;
export const selectAutoScrollLogs = (state: ExecutionStore) => state.autoScrollLogs;
// Helper to check if execution is active

View File

@@ -12,9 +12,32 @@ import type {
FlowEdge,
NodeData,
FlowEdgeData,
QuickTemplate,
} from '../types/flow';
import { NODE_TYPE_CONFIGS as nodeConfigs, QUICK_TEMPLATES } from '../types/flow';
// localStorage key for custom templates
const CUSTOM_TEMPLATES_KEY = 'ccw-orchestrator-custom-templates';
// Load custom templates from localStorage
function loadCustomTemplatesFromStorage(): QuickTemplate[] {
try {
const raw = localStorage.getItem(CUSTOM_TEMPLATES_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
// Save custom templates to localStorage
function saveCustomTemplatesToStorage(templates: QuickTemplate[]): void {
try {
localStorage.setItem(CUSTOM_TEMPLATES_KEY, JSON.stringify(templates));
} catch (e) {
console.error('Failed to save custom templates:', e);
}
}
// Helper to generate unique IDs
const generateId = (prefix: string): string => {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
@@ -45,6 +68,9 @@ const initialState = {
isPaletteOpen: true,
isPropertyPanelOpen: true,
leftPanelTab: 'nodes' as const,
// Custom templates (loaded from localStorage)
customTemplates: loadCustomTemplatesFromStorage(),
};
export const useFlowStore = create<FlowStore>()(
@@ -259,7 +285,9 @@ export const useFlowStore = create<FlowStore>()(
},
addNodeFromTemplate: (templateId: string, position: { x: number; y: number }): string => {
const template = QUICK_TEMPLATES.find((t) => t.id === templateId);
// Look up in built-in templates first, then custom templates
const template = QUICK_TEMPLATES.find((t) => t.id === templateId)
|| get().customTemplates.find((t) => t.id === templateId);
if (!template) {
console.error(`Template not found: ${templateId}`);
return get().addNode(position);
@@ -434,6 +462,55 @@ export const useFlowStore = create<FlowStore>()(
set({ leftPanelTab: tab }, false, 'setLeftPanelTab');
},
// ========== Custom Templates ==========
addCustomTemplate: (template: QuickTemplate) => {
set(
(state) => {
const updated = [...state.customTemplates, template];
saveCustomTemplatesToStorage(updated);
return { customTemplates: updated };
},
false,
'addCustomTemplate'
);
},
removeCustomTemplate: (id: string) => {
set(
(state) => {
const updated = state.customTemplates.filter((t) => t.id !== id);
saveCustomTemplatesToStorage(updated);
return { customTemplates: updated };
},
false,
'removeCustomTemplate'
);
},
saveNodeAsTemplate: (nodeId: string, label: string, description: string): QuickTemplate | null => {
const node = get().nodes.find((n) => n.id === nodeId);
if (!node) return null;
const { executionStatus, executionError, executionResult, ...templateData } = node.data;
const template: QuickTemplate = {
id: `custom-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
label,
description,
icon: 'MessageSquare',
color: 'bg-blue-500',
category: 'command',
data: { ...templateData, label },
};
get().addCustomTemplate(template);
return template;
},
loadCustomTemplates: () => {
set({ customTemplates: loadCustomTemplatesFromStorage() }, false, 'loadCustomTemplates');
},
// ========== Utility ==========
resetFlow: () => {

View File

@@ -71,26 +71,12 @@ export {
selectCurrentExecution,
selectNodeStates,
selectLogs,
selectIsMonitorExpanded,
selectIsMonitorPanelOpen,
selectAutoScrollLogs,
selectIsExecuting,
selectNodeStatus,
} from './executionStore';
// Coordinator Store
export {
useCoordinatorStore,
useCoordinatorActions,
selectCoordinatorStatus,
selectCurrentExecutionId,
selectCoordinatorLogs,
selectActiveQuestion,
selectCommandChain,
selectCurrentNode,
selectPipelineDetails,
selectIsPipelineLoaded,
} from './coordinatorStore';
// Viewer Store
export {
useViewerStore,
@@ -148,16 +134,6 @@ export type {
AskQuestionPayload,
} from '../types/store';
// Coordinator Store Types
export type {
CoordinatorState,
CoordinatorStatus,
CommandNode,
CoordinatorLog,
CoordinatorQuestion,
PipelineDetails,
} from './coordinatorStore';
// Viewer Store Types
export type {
PaneId,

View File

@@ -155,7 +155,7 @@ export interface ExecutionStoreState {
maxLogs: number;
// UI state
isMonitorExpanded: boolean;
isMonitorPanelOpen: boolean;
autoScrollLogs: boolean;
}
@@ -177,7 +177,7 @@ export interface ExecutionStoreActions {
clearLogs: () => void;
// UI state
setMonitorExpanded: (expanded: boolean) => void;
setMonitorPanelOpen: (open: boolean) => void;
setAutoScrollLogs: (autoScroll: boolean) => void;
}

View File

@@ -212,6 +212,9 @@ export interface FlowState {
flows: Flow[];
isLoadingFlows: boolean;
// Custom node templates (user-defined, persisted to localStorage)
customTemplates: QuickTemplate[];
// UI state
isPaletteOpen: boolean;
isPropertyPanelOpen: boolean;
@@ -252,6 +255,12 @@ export interface FlowActions {
setIsPropertyPanelOpen: (open: boolean) => void;
setLeftPanelTab: (tab: 'templates' | 'nodes') => void;
// Custom templates
addCustomTemplate: (template: QuickTemplate) => void;
removeCustomTemplate: (id: string) => void;
saveNodeAsTemplate: (nodeId: string, label: string, description: string) => QuickTemplate | null;
loadCustomTemplates: () => void;
// Utility
resetFlow: () => void;
getSelectedNode: () => FlowNode | undefined;
@@ -354,170 +363,4 @@ export const QUICK_TEMPLATES: QuickTemplate[] = [
nodeCategory: 'command',
},
},
{
id: 'analysis',
label: 'Analysis',
description: 'Code review, architecture analysis',
icon: 'Search',
color: 'bg-emerald-500',
category: 'command',
data: {
label: 'Analyze',
instruction: 'Analyze the code for:\n1. Architecture patterns\n2. Code quality\n3. Potential issues',
tool: 'gemini',
mode: 'analysis',
nodeCategory: 'command',
},
},
{
id: 'implementation',
label: 'Implementation',
description: 'Write code, create files',
icon: 'Code',
color: 'bg-violet-500',
category: 'command',
data: {
label: 'Implement',
instruction: 'Implement the following:\n\n[Describe what to implement]',
tool: 'codex',
mode: 'write',
nodeCategory: 'command',
},
},
// ========== Phase Templates ==========
{
id: 'phase-session',
label: 'Session',
description: 'Initialize workflow session and environment',
icon: 'FolderOpen',
color: 'bg-sky-500',
category: 'phase',
data: {
label: 'Session Setup',
instruction: 'Initialize workflow session:\n- Set project context\n- Load configuration\n- Validate environment',
phase: 'session',
nodeCategory: 'phase',
mode: 'mainprocess',
},
},
{
id: 'phase-context',
label: 'Context',
description: 'Collect and prepare context information',
icon: 'Database',
color: 'bg-cyan-500',
category: 'phase',
data: {
label: 'Context Gathering',
instruction: 'Gather context:\n- Analyze codebase structure\n- Identify relevant files\n- Build context package',
phase: 'context',
nodeCategory: 'phase',
mode: 'analysis',
tool: 'gemini',
artifacts: ['context-package.json'],
},
},
{
id: 'phase-plan',
label: 'Plan',
description: 'Generate execution plan and task breakdown',
icon: 'ListTodo',
color: 'bg-amber-500',
category: 'phase',
data: {
label: 'Planning',
instruction: 'Create execution plan:\n- Break requirements into tasks\n- Identify dependencies\n- Evaluate complexity',
phase: 'plan',
nodeCategory: 'phase',
mode: 'analysis',
tool: 'gemini',
artifacts: ['execution-plan.md'],
},
},
{
id: 'phase-execute',
label: 'Execute',
description: 'Execute tasks according to plan',
icon: 'Play',
color: 'bg-green-500',
category: 'phase',
data: {
label: 'Execution',
instruction: 'Execute planned tasks:\n- Follow dependency order\n- Apply code changes\n- Run validation',
phase: 'execute',
nodeCategory: 'phase',
mode: 'write',
tool: 'codex',
},
},
{
id: 'phase-review',
label: 'Review',
description: 'Review results and validate output',
icon: 'CheckCircle',
color: 'bg-purple-500',
category: 'phase',
data: {
label: 'Review',
instruction: 'Review execution results:\n- Validate code changes\n- Run tests\n- Check regressions',
phase: 'review',
nodeCategory: 'phase',
mode: 'analysis',
tool: 'gemini',
},
},
// ========== Tool Templates ==========
{
id: 'tool-context-gather',
label: 'Context Gather',
description: 'Automated context collection tool',
icon: 'FolderSearch',
color: 'bg-teal-500',
category: 'tool',
data: {
label: 'Context Gather',
instruction: 'Collect project context:\n- Scan file structure\n- Identify key modules\n- Extract type definitions\n- Map dependencies',
tool: 'gemini',
mode: 'analysis',
nodeCategory: 'tool',
phase: 'context',
outputName: 'context',
artifacts: ['context-package.json'],
},
},
{
id: 'tool-conflict-resolution',
label: 'Conflict Resolution',
description: 'Resolve code conflicts and inconsistencies',
icon: 'GitMerge',
color: 'bg-orange-500',
category: 'tool',
data: {
label: 'Conflict Resolution',
instruction: 'Resolve conflicts:\n- Identify conflicting changes\n- Analyze intent of each side\n- Generate merge solution\n- Verify consistency',
tool: 'gemini',
mode: 'analysis',
nodeCategory: 'tool',
phase: 'execute',
outputName: 'resolution',
},
},
{
id: 'tool-task-generate',
label: 'Task Generate',
description: 'Generate task breakdown from requirements',
icon: 'ListChecks',
color: 'bg-indigo-500',
category: 'tool',
data: {
label: 'Task Generation',
instruction: 'Generate tasks:\n- Parse requirements\n- Break into atomic tasks\n- Set dependencies\n- Assign priorities',
tool: 'gemini',
mode: 'analysis',
nodeCategory: 'tool',
phase: 'plan',
outputName: 'tasks',
artifacts: ['task-list.json'],
},
},
];