mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
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:
@@ -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;
|
||||
@@ -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' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user