mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-03 19:15:37 +08:00
refactor(task-master): migrate tasks to a typed feature module
- introduce a new feature-oriented TaskMaster domain under src/components/task-master - add typed TaskMaster context/provider with explicit project, task, MCP, and loading state handling - split task UI into focused components (panel, board, toolbar, content, card, detail modal, setup/help modals, banner) - move task board filtering/sorting/kanban derivation into dedicated hooks and utilities - relocate CreateTaskModal into the feature module and keep task views modular/readable - remove legacy monolithic TaskList/TaskDetail/TaskCard files and route main task panel to the new feature panel - replace contexts/TaskMasterContext.jsx with a typed contexts/TaskMasterContext.ts re-export to the feature context - update MainContent project sync logic to compare by project name to avoid state churn - validation: npm run typecheck, npm run build
This commit is contained in:
@@ -1,695 +1,3 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause, ChevronDown, ChevronUp, Plus, FileText, Settings, X, Terminal, Eye, Play, Zap, Target } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { api } from '../utils/api';
|
||||
import Shell from './shell/view/Shell';
|
||||
import TaskDetail from './TaskDetail';
|
||||
import NextTaskBanner from './task-master/view/NextTaskBanner';
|
||||
|
||||
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => {
|
||||
const { nextTask, tasks, currentProject, isLoadingTasks, projectTaskMaster, refreshTasks, refreshProjects } = useTaskMaster();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showTaskOptions, setShowTaskOptions] = useState(false);
|
||||
const [showCreateTaskModal, setShowCreateTaskModal] = useState(false);
|
||||
const [showTemplateSelector, setShowTemplateSelector] = useState(false);
|
||||
const [showCLI, setShowCLI] = useState(false);
|
||||
const [showTaskDetail, setShowTaskDetail] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Handler functions
|
||||
const handleInitializeTaskMaster = async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.taskmaster.init(currentProject.name);
|
||||
if (response.ok) {
|
||||
await refreshProjects();
|
||||
setShowTaskOptions(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to initialize TaskMaster:', error);
|
||||
alert(`Failed to initialize TaskMaster: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing TaskMaster:', error);
|
||||
alert('Error initializing TaskMaster. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateManualTask = () => {
|
||||
setShowCreateTaskModal(true);
|
||||
setShowTaskOptions(false);
|
||||
};
|
||||
|
||||
const handleParsePRD = () => {
|
||||
setShowTemplateSelector(true);
|
||||
setShowTaskOptions(false);
|
||||
};
|
||||
|
||||
// Don't show if no project or still loading
|
||||
if (!currentProject || isLoadingTasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bannerContent;
|
||||
|
||||
// Show setup message only if no tasks exist AND TaskMaster is not configured
|
||||
if ((!tasks || tasks.length === 0) && !projectTaskMaster?.hasTaskmaster) {
|
||||
bannerContent = (
|
||||
<div className={cn(
|
||||
'bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<List className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
TaskMaster AI is not configured
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowTaskOptions(!showTaskOptions)}
|
||||
className="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
Initialize TaskMaster AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTaskOptions && (
|
||||
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
|
||||
{!projectTaskMaster?.hasTaskmaster && (
|
||||
<div className="mb-3 p-3 bg-blue-50 dark:bg-blue-900/50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
🎯 What is TaskMaster?
|
||||
</h4>
|
||||
<div className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<p>• <strong>AI-Powered Task Management:</strong> Break complex projects into manageable subtasks</p>
|
||||
<p>• <strong>PRD Templates:</strong> Generate tasks from Product Requirements Documents</p>
|
||||
<p>• <strong>Dependency Tracking:</strong> Understand task relationships and execution order</p>
|
||||
<p>• <strong>Progress Visualization:</strong> Kanban boards and detailed task analytics</p>
|
||||
<p>• <strong>CLI Integration:</strong> Use taskmaster commands for advanced workflows</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{!projectTaskMaster?.hasTaskmaster ? (
|
||||
<button
|
||||
className="text-xs px-3 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-200 rounded transition-colors text-left flex items-center gap-2"
|
||||
onClick={() => setShowCLI(true)}
|
||||
>
|
||||
<Terminal className="w-3 h-3" />
|
||||
Initialize TaskMaster
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2 p-2 bg-green-50 dark:bg-green-900/30 rounded text-xs text-green-800 dark:text-green-200">
|
||||
<strong>Add more tasks:</strong> Create additional tasks manually or generate them from a PRD template
|
||||
</div>
|
||||
<button
|
||||
className="text-xs px-3 py-2 bg-green-100 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-800 text-green-800 dark:text-green-200 rounded transition-colors text-left flex items-center gap-2 disabled:opacity-50"
|
||||
onClick={handleCreateManualTask}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Create a new task manually
|
||||
</button>
|
||||
<button
|
||||
className="text-xs px-3 py-2 bg-purple-100 dark:bg-purple-900 hover:bg-purple-200 dark:hover:bg-purple-800 text-purple-800 dark:text-purple-200 rounded transition-colors text-left flex items-center gap-2 disabled:opacity-50"
|
||||
onClick={handleParsePRD}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{isLoading ? 'Parsing...' : 'Generate tasks from PRD template'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (nextTask) {
|
||||
// Show next task if available
|
||||
bannerContent = (
|
||||
<div className={cn(
|
||||
'bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-3 mb-4',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 bg-blue-100 dark:bg-blue-900/50 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Target className="w-3 h-3 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400 font-medium">Task {nextTask.id}</span>
|
||||
{nextTask.priority === 'high' && (
|
||||
<div className="w-4 h-4 rounded bg-red-100 dark:bg-red-900/50 flex items-center justify-center" title="High Priority">
|
||||
<Zap className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
{nextTask.priority === 'medium' && (
|
||||
<div className="w-4 h-4 rounded bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center" title="Medium Priority">
|
||||
<Flag className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
)}
|
||||
{nextTask.priority === 'low' && (
|
||||
<div className="w-4 h-4 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center" title="Low Priority">
|
||||
<Circle className="w-2.5 h-2.5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 line-clamp-1">
|
||||
{nextTask.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onStartTask?.()}
|
||||
className="text-xs px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium transition-colors shadow-sm flex items-center gap-1"
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
Start Task
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTaskDetail(true)}
|
||||
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md transition-colors flex items-center gap-1"
|
||||
title="View task details"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</button>
|
||||
{onShowAllTasks && (
|
||||
<button
|
||||
onClick={onShowAllTasks}
|
||||
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md transition-colors flex items-center gap-1"
|
||||
title="View all tasks"
|
||||
>
|
||||
<List className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
} else if (tasks && tasks.length > 0) {
|
||||
// Show completion message only if there are tasks and all are done
|
||||
const completedTasks = tasks.filter(task => task.status === 'done').length;
|
||||
const totalTasks = tasks.length;
|
||||
|
||||
bannerContent = (
|
||||
<div className={cn(
|
||||
'bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg p-3 mb-4',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{completedTasks === totalTasks ? "All done! 🎉" : "No pending tasks"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{completedTasks}/{totalTasks}
|
||||
</span>
|
||||
<button
|
||||
onClick={onShowAllTasks}
|
||||
className="text-xs px-2 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// TaskMaster is configured but no tasks exist - don't show anything in chat
|
||||
bannerContent = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{bannerContent}
|
||||
|
||||
{/* Create Task Modal */}
|
||||
{showCreateTaskModal && (
|
||||
<CreateTaskModal
|
||||
currentProject={currentProject}
|
||||
onClose={() => setShowCreateTaskModal(false)}
|
||||
onTaskCreated={() => {
|
||||
refreshTasks();
|
||||
setShowCreateTaskModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Template Selector Modal */}
|
||||
{showTemplateSelector && (
|
||||
<TemplateSelector
|
||||
currentProject={currentProject}
|
||||
onClose={() => setShowTemplateSelector(false)}
|
||||
onTemplateApplied={() => {
|
||||
refreshTasks();
|
||||
setShowTemplateSelector(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TaskMaster CLI Setup Modal */}
|
||||
{showCLI && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-4xl h-[600px] flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||
<Terminal className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">TaskMaster Setup</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Interactive CLI for {currentProject?.displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCLI(false)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Terminal Container */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full bg-black rounded-lg overflow-hidden">
|
||||
<Shell
|
||||
selectedProject={currentProject}
|
||||
selectedSession={null}
|
||||
isActive={true}
|
||||
initialCommand="npx task-master init"
|
||||
isPlainShell={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
TaskMaster initialization will start automatically
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCLI(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
{showTaskDetail && nextTask && (
|
||||
<TaskDetail
|
||||
task={nextTask}
|
||||
isOpen={showTaskDetail}
|
||||
onClose={() => setShowTaskDetail(false)}
|
||||
onStatusChange={() => refreshTasks?.()}
|
||||
onTaskClick={null} // Disable dependency navigation in NextTaskBanner for now
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Simple Create Task Modal Component
|
||||
const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
useAI: false,
|
||||
prompt: ''
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const taskData = formData.useAI
|
||||
? { prompt: formData.prompt, priority: formData.priority }
|
||||
: { title: formData.title, description: formData.description, priority: formData.priority };
|
||||
|
||||
const response = await api.taskmaster.addTask(currentProject.name, taskData);
|
||||
|
||||
if (response.ok) {
|
||||
onTaskCreated();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to create task:', error);
|
||||
alert(`Failed to create task: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
alert('Error creating task. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create New Task</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.useAI}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, useAI: e.target.checked }))}
|
||||
/>
|
||||
Use AI to generate task details
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.useAI ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Task Description (AI will generate details)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.prompt}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, prompt: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
rows="3"
|
||||
placeholder="Describe what you want to accomplish..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Task Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Enter task title..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
rows="3"
|
||||
placeholder="Describe the task..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded disabled:opacity-50"
|
||||
disabled={isSubmitting || (formData.useAI && !formData.prompt.trim()) || (!formData.useAI && (!formData.title.trim() || !formData.description.trim()))}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Task'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Template Selector Modal Component
|
||||
const TemplateSelector = ({ currentProject, onClose, onTemplateApplied }) => {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||
const [customizations, setCustomizations] = useState({});
|
||||
const [fileName, setFileName] = useState('prd.txt');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [step, setStep] = useState('select'); // 'select', 'customize', 'generate'
|
||||
|
||||
useEffect(() => {
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
const response = await api.taskmaster.getTemplates();
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTemplates(data.templates);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading templates:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const handleSelectTemplate = (template) => {
|
||||
setSelectedTemplate(template);
|
||||
// Find placeholders in template content
|
||||
const placeholders = template.content.match(/\[([^\]]+)\]/g) || [];
|
||||
const uniquePlaceholders = [...new Set(placeholders.map(p => p.slice(1, -1)))];
|
||||
|
||||
const initialCustomizations = {};
|
||||
uniquePlaceholders.forEach(placeholder => {
|
||||
initialCustomizations[placeholder] = '';
|
||||
});
|
||||
|
||||
setCustomizations(initialCustomizations);
|
||||
setStep('customize');
|
||||
};
|
||||
|
||||
const handleApplyTemplate = async () => {
|
||||
if (!selectedTemplate || !currentProject) return;
|
||||
|
||||
setIsApplying(true);
|
||||
try {
|
||||
// Apply template
|
||||
const applyResponse = await api.taskmaster.applyTemplate(currentProject.name, {
|
||||
templateId: selectedTemplate.id,
|
||||
fileName,
|
||||
customizations
|
||||
});
|
||||
|
||||
if (!applyResponse.ok) {
|
||||
const error = await applyResponse.json();
|
||||
throw new Error(error.message || 'Failed to apply template');
|
||||
}
|
||||
|
||||
// Parse PRD to generate tasks
|
||||
const parseResponse = await api.taskmaster.parsePRD(currentProject.name, {
|
||||
fileName,
|
||||
numTasks: 10
|
||||
});
|
||||
|
||||
if (!parseResponse.ok) {
|
||||
const error = await parseResponse.json();
|
||||
throw new Error(error.message || 'Failed to generate tasks');
|
||||
}
|
||||
|
||||
setStep('generate');
|
||||
setTimeout(() => {
|
||||
onTemplateApplied();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error applying template:', error);
|
||||
alert(`Error: ${error.message}`);
|
||||
setIsApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-900 dark:text-white">Loading templates...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{step === 'select' ? 'Select PRD Template' :
|
||||
step === 'customize' ? 'Customize Template' :
|
||||
'Generating Tasks'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step === 'select' && (
|
||||
<div className="space-y-3">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="p-4 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{template.name}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{template.description}</p>
|
||||
<span className="inline-block text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded mt-2">
|
||||
{template.category}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-gray-400 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'customize' && selectedTemplate && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
File Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="prd.txt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Object.keys(customizations).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Customize Template
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(customizations).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setCustomizations(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder={`Enter ${key.toLowerCase()}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => setStep('select')}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApplyTemplate}
|
||||
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded disabled:opacity-50"
|
||||
disabled={isApplying}
|
||||
>
|
||||
{isApplying ? 'Applying...' : 'Apply & Generate Tasks'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'generate' && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Template Applied Successfully!
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Your PRD has been created and tasks are being generated...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextTaskBanner;
|
||||
export default NextTaskBanner;
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Clock, CheckCircle, Circle, AlertCircle, Pause, X, ArrowRight, ChevronUp, Minus, Flag } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Tooltip } from '../shared/view/ui';
|
||||
|
||||
const TaskCard = ({
|
||||
task,
|
||||
onClick,
|
||||
showParent = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const getStatusConfig = (status) => {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
bgColor: 'bg-green-50 dark:bg-green-950',
|
||||
borderColor: 'border-green-200 dark:border-green-800',
|
||||
iconColor: 'text-green-600 dark:text-green-400',
|
||||
textColor: 'text-green-900 dark:text-green-100',
|
||||
statusText: 'Done'
|
||||
};
|
||||
|
||||
case 'in-progress':
|
||||
return {
|
||||
icon: Clock,
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-950',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
textColor: 'text-blue-900 dark:text-blue-100',
|
||||
statusText: 'In Progress'
|
||||
};
|
||||
|
||||
case 'review':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-950',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
textColor: 'text-amber-900 dark:text-amber-100',
|
||||
statusText: 'Review'
|
||||
};
|
||||
|
||||
case 'deferred':
|
||||
return {
|
||||
icon: Pause,
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
||||
borderColor: 'border-gray-200 dark:border-gray-700',
|
||||
iconColor: 'text-gray-500 dark:text-gray-400',
|
||||
textColor: 'text-gray-700 dark:text-gray-300',
|
||||
statusText: 'Deferred'
|
||||
};
|
||||
|
||||
case 'cancelled':
|
||||
return {
|
||||
icon: X,
|
||||
bgColor: 'bg-red-50 dark:bg-red-950',
|
||||
borderColor: 'border-red-200 dark:border-red-800',
|
||||
iconColor: 'text-red-600 dark:text-red-400',
|
||||
textColor: 'text-red-900 dark:text-red-100',
|
||||
statusText: 'Cancelled'
|
||||
};
|
||||
|
||||
case 'pending':
|
||||
default:
|
||||
return {
|
||||
icon: Circle,
|
||||
bgColor: 'bg-slate-50 dark:bg-slate-800',
|
||||
borderColor: 'border-slate-200 dark:border-slate-700',
|
||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||
textColor: 'text-slate-900 dark:text-slate-100',
|
||||
statusText: 'Pending'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(task.status);
|
||||
const Icon = config.icon;
|
||||
|
||||
const getPriorityIcon = (priority) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return (
|
||||
<Tooltip content="High Priority">
|
||||
<div className="w-4 h-4 bg-red-100 dark:bg-red-900/30 rounded flex items-center justify-center">
|
||||
<ChevronUp className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'medium':
|
||||
return (
|
||||
<Tooltip content="Medium Priority">
|
||||
<div className="w-4 h-4 bg-amber-100 dark:bg-amber-900/30 rounded flex items-center justify-center">
|
||||
<Minus className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'low':
|
||||
return (
|
||||
<Tooltip content="Low Priority">
|
||||
<div className="w-4 h-4 bg-blue-100 dark:bg-blue-900/30 rounded flex items-center justify-center">
|
||||
<Circle className="w-1.5 h-1.5 text-blue-600 dark:text-blue-400 fill-current" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tooltip content="No Priority Set">
|
||||
<div className="w-4 h-4 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
|
||||
<Circle className="w-1.5 h-1.5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700',
|
||||
'hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-200 cursor-pointer',
|
||||
'p-3 space-y-3',
|
||||
onClick && 'hover:-translate-y-0.5',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Header with Task ID, Title, and Priority */}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
{/* Task ID and Title */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip content={`Task ID: ${task.id}`}>
|
||||
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{task.id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<h3 className="font-medium text-sm text-gray-900 dark:text-white line-clamp-2 leading-tight">
|
||||
{task.title}
|
||||
</h3>
|
||||
{showParent && task.parentId && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
Task {task.parentId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priority Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{getPriorityIcon(task.priority)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with Dependencies and Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Dependencies */}
|
||||
<div className="flex items-center">
|
||||
{task.dependencies && Array.isArray(task.dependencies) && task.dependencies.length > 0 && (
|
||||
<Tooltip content={`Depends on: ${task.dependencies.map(dep => `Task ${dep}`).join(', ')}`}>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
<span>Depends on: {task.dependencies.join(', ')}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Tooltip content={`Status: ${config.statusText}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-full', config.iconColor.replace('text-', 'bg-'))} />
|
||||
<span className={cn('text-xs font-medium', config.textColor)}>
|
||||
{config.statusText}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Subtask Progress (if applicable) */}
|
||||
{task.subtasks && task.subtasks.length > 0 && (
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Progress:</span>
|
||||
<Tooltip content={`${task.subtasks.filter(st => st.status === 'done').length} of ${task.subtasks.length} subtasks completed`}>
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
task.status === 'done' ? 'bg-green-500' : 'bg-blue-500'
|
||||
)}
|
||||
style={{
|
||||
width: `${Math.round((task.subtasks.filter(st => st.status === 'done').length / task.subtasks.length) * 100)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip content={`${task.subtasks.filter(st => st.status === 'done').length} completed, ${task.subtasks.filter(st => st.status === 'pending').length} pending, ${task.subtasks.filter(st => st.status === 'in-progress').length} in progress`}>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{task.subtasks.filter(st => st.status === 'done').length}/{task.subtasks.length}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCard;
|
||||
@@ -1,406 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Flag, User, ArrowRight, CheckCircle, Circle, AlertCircle, Pause, Edit, Save, Copy, ChevronDown, ChevronRight, Clock } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { api } from '../utils/api';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { copyTextToClipboard } from '../utils/clipboard';
|
||||
|
||||
const TaskDetail = ({
|
||||
task,
|
||||
onClose,
|
||||
onEdit,
|
||||
onStatusChange,
|
||||
onTaskClick,
|
||||
isOpen = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editedTask, setEditedTask] = useState(task || {});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showTestStrategy, setShowTestStrategy] = useState(false);
|
||||
const { currentProject, refreshTasks } = useTaskMaster();
|
||||
|
||||
if (!isOpen || !task) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Only include changed fields
|
||||
const updates = {};
|
||||
if (editedTask.title !== task.title) updates.title = editedTask.title;
|
||||
if (editedTask.description !== task.description) updates.description = editedTask.description;
|
||||
if (editedTask.details !== task.details) updates.details = editedTask.details;
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
const response = await api.taskmaster.updateTask(currentProject.name, task.id, updates);
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh tasks to get updated data
|
||||
refreshTasks?.();
|
||||
onEdit?.(editedTask);
|
||||
setEditMode(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to update task:', error);
|
||||
alert(`Failed to update task: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
setEditMode(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating task:', error);
|
||||
alert('Error updating task. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const response = await api.taskmaster.updateTask(currentProject.name, task.id, { status: newStatus });
|
||||
|
||||
if (response.ok) {
|
||||
refreshTasks?.();
|
||||
onStatusChange?.(task.id, newStatus);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to update task status:', error);
|
||||
alert(`Failed to update task status: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating task status:', error);
|
||||
alert('Error updating task status. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const copyTaskId = () => {
|
||||
copyTextToClipboard(task.id.toString());
|
||||
};
|
||||
|
||||
const getStatusConfig = (status) => {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return { icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-950' };
|
||||
case 'in-progress':
|
||||
return { icon: Clock, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-950' };
|
||||
case 'review':
|
||||
return { icon: AlertCircle, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-950' };
|
||||
case 'deferred':
|
||||
return { icon: Pause, color: 'text-gray-500 dark:text-gray-400', bg: 'bg-gray-50 dark:bg-gray-800' };
|
||||
case 'cancelled':
|
||||
return { icon: X, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-950' };
|
||||
default:
|
||||
return { icon: Circle, color: 'text-slate-500 dark:text-slate-400', bg: 'bg-slate-50 dark:bg-slate-800' };
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(task.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
|
||||
const getPriorityColor = (priority) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950';
|
||||
case 'medium': return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950';
|
||||
case 'low': return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950';
|
||||
default: return 'text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'in-progress', label: 'In Progress' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
{ value: 'deferred', label: 'Deferred' },
|
||||
{ value: 'cancelled', label: 'Cancelled' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-black/50">
|
||||
<div className={cn(
|
||||
'bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl',
|
||||
'w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col',
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<StatusIcon className={cn('w-6 h-6', statusConfig.color)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<button
|
||||
onClick={copyTaskId}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Click to copy task ID"
|
||||
>
|
||||
<span>Task {task.id}</span>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
{task.parentId && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Subtask of Task {task.parentId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{editMode ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editedTask.title || ''}
|
||||
onChange={(e) => setEditedTask({ ...editedTask, title: e.target.value })}
|
||||
className="w-full text-lg font-semibold bg-transparent border-b-2 border-blue-500 focus:outline-none text-gray-900 dark:text-white"
|
||||
placeholder="Task title"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="text-lg md:text-xl font-semibold text-gray-900 dark:text-white line-clamp-2">
|
||||
{task.title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="p-2 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={isSaving ? "Saving..." : "Save changes"}
|
||||
>
|
||||
<Save className={cn("w-5 h-5", isSaving && "animate-spin")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
setEditedTask(task);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Cancel editing"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
title="Edit task"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6 min-h-0">
|
||||
{/* Status and Metadata Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
||||
<div className={cn(
|
||||
'w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600',
|
||||
statusConfig.bg,
|
||||
statusConfig.color
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className="w-4 h-4" />
|
||||
<span className="font-medium capitalize">
|
||||
{statusOptions.find(option => option.value === task.status)?.label || task.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
|
||||
<div className={cn(
|
||||
'px-3 py-2 rounded-md text-sm font-medium capitalize',
|
||||
getPriorityColor(task.priority)
|
||||
)}>
|
||||
<Flag className="w-4 h-4 inline mr-2" />
|
||||
{task.priority || 'Not set'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Dependencies</label>
|
||||
{task.dependencies && task.dependencies.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{task.dependencies.map(depId => (
|
||||
<button
|
||||
key={depId}
|
||||
onClick={() => onTaskClick && onTaskClick({ id: depId })}
|
||||
className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-sm hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer disabled:cursor-default disabled:opacity-50"
|
||||
disabled={!onTaskClick}
|
||||
title={onTaskClick ? `Click to view Task ${depId}` : `Task ${depId}`}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3 inline mr-1" />
|
||||
{depId}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">No dependencies</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editedTask.description || ''}
|
||||
onChange={(e) => setEditedTask({ ...editedTask, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="Task description"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{task.description || 'No description provided'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Implementation Details */}
|
||||
{task.details && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Implementation Details
|
||||
</span>
|
||||
{showDetails ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
{showDetails && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editedTask.details || ''}
|
||||
onChange={(e) => setEditedTask({ ...editedTask, details: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="Implementation details"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-md p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{task.details}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Strategy */}
|
||||
{task.testStrategy && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowTestStrategy(!showTestStrategy)}
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Test Strategy
|
||||
</span>
|
||||
{showTestStrategy ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
{showTestStrategy && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-950 rounded-md p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{task.testStrategy}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtasks */}
|
||||
{task.subtasks && task.subtasks.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Subtasks ({task.subtasks.length})
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{task.subtasks.map(subtask => {
|
||||
const subtaskConfig = getStatusConfig(subtask.status);
|
||||
const SubtaskIcon = subtaskConfig.icon;
|
||||
return (
|
||||
<div key={subtask.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
|
||||
<SubtaskIcon className={cn('w-4 h-4', subtaskConfig.color)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{subtask.title}
|
||||
</h4>
|
||||
{subtask.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{subtask.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{subtask.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Task ID: {task.id}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDetail;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,10 +75,13 @@ function MainContent({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject && selectedProject !== currentProject) {
|
||||
const selectedProjectName = selectedProject?.name;
|
||||
const currentProjectName = currentProject?.name;
|
||||
|
||||
if (selectedProject && selectedProjectName !== currentProjectName) {
|
||||
setCurrentProject?.(selectedProject);
|
||||
}
|
||||
}, [selectedProject, currentProject, setCurrentProject]);
|
||||
}, [selectedProject, currentProject?.name, setCurrentProject]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldShowTasksTab && activeTab === 'tasks') {
|
||||
|
||||
@@ -1,206 +1 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import TaskList from '../../../TaskList';
|
||||
import TaskDetail from '../../../TaskDetail';
|
||||
import PRDEditor from '../../../prd-editor';
|
||||
import { useTaskMaster } from '../../../../contexts/TaskMasterContext';
|
||||
import { api } from '../../../../utils/api';
|
||||
import type { Project } from '../../../../types/app';
|
||||
import type { PrdFile, TaskMasterPanelProps, TaskMasterTask, TaskSelection } from '../../types/types';
|
||||
|
||||
const AnyTaskList = TaskList as any;
|
||||
const AnyTaskDetail = TaskDetail as any;
|
||||
|
||||
type TaskMasterContextValue = {
|
||||
tasks?: TaskMasterTask[];
|
||||
currentProject?: Project | null;
|
||||
refreshTasks?: (() => void) | null;
|
||||
};
|
||||
|
||||
type PrdListResponse = {
|
||||
prdFiles?: PrdFile[];
|
||||
prds?: PrdFile[];
|
||||
};
|
||||
|
||||
const PRD_SAVED_MESSAGE = 'PRD saved successfully!';
|
||||
|
||||
function getPrdFiles(data: PrdListResponse): PrdFile[] {
|
||||
return data.prdFiles || data.prds || [];
|
||||
}
|
||||
|
||||
export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
|
||||
const { tasks = [], currentProject, refreshTasks } = useTaskMaster() as TaskMasterContextValue;
|
||||
|
||||
const [selectedTask, setSelectedTask] = useState<TaskMasterTask | null>(null);
|
||||
const [showTaskDetail, setShowTaskDetail] = useState(false);
|
||||
|
||||
const [showPRDEditor, setShowPRDEditor] = useState(false);
|
||||
const [selectedPRD, setSelectedPRD] = useState<PrdFile | null>(null);
|
||||
const [existingPRDs, setExistingPRDs] = useState<PrdFile[]>([]);
|
||||
const [prdNotification, setPRDNotification] = useState<string | null>(null);
|
||||
|
||||
const prdNotificationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const showPrdNotification = useCallback((message: string) => {
|
||||
if (prdNotificationTimeoutRef.current) {
|
||||
clearTimeout(prdNotificationTimeoutRef.current);
|
||||
}
|
||||
|
||||
setPRDNotification(message);
|
||||
prdNotificationTimeoutRef.current = setTimeout(() => {
|
||||
setPRDNotification(null);
|
||||
prdNotificationTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
const loadExistingPrds = useCallback(async () => {
|
||||
if (!currentProject?.name) {
|
||||
setExistingPRDs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
|
||||
if (!response.ok) {
|
||||
setExistingPRDs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PrdListResponse;
|
||||
setExistingPRDs(getPrdFiles(data));
|
||||
} catch (error) {
|
||||
console.error('Failed to load existing PRDs:', error);
|
||||
setExistingPRDs([]);
|
||||
}
|
||||
}, [currentProject?.name]);
|
||||
|
||||
const refreshPrds = useCallback(
|
||||
async (showNotification = false) => {
|
||||
await loadExistingPrds();
|
||||
|
||||
if (showNotification) {
|
||||
showPrdNotification(PRD_SAVED_MESSAGE);
|
||||
}
|
||||
},
|
||||
[loadExistingPrds, showPrdNotification],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadExistingPrds();
|
||||
}, [loadExistingPrds]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (prdNotificationTimeoutRef.current) {
|
||||
clearTimeout(prdNotificationTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTaskClick = useCallback(
|
||||
(task: TaskSelection) => {
|
||||
if (!task || typeof task !== 'object' || !('id' in task)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('title' in task) || !task.title) {
|
||||
const fullTask = tasks.find((candidate) => String(candidate.id) === String(task.id));
|
||||
if (fullTask) {
|
||||
setSelectedTask(fullTask);
|
||||
setShowTaskDetail(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTask(task as TaskMasterTask);
|
||||
setShowTaskDetail(true);
|
||||
},
|
||||
[tasks],
|
||||
);
|
||||
|
||||
const handleTaskDetailClose = useCallback(() => {
|
||||
setShowTaskDetail(false);
|
||||
setSelectedTask(null);
|
||||
}, []);
|
||||
|
||||
const handleTaskStatusChange = useCallback(
|
||||
(taskId: string | number, newStatus: string) => {
|
||||
console.log('Update task status:', taskId, newStatus);
|
||||
refreshTasks?.();
|
||||
},
|
||||
[refreshTasks],
|
||||
);
|
||||
|
||||
const handleOpenPrdEditor = useCallback((prd: PrdFile | null = null) => {
|
||||
setSelectedPRD(prd);
|
||||
setShowPRDEditor(true);
|
||||
}, []);
|
||||
|
||||
const handleClosePrdEditor = useCallback(() => {
|
||||
setShowPRDEditor(false);
|
||||
setSelectedPRD(null);
|
||||
}, []);
|
||||
|
||||
const handlePrdSave = useCallback(async () => {
|
||||
handleClosePrdEditor();
|
||||
await refreshPrds(true);
|
||||
refreshTasks?.();
|
||||
}, [handleClosePrdEditor, refreshPrds, refreshTasks]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`h-full ${isVisible ? 'block' : 'hidden'}`}>
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<AnyTaskList
|
||||
tasks={tasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
showParentTasks
|
||||
className="flex-1 overflow-y-auto p-4"
|
||||
currentProject={currentProject}
|
||||
onTaskCreated={refreshTasks || undefined}
|
||||
onShowPRDEditor={handleOpenPrdEditor}
|
||||
existingPRDs={existingPRDs}
|
||||
onRefreshPRDs={(showNotification = false) => {
|
||||
void refreshPrds(showNotification);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTaskDetail && selectedTask && (
|
||||
<AnyTaskDetail
|
||||
task={selectedTask}
|
||||
isOpen={showTaskDetail}
|
||||
onClose={handleTaskDetailClose}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onTaskClick={handleTaskClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPRDEditor && (
|
||||
<PRDEditor
|
||||
project={currentProject}
|
||||
projectPath={currentProject?.fullPath || currentProject?.path}
|
||||
onClose={handleClosePrdEditor}
|
||||
isNewFile={!selectedPRD?.isExisting}
|
||||
file={{
|
||||
name: selectedPRD?.name || 'prd.txt',
|
||||
content: selectedPRD?.content || '',
|
||||
isExisting: selectedPRD?.isExisting,
|
||||
}}
|
||||
onSave={handlePrdSave}
|
||||
/>
|
||||
)}
|
||||
|
||||
{prdNotification && (
|
||||
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="bg-green-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="font-medium">{prdNotification}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
export { default } from '../../../task-master/view/TaskMasterPanel';
|
||||
|
||||
279
src/components/task-master/context/TaskMasterContext.tsx
Normal file
279
src/components/task-master/context/TaskMasterContext.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../../../utils/api';
|
||||
import { useAuth } from '../../../contexts/AuthContext';
|
||||
import { useWebSocket } from '../../../contexts/WebSocketContext';
|
||||
import type {
|
||||
TaskMasterContextError,
|
||||
TaskMasterContextValue,
|
||||
TaskMasterMcpStatus,
|
||||
TaskMasterProject,
|
||||
TaskMasterProjectInfo,
|
||||
TaskMasterProjectInput,
|
||||
TaskMasterTask,
|
||||
TaskMasterWebSocketMessage,
|
||||
} from '../types';
|
||||
|
||||
const TaskMasterContext = createContext<TaskMasterContextValue | null>(null);
|
||||
|
||||
type AuthContextValue = {
|
||||
user: unknown;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
function createTaskMasterError(context: string, error: unknown): TaskMasterContextError {
|
||||
const message = error instanceof Error ? error.message : `Failed to ${context}`;
|
||||
return {
|
||||
message,
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function enrichProject(project: TaskMasterProject): TaskMasterProject {
|
||||
return {
|
||||
...project,
|
||||
taskMasterConfigured: project.taskmaster?.hasTaskmaster ?? false,
|
||||
taskMasterStatus: project.taskmaster?.status ?? 'not-configured',
|
||||
taskCount: Number(project.taskmaster?.metadata?.taskCount ?? 0),
|
||||
completedCount: Number(project.taskmaster?.metadata?.completed ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function getNextTask(tasks: TaskMasterTask[]): TaskMasterTask | null {
|
||||
return tasks.find((task) => task.status === 'pending' || task.status === 'in-progress') ?? null;
|
||||
}
|
||||
|
||||
function isTaskMasterMessage(
|
||||
message: TaskMasterWebSocketMessage | null,
|
||||
): message is TaskMasterWebSocketMessage & { type: string } {
|
||||
if (!message?.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return message.type.startsWith('taskmaster-');
|
||||
}
|
||||
|
||||
export function useTaskMaster() {
|
||||
const context = useContext(TaskMasterContext);
|
||||
if (!context) {
|
||||
throw new Error('useTaskMaster must be used within a TaskMasterProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function TaskMasterProvider({ children }: { children: React.ReactNode }) {
|
||||
const { latestMessage } = useWebSocket();
|
||||
const { user, token, isLoading: isAuthLoading } = useAuth() as AuthContextValue;
|
||||
|
||||
const [projects, setProjects] = useState<TaskMasterProject[]>([]);
|
||||
const [currentProject, setCurrentProjectState] = useState<TaskMasterProject | null>(null);
|
||||
const [projectTaskMaster, setProjectTaskMaster] = useState<TaskMasterProjectInfo | null>(null);
|
||||
const [mcpServerStatus, setMcpServerStatus] = useState<TaskMasterMcpStatus>(null);
|
||||
|
||||
const [tasks, setTasks] = useState<TaskMasterTask[]>([]);
|
||||
const [nextTask, setNextTask] = useState<TaskMasterTask | null>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingTasks, setIsLoadingTasks] = useState(false);
|
||||
const [isLoadingMCP, setIsLoadingMCP] = useState(false);
|
||||
const [error, setError] = useState<TaskMasterContextError | null>(null);
|
||||
|
||||
const currentProjectNameRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
currentProjectNameRef.current = currentProject?.name ?? null;
|
||||
}, [currentProject?.name]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleError = useCallback((context: string, caughtError: unknown) => {
|
||||
console.error(`TaskMaster ${context} error:`, caughtError);
|
||||
setError(createTaskMasterError(context, caughtError));
|
||||
}, []);
|
||||
|
||||
const setCurrentProject = useCallback((project: TaskMasterProjectInput) => {
|
||||
const normalizedProject = project ? enrichProject(project as TaskMasterProject) : null;
|
||||
setCurrentProjectState(normalizedProject);
|
||||
setProjectTaskMaster(normalizedProject?.taskmaster ?? null);
|
||||
|
||||
// Project-scoped task data is reset immediately to avoid stale task rendering.
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
}, []);
|
||||
|
||||
const refreshProjects = useCallback(async () => {
|
||||
if (!user || !token) {
|
||||
setProjects([]);
|
||||
setCurrentProjectState(null);
|
||||
setProjectTaskMaster(null);
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
clearError();
|
||||
|
||||
const response = await api.get('/projects');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch projects: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as unknown;
|
||||
const loadedProjects = Array.isArray(data) ? (data as TaskMasterProject[]) : [];
|
||||
const enrichedProjects = loadedProjects.map((project) => enrichProject(project));
|
||||
|
||||
setProjects(enrichedProjects);
|
||||
|
||||
const currentProjectName = currentProjectNameRef.current;
|
||||
if (!currentProjectName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingProject = enrichedProjects.find((project) => project.name === currentProjectName) ?? null;
|
||||
setCurrentProjectState(matchingProject);
|
||||
setProjectTaskMaster(matchingProject?.taskmaster ?? null);
|
||||
} catch (caughtError) {
|
||||
handleError('load projects', caughtError);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [clearError, handleError, token, user]);
|
||||
|
||||
const refreshTasks = useCallback(async () => {
|
||||
const projectName = currentProject?.name;
|
||||
|
||||
if (!projectName || !user || !token) {
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingTasks(true);
|
||||
clearError();
|
||||
|
||||
const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(projectName)}`);
|
||||
if (!response.ok) {
|
||||
const errorPayload = (await response.json()) as { message?: string };
|
||||
throw new Error(errorPayload.message ?? 'Failed to load tasks');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { tasks?: TaskMasterTask[] };
|
||||
const loadedTasks = Array.isArray(data.tasks) ? data.tasks : [];
|
||||
|
||||
setTasks(loadedTasks);
|
||||
setNextTask(getNextTask(loadedTasks));
|
||||
} catch (caughtError) {
|
||||
handleError('load tasks', caughtError);
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
} finally {
|
||||
setIsLoadingTasks(false);
|
||||
}
|
||||
}, [clearError, currentProject?.name, handleError, token, user]);
|
||||
|
||||
const refreshMCPStatus = useCallback(async () => {
|
||||
if (!user || !token) {
|
||||
setMcpServerStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingMCP(true);
|
||||
clearError();
|
||||
|
||||
const response = await api.get('/mcp-utils/taskmaster-server');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load MCP status: ${response.status}`);
|
||||
}
|
||||
|
||||
const status = (await response.json()) as TaskMasterMcpStatus;
|
||||
setMcpServerStatus(status);
|
||||
} catch (caughtError) {
|
||||
handleError('check MCP server status', caughtError);
|
||||
setMcpServerStatus(null);
|
||||
} finally {
|
||||
setIsLoadingMCP(false);
|
||||
}
|
||||
}, [clearError, handleError, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthLoading && user && token) {
|
||||
void refreshProjects();
|
||||
void refreshMCPStatus();
|
||||
}
|
||||
}, [isAuthLoading, refreshMCPStatus, refreshProjects, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject?.name && user && token) {
|
||||
void refreshTasks();
|
||||
}
|
||||
}, [currentProject?.name, refreshTasks, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
const message = latestMessage as TaskMasterWebSocketMessage | null;
|
||||
if (!isTaskMasterMessage(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'taskmaster-project-updated' && message.projectName) {
|
||||
void refreshProjects();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'taskmaster-tasks-updated' && message.projectName === currentProject?.name) {
|
||||
void refreshTasks();
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'taskmaster-mcp-status-changed') {
|
||||
void refreshMCPStatus();
|
||||
}
|
||||
}, [currentProject?.name, latestMessage, refreshMCPStatus, refreshProjects, refreshTasks]);
|
||||
|
||||
const contextValue = useMemo<TaskMasterContextValue>(
|
||||
() => ({
|
||||
projects,
|
||||
currentProject,
|
||||
projectTaskMaster,
|
||||
mcpServerStatus,
|
||||
tasks,
|
||||
nextTask,
|
||||
isLoading,
|
||||
isLoadingTasks,
|
||||
isLoadingMCP,
|
||||
error,
|
||||
refreshProjects,
|
||||
setCurrentProject,
|
||||
refreshTasks,
|
||||
refreshMCPStatus,
|
||||
clearError,
|
||||
}),
|
||||
[
|
||||
clearError,
|
||||
currentProject,
|
||||
error,
|
||||
isLoading,
|
||||
isLoadingMCP,
|
||||
isLoadingTasks,
|
||||
mcpServerStatus,
|
||||
nextTask,
|
||||
projectTaskMaster,
|
||||
projects,
|
||||
refreshMCPStatus,
|
||||
refreshProjects,
|
||||
refreshTasks,
|
||||
setCurrentProject,
|
||||
tasks,
|
||||
],
|
||||
);
|
||||
|
||||
return <TaskMasterContext.Provider value={contextValue}>{children}</TaskMasterContext.Provider>;
|
||||
}
|
||||
|
||||
export default TaskMasterContext;
|
||||
64
src/components/task-master/hooks/useProjectPrdFiles.ts
Normal file
64
src/components/task-master/hooks/useProjectPrdFiles.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from '../../../utils/api';
|
||||
import type { PrdFile } from '../types';
|
||||
|
||||
type UseProjectPrdFilesOptions = {
|
||||
projectName?: string;
|
||||
};
|
||||
|
||||
type PrdResponse = {
|
||||
prdFiles?: PrdFile[];
|
||||
prds?: PrdFile[];
|
||||
};
|
||||
|
||||
function normalizePrdResponse(responseData: PrdResponse): PrdFile[] {
|
||||
if (Array.isArray(responseData.prdFiles)) {
|
||||
return responseData.prdFiles;
|
||||
}
|
||||
|
||||
if (Array.isArray(responseData.prds)) {
|
||||
return responseData.prds;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function useProjectPrdFiles({ projectName }: UseProjectPrdFilesOptions) {
|
||||
const [prdFiles, setPrdFiles] = useState<PrdFile[]>([]);
|
||||
const [isLoadingPrdFiles, setIsLoadingPrdFiles] = useState(false);
|
||||
|
||||
const refreshPrdFiles = useCallback(async () => {
|
||||
if (!projectName) {
|
||||
setPrdFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingPrdFiles(true);
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectName)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
setPrdFiles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as PrdResponse;
|
||||
setPrdFiles(normalizePrdResponse(data));
|
||||
} catch (error) {
|
||||
console.error('Failed to load PRD files:', error);
|
||||
setPrdFiles([]);
|
||||
} finally {
|
||||
setIsLoadingPrdFiles(false);
|
||||
}
|
||||
}, [projectName]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshPrdFiles();
|
||||
}, [refreshPrdFiles]);
|
||||
|
||||
return {
|
||||
prdFiles,
|
||||
isLoadingPrdFiles,
|
||||
refreshPrdFiles,
|
||||
};
|
||||
}
|
||||
97
src/components/task-master/hooks/useTaskBoardState.ts
Normal file
97
src/components/task-master/hooks/useTaskBoardState.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TaskBoardSortField, TaskBoardSortOrder, TaskBoardView, TaskKanbanColumn, TaskMasterTask } from '../types';
|
||||
import { buildKanbanColumns } from '../utils/taskKanban';
|
||||
import { sortTasks, toggleSortOrder } from '../utils/taskSorting';
|
||||
|
||||
type UseTaskBoardStateOptions = {
|
||||
tasks: TaskMasterTask[];
|
||||
defaultView?: TaskBoardView;
|
||||
};
|
||||
|
||||
function matchesSearch(task: TaskMasterTask, searchTerm: string): boolean {
|
||||
if (!searchTerm) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedSearch = searchTerm.toLowerCase();
|
||||
const description = typeof task.description === 'string' ? task.description : '';
|
||||
|
||||
return (
|
||||
task.title.toLowerCase().includes(normalizedSearch)
|
||||
|| description.toLowerCase().includes(normalizedSearch)
|
||||
|| String(task.id).toLowerCase().includes(normalizedSearch)
|
||||
);
|
||||
}
|
||||
|
||||
export function useTaskBoardState({ tasks, defaultView = 'kanban' }: UseTaskBoardStateOptions) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
const [sortField, setSortField] = useState<TaskBoardSortField>('id');
|
||||
const [sortOrder, setSortOrder] = useState<TaskBoardSortOrder>('asc');
|
||||
const [viewMode, setViewMode] = useState<TaskBoardView>(defaultView);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return [...new Set(tasks.map((task) => task.status).filter(Boolean))] as string[];
|
||||
}, [tasks]);
|
||||
|
||||
const priorities = useMemo(() => {
|
||||
return [...new Set(tasks.map((task) => task.priority).filter(Boolean))] as string[];
|
||||
}, [tasks]);
|
||||
|
||||
const filteredTasks = useMemo(() => {
|
||||
const filtered = tasks.filter((task) => {
|
||||
const status = task.status ?? 'pending';
|
||||
const priority = task.priority ?? 'medium';
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
||||
const matchesPriority = priorityFilter === 'all' || priority === priorityFilter;
|
||||
|
||||
return matchesSearch(task, searchTerm) && matchesStatus && matchesPriority;
|
||||
});
|
||||
|
||||
return sortTasks(filtered, sortField, sortOrder);
|
||||
}, [tasks, searchTerm, statusFilter, priorityFilter, sortField, sortOrder]);
|
||||
|
||||
const kanbanColumns = useMemo<TaskKanbanColumn[]>(() => {
|
||||
return buildKanbanColumns(filteredTasks, t);
|
||||
}, [filteredTasks, t]);
|
||||
|
||||
const handleSortChange = (nextSortField: TaskBoardSortField) => {
|
||||
setSortOrder((currentOrder) => toggleSortOrder(sortField, currentOrder, nextSortField));
|
||||
setSortField(nextSortField);
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
setStatusFilter('all');
|
||||
setPriorityFilter('all');
|
||||
};
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
priorityFilter,
|
||||
setPriorityFilter,
|
||||
sortField,
|
||||
setSortField,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
statuses,
|
||||
priorities,
|
||||
filteredTasks,
|
||||
kanbanColumns,
|
||||
handleSortChange,
|
||||
clearFilters,
|
||||
};
|
||||
}
|
||||
4
src/components/task-master/index.ts
Normal file
4
src/components/task-master/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as TaskMasterPanel } from './view/TaskMasterPanel';
|
||||
export { default as NextTaskBanner } from './view/NextTaskBanner';
|
||||
|
||||
export { TaskMasterProvider, useTaskMaster } from './context/TaskMasterContext';
|
||||
128
src/components/task-master/types.ts
Normal file
128
src/components/task-master/types.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { Project } from '../../types/app';
|
||||
|
||||
export type TaskId = string | number;
|
||||
|
||||
export type TaskStatus =
|
||||
| 'pending'
|
||||
| 'in-progress'
|
||||
| 'done'
|
||||
| 'review'
|
||||
| 'blocked'
|
||||
| 'deferred'
|
||||
| 'cancelled'
|
||||
| string;
|
||||
|
||||
export type TaskPriority = 'high' | 'medium' | 'low' | string;
|
||||
|
||||
export type TaskMasterTask = {
|
||||
id: TaskId;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: TaskStatus;
|
||||
priority?: TaskPriority;
|
||||
details?: string;
|
||||
testStrategy?: string;
|
||||
parentId?: TaskId;
|
||||
dependencies?: TaskId[];
|
||||
subtasks?: TaskMasterTask[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TaskReference = {
|
||||
id: TaskId;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TaskSelection = TaskMasterTask | TaskReference;
|
||||
|
||||
export type PrdFile = {
|
||||
name: string;
|
||||
content?: string;
|
||||
isExisting?: boolean;
|
||||
modified?: string;
|
||||
created?: string;
|
||||
path?: string;
|
||||
size?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TaskMasterProjectInfo = {
|
||||
hasTaskmaster?: boolean;
|
||||
status?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TaskMasterProject = Project & {
|
||||
taskMasterConfigured?: boolean;
|
||||
taskMasterStatus?: string;
|
||||
taskCount?: number;
|
||||
completedCount?: number;
|
||||
taskmaster?: TaskMasterProjectInfo;
|
||||
};
|
||||
|
||||
export type TaskMasterProjectInput = TaskMasterProject | Project | null;
|
||||
|
||||
export type TaskMasterContextError = {
|
||||
message: string;
|
||||
context: string;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
export type TaskMasterMcpStatus = {
|
||||
hasMCPServer?: boolean;
|
||||
isConfigured?: boolean;
|
||||
hasApiKeys?: boolean;
|
||||
scope?: string;
|
||||
config?: {
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
envVars?: string[];
|
||||
type?: string;
|
||||
};
|
||||
reason?: string;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
|
||||
export type TaskMasterWebSocketMessage = {
|
||||
type?: string;
|
||||
projectName?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type TaskMasterContextValue = {
|
||||
projects: TaskMasterProject[];
|
||||
currentProject: TaskMasterProject | null;
|
||||
projectTaskMaster: TaskMasterProjectInfo | null;
|
||||
mcpServerStatus: TaskMasterMcpStatus;
|
||||
tasks: TaskMasterTask[];
|
||||
nextTask: TaskMasterTask | null;
|
||||
isLoading: boolean;
|
||||
isLoadingTasks: boolean;
|
||||
isLoadingMCP: boolean;
|
||||
error: TaskMasterContextError | null;
|
||||
refreshProjects: () => Promise<void>;
|
||||
setCurrentProject: (project: TaskMasterProjectInput) => void;
|
||||
refreshTasks: () => Promise<void>;
|
||||
refreshMCPStatus: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
};
|
||||
|
||||
export type TaskBoardView = 'kanban' | 'list' | 'grid';
|
||||
|
||||
export type TaskBoardSortField = 'id' | 'title' | 'status' | 'priority' | 'updated';
|
||||
|
||||
export type TaskBoardSortOrder = 'asc' | 'desc';
|
||||
|
||||
export type TaskKanbanColumn = {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
color: string;
|
||||
headerColor: string;
|
||||
tasks: TaskMasterTask[];
|
||||
};
|
||||
72
src/components/task-master/utils/taskKanban.ts
Normal file
72
src/components/task-master/utils/taskKanban.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { TaskKanbanColumn, TaskMasterTask } from '../types';
|
||||
|
||||
const KANBAN_COLUMN_CONFIG = [
|
||||
{
|
||||
id: 'pending',
|
||||
titleKey: 'kanban.pending',
|
||||
status: 'pending',
|
||||
color: 'bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700',
|
||||
headerColor: 'bg-slate-100 dark:bg-slate-800 text-slate-800 dark:text-slate-200',
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
titleKey: 'kanban.inProgress',
|
||||
status: 'in-progress',
|
||||
color: 'bg-blue-50 dark:bg-blue-900/50 border-blue-200 dark:border-blue-700',
|
||||
headerColor: 'bg-blue-100 dark:bg-blue-800 text-blue-800 dark:text-blue-200',
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
titleKey: 'kanban.done',
|
||||
status: 'done',
|
||||
color: 'bg-emerald-50 dark:bg-emerald-900/50 border-emerald-200 dark:border-emerald-700',
|
||||
headerColor: 'bg-emerald-100 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200',
|
||||
},
|
||||
{
|
||||
id: 'blocked',
|
||||
titleKey: 'kanban.blocked',
|
||||
status: 'blocked',
|
||||
color: 'bg-red-50 dark:bg-red-900/50 border-red-200 dark:border-red-700',
|
||||
headerColor: 'bg-red-100 dark:bg-red-800 text-red-800 dark:text-red-200',
|
||||
},
|
||||
{
|
||||
id: 'deferred',
|
||||
titleKey: 'kanban.deferred',
|
||||
status: 'deferred',
|
||||
color: 'bg-amber-50 dark:bg-amber-900/50 border-amber-200 dark:border-amber-700',
|
||||
headerColor: 'bg-amber-100 dark:bg-amber-800 text-amber-800 dark:text-amber-200',
|
||||
},
|
||||
{
|
||||
id: 'cancelled',
|
||||
titleKey: 'kanban.cancelled',
|
||||
status: 'cancelled',
|
||||
color: 'bg-gray-50 dark:bg-gray-900/50 border-gray-200 dark:border-gray-700',
|
||||
headerColor: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const CORE_WORKFLOW_STATUSES = new Set(['pending', 'in-progress', 'done']);
|
||||
|
||||
export function buildKanbanColumns(tasks: TaskMasterTask[], t: TFunction<'tasks'>): TaskKanbanColumn[] {
|
||||
const tasksByStatus = tasks.reduce<Record<string, TaskMasterTask[]>>((accumulator, task) => {
|
||||
const status = task.status ?? 'pending';
|
||||
if (!accumulator[status]) {
|
||||
accumulator[status] = [];
|
||||
}
|
||||
accumulator[status].push(task);
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
return KANBAN_COLUMN_CONFIG.filter((column) => {
|
||||
const hasTasks = (tasksByStatus[column.status] ?? []).length > 0;
|
||||
return hasTasks || CORE_WORKFLOW_STATUSES.has(column.status);
|
||||
}).map((column) => ({
|
||||
id: column.id,
|
||||
title: t(column.titleKey),
|
||||
status: column.status,
|
||||
color: column.color,
|
||||
headerColor: column.headerColor,
|
||||
tasks: tasksByStatus[column.status] ?? [],
|
||||
}));
|
||||
}
|
||||
100
src/components/task-master/utils/taskSorting.ts
Normal file
100
src/components/task-master/utils/taskSorting.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { TaskBoardSortField, TaskBoardSortOrder, TaskMasterTask } from '../types';
|
||||
|
||||
const STATUS_ORDER: Record<string, number> = {
|
||||
pending: 1,
|
||||
'in-progress': 2,
|
||||
review: 3,
|
||||
done: 4,
|
||||
blocked: 5,
|
||||
deferred: 6,
|
||||
cancelled: 7,
|
||||
};
|
||||
|
||||
const PRIORITY_ORDER: Record<string, number> = {
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3,
|
||||
};
|
||||
|
||||
function toComparableIdParts(taskId: string | number): number[] {
|
||||
return String(taskId)
|
||||
.split('.')
|
||||
.map((part) => Number.parseInt(part, 10))
|
||||
.map((part) => (Number.isNaN(part) ? 0 : part));
|
||||
}
|
||||
|
||||
function compareTaskIds(leftId: string | number, rightId: string | number): number {
|
||||
const leftParts = toComparableIdParts(leftId);
|
||||
const rightParts = toComparableIdParts(rightId);
|
||||
const maxDepth = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (let index = 0; index < maxDepth; index += 1) {
|
||||
const left = leftParts[index] ?? 0;
|
||||
const right = rightParts[index] ?? 0;
|
||||
if (left !== right) {
|
||||
return left - right;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getSortValue(task: TaskMasterTask, field: TaskBoardSortField): number | string {
|
||||
if (field === 'title') {
|
||||
return task.title.toLowerCase();
|
||||
}
|
||||
|
||||
if (field === 'status') {
|
||||
return STATUS_ORDER[task.status ?? 'pending'] ?? 999;
|
||||
}
|
||||
|
||||
if (field === 'priority') {
|
||||
return PRIORITY_ORDER[task.priority ?? 'medium'] ?? 0;
|
||||
}
|
||||
|
||||
if (field === 'updated') {
|
||||
const timestamp = task.updatedAt ?? task.createdAt ?? '';
|
||||
return new Date(timestamp).getTime() || 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function sortTasks(
|
||||
tasks: TaskMasterTask[],
|
||||
field: TaskBoardSortField,
|
||||
order: TaskBoardSortOrder,
|
||||
): TaskMasterTask[] {
|
||||
const sortedTasks = [...tasks];
|
||||
|
||||
sortedTasks.sort((leftTask, rightTask) => {
|
||||
const direction = order === 'asc' ? 1 : -1;
|
||||
|
||||
if (field === 'id') {
|
||||
return compareTaskIds(leftTask.id, rightTask.id) * direction;
|
||||
}
|
||||
|
||||
const leftValue = getSortValue(leftTask, field);
|
||||
const rightValue = getSortValue(rightTask, field);
|
||||
|
||||
if (typeof leftValue === 'string' && typeof rightValue === 'string') {
|
||||
return leftValue.localeCompare(rightValue) * direction;
|
||||
}
|
||||
|
||||
return (Number(leftValue) - Number(rightValue)) * direction;
|
||||
});
|
||||
|
||||
return sortedTasks;
|
||||
}
|
||||
|
||||
export function toggleSortOrder(
|
||||
currentField: TaskBoardSortField,
|
||||
currentOrder: TaskBoardSortOrder,
|
||||
nextField: TaskBoardSortField,
|
||||
): TaskBoardSortOrder {
|
||||
if (currentField !== nextField) {
|
||||
return 'asc';
|
||||
}
|
||||
|
||||
return currentOrder === 'asc' ? 'desc' : 'asc';
|
||||
}
|
||||
213
src/components/task-master/view/NextTaskBanner.tsx
Normal file
213
src/components/task-master/view/NextTaskBanner.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CheckCircle,
|
||||
Circle,
|
||||
Eye,
|
||||
Flag,
|
||||
List,
|
||||
Play,
|
||||
Settings,
|
||||
Target,
|
||||
Terminal,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { useTaskMaster } from '../context/TaskMasterContext';
|
||||
import TaskDetailModal from './TaskDetailModal';
|
||||
import TaskMasterSetupModal from './modals/TaskMasterSetupModal';
|
||||
|
||||
type NextTaskBannerProps = {
|
||||
onShowAllTasks?: (() => void) | null;
|
||||
onStartTask?: (() => void) | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function PriorityIndicator({ priority }: { priority?: string }) {
|
||||
if (priority === 'high') {
|
||||
return (
|
||||
<div className="w-4 h-4 rounded bg-red-100 dark:bg-red-900/50 flex items-center justify-center" title="High Priority">
|
||||
<Zap className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (priority === 'medium') {
|
||||
return (
|
||||
<div className="w-4 h-4 rounded bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center" title="Medium Priority">
|
||||
<Flag className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-4 h-4 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center" title="Low Priority">
|
||||
<Circle className="w-2.5 h-2.5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NextTaskBanner({ onShowAllTasks = null, onStartTask = null, className = '' }: NextTaskBannerProps) {
|
||||
const {
|
||||
nextTask,
|
||||
tasks,
|
||||
currentProject,
|
||||
isLoadingTasks,
|
||||
projectTaskMaster,
|
||||
refreshTasks,
|
||||
refreshProjects,
|
||||
setCurrentProject,
|
||||
} = useTaskMaster();
|
||||
|
||||
const [showTaskDetail, setShowTaskDetail] = useState(false);
|
||||
const [showSetupModal, setShowSetupModal] = useState(false);
|
||||
const [showSetupDetails, setShowSetupDetails] = useState(false);
|
||||
|
||||
if (!currentProject || isLoadingTasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasTasks = Array.isArray(tasks) && tasks.length > 0;
|
||||
const hasTaskMaster = Boolean(projectTaskMaster?.hasTaskmaster || currentProject.taskmaster?.hasTaskmaster);
|
||||
|
||||
const handleSetupRefresh = () => {
|
||||
void refreshProjects();
|
||||
setCurrentProject(currentProject);
|
||||
void refreshTasks();
|
||||
};
|
||||
|
||||
if (!hasTasks && !hasTaskMaster) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4', className)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<List className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">TaskMaster AI is not configured</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowSetupModal(true)}
|
||||
className="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Terminal className="w-3 h-3" />
|
||||
Initialize
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowSetupDetails((current) => !current)}
|
||||
className="mt-2 text-xs text-blue-700 dark:text-blue-300 hover:underline flex items-center gap-1"
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
{showSetupDetails ? 'Hide details' : 'What is TaskMaster?'}
|
||||
</button>
|
||||
|
||||
{showSetupDetails && (
|
||||
<div className="mt-3 text-xs text-blue-900 dark:text-blue-100 space-y-1">
|
||||
<p>- AI-powered task management with dependencies and subtasks.</p>
|
||||
<p>- PRD-driven task generation for faster project bootstrapping.</p>
|
||||
<p>- Kanban and list views for day-to-day execution.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TaskMasterSetupModal
|
||||
isOpen={showSetupModal}
|
||||
project={currentProject}
|
||||
onClose={() => setShowSetupModal(false)}
|
||||
onAfterClose={handleSetupRefresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (nextTask) {
|
||||
return (
|
||||
<>
|
||||
<div className={cn('bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-3 mb-4', className)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 bg-blue-100 dark:bg-blue-900/50 rounded-full flex items-center justify-center">
|
||||
<Target className="w-3 h-3 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400 font-medium">Task {nextTask.id}</span>
|
||||
<PriorityIndicator priority={nextTask.priority} />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 line-clamp-1">{nextTask.title}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onStartTask?.()}
|
||||
className="text-xs px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium flex items-center gap-1"
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
Start Task
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowTaskDetail(true)}
|
||||
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md"
|
||||
title="View task details"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
{onShowAllTasks && (
|
||||
<button
|
||||
onClick={onShowAllTasks}
|
||||
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md"
|
||||
title="View all tasks"
|
||||
>
|
||||
<List className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDetailModal
|
||||
task={nextTask}
|
||||
isOpen={showTaskDetail}
|
||||
onClose={() => setShowTaskDetail(false)}
|
||||
onStatusChange={() => {
|
||||
void refreshTasks();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasTasks) {
|
||||
const completedTasks = tasks.filter((task) => task.status === 'done').length;
|
||||
|
||||
return (
|
||||
<div className={cn('bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg p-3 mb-4', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{completedTasks === tasks.length ? 'All tasks complete' : 'No pending tasks'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{completedTasks}/{tasks.length}
|
||||
</span>
|
||||
{onShowAllTasks && (
|
||||
<button
|
||||
onClick={onShowAllTasks}
|
||||
className="text-xs px-2 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
199
src/components/task-master/view/TaskBoard.tsx
Normal file
199
src/components/task-master/view/TaskBoard.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import { useState } from 'react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { api } from '../../../utils/api';
|
||||
import { useTaskMaster } from '../context/TaskMasterContext';
|
||||
import { useTaskBoardState } from '../hooks/useTaskBoardState';
|
||||
import type { PrdFile, TaskBoardView, TaskMasterProject, TaskMasterTask, TaskSelection } from '../types';
|
||||
import TaskBoardContent from './TaskBoardContent';
|
||||
import TaskBoardToolbar from './TaskBoardToolbar';
|
||||
import TaskEmptyState from './TaskEmptyState';
|
||||
import CreateTaskModal from './modals/CreateTaskModal';
|
||||
import TaskHelpModal from './modals/TaskHelpModal';
|
||||
import TaskMasterSetupModal from './modals/TaskMasterSetupModal';
|
||||
|
||||
type TaskBoardProps = {
|
||||
tasks?: TaskMasterTask[];
|
||||
onTaskClick?: ((task: TaskSelection) => void) | null;
|
||||
className?: string;
|
||||
showParentTasks?: boolean;
|
||||
defaultView?: TaskBoardView;
|
||||
currentProject?: TaskMasterProject | null;
|
||||
onTaskCreated?: (() => void) | null;
|
||||
onShowPRDEditor?: ((file?: PrdFile) => void) | null;
|
||||
existingPRDs?: PrdFile[];
|
||||
onRefreshPRDs?: ((showNotification?: boolean) => void) | null;
|
||||
};
|
||||
|
||||
export default function TaskBoard({
|
||||
tasks = [],
|
||||
onTaskClick = null,
|
||||
className = '',
|
||||
showParentTasks = false,
|
||||
defaultView = 'kanban',
|
||||
currentProject = null,
|
||||
onTaskCreated = null,
|
||||
onShowPRDEditor = null,
|
||||
existingPRDs = [],
|
||||
onRefreshPRDs = null,
|
||||
}: TaskBoardProps) {
|
||||
const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showHelpModal, setShowHelpModal] = useState(false);
|
||||
const [showSetupModal, setShowSetupModal] = useState(false);
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
priorityFilter,
|
||||
setPriorityFilter,
|
||||
sortField,
|
||||
setSortField,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
showFilters,
|
||||
setShowFilters,
|
||||
statuses,
|
||||
priorities,
|
||||
filteredTasks,
|
||||
kanbanColumns,
|
||||
handleSortChange,
|
||||
clearFilters,
|
||||
} = useTaskBoardState({ tasks, defaultView });
|
||||
|
||||
const hasTaskMasterDirectory = Boolean(
|
||||
currentProject?.taskMasterConfigured
|
||||
|| currentProject?.taskmaster?.hasTaskmaster
|
||||
|| projectTaskMaster?.hasTaskmaster,
|
||||
);
|
||||
|
||||
const loadPrdAndOpenEditor = async (prd: PrdFile) => {
|
||||
if (!currentProject?.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`,
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load PRD ${prd.name}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { content?: string };
|
||||
onShowPRDEditor?.({
|
||||
name: prd.name,
|
||||
content: data.content ?? '',
|
||||
isExisting: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to open PRD in editor:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAfterSetup = () => {
|
||||
void refreshProjects();
|
||||
if (currentProject) {
|
||||
setCurrentProject(currentProject);
|
||||
}
|
||||
void refreshTasks();
|
||||
onRefreshPRDs?.(false);
|
||||
};
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<TaskEmptyState
|
||||
className={className}
|
||||
hasTaskMasterDirectory={hasTaskMasterDirectory}
|
||||
existingPrds={existingPRDs}
|
||||
onOpenSetupModal={() => setShowSetupModal(true)}
|
||||
onCreatePrd={() => onShowPRDEditor?.()}
|
||||
onOpenPrd={(prd) => {
|
||||
void loadPrdAndOpenEditor(prd);
|
||||
}}
|
||||
/>
|
||||
|
||||
<TaskMasterSetupModal
|
||||
isOpen={showSetupModal}
|
||||
project={currentProject}
|
||||
onClose={() => setShowSetupModal(false)}
|
||||
onAfterClose={refreshAfterSetup}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<TaskBoardToolbar
|
||||
hasProject={Boolean(currentProject)}
|
||||
hasTaskMasterConfigured={hasTaskMasterDirectory}
|
||||
totalTaskCount={tasks.length}
|
||||
filteredTaskCount={filteredTasks.length}
|
||||
searchTerm={searchTerm}
|
||||
onSearchTermChange={setSearchTerm}
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
showFilters={showFilters}
|
||||
onToggleFilters={() => setShowFilters((current) => !current)}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={setStatusFilter}
|
||||
priorityFilter={priorityFilter}
|
||||
onPriorityFilterChange={setPriorityFilter}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
onSortConfigChange={(field, order) => {
|
||||
setSortField(field);
|
||||
setSortOrder(order);
|
||||
}}
|
||||
statuses={statuses}
|
||||
priorities={priorities}
|
||||
onClearFilters={clearFilters}
|
||||
existingPrds={existingPRDs}
|
||||
onCreatePrd={() => onShowPRDEditor?.()}
|
||||
onOpenPrd={(prd) => {
|
||||
void loadPrdAndOpenEditor(prd);
|
||||
}}
|
||||
onOpenHelp={() => setShowHelpModal(true)}
|
||||
onOpenCreateTask={() => setShowCreateModal(true)}
|
||||
/>
|
||||
|
||||
<TaskBoardContent
|
||||
viewMode={viewMode}
|
||||
filteredTaskCount={filteredTasks.length}
|
||||
kanbanColumns={kanbanColumns}
|
||||
filteredTasks={filteredTasks}
|
||||
showParentTasks={showParentTasks}
|
||||
onTaskClick={(task) => onTaskClick?.(task)}
|
||||
/>
|
||||
|
||||
<CreateTaskModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => {
|
||||
setShowCreateModal(false);
|
||||
onTaskCreated?.();
|
||||
}}
|
||||
/>
|
||||
|
||||
<TaskHelpModal
|
||||
isOpen={showHelpModal}
|
||||
onClose={() => setShowHelpModal(false)}
|
||||
onCreatePrd={() => onShowPRDEditor?.()}
|
||||
/>
|
||||
|
||||
<TaskMasterSetupModal
|
||||
isOpen={showSetupModal}
|
||||
project={currentProject}
|
||||
onClose={() => setShowSetupModal(false)}
|
||||
onAfterClose={refreshAfterSetup}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/components/task-master/view/TaskBoardContent.tsx
Normal file
124
src/components/task-master/view/TaskBoardContent.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { TaskBoardView, TaskKanbanColumn, TaskMasterTask, TaskSelection } from '../types';
|
||||
import TaskCard from './TaskCard';
|
||||
|
||||
type TaskBoardContentProps = {
|
||||
viewMode: TaskBoardView;
|
||||
filteredTaskCount: number;
|
||||
kanbanColumns: TaskKanbanColumn[];
|
||||
filteredTasks: TaskMasterTask[];
|
||||
showParentTasks: boolean;
|
||||
onTaskClick: (task: TaskSelection) => void;
|
||||
};
|
||||
|
||||
function KanbanColumns({
|
||||
columns,
|
||||
showParentTasks,
|
||||
onTaskClick,
|
||||
}: {
|
||||
columns: TaskKanbanColumn[];
|
||||
showParentTasks: boolean;
|
||||
onTaskClick: (task: TaskSelection) => void;
|
||||
}) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'grid gap-6',
|
||||
columns.length === 1 && 'grid-cols-1 max-w-md mx-auto',
|
||||
columns.length === 2 && 'grid-cols-1 md:grid-cols-2',
|
||||
columns.length === 3 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
columns.length === 4 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4',
|
||||
columns.length === 5 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
|
||||
columns.length >= 6 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
|
||||
)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<div key={column.id} className={cn('rounded-xl border shadow-sm transition-shadow hover:shadow-md', column.color)}>
|
||||
<div className={cn('px-4 py-3 rounded-t-xl border-b', column.headerColor)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-sm">{column.title}</h3>
|
||||
<span className="text-xs font-medium px-2 py-1 bg-white/60 dark:bg-black/20 rounded-full">
|
||||
{column.tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3 min-h-[200px] max-h-[calc(100vh-300px)] overflow-y-auto">
|
||||
{column.tasks.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400 dark:text-gray-500">
|
||||
<div className="w-8 h-8 mx-auto mb-2 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<div className="w-3 h-3 rounded-full bg-gray-300 dark:bg-gray-600" />
|
||||
</div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400">{t('kanban.noTasksYet')}</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{column.status === 'pending'
|
||||
? t('kanban.tasksWillAppear')
|
||||
: column.status === 'in-progress'
|
||||
? t('kanban.moveTasksHere')
|
||||
: column.status === 'done'
|
||||
? t('kanban.completedTasksHere')
|
||||
: t('kanban.statusTasksHere')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
column.tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={String(task.id)}
|
||||
task={task}
|
||||
onClick={() => onTaskClick(task)}
|
||||
showParent={showParentTasks}
|
||||
className="w-full shadow-sm hover:shadow-md"
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskBoardContent({
|
||||
viewMode,
|
||||
filteredTaskCount,
|
||||
kanbanColumns,
|
||||
filteredTasks,
|
||||
showParentTasks,
|
||||
onTaskClick,
|
||||
}: TaskBoardContentProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
if (filteredTaskCount === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<Search className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<h3 className="text-lg font-medium mb-2">{t('noMatchingTasks.title')}</h3>
|
||||
<p className="text-sm">{t('noMatchingTasks.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === 'kanban') {
|
||||
return <KanbanColumns columns={kanbanColumns} showParentTasks={showParentTasks} onTaskClick={onTaskClick} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('gap-4', viewMode === 'grid' ? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3' : 'space-y-4')}>
|
||||
{filteredTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={String(task.id)}
|
||||
task={task}
|
||||
onClick={() => onTaskClick(task)}
|
||||
showParent={showParentTasks}
|
||||
className={viewMode === 'grid' ? 'h-full' : ''}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
266
src/components/task-master/view/TaskBoardToolbar.tsx
Normal file
266
src/components/task-master/view/TaskBoardToolbar.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
Columns,
|
||||
FileText,
|
||||
Filter,
|
||||
Grid,
|
||||
HelpCircle,
|
||||
List,
|
||||
Plus,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { PrdFile, TaskBoardSortField, TaskBoardSortOrder, TaskBoardView } from '../types';
|
||||
import TaskFiltersPanel from './shared/TaskFiltersPanel';
|
||||
import TaskQuickSortBar from './shared/TaskQuickSortBar';
|
||||
|
||||
type TaskBoardToolbarProps = {
|
||||
hasProject: boolean;
|
||||
hasTaskMasterConfigured: boolean;
|
||||
totalTaskCount: number;
|
||||
filteredTaskCount: number;
|
||||
searchTerm: string;
|
||||
onSearchTermChange: (value: string) => void;
|
||||
viewMode: TaskBoardView;
|
||||
onViewModeChange: (viewMode: TaskBoardView) => void;
|
||||
showFilters: boolean;
|
||||
onToggleFilters: () => void;
|
||||
statusFilter: string;
|
||||
onStatusFilterChange: (status: string) => void;
|
||||
priorityFilter: string;
|
||||
onPriorityFilterChange: (priority: string) => void;
|
||||
sortField: TaskBoardSortField;
|
||||
sortOrder: TaskBoardSortOrder;
|
||||
onSortChange: (field: TaskBoardSortField) => void;
|
||||
onSortConfigChange: (field: TaskBoardSortField, order: TaskBoardSortOrder) => void;
|
||||
statuses: string[];
|
||||
priorities: string[];
|
||||
onClearFilters: () => void;
|
||||
existingPrds: PrdFile[];
|
||||
onCreatePrd: () => void;
|
||||
onOpenPrd: (prd: PrdFile) => void;
|
||||
onOpenHelp: () => void;
|
||||
onOpenCreateTask: () => void;
|
||||
};
|
||||
|
||||
export default function TaskBoardToolbar({
|
||||
hasProject,
|
||||
hasTaskMasterConfigured,
|
||||
totalTaskCount,
|
||||
filteredTaskCount,
|
||||
searchTerm,
|
||||
onSearchTermChange,
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
showFilters,
|
||||
onToggleFilters,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
priorityFilter,
|
||||
onPriorityFilterChange,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
onSortConfigChange,
|
||||
statuses,
|
||||
priorities,
|
||||
onClearFilters,
|
||||
existingPrds,
|
||||
onCreatePrd,
|
||||
onOpenPrd,
|
||||
onOpenHelp,
|
||||
onOpenCreateTask,
|
||||
}: TaskBoardToolbarProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
const [isPrdDropdownOpen, setIsPrdDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsPrdDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
return () => document.removeEventListener('mousedown', handleMouseDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col lg:flex-row gap-3 lg:items-center lg:justify-between">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(event) => onSearchTermChange(event.target.value)}
|
||||
placeholder={t('search.placeholder')}
|
||||
className="pl-10 pr-4 py-2 w-full border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => onViewModeChange('kanban')}
|
||||
className={cn(
|
||||
'p-2 rounded-md',
|
||||
viewMode === 'kanban'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
|
||||
)}
|
||||
title={t('views.kanban')}
|
||||
>
|
||||
<Columns className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onViewModeChange('list')}
|
||||
className={cn(
|
||||
'p-2 rounded-md',
|
||||
viewMode === 'list'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
|
||||
)}
|
||||
title={t('views.list')}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onViewModeChange('grid')}
|
||||
className={cn(
|
||||
'p-2 rounded-md',
|
||||
viewMode === 'grid'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300',
|
||||
)}
|
||||
title={t('views.grid')}
|
||||
>
|
||||
<Grid className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onToggleFilters}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors',
|
||||
showFilters
|
||||
? 'bg-blue-50 dark:bg-blue-900 border-blue-200 dark:border-blue-700 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('filters.button')}</span>
|
||||
<ChevronDown className={cn('w-4 h-4 transition-transform', showFilters && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{hasProject && (
|
||||
<>
|
||||
<button
|
||||
onClick={onOpenHelp}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg border border-gray-300 dark:border-gray-600"
|
||||
title={t('buttons.help')}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div ref={dropdownRef} className="relative">
|
||||
{existingPrds.length > 0 ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsPrdDropdownOpen((current) => !current)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium"
|
||||
title={t('buttons.prdsAvailable', { count: existingPrds.length })}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('buttons.prds')}</span>
|
||||
<span className="px-1.5 py-0.5 text-xs bg-purple-500 rounded-full min-w-[1.25rem] text-center">
|
||||
{existingPrds.length}
|
||||
</span>
|
||||
<ChevronDown className={cn('w-3 h-3 transition-transform hidden sm:block', isPrdDropdownOpen && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{isPrdDropdownOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-xl z-30">
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onCreatePrd();
|
||||
setIsPrdDropdownOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm font-medium text-purple-700 dark:text-purple-300 hover:bg-purple-50 dark:hover:bg-purple-900/30 rounded flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('buttons.createNewPRD')}
|
||||
</button>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 my-1" />
|
||||
|
||||
{existingPrds.map((prd) => (
|
||||
<button
|
||||
key={prd.name}
|
||||
onClick={() => {
|
||||
onOpenPrd(prd);
|
||||
setIsPrdDropdownOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="truncate">{prd.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={onCreatePrd}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium"
|
||||
title={t('buttons.addPRD')}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('buttons.addPRD')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(hasTaskMasterConfigured || totalTaskCount > 0) && (
|
||||
<button
|
||||
onClick={onOpenCreateTask}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium"
|
||||
title={t('buttons.addTask')}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t('buttons.addTask')}</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskFiltersPanel
|
||||
showFilters={showFilters}
|
||||
statusFilter={statusFilter}
|
||||
onStatusFilterChange={onStatusFilterChange}
|
||||
priorityFilter={priorityFilter}
|
||||
onPriorityFilterChange={onPriorityFilterChange}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
onSortConfigChange={onSortConfigChange}
|
||||
statuses={statuses}
|
||||
priorities={priorities}
|
||||
filteredTaskCount={filteredTaskCount}
|
||||
totalTaskCount={totalTaskCount}
|
||||
onClearFilters={onClearFilters}
|
||||
/>
|
||||
|
||||
<TaskQuickSortBar sortField={sortField} sortOrder={sortOrder} onSortChange={onSortChange} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
210
src/components/task-master/view/TaskCard.tsx
Normal file
210
src/components/task-master/view/TaskCard.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
ChevronUp,
|
||||
Circle,
|
||||
Clock,
|
||||
Minus,
|
||||
Pause,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { Tooltip } from '../../../shared/view/ui';
|
||||
import type { TaskMasterTask } from '../types';
|
||||
|
||||
type TaskCardProps = {
|
||||
task: TaskMasterTask;
|
||||
onClick?: (() => void) | null;
|
||||
showParent?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type TaskStatusStyle = {
|
||||
icon: typeof Circle;
|
||||
statusText: string;
|
||||
iconColor: string;
|
||||
textColor: string;
|
||||
};
|
||||
|
||||
function getStatusStyle(status?: string): TaskStatusStyle {
|
||||
if (status === 'done') {
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
statusText: 'Done',
|
||||
iconColor: 'text-green-600 dark:text-green-400',
|
||||
textColor: 'text-green-900 dark:text-green-100',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'in-progress') {
|
||||
return {
|
||||
icon: Clock,
|
||||
statusText: 'In Progress',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
textColor: 'text-blue-900 dark:text-blue-100',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'review') {
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
statusText: 'Review',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
textColor: 'text-amber-900 dark:text-amber-100',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'deferred') {
|
||||
return {
|
||||
icon: Pause,
|
||||
statusText: 'Deferred',
|
||||
iconColor: 'text-gray-500 dark:text-gray-400',
|
||||
textColor: 'text-gray-700 dark:text-gray-300',
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'cancelled') {
|
||||
return {
|
||||
icon: X,
|
||||
statusText: 'Cancelled',
|
||||
iconColor: 'text-red-600 dark:text-red-400',
|
||||
textColor: 'text-red-900 dark:text-red-100',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: Circle,
|
||||
statusText: 'Pending',
|
||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||
textColor: 'text-slate-900 dark:text-slate-100',
|
||||
};
|
||||
}
|
||||
|
||||
function renderPriorityIcon(priority?: string) {
|
||||
if (priority === 'high') {
|
||||
return (
|
||||
<Tooltip content="High priority">
|
||||
<div className="w-4 h-4 bg-red-100 dark:bg-red-900/30 rounded flex items-center justify-center">
|
||||
<ChevronUp className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (priority === 'medium') {
|
||||
return (
|
||||
<Tooltip content="Medium priority">
|
||||
<div className="w-4 h-4 bg-amber-100 dark:bg-amber-900/30 rounded flex items-center justify-center">
|
||||
<Minus className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (priority === 'low') {
|
||||
return (
|
||||
<Tooltip content="Low priority">
|
||||
<div className="w-4 h-4 bg-blue-100 dark:bg-blue-900/30 rounded flex items-center justify-center">
|
||||
<Circle className="w-1.5 h-1.5 text-blue-600 dark:text-blue-400 fill-current" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content="No priority set">
|
||||
<div className="w-4 h-4 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
|
||||
<Circle className="w-1.5 h-1.5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function getSubtaskProgress(task: TaskMasterTask): { completed: number; total: number; percentage: number } {
|
||||
const subtasks = task.subtasks ?? [];
|
||||
const total = subtasks.length;
|
||||
const completed = subtasks.filter((subtask) => subtask.status === 'done').length;
|
||||
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return { completed, total, percentage };
|
||||
}
|
||||
|
||||
function TaskCard({ task, onClick = null, showParent = false, className = '' }: TaskCardProps) {
|
||||
const statusStyle = getStatusStyle(task.status);
|
||||
const progress = getSubtaskProgress(task);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-3 space-y-3',
|
||||
'hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-200',
|
||||
onClick ? 'cursor-pointer hover:-translate-y-0.5' : 'cursor-default',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick ?? undefined}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip content={`Task ID: ${task.id}`}>
|
||||
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{task.id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<h3 className="font-medium text-sm text-gray-900 dark:text-white line-clamp-2 leading-tight">
|
||||
{task.title}
|
||||
</h3>
|
||||
|
||||
{showParent && task.parentId && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">Task {task.parentId}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">{renderPriorityIcon(task.priority)}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
{Array.isArray(task.dependencies) && task.dependencies.length > 0 && (
|
||||
<Tooltip content={`Depends on: ${task.dependencies.map((dependency) => `Task ${dependency}`).join(', ')}`}>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
<span>Depends on: {task.dependencies.join(', ')}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip content={`Status: ${statusStyle.statusText}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-full', statusStyle.iconColor.replace('text-', 'bg-'))} />
|
||||
<span className={cn('text-xs font-medium', statusStyle.textColor)}>{statusStyle.statusText}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{progress.total > 0 && (
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Progress:</span>
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5" title={`${progress.completed} of ${progress.total} subtasks completed`}>
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-300', task.status === 'done' ? 'bg-green-500' : 'bg-blue-500')}
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{progress.completed}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(TaskCard);
|
||||
318
src/components/task-master/view/TaskDetailModal.tsx
Normal file
318
src/components/task-master/view/TaskDetailModal.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Clock,
|
||||
Copy,
|
||||
Edit,
|
||||
Pause,
|
||||
Save,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import { copyTextToClipboard } from '../../../utils/clipboard';
|
||||
import { api } from '../../../utils/api';
|
||||
import { useTaskMaster } from '../context/TaskMasterContext';
|
||||
import type { TaskId, TaskMasterTask, TaskReference } from '../types';
|
||||
|
||||
type TaskDetailModalProps = {
|
||||
task: TaskMasterTask | null;
|
||||
isOpen?: boolean;
|
||||
className?: string;
|
||||
onClose: () => void;
|
||||
onEdit?: ((task: TaskMasterTask) => void) | null;
|
||||
onStatusChange?: ((taskId: TaskId, status: string) => void) | null;
|
||||
onTaskClick?: ((task: TaskReference) => void) | null;
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'in-progress', label: 'In Progress' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
{ value: 'deferred', label: 'Deferred' },
|
||||
{ value: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
|
||||
function getStatusIcon(status?: string) {
|
||||
if (status === 'done') return CheckCircle;
|
||||
if (status === 'in-progress') return Clock;
|
||||
if (status === 'review') return AlertCircle;
|
||||
if (status === 'deferred') return Pause;
|
||||
if (status === 'cancelled') return X;
|
||||
return Circle;
|
||||
}
|
||||
|
||||
function getPriorityBadgeClass(priority?: string): string {
|
||||
if (priority === 'high') return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950';
|
||||
if (priority === 'medium') return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950';
|
||||
if (priority === 'low') return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950';
|
||||
return 'text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800';
|
||||
}
|
||||
|
||||
export default function TaskDetailModal({
|
||||
task,
|
||||
isOpen = true,
|
||||
className = '',
|
||||
onClose,
|
||||
onEdit = null,
|
||||
onStatusChange = null,
|
||||
onTaskClick = null,
|
||||
}: TaskDetailModalProps) {
|
||||
const { currentProject, refreshTasks } = useTaskMaster();
|
||||
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showTestStrategy, setShowTestStrategy] = useState(false);
|
||||
const [editableTask, setEditableTask] = useState<TaskMasterTask | null>(task);
|
||||
|
||||
useEffect(() => {
|
||||
setEditableTask(task);
|
||||
setIsEditMode(false);
|
||||
}, [task]);
|
||||
|
||||
const StatusIcon = useMemo(() => getStatusIcon(task?.status), [task?.status]);
|
||||
|
||||
if (!isOpen || !task || !editableTask) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (!currentProject?.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
|
||||
if (editableTask.title !== task.title) {
|
||||
updates.title = editableTask.title;
|
||||
}
|
||||
|
||||
if (editableTask.description !== task.description) {
|
||||
updates.description = editableTask.description ?? '';
|
||||
}
|
||||
|
||||
if (editableTask.details !== task.details) {
|
||||
updates.details = editableTask.details ?? '';
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
setIsEditMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await api.taskmaster.updateTask(currentProject.name, task.id, updates);
|
||||
if (!response.ok) {
|
||||
const errorPayload = (await response.json()) as { message?: string };
|
||||
throw new Error(errorPayload.message ?? 'Failed to update task');
|
||||
}
|
||||
|
||||
setIsEditMode(false);
|
||||
await refreshTasks();
|
||||
onEdit?.(editableTask);
|
||||
} catch (error) {
|
||||
console.error('Failed to save task changes:', error);
|
||||
alert(error instanceof Error ? error.message : 'Failed to update task');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusSelect = async (nextStatus: string) => {
|
||||
if (!currentProject?.name || nextStatus === task.status) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.taskmaster.updateTask(currentProject.name, task.id, { status: nextStatus });
|
||||
if (!response.ok) {
|
||||
const errorPayload = (await response.json()) as { message?: string };
|
||||
throw new Error(errorPayload.message ?? 'Failed to update task status');
|
||||
}
|
||||
|
||||
await refreshTasks();
|
||||
onStatusChange?.(task.id, nextStatus);
|
||||
} catch (error) {
|
||||
console.error('Failed to update task status:', error);
|
||||
alert(error instanceof Error ? error.message : 'Failed to update task status');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-black/50 flex items-center justify-center md:p-4">
|
||||
<div
|
||||
className={cn(
|
||||
'w-full md:max-w-4xl h-full md:h-[90vh] bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl flex flex-col',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<StatusIcon className="w-6 h-6 text-blue-600 dark:text-blue-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
onClick={() => copyTextToClipboard(String(task.id))}
|
||||
className="mb-2 inline-flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
title="Copy task ID"
|
||||
>
|
||||
<span>Task {task.id}</span>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
|
||||
{isEditMode ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editableTask.title}
|
||||
onChange={(event) => setEditableTask({ ...editableTask, title: event.target.value })}
|
||||
className="w-full text-lg font-semibold bg-transparent border-b-2 border-blue-500 focus:outline-none text-gray-900 dark:text-white"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="text-lg md:text-xl font-semibold text-gray-900 dark:text-white line-clamp-2">{task.title}</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditMode ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSaveChanges}
|
||||
disabled={isSaving}
|
||||
className="p-2 text-green-600 hover:bg-green-50 dark:hover:bg-green-950 rounded-md disabled:opacity-50"
|
||||
title="Save"
|
||||
>
|
||||
<Save className={cn('w-5 h-5', isSaving && 'animate-spin')} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditableTask(task);
|
||||
setIsEditMode(false);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md"
|
||||
title="Cancel editing"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditMode(true)}
|
||||
className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md"
|
||||
title="Edit task"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onClose} className="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md" title="Close">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
||||
<select
|
||||
value={task.status ?? 'pending'}
|
||||
onChange={(event) => {
|
||||
void handleStatusSelect(event.target.value);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
|
||||
<div className={cn('px-3 py-2 rounded-md text-sm font-medium capitalize', getPriorityBadgeClass(task.priority))}>
|
||||
{task.priority ?? 'Not set'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Dependencies</label>
|
||||
{Array.isArray(task.dependencies) && task.dependencies.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{task.dependencies.map((dependency) => (
|
||||
<button
|
||||
key={String(dependency)}
|
||||
onClick={() => onTaskClick?.({ id: dependency })}
|
||||
className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-sm hover:bg-blue-200 dark:hover:bg-blue-800"
|
||||
>
|
||||
<ArrowRight className="w-3 h-3 inline mr-1" />
|
||||
{dependency}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">No dependencies</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
{isEditMode ? (
|
||||
<textarea
|
||||
rows={4}
|
||||
value={editableTask.description ?? ''}
|
||||
onChange={(event) => setEditableTask({ ...editableTask, description: event.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{task.description || 'No description provided'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{task.details && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowDetails((current) => !current)}
|
||||
className="w-full p-4 flex items-center justify-between text-left hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Implementation Details</span>
|
||||
{showDetails ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
{showDetails && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{task.details}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.testStrategy && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowTestStrategy((current) => !current)}
|
||||
className="w-full p-4 flex items-center justify-between text-left hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Test Strategy</span>
|
||||
{showTestStrategy ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
{showTestStrategy && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-blue-50 dark:bg-blue-950/30">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{task.testStrategy}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
134
src/components/task-master/view/TaskEmptyState.tsx
Normal file
134
src/components/task-master/view/TaskEmptyState.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { FileText, Settings, Terminal } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../lib/utils';
|
||||
import type { PrdFile } from '../types';
|
||||
|
||||
type TaskEmptyStateProps = {
|
||||
className?: string;
|
||||
hasTaskMasterDirectory: boolean;
|
||||
existingPrds: PrdFile[];
|
||||
onOpenSetupModal: () => void;
|
||||
onCreatePrd: () => void;
|
||||
onOpenPrd: (prd: PrdFile) => void;
|
||||
};
|
||||
|
||||
export default function TaskEmptyState({
|
||||
className = '',
|
||||
hasTaskMasterDirectory,
|
||||
existingPrds,
|
||||
onOpenSetupModal,
|
||||
onCreatePrd,
|
||||
onOpenPrd,
|
||||
}: TaskEmptyStateProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
if (!hasTaskMasterDirectory) {
|
||||
return (
|
||||
<div className={cn('text-center py-12', className)}>
|
||||
<div className="max-w-md mx-auto">
|
||||
<div className="text-blue-600 dark:text-blue-400 mb-4">
|
||||
<Settings className="w-12 h-12 mx-auto mb-4" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">{t('notConfigured.title')}</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">{t('notConfigured.description')}</p>
|
||||
|
||||
<div className="mb-6 p-4 bg-blue-50 dark:bg-blue-950 rounded-lg text-left">
|
||||
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-3">{t('notConfigured.whatIsTitle')}</h4>
|
||||
<div className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<p>- {t('notConfigured.features.aiPowered')}</p>
|
||||
<p>- {t('notConfigured.features.prdTemplates')}</p>
|
||||
<p>- {t('notConfigured.features.dependencyTracking')}</p>
|
||||
<p>- {t('notConfigured.features.progressVisualization')}</p>
|
||||
<p>- {t('notConfigured.features.cliIntegration')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpenSetupModal}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
{t('notConfigured.initializeButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('text-center py-12', className)}>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-blue-950/50 dark:to-indigo-950/50 rounded-xl border border-blue-200 dark:border-blue-800 p-6 mb-6 text-left">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('gettingStarted.title')}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 mb-4">
|
||||
<div className="p-3 bg-white dark:bg-gray-800/60 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">1. {t('gettingStarted.steps.createPRD.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">{t('gettingStarted.steps.createPRD.description')}</p>
|
||||
|
||||
<button
|
||||
onClick={onCreatePrd}
|
||||
className="inline-flex items-center gap-2 text-xs bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 px-2 py-1 rounded hover:bg-purple-200 dark:hover:bg-purple-900/50"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{t('gettingStarted.steps.createPRD.addButton')}
|
||||
</button>
|
||||
|
||||
{existingPrds.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">{t('gettingStarted.steps.createPRD.existingPRDs')}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{existingPrds.map((prd) => (
|
||||
<button
|
||||
key={prd.name}
|
||||
onClick={() => onOpenPrd(prd)}
|
||||
className="inline-flex items-center gap-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 px-2 py-1 rounded hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{prd.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white dark:bg-gray-800/60 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">2. {t('gettingStarted.steps.generateTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.generateTasks.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white dark:bg-gray-800/60 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">3. {t('gettingStarted.steps.analyzeTasks.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.analyzeTasks.description')}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-white dark:bg-gray-800/60 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-1">4. {t('gettingStarted.steps.startBuilding.title')}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('gettingStarted.steps.startBuilding.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onCreatePrd}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg font-medium"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
{t('buttons.addPRD')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('gettingStarted.tip')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/components/task-master/view/TaskMasterPanel.tsx
Normal file
150
src/components/task-master/view/TaskMasterPanel.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import PRDEditor from '../../prd-editor';
|
||||
import { useTaskMaster } from '../context/TaskMasterContext';
|
||||
import { useProjectPrdFiles } from '../hooks/useProjectPrdFiles';
|
||||
import type { PrdFile, TaskMasterTask, TaskSelection } from '../types';
|
||||
import TaskBoard from './TaskBoard';
|
||||
import TaskDetailModal from './TaskDetailModal';
|
||||
|
||||
type TaskMasterPanelProps = {
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
const PRD_SAVE_MESSAGE = 'PRD saved successfully!';
|
||||
|
||||
export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
|
||||
const { tasks, currentProject, refreshTasks } = useTaskMaster();
|
||||
|
||||
const [selectedTask, setSelectedTask] = useState<TaskMasterTask | null>(null);
|
||||
const [isTaskDetailOpen, setIsTaskDetailOpen] = useState(false);
|
||||
|
||||
const [isPrdEditorOpen, setIsPrdEditorOpen] = useState(false);
|
||||
const [selectedPrd, setSelectedPrd] = useState<PrdFile | null>(null);
|
||||
|
||||
const [prdNotification, setPrdNotification] = useState<string | null>(null);
|
||||
const notificationTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
const { prdFiles, refreshPrdFiles } = useProjectPrdFiles({ projectName: currentProject?.name });
|
||||
|
||||
const showPrdNotification = useCallback((message: string) => {
|
||||
if (notificationTimeoutRef.current) {
|
||||
window.clearTimeout(notificationTimeoutRef.current);
|
||||
}
|
||||
|
||||
setPrdNotification(message);
|
||||
|
||||
notificationTimeoutRef.current = window.setTimeout(() => {
|
||||
setPrdNotification(null);
|
||||
notificationTimeoutRef.current = null;
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
const refreshPrdData = useCallback(
|
||||
async (showNotification = false) => {
|
||||
await refreshPrdFiles();
|
||||
if (showNotification) {
|
||||
showPrdNotification(PRD_SAVE_MESSAGE);
|
||||
}
|
||||
},
|
||||
[refreshPrdFiles, showPrdNotification],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (notificationTimeoutRef.current) {
|
||||
window.clearTimeout(notificationTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTaskClick = useCallback(
|
||||
(taskSelection: TaskSelection) => {
|
||||
const selectedId = String(taskSelection.id);
|
||||
|
||||
if (!taskSelection.title) {
|
||||
const fullTask = tasks.find((task) => String(task.id) === selectedId) ?? null;
|
||||
if (fullTask) {
|
||||
setSelectedTask(fullTask);
|
||||
setIsTaskDetailOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedTask(taskSelection as TaskMasterTask);
|
||||
setIsTaskDetailOpen(true);
|
||||
},
|
||||
[tasks],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`h-full ${isVisible ? 'block' : 'hidden'}`}>
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<TaskBoard
|
||||
tasks={tasks}
|
||||
onTaskClick={handleTaskClick}
|
||||
showParentTasks
|
||||
className="flex-1 overflow-y-auto p-4"
|
||||
currentProject={currentProject}
|
||||
onTaskCreated={refreshTasks}
|
||||
onShowPRDEditor={(prd) => {
|
||||
setSelectedPrd(prd ?? null);
|
||||
setIsPrdEditorOpen(true);
|
||||
}}
|
||||
existingPRDs={prdFiles}
|
||||
onRefreshPRDs={(showNotification = false) => {
|
||||
void refreshPrdData(showNotification);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskDetailModal
|
||||
task={selectedTask}
|
||||
isOpen={isTaskDetailOpen}
|
||||
onClose={() => {
|
||||
setIsTaskDetailOpen(false);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
onStatusChange={() => {
|
||||
void refreshTasks();
|
||||
}}
|
||||
onTaskClick={handleTaskClick}
|
||||
/>
|
||||
|
||||
{isPrdEditorOpen && (
|
||||
<PRDEditor
|
||||
project={currentProject}
|
||||
projectPath={currentProject?.fullPath || currentProject?.path}
|
||||
onClose={() => {
|
||||
setIsPrdEditorOpen(false);
|
||||
setSelectedPrd(null);
|
||||
}}
|
||||
isNewFile={!selectedPrd?.isExisting}
|
||||
file={{
|
||||
name: selectedPrd?.name || 'prd.txt',
|
||||
content: selectedPrd?.content || '',
|
||||
isExisting: selectedPrd?.isExisting,
|
||||
}}
|
||||
onSave={async () => {
|
||||
setIsPrdEditorOpen(false);
|
||||
setSelectedPrd(null);
|
||||
await refreshPrdData(true);
|
||||
await refreshTasks();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{prdNotification && (
|
||||
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="bg-green-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="font-medium">{prdNotification}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,18 @@
|
||||
import React from 'react';
|
||||
import { X, Sparkles } from 'lucide-react';
|
||||
import { Sparkles, X } from 'lucide-react';
|
||||
|
||||
const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
|
||||
type CreateTaskModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function CreateTaskModal({ isOpen, onClose }: CreateTaskModalProps) {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-200 dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||
@@ -22,67 +28,46 @@ const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* AI-First Approach */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
💡 Pro Tip: Ask Claude Code Directly!
|
||||
</h4>
|
||||
<div>
|
||||
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">Pro tip: ask Claude Code directly</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
|
||||
You can simply ask Claude Code in the chat to create tasks for you.
|
||||
The AI assistant will automatically generate detailed tasks with research-backed insights.
|
||||
Ask for a task in chat with context and requirements. TaskMaster can generate implementation-ready tasks.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-blue-200 dark:border-blue-700 p-3 mb-3">
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-blue-200 dark:border-blue-700 p-3">
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Example:</p>
|
||||
<p className="text-sm text-gray-900 dark:text-white font-mono">
|
||||
"Please add a new task to implement user profile image uploads using Cloudinary, research the best approach."
|
||||
Please add a task for profile image uploads and include best-practice research.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<strong>This runs:</strong> <code className="bg-blue-100 dark:bg-blue-900/50 px-1 rounded text-xs">
|
||||
task-master add-task --prompt="Implement user profile image uploads using Cloudinary" --research
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learn More Link */}
|
||||
<div className="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
For more examples and advanced usage patterns:
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-medium"
|
||||
>
|
||||
View TaskMaster Documentation →
|
||||
View TaskMaster documentation
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Got it, I'll ask Claude Code directly
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
|
||||
>
|
||||
Got it
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTaskModal;
|
||||
}
|
||||
129
src/components/task-master/view/modals/TaskHelpModal.tsx
Normal file
129
src/components/task-master/view/modals/TaskHelpModal.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { ExternalLink, FileText, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type TaskHelpModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreatePrd: () => void;
|
||||
};
|
||||
|
||||
type HelpStep = {
|
||||
index: number;
|
||||
title: string;
|
||||
description: string;
|
||||
accent: string;
|
||||
};
|
||||
|
||||
export default function TaskHelpModal({ isOpen, onClose, onCreatePrd }: TaskHelpModalProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const steps: HelpStep[] = [
|
||||
{
|
||||
index: 1,
|
||||
title: t('gettingStarted.steps.createPRD.title'),
|
||||
description: t('gettingStarted.steps.createPRD.description'),
|
||||
accent: 'border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/40',
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
title: t('gettingStarted.steps.generateTasks.title'),
|
||||
description: t('gettingStarted.steps.generateTasks.description'),
|
||||
accent: 'border-emerald-200 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950/40',
|
||||
},
|
||||
{
|
||||
index: 3,
|
||||
title: t('gettingStarted.steps.analyzeTasks.title'),
|
||||
description: t('gettingStarted.steps.analyzeTasks.description'),
|
||||
accent: 'border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-950/40',
|
||||
},
|
||||
{
|
||||
index: 4,
|
||||
title: t('gettingStarted.steps.startBuilding.title'),
|
||||
description: t('gettingStarted.steps.startBuilding.description'),
|
||||
accent: 'border-purple-200 dark:border-purple-800 bg-purple-50 dark:bg-purple-950/40',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-4xl max-h-[90vh] overflow-hidden">
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||
<FileText className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">{t('helpGuide.title')}</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{t('helpGuide.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)] space-y-4">
|
||||
{steps.map((step) => (
|
||||
<div key={step.index} className={`rounded-lg border p-4 ${step.accent}`}>
|
||||
<div className="flex gap-4">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-600 text-white text-sm font-semibold flex items-center justify-center flex-shrink-0">
|
||||
{step.index}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{step.title}</h4>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">{step.description}</p>
|
||||
|
||||
{step.index === 1 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
onCreatePrd();
|
||||
onClose();
|
||||
}}
|
||||
className="mt-3 inline-flex items-center gap-2 text-sm bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 px-3 py-1.5 rounded-lg hover:bg-purple-200 dark:hover:bg-purple-900/50"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
{t('buttons.addPRD')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-800/50">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">{t('helpGuide.proTips.title')}</h4>
|
||||
<ul className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li>{t('helpGuide.proTips.search')}</li>
|
||||
<li>{t('helpGuide.proTips.views')}</li>
|
||||
<li>{t('helpGuide.proTips.filters')}</li>
|
||||
<li>{t('helpGuide.proTips.details')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-blue-200 dark:border-blue-800 p-4 bg-blue-50 dark:bg-blue-950/40">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">{t('helpGuide.learnMore.title')}</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">{t('helpGuide.learnMore.description')}</p>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm bg-blue-600 hover:bg-blue-700 text-white px-3 py-2 rounded-lg font-medium"
|
||||
>
|
||||
{t('helpGuide.learnMore.githubButton')}
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/task-master/view/modals/TaskMasterSetupModal.tsx
Normal file
102
src/components/task-master/view/modals/TaskMasterSetupModal.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, Terminal } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import Shell from '../../../shell/view/Shell';
|
||||
import type { TaskMasterProject } from '../../types';
|
||||
|
||||
type TaskMasterSetupModalProps = {
|
||||
isOpen: boolean;
|
||||
project: TaskMasterProject | null;
|
||||
onClose: () => void;
|
||||
onAfterClose?: (() => void) | null;
|
||||
};
|
||||
|
||||
export default function TaskMasterSetupModal({ isOpen, project, onClose, onAfterClose = null }: TaskMasterSetupModalProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);
|
||||
|
||||
if (!isOpen || !project) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
onClose();
|
||||
setIsTaskMasterComplete(false);
|
||||
|
||||
// Delay refresh slightly so the CLI has time to flush writes to disk.
|
||||
window.setTimeout(() => {
|
||||
onAfterClose?.();
|
||||
}, 800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 pt-16 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-4xl h-[600px] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||
<Terminal className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{t('setupModal.title')}</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{t('setupModal.subtitle', { projectName: project.displayName })}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
title="Close"
|
||||
>
|
||||
<Plus className="w-5 h-5 rotate-45" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full bg-black rounded-lg overflow-hidden">
|
||||
<Shell
|
||||
selectedProject={project}
|
||||
selectedSession={null}
|
||||
initialCommand="npx task-master init"
|
||||
isPlainShell
|
||||
isActive
|
||||
onProcessComplete={(exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
setIsTaskMasterComplete(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{isTaskMasterComplete ? (
|
||||
<span className="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
{t('setupModal.completed')}
|
||||
</span>
|
||||
) : (
|
||||
t('setupModal.willStart')
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={closeModal}
|
||||
className={cn(
|
||||
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
|
||||
isTaskMasterComplete
|
||||
? 'bg-green-600 hover:bg-green-700 text-white'
|
||||
: 'text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600',
|
||||
)}
|
||||
>
|
||||
{isTaskMasterComplete ? t('setupModal.closeContinueButton') : t('setupModal.closeButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/components/task-master/view/shared/TaskFiltersPanel.tsx
Normal file
108
src/components/task-master/view/shared/TaskFiltersPanel.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TaskBoardSortField, TaskBoardSortOrder } from '../../types';
|
||||
|
||||
type TaskFiltersPanelProps = {
|
||||
showFilters: boolean;
|
||||
statusFilter: string;
|
||||
onStatusFilterChange: (status: string) => void;
|
||||
priorityFilter: string;
|
||||
onPriorityFilterChange: (priority: string) => void;
|
||||
sortField: TaskBoardSortField;
|
||||
sortOrder: TaskBoardSortOrder;
|
||||
onSortConfigChange: (field: TaskBoardSortField, order: TaskBoardSortOrder) => void;
|
||||
statuses: string[];
|
||||
priorities: string[];
|
||||
filteredTaskCount: number;
|
||||
totalTaskCount: number;
|
||||
onClearFilters: () => void;
|
||||
};
|
||||
|
||||
export default function TaskFiltersPanel({
|
||||
showFilters,
|
||||
statusFilter,
|
||||
onStatusFilterChange,
|
||||
priorityFilter,
|
||||
onPriorityFilterChange,
|
||||
sortField,
|
||||
sortOrder,
|
||||
onSortConfigChange,
|
||||
statuses,
|
||||
priorities,
|
||||
filteredTaskCount,
|
||||
totalTaskCount,
|
||||
onClearFilters,
|
||||
}: TaskFiltersPanelProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
if (!showFilters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('filters.status')}</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => onStatusFilterChange(event.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="all">{t('filters.allStatuses')}</option>
|
||||
{statuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{t(`statuses.${status}`, status)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('filters.priority')}</label>
|
||||
<select
|
||||
value={priorityFilter}
|
||||
onChange={(event) => onPriorityFilterChange(event.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="all">{t('filters.allPriorities')}</option>
|
||||
{priorities.map((priority) => (
|
||||
<option key={priority} value={priority}>
|
||||
{t(`priorities.${priority}`, priority)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{t('filters.sortBy')}</label>
|
||||
<select
|
||||
value={`${sortField}-${sortOrder}`}
|
||||
onChange={(event) => {
|
||||
const [field, order] = event.target.value.split('-') as [TaskBoardSortField, TaskBoardSortOrder];
|
||||
onSortConfigChange(field, order);
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800"
|
||||
>
|
||||
<option value="id-asc">{t('sort.idAsc')}</option>
|
||||
<option value="id-desc">{t('sort.idDesc')}</option>
|
||||
<option value="title-asc">{t('sort.titleAsc')}</option>
|
||||
<option value="title-desc">{t('sort.titleDesc')}</option>
|
||||
<option value="status-asc">{t('sort.statusAsc')}</option>
|
||||
<option value="status-desc">{t('sort.statusDesc')}</option>
|
||||
<option value="priority-asc">{t('sort.priorityAsc')}</option>
|
||||
<option value="priority-desc">{t('sort.priorityDesc')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t('filters.showing', { filtered: filteredTaskCount, total: totalTaskCount })}
|
||||
</div>
|
||||
<button onClick={onClearFilters} className="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium">
|
||||
{t('filters.clearFilters')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/components/task-master/view/shared/TaskQuickSortBar.tsx
Normal file
62
src/components/task-master/view/shared/TaskQuickSortBar.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '../../../../lib/utils';
|
||||
import type { TaskBoardSortField, TaskBoardSortOrder } from '../../types';
|
||||
|
||||
type TaskQuickSortBarProps = {
|
||||
sortField: TaskBoardSortField;
|
||||
sortOrder: TaskBoardSortOrder;
|
||||
onSortChange: (field: TaskBoardSortField) => void;
|
||||
};
|
||||
|
||||
function getSortIcon(field: TaskBoardSortField, currentField: TaskBoardSortField, currentOrder: TaskBoardSortOrder) {
|
||||
if (field !== currentField) {
|
||||
return <ArrowUpDown className="w-4 h-4" />;
|
||||
}
|
||||
|
||||
return currentOrder === 'asc' ? <ArrowUp className="w-4 h-4" /> : <ArrowDown className="w-4 h-4" />;
|
||||
}
|
||||
|
||||
export default function TaskQuickSortBar({ sortField, sortOrder, onSortChange }: TaskQuickSortBarProps) {
|
||||
const { t } = useTranslation('tasks');
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => onSortChange('id')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',
|
||||
sortField === 'id'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
{t('sort.id')} {getSortIcon('id', sortField, sortOrder)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSortChange('status')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',
|
||||
sortField === 'status'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
{t('sort.status')} {getSortIcon('status', sortField, sortOrder)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSortChange('priority')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-md text-sm',
|
||||
sortField === 'priority'
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
{t('sort.priority')} {getSortIcon('priority', sortField, sortOrder)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useWebSocket } from './WebSocketContext';
|
||||
|
||||
const TaskMasterContext = createContext({
|
||||
// TaskMaster project state
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
projectTaskMaster: null,
|
||||
|
||||
// MCP server state
|
||||
mcpServerStatus: null,
|
||||
|
||||
// Tasks state
|
||||
tasks: [],
|
||||
nextTask: null,
|
||||
|
||||
// Loading states
|
||||
isLoading: false,
|
||||
isLoadingTasks: false,
|
||||
isLoadingMCP: false,
|
||||
|
||||
// Error state
|
||||
error: null,
|
||||
|
||||
// Actions
|
||||
refreshProjects: () => {},
|
||||
setCurrentProject: () => {},
|
||||
refreshTasks: () => {},
|
||||
refreshMCPStatus: () => {},
|
||||
clearError: () => {}
|
||||
});
|
||||
|
||||
export const useTaskMaster = () => {
|
||||
const context = useContext(TaskMasterContext);
|
||||
if (!context) {
|
||||
throw new Error('useTaskMaster must be used within a TaskMasterProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const TaskMasterProvider = ({ children }) => {
|
||||
// Get WebSocket messages from shared context to avoid duplicate connections
|
||||
const { latestMessage } = useWebSocket();
|
||||
|
||||
// Authentication context
|
||||
const { user, token, isLoading: authLoading } = useAuth();
|
||||
|
||||
// State
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [currentProject, setCurrentProjectState] = useState(null);
|
||||
const [projectTaskMaster, setProjectTaskMaster] = useState(null);
|
||||
const [mcpServerStatus, setMCPServerStatus] = useState(null);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [nextTask, setNextTask] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingTasks, setIsLoadingTasks] = useState(false);
|
||||
const [isLoadingMCP, setIsLoadingMCP] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Helper to handle API errors
|
||||
const handleError = (error, context) => {
|
||||
console.error(`TaskMaster ${context} error:`, error);
|
||||
setError({
|
||||
message: error.message || `Failed to ${context}`,
|
||||
context,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
// Clear error state
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// This will be defined after the functions are declared
|
||||
|
||||
// Refresh projects with TaskMaster metadata
|
||||
const refreshProjects = useCallback(async () => {
|
||||
// Only make API calls if user is authenticated
|
||||
if (!user || !token) {
|
||||
setProjects([]);
|
||||
setCurrentProjectState(null); // This might be the problem!
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
clearError();
|
||||
const response = await api.get('/projects');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch projects: ${response.status}`);
|
||||
}
|
||||
|
||||
const projectsData = await response.json();
|
||||
|
||||
// Check if projectsData is an array
|
||||
if (!Array.isArray(projectsData)) {
|
||||
console.error('Projects API returned non-array data:', projectsData);
|
||||
setProjects([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter and enrich projects with TaskMaster data
|
||||
const enrichedProjects = projectsData.map(project => ({
|
||||
...project,
|
||||
taskMasterConfigured: project.taskmaster?.hasTaskmaster || false,
|
||||
taskMasterStatus: project.taskmaster?.status || 'not-configured',
|
||||
taskCount: project.taskmaster?.metadata?.taskCount || 0,
|
||||
completedCount: project.taskmaster?.metadata?.completed || 0
|
||||
}));
|
||||
|
||||
setProjects(enrichedProjects);
|
||||
|
||||
// If current project is set, update its TaskMaster data
|
||||
if (currentProject) {
|
||||
const updatedCurrent = enrichedProjects.find(p => p.name === currentProject.name);
|
||||
if (updatedCurrent) {
|
||||
setCurrentProjectState(updatedCurrent);
|
||||
setProjectTaskMaster(updatedCurrent.taskmaster);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, 'load projects');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user, token]); // Remove currentProject dependency to avoid infinite loops
|
||||
|
||||
// Set current project and load its TaskMaster details
|
||||
const setCurrentProject = useCallback(async (project) => {
|
||||
try {
|
||||
setCurrentProjectState(project);
|
||||
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
|
||||
setProjectTaskMaster(project?.taskmaster || null);
|
||||
} catch (err) {
|
||||
console.error('Error in setCurrentProject:', err);
|
||||
handleError(err, 'set current project');
|
||||
setProjectTaskMaster(project?.taskmaster || null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Refresh MCP server status
|
||||
const refreshMCPStatus = useCallback(async () => {
|
||||
// Only make API calls if user is authenticated
|
||||
if (!user || !token) {
|
||||
setMCPServerStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingMCP(true);
|
||||
clearError();
|
||||
const mcpStatus = await api.get('/mcp-utils/taskmaster-server');
|
||||
setMCPServerStatus(mcpStatus);
|
||||
} catch (err) {
|
||||
handleError(err, 'check MCP server status');
|
||||
} finally {
|
||||
setIsLoadingMCP(false);
|
||||
}
|
||||
}, [user, token]);
|
||||
|
||||
// Refresh tasks for current project - load real TaskMaster data
|
||||
const refreshTasks = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only make API calls if user is authenticated
|
||||
if (!user || !token) {
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingTasks(true);
|
||||
clearError();
|
||||
|
||||
// Load tasks from the TaskMaster API endpoint
|
||||
const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(currentProject.name)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to load tasks');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setTasks(data.tasks || []);
|
||||
|
||||
// Find next task (pending or in-progress)
|
||||
const nextTask = data.tasks?.find(task =>
|
||||
task.status === 'pending' || task.status === 'in-progress'
|
||||
) || null;
|
||||
setNextTask(nextTask);
|
||||
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading tasks:', err);
|
||||
handleError(err, 'load tasks');
|
||||
// Set empty state on error
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
} finally {
|
||||
setIsLoadingTasks(false);
|
||||
}
|
||||
}, [currentProject, user, token]);
|
||||
|
||||
// Load initial data on mount or when auth changes
|
||||
useEffect(() => {
|
||||
if (!authLoading && user && token) {
|
||||
refreshProjects();
|
||||
refreshMCPStatus();
|
||||
} else {
|
||||
console.log('Auth not ready or no user, skipping project load:', { authLoading, user: !!user, token: !!token });
|
||||
}
|
||||
}, [refreshProjects, refreshMCPStatus, authLoading, user, token]);
|
||||
|
||||
// Clear errors when authentication changes
|
||||
useEffect(() => {
|
||||
if (user && token) {
|
||||
clearError();
|
||||
}
|
||||
}, [user, token, clearError]);
|
||||
|
||||
// Refresh tasks when current project changes
|
||||
useEffect(() => {
|
||||
if (currentProject?.name && user && token) {
|
||||
refreshTasks();
|
||||
}
|
||||
}, [currentProject?.name, user, token, refreshTasks]);
|
||||
|
||||
// Handle WebSocket latestMessage for TaskMaster updates
|
||||
useEffect(() => {
|
||||
if (!latestMessage) return;
|
||||
|
||||
|
||||
switch (latestMessage.type) {
|
||||
case 'taskmaster-project-updated':
|
||||
// Refresh projects when TaskMaster state changes
|
||||
if (latestMessage.projectName) {
|
||||
refreshProjects();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'taskmaster-tasks-updated':
|
||||
// Refresh tasks for the current project
|
||||
if (latestMessage.projectName === currentProject?.name) {
|
||||
refreshTasks();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'taskmaster-mcp-status-changed':
|
||||
// Refresh MCP server status
|
||||
refreshMCPStatus();
|
||||
break;
|
||||
|
||||
default:
|
||||
// Ignore non-TaskMaster messages
|
||||
break;
|
||||
}
|
||||
}, [latestMessage, refreshProjects, refreshTasks, refreshMCPStatus, currentProject]);
|
||||
|
||||
// Context value
|
||||
const contextValue = {
|
||||
// State
|
||||
projects,
|
||||
currentProject,
|
||||
projectTaskMaster,
|
||||
mcpServerStatus,
|
||||
tasks,
|
||||
nextTask,
|
||||
isLoading,
|
||||
isLoadingTasks,
|
||||
isLoadingMCP,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
refreshProjects,
|
||||
setCurrentProject,
|
||||
refreshTasks,
|
||||
refreshMCPStatus,
|
||||
clearError
|
||||
};
|
||||
|
||||
return (
|
||||
<TaskMasterContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</TaskMasterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskMasterContext;
|
||||
6
src/contexts/TaskMasterContext.ts
Normal file
6
src/contexts/TaskMasterContext.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
TaskMasterProvider,
|
||||
useTaskMaster,
|
||||
} from '../components/task-master/context/TaskMasterContext';
|
||||
|
||||
export { default } from '../components/task-master/context/TaskMasterContext';
|
||||
Reference in New Issue
Block a user