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:
Haileyesus
2026-03-02 17:55:57 +03:00
parent b18e0de2f3
commit cd122913ce
28 changed files with 2796 additions and 2912 deletions

View File

@@ -1,695 +1,3 @@
import React, { useState } from 'react'; import NextTaskBanner from './task-master/view/NextTaskBanner';
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';
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => { export default NextTaskBanner;
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;

View File

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

View File

@@ -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

View File

@@ -75,10 +75,13 @@ function MainContent({
}); });
useEffect(() => { useEffect(() => {
if (selectedProject && selectedProject !== currentProject) { const selectedProjectName = selectedProject?.name;
const currentProjectName = currentProject?.name;
if (selectedProject && selectedProjectName !== currentProjectName) {
setCurrentProject?.(selectedProject); setCurrentProject?.(selectedProject);
} }
}, [selectedProject, currentProject, setCurrentProject]); }, [selectedProject, currentProject?.name, setCurrentProject]);
useEffect(() => { useEffect(() => {
if (!shouldShowTasksTab && activeTab === 'tasks') { if (!shouldShowTasksTab && activeTab === 'tasks') {

View File

@@ -1,206 +1 @@
import { useCallback, useEffect, useRef, useState } from 'react'; export { default } from '../../../task-master/view/TaskMasterPanel';
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>
)}
</>
);
}

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

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

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

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

View 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[];
};

View 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] ?? [],
}));
}

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

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

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

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

View 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} />
</>
);
}

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

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

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

View 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>
)}
</>
);
}

View File

@@ -1,12 +1,18 @@
import React from 'react'; import { Sparkles, X } from 'lucide-react';
import { X, Sparkles } 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 ( return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"> <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"> <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 justify-between p-6 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3"> <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"> <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> </button>
</div> </div>
{/* Content */}
<div className="p-6 space-y-6"> <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="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="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"> <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" /> <Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
</div> </div>
<div className="flex-1"> <div>
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2"> <h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">Pro tip: ask Claude Code directly</h4>
💡 Pro Tip: Ask Claude Code Directly!
</h4>
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3"> <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. Ask for a task in chat with context and requirements. TaskMaster can generate implementation-ready tasks.
The AI assistant will automatically generate detailed tasks with research-backed insights.
</p> </p>
<div className="bg-white dark:bg-gray-800 rounded border border-blue-200 dark:border-blue-700 p-3">
<div className="bg-white dark:bg-gray-800 rounded border border-blue-200 dark:border-blue-700 p-3 mb-3">
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Example:</p> <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"> <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> </p>
</div> </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> </div>
</div> </div>
{/* Learn More Link */}
<div className="text-center pt-4 border-t border-gray-200 dark:border-gray-700"> <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 <a
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md" href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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> </a>
</div> </div>
{/* Footer */} <button
<div className="pt-4"> onClick={onClose}
<button 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"
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
> </button>
Got it, I'll ask Claude Code directly
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
); );
}; }
export default CreateTaskModal;

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

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

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

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

View File

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

View File

@@ -0,0 +1,6 @@
export {
TaskMasterProvider,
useTaskMaster,
} from '../components/task-master/context/TaskMasterContext';
export { default } from '../components/task-master/context/TaskMasterContext';