- {/* Header with Task ID, Title, and Priority */}
-
- {/* Task ID and Title */}
-
-
-
-
- {task.id}
-
-
-
-
- {task.title}
-
- {showParent && task.parentId && (
-
- Task {task.parentId}
-
- )}
-
-
- {/* Priority Icon */}
-
- {getPriorityIcon(task.priority)}
-
-
-
- {/* Footer with Dependencies and Status */}
-
- {/* Dependencies */}
-
- {task.dependencies && Array.isArray(task.dependencies) && task.dependencies.length > 0 && (
-
`Task ${dep}`).join(', ')}`}>
-
-
-
Depends on: {task.dependencies.join(', ')}
-
-
- )}
-
-
- {/* Status Badge */}
-
-
-
-
- {config.statusText}
-
-
-
-
-
- {/* Subtask Progress (if applicable) */}
- {task.subtasks && task.subtasks.length > 0 && (
-
-
-
Progress:
-
st.status === 'done').length} of ${task.subtasks.length} subtasks completed`}>
-
-
st.status === 'done').length / task.subtasks.length) * 100)}%`
- }}
- />
-
-
-
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`}>
-
- {task.subtasks.filter(st => st.status === 'done').length}/{task.subtasks.length}
-
-
-
-
- )}
-
- );
-};
-
-export default TaskCard;
diff --git a/src/components/TaskDetail.jsx b/src/components/TaskDetail.jsx
deleted file mode 100644
index 204094b9..00000000
--- a/src/components/TaskDetail.jsx
+++ /dev/null
@@ -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 (
-
-
- {/* Header */}
-
-
-
-
-
-
- {task.parentId && (
-
- Subtask of Task {task.parentId}
-
- )}
-
- {editMode ? (
-
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"
- />
- ) : (
-
- {task.title}
-
- )}
-
-
-
-
- {editMode ? (
- <>
-
-
- >
- ) : (
-
- )}
-
-
-
-
-
- {/* Content */}
-
- {/* Status and Metadata Row */}
-
- {/* Status */}
-
-
-
-
-
-
- {statusOptions.find(option => option.value === task.status)?.label || task.status}
-
-
-
-
-
- {/* Priority */}
-
-
-
-
- {task.priority || 'Not set'}
-
-
-
- {/* Dependencies */}
-
-
- {task.dependencies && task.dependencies.length > 0 ? (
-
- {task.dependencies.map(depId => (
-
- ))}
-
- ) : (
-
No dependencies
- )}
-
-
-
- {/* Description */}
-
-
- {editMode ? (
-
-
- {/* Implementation Details */}
- {task.details && (
-
-
- {showDetails && (
-
- )}
-
- )}
-
- {/* Test Strategy */}
- {task.testStrategy && (
-
-
- {showTestStrategy && (
-
-
-
- {task.testStrategy}
-
-
-
- )}
-
- )}
-
- {/* Subtasks */}
- {task.subtasks && task.subtasks.length > 0 && (
-
-
-
- {task.subtasks.map(subtask => {
- const subtaskConfig = getStatusConfig(subtask.status);
- const SubtaskIcon = subtaskConfig.icon;
- return (
-
-
-
-
- {subtask.title}
-
- {subtask.description && (
-
- {subtask.description}
-
- )}
-
-
- {subtask.id}
-
-
- );
- })}
-
-
- )}
-
-
-
- {/* Footer */}
-
-
- Task ID: {task.id}
-
-
-
-
-
-
-
-
- );
-};
-
-export default TaskDetail;
\ No newline at end of file
diff --git a/src/components/TaskList.jsx b/src/components/TaskList.jsx
deleted file mode 100644
index 1e89310f..00000000
--- a/src/components/TaskList.jsx
+++ /dev/null
@@ -1,1055 +0,0 @@
-import React, { useState, useMemo, useEffect, useRef } from 'react';
-import { Search, Filter, ArrowUpDown, ArrowUp, ArrowDown, List, Grid, ChevronDown, Columns, Plus, Settings, Terminal, FileText, HelpCircle, X } from 'lucide-react';
-import { cn } from '../lib/utils';
-import TaskCard from './TaskCard';
-import CreateTaskModal from './CreateTaskModal';
-import { useTaskMaster } from '../contexts/TaskMasterContext';
-import Shell from './shell/view/Shell';
-import { api } from '../utils/api';
-import { useTranslation } from 'react-i18next';
-
-const TaskList = ({
- tasks = [],
- onTaskClick,
- className = '',
- showParentTasks = false,
- defaultView = 'kanban', // 'list', 'grid', or 'kanban'
- currentProject,
- onTaskCreated,
- onShowPRDEditor,
- existingPRDs = [],
- onRefreshPRDs
-}) => {
- const [searchTerm, setSearchTerm] = useState('');
- const [statusFilter, setStatusFilter] = useState('all');
- const [priorityFilter, setPriorityFilter] = useState('all');
- const [sortBy, setSortBy] = useState('id'); // 'id', 'title', 'status', 'priority', 'updated'
- const [sortOrder, setSortOrder] = useState('asc'); // 'asc' or 'desc'
- const [viewMode, setViewMode] = useState(defaultView);
- const [showFilters, setShowFilters] = useState(false);
- const [showCreateModal, setShowCreateModal] = useState(false);
- const [showCLI, setShowCLI] = useState(false);
- const [showHelpGuide, setShowHelpGuide] = useState(false);
- const [isTaskMasterComplete, setIsTaskMasterComplete] = useState(false);
- const [showPRDDropdown, setShowPRDDropdown] = useState(false);
- const dropdownRef = useRef(null);
-
- const { projectTaskMaster, refreshProjects, refreshTasks, setCurrentProject } = useTaskMaster();
- const { t } = useTranslation('tasks');
-
- // Close PRD dropdown when clicking outside
- useEffect(() => {
- const handleClickOutside = (event) => {
- if (
- showPRDDropdown &&
- dropdownRef.current &&
- !dropdownRef.current.contains(event.target)
- ) {
- setShowPRDDropdown(false);
- }
- };
-
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }, [showPRDDropdown]);
-
- const loadPRDOptions = async (prd, closeDropdown = false) => {
- if (!currentProject) {
- return;
- }
-
- try {
- const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}/${encodeURIComponent(prd.name)}`);
- if (response.ok) {
- const prdData = await response.json();
- onShowPRDEditor?.({
- name: prd.name,
- content: prdData.content,
- isExisting: true
- });
- if (closeDropdown) {
- setShowPRDDropdown(false);
- }
- } else {
- console.error('Failed to load PRD:', response.statusText);
- }
- } catch (error) {
- console.error('Error loading PRD:', error);
- }
- };
-
- // Get unique status values from tasks
- const statuses = useMemo(() => {
- const statusSet = new Set(tasks.map(task => task.status).filter(Boolean));
- return Array.from(statusSet).sort();
- }, [tasks]);
-
- // Get unique priority values from tasks
- const priorities = useMemo(() => {
- const prioritySet = new Set(tasks.map(task => task.priority).filter(Boolean));
- return Array.from(prioritySet).sort();
- }, [tasks]);
-
- // Filter and sort tasks
- const filteredAndSortedTasks = useMemo(() => {
- let filtered = tasks.filter(task => {
- // Text search
- const searchLower = searchTerm.toLowerCase();
- const matchesSearch = !searchTerm ||
- task.title.toLowerCase().includes(searchLower) ||
- task.description?.toLowerCase().includes(searchLower) ||
- task.id.toString().includes(searchLower);
-
- // Status filter
- const matchesStatus = statusFilter === 'all' || task.status === statusFilter;
-
- // Priority filter
- const matchesPriority = priorityFilter === 'all' || task.priority === priorityFilter;
-
- return matchesSearch && matchesStatus && matchesPriority;
- });
-
- // Sort tasks
- filtered.sort((a, b) => {
- let aVal, bVal;
-
- switch (sortBy) {
- case 'title':
- aVal = a.title.toLowerCase();
- bVal = b.title.toLowerCase();
- break;
- case 'status':
- // Custom status ordering: pending, in-progress, done, blocked, deferred, cancelled
- const statusOrder = { pending: 1, 'in-progress': 2, done: 3, blocked: 4, deferred: 5, cancelled: 6 };
- aVal = statusOrder[a.status] || 99;
- bVal = statusOrder[b.status] || 99;
- break;
- case 'priority':
- // Custom priority ordering: high should be sorted first in descending
- const priorityOrder = { high: 3, medium: 2, low: 1 };
- aVal = priorityOrder[a.priority] || 0;
- bVal = priorityOrder[b.priority] || 0;
- break;
- case 'updated':
- aVal = new Date(a.updatedAt || a.createdAt || 0);
- bVal = new Date(b.updatedAt || b.createdAt || 0);
- break;
- case 'id':
- default:
- // Handle numeric and dotted IDs (1, 1.1, 1.2, 2, 2.1, etc.)
- const parseId = (id) => {
- const parts = id.toString().split('.');
- return parts.map(part => parseInt(part, 10));
- };
-
- const aIds = parseId(a.id);
- const bIds = parseId(b.id);
-
- // Compare each part
- for (let i = 0; i < Math.max(aIds.length, bIds.length); i++) {
- const aId = aIds[i] || 0;
- const bId = bIds[i] || 0;
- if (aId !== bId) {
- aVal = aId;
- bVal = bId;
- break;
- }
- }
- break;
- }
-
- if (sortBy === 'updated') {
- return sortOrder === 'asc' ? aVal - bVal : bVal - aVal;
- }
-
- if (typeof aVal === 'string') {
- return sortOrder === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
- }
-
- return sortOrder === 'asc' ? aVal - bVal : bVal - aVal;
- });
-
- return filtered;
- }, [tasks, searchTerm, statusFilter, priorityFilter, sortBy, sortOrder]);
-
- // Organize tasks by status for Kanban view
- const kanbanColumns = useMemo(() => {
- const allColumns = [
- {
- id: 'pending',
- title: t('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',
- title: t('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',
- title: t('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',
- title: t('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',
- title: t('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',
- title: t('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'
- }
- ];
-
- // Only show columns that have tasks or are part of the main workflow
- const mainWorkflowStatuses = ['pending', 'in-progress', 'done'];
- const columnsWithTasks = allColumns.filter(column => {
- const hasTask = filteredAndSortedTasks.some(task => task.status === column.status);
- const isMainWorkflow = mainWorkflowStatuses.includes(column.status);
- return hasTask || isMainWorkflow;
- });
-
- return columnsWithTasks.map(column => ({
- ...column,
- tasks: filteredAndSortedTasks.filter(task => task.status === column.status)
- }));
- }, [filteredAndSortedTasks, t]);
-
- const handleSortChange = (newSortBy) => {
- if (sortBy === newSortBy) {
- setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
- } else {
- setSortBy(newSortBy);
- setSortOrder('asc');
- }
- };
-
- const clearFilters = () => {
- setSearchTerm('');
- setStatusFilter('all');
- setPriorityFilter('all');
- };
-
- const getSortIcon = (field) => {
- if (sortBy !== field) return
;
- return sortOrder === 'asc' ?
:
;
- };
-
- if (tasks.length === 0) {
- // Check if TaskMaster is configured by looking for .taskmaster directory
- const hasTaskMasterDirectory = currentProject?.taskMasterConfigured ||
- currentProject?.taskmaster?.hasTaskmaster ||
- projectTaskMaster?.hasTaskmaster;
-
- return (
-
- {!hasTaskMasterDirectory ? (
- // TaskMaster not configured
-
-
-
-
-
- {t('notConfigured.title')}
-
-
- {t('notConfigured.description')}
-
-
- {/* What is TaskMaster section */}
-
-
- {t('notConfigured.whatIsTitle')}
-
-
-
• {t('notConfigured.features.aiPowered')}
-
• {t('notConfigured.features.prdTemplates')}
-
• {t('notConfigured.features.dependencyTracking')}
-
• {t('notConfigured.features.progressVisualization')}
-
• {t('notConfigured.features.cliIntegration')}
-
-
-
-
-
- ) : (
- // TaskMaster configured but no tasks - show Getting Started guide
-
-
-
-
-
-
-
-
{t('gettingStarted.title')}
-
{t('gettingStarted.subtitle')}
-
-
-
-
-
- {/* Step 1 */}
-
-
1
-
-
{t('gettingStarted.steps.createPRD.title')}
-
{t('gettingStarted.steps.createPRD.description')}
-
-
- {/* Show existing PRDs if any */}
- {existingPRDs.length > 0 && (
-
-
{t('gettingStarted.steps.createPRD.existingPRDs')}
-
- {existingPRDs.map((prd) => (
-
- ))}
-
-
- )}
-
-
-
- {/* Step 2 */}
-
-
2
-
-
{t('gettingStarted.steps.generateTasks.title')}
-
{t('gettingStarted.steps.generateTasks.description')}
-
-
-
- {/* Step 3 */}
-
-
3
-
-
{t('gettingStarted.steps.analyzeTasks.title')}
-
{t('gettingStarted.steps.analyzeTasks.description')}
-
-
-
- {/* Step 4 */}
-
-
4
-
-
{t('gettingStarted.steps.startBuilding.title')}
-
{t('gettingStarted.steps.startBuilding.description')}
-
-
-
-
-
-
-
-
-
-
-
-
- {t('gettingStarted.tip')}
-
-
-
- )}
-
- {/* TaskMaster CLI Setup Modal */}
- {showCLI && (
-
-
- {/* Modal Header */}
-
-
-
-
-
-
-
{t('setupModal.title')}
-
{t('setupModal.subtitle', { projectName: currentProject?.displayName })}
-
-
-
-
-
- {/* Terminal Container */}
-
-
{
- // Focus the terminal when clicked
- const terminalElement = e.currentTarget.querySelector('.xterm-screen');
- if (terminalElement) {
- terminalElement.focus();
- }
- }}
- >
- {
- setIsTaskMasterComplete(true);
- if (exitCode === 0) {
- // Auto-refresh after successful completion
- setTimeout(() => {
- refreshProjects();
- if (currentProject) {
- setCurrentProject(currentProject);
- }
- }, 1000);
- }
- }}
- />
-
-
-
- {/* Modal Footer */}
-
-
-
- {isTaskMasterComplete ? (
-
-
- {t('setupModal.completed')}
-
- ) : (
- t('setupModal.willStart')
- )}
-
-
-
-
-
-
- )}
-
- );
- }
-
- return (
-
- {/* Header Controls */}
-
- {/* Search Bar */}
-
-
- setSearchTerm(e.target.value)}
- 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 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
- />
-
-
- {/* Controls */}
-
- {/* View Toggle */}
-
-
-
-
-
-
- {/* Filters Toggle */}
-
-
- {/* Action Buttons */}
- {currentProject && (
- <>
- {/* Help Button */}
-
-
- {/* PRD Management */}
-
- {existingPRDs.length > 0 ? (
- // Dropdown when PRDs exist
-
-
-
- {showPRDDropdown && (
-
-
-
-
-
{t('gettingStarted.steps.createPRD.existingPRDs')}
- {existingPRDs.map((prd) => (
-
- ))}
-
-
- )}
-
- ) : (
- // Simple button when no PRDs exist
-
- )}
-
-
- {/* Add Task Button */}
- {((currentProject?.taskMasterConfigured || currentProject?.taskmaster?.hasTaskmaster || projectTaskMaster?.hasTaskmaster) || tasks.length > 0) && (
-
- )}
- >
- )}
-
-
-
- {/* Expanded Filters */}
- {showFilters && (
-
-
- {/* Status Filter */}
-
-
-
-
-
- {/* Priority Filter */}
-
-
-
-
-
- {/* Sort By */}
-
-
-
-
-
-
- {/* Filter Actions */}
-
-
- {t('filters.showing', { filtered: filteredAndSortedTasks.length, total: tasks.length })}
-
-
-
-
- )}
-
- {/* Quick Sort Buttons */}
-
-
-
-
-
-
- {/* Task Cards */}
- {filteredAndSortedTasks.length === 0 ? (
-
-
-
-
{t('noMatchingTasks.title')}
-
{t('noMatchingTasks.description')}
-
-
- ) : viewMode === 'kanban' ? (
- /* Kanban Board Layout - Dynamic grid based on column count */
-
= 6 && "grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6"
- )}>
- {kanbanColumns.map((column) => (
-
- {/* Column Header */}
-
-
-
- {column.title}
-
-
-
- {column.tasks.length}
-
-
-
-
-
- {/* Column Tasks */}
-
- {column.tasks.length === 0 ? (
-
-
-
- {t('kanban.noTasksYet')}
-
-
- {column.status === 'pending' ? t('kanban.tasksWillAppear') :
- column.status === 'in-progress' ? t('kanban.moveTasksHere') :
- column.status === 'done' ? t('kanban.completedTasksHere') :
- t('kanban.statusTasksHere')}
-
-
- ) : (
- column.tasks.map((task) => (
-
- onTaskClick?.(task)}
- showParent={showParentTasks}
- className="w-full shadow-sm hover:shadow-md transition-shadow cursor-pointer"
- />
-
- ))
- )}
-
-
- ))}
-
- ) : (
-
- {filteredAndSortedTasks.map((task) => (
- onTaskClick?.(task)}
- showParent={showParentTasks}
- className={viewMode === 'grid' ? 'h-full' : ''}
- />
- ))}
-
- )}
-
- {/* Create Task Modal */}
- {showCreateModal && (
-
setShowCreateModal(false)}
- onTaskCreated={() => {
- setShowCreateModal(false);
- if (onTaskCreated) onTaskCreated();
- }}
- />
- )}
-
- {/* Help Guide Modal */}
- {showHelpGuide && (
-
-
- {/* Modal Header */}
-
-
-
-
-
-
-
{t('helpGuide.title')}
-
{t('helpGuide.subtitle')}
-
-
-
-
-
- {/* Modal Content */}
-
-
- {/* Step 1 */}
-
-
1
-
-
{t('gettingStarted.steps.createPRD.title')}
-
{t('gettingStarted.steps.createPRD.description')}
-
-
-
-
- {/* Step 2 */}
-
-
2
-
-
{t('gettingStarted.steps.generateTasks.title')}
-
{t('gettingStarted.steps.generateTasks.description')}
-
-
- {t('helpGuide.examples.parsePRD')}
-
-
-
-
-
- {/* Step 3 */}
-
-
3
-
-
{t('gettingStarted.steps.analyzeTasks.title')}
-
{t('gettingStarted.steps.analyzeTasks.description')}
-
-
- {t('helpGuide.examples.expandTask')}
-
-
-
-
-
- {/* Step 4 */}
-
-
4
-
-
{t('gettingStarted.steps.startBuilding.title')}
-
{t('gettingStarted.steps.startBuilding.description')}
-
-
- {t('helpGuide.examples.addTask')}
-
-
-
- {t('helpGuide.moreExamples')}
-
-
-
-
- {/* Pro Tips */}
-
-
{t('helpGuide.proTips.title')}
-
- -
-
- {t('helpGuide.proTips.search')}
-
- -
-
- {t('helpGuide.proTips.views')}
-
- -
-
- {t('helpGuide.proTips.filters')}
-
- -
-
- {t('helpGuide.proTips.details')}
-
-
-
-
- {/* Learn More Section */}
-
-
-
-
-
- )}
-
- );
-};
-
-export default TaskList;
diff --git a/src/components/main-content/view/MainContent.tsx b/src/components/main-content/view/MainContent.tsx
index 8cfdfb0d..471e19ea 100644
--- a/src/components/main-content/view/MainContent.tsx
+++ b/src/components/main-content/view/MainContent.tsx
@@ -75,10 +75,13 @@ function MainContent({
});
useEffect(() => {
- if (selectedProject && selectedProject !== currentProject) {
+ const selectedProjectName = selectedProject?.name;
+ const currentProjectName = currentProject?.name;
+
+ if (selectedProject && selectedProjectName !== currentProjectName) {
setCurrentProject?.(selectedProject);
}
- }, [selectedProject, currentProject, setCurrentProject]);
+ }, [selectedProject, currentProject?.name, setCurrentProject]);
useEffect(() => {
if (!shouldShowTasksTab && activeTab === 'tasks') {
diff --git a/src/components/main-content/view/subcomponents/TaskMasterPanel.tsx b/src/components/main-content/view/subcomponents/TaskMasterPanel.tsx
index 0355c2bc..ad4d89df 100644
--- a/src/components/main-content/view/subcomponents/TaskMasterPanel.tsx
+++ b/src/components/main-content/view/subcomponents/TaskMasterPanel.tsx
@@ -1,206 +1 @@
-import { useCallback, useEffect, useRef, useState } from 'react';
-import TaskList from '../../../TaskList';
-import TaskDetail from '../../../TaskDetail';
-import PRDEditor from '../../../prd-editor';
-import { useTaskMaster } from '../../../../contexts/TaskMasterContext';
-import { api } from '../../../../utils/api';
-import type { Project } from '../../../../types/app';
-import type { PrdFile, TaskMasterPanelProps, TaskMasterTask, TaskSelection } from '../../types/types';
-
-const AnyTaskList = TaskList as any;
-const AnyTaskDetail = TaskDetail as any;
-
-type TaskMasterContextValue = {
- tasks?: TaskMasterTask[];
- currentProject?: Project | null;
- refreshTasks?: (() => void) | null;
-};
-
-type PrdListResponse = {
- prdFiles?: PrdFile[];
- prds?: PrdFile[];
-};
-
-const PRD_SAVED_MESSAGE = 'PRD saved successfully!';
-
-function getPrdFiles(data: PrdListResponse): PrdFile[] {
- return data.prdFiles || data.prds || [];
-}
-
-export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
- const { tasks = [], currentProject, refreshTasks } = useTaskMaster() as TaskMasterContextValue;
-
- const [selectedTask, setSelectedTask] = useState
(null);
- const [showTaskDetail, setShowTaskDetail] = useState(false);
-
- const [showPRDEditor, setShowPRDEditor] = useState(false);
- const [selectedPRD, setSelectedPRD] = useState(null);
- const [existingPRDs, setExistingPRDs] = useState([]);
- const [prdNotification, setPRDNotification] = useState(null);
-
- const prdNotificationTimeoutRef = useRef | 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 (
- <>
-
-
-
{
- void refreshPrds(showNotification);
- }}
- />
-
-
-
- {showTaskDetail && selectedTask && (
-
- )}
-
- {showPRDEditor && (
-
- )}
-
- {prdNotification && (
-
-
-
-
{prdNotification}
-
-
- )}
- >
- );
-}
+export { default } from '../../../task-master/view/TaskMasterPanel';
diff --git a/src/components/task-master/context/TaskMasterContext.tsx b/src/components/task-master/context/TaskMasterContext.tsx
new file mode 100644
index 00000000..01f6e300
--- /dev/null
+++ b/src/components/task-master/context/TaskMasterContext.tsx
@@ -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(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([]);
+ 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);
+
+ const currentProjectNameRef = useRef(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(
+ () => ({
+ 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 {children};
+}
+
+export default TaskMasterContext;
diff --git a/src/components/task-master/hooks/useProjectPrdFiles.ts b/src/components/task-master/hooks/useProjectPrdFiles.ts
new file mode 100644
index 00000000..36ffa92a
--- /dev/null
+++ b/src/components/task-master/hooks/useProjectPrdFiles.ts
@@ -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([]);
+ 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,
+ };
+}
diff --git a/src/components/task-master/hooks/useTaskBoardState.ts b/src/components/task-master/hooks/useTaskBoardState.ts
new file mode 100644
index 00000000..299bd79f
--- /dev/null
+++ b/src/components/task-master/hooks/useTaskBoardState.ts
@@ -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('id');
+ const [sortOrder, setSortOrder] = useState('asc');
+ const [viewMode, setViewMode] = useState(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(() => {
+ 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,
+ };
+}
diff --git a/src/components/task-master/index.ts b/src/components/task-master/index.ts
new file mode 100644
index 00000000..389066fb
--- /dev/null
+++ b/src/components/task-master/index.ts
@@ -0,0 +1,4 @@
+export { default as TaskMasterPanel } from './view/TaskMasterPanel';
+export { default as NextTaskBanner } from './view/NextTaskBanner';
+
+export { TaskMasterProvider, useTaskMaster } from './context/TaskMasterContext';
\ No newline at end of file
diff --git a/src/components/task-master/types.ts b/src/components/task-master/types.ts
new file mode 100644
index 00000000..bfbebbe2
--- /dev/null
+++ b/src/components/task-master/types.ts
@@ -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;
+ [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;
+ setCurrentProject: (project: TaskMasterProjectInput) => void;
+ refreshTasks: () => Promise;
+ refreshMCPStatus: () => Promise;
+ 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[];
+};
diff --git a/src/components/task-master/utils/taskKanban.ts b/src/components/task-master/utils/taskKanban.ts
new file mode 100644
index 00000000..6f07ce4b
--- /dev/null
+++ b/src/components/task-master/utils/taskKanban.ts
@@ -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>((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] ?? [],
+ }));
+}
diff --git a/src/components/task-master/utils/taskSorting.ts b/src/components/task-master/utils/taskSorting.ts
new file mode 100644
index 00000000..c998b2df
--- /dev/null
+++ b/src/components/task-master/utils/taskSorting.ts
@@ -0,0 +1,100 @@
+import type { TaskBoardSortField, TaskBoardSortOrder, TaskMasterTask } from '../types';
+
+const STATUS_ORDER: Record = {
+ pending: 1,
+ 'in-progress': 2,
+ review: 3,
+ done: 4,
+ blocked: 5,
+ deferred: 6,
+ cancelled: 7,
+};
+
+const PRIORITY_ORDER: Record = {
+ 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';
+}
diff --git a/src/components/task-master/view/NextTaskBanner.tsx b/src/components/task-master/view/NextTaskBanner.tsx
new file mode 100644
index 00000000..77b2151c
--- /dev/null
+++ b/src/components/task-master/view/NextTaskBanner.tsx
@@ -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 (
+
+
+
+ );
+ }
+
+ if (priority === 'medium') {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
+
+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 (
+ <>
+
+
+
+
+
TaskMaster AI is not configured
+
+
+
+
+
+
+
+ {showSetupDetails && (
+
+
- AI-powered task management with dependencies and subtasks.
+
- PRD-driven task generation for faster project bootstrapping.
+
- Kanban and list views for day-to-day execution.
+
+ )}
+
+
+ setShowSetupModal(false)}
+ onAfterClose={handleSetupRefresh}
+ />
+ >
+ );
+ }
+
+ if (nextTask) {
+ return (
+ <>
+
+
+
+
+
+
+
+
Task {nextTask.id}
+
+
+
{nextTask.title}
+
+
+
+
+
+
+
+ {onShowAllTasks && (
+
+ )}
+
+
+
+
+ setShowTaskDetail(false)}
+ onStatusChange={() => {
+ void refreshTasks();
+ }}
+ />
+ >
+ );
+ }
+
+ if (hasTasks) {
+ const completedTasks = tasks.filter((task) => task.status === 'done').length;
+
+ return (
+
+
+
+
+
+ {completedTasks === tasks.length ? 'All tasks complete' : 'No pending tasks'}
+
+
+
+
+ {completedTasks}/{tasks.length}
+
+ {onShowAllTasks && (
+
+ )}
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/components/task-master/view/TaskBoard.tsx b/src/components/task-master/view/TaskBoard.tsx
new file mode 100644
index 00000000..392cf15d
--- /dev/null
+++ b/src/components/task-master/view/TaskBoard.tsx
@@ -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 (
+ <>
+ setShowSetupModal(true)}
+ onCreatePrd={() => onShowPRDEditor?.()}
+ onOpenPrd={(prd) => {
+ void loadPrdAndOpenEditor(prd);
+ }}
+ />
+
+ setShowSetupModal(false)}
+ onAfterClose={refreshAfterSetup}
+ />
+ >
+ );
+ }
+
+ return (
+
+ 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)}
+ />
+
+ onTaskClick?.(task)}
+ />
+
+ {
+ setShowCreateModal(false);
+ onTaskCreated?.();
+ }}
+ />
+
+ setShowHelpModal(false)}
+ onCreatePrd={() => onShowPRDEditor?.()}
+ />
+
+ setShowSetupModal(false)}
+ onAfterClose={refreshAfterSetup}
+ />
+
+ );
+}
diff --git a/src/components/task-master/view/TaskBoardContent.tsx b/src/components/task-master/view/TaskBoardContent.tsx
new file mode 100644
index 00000000..e5a3ae7f
--- /dev/null
+++ b/src/components/task-master/view/TaskBoardContent.tsx
@@ -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 (
+ = 6 && 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
+ )}
+ >
+ {columns.map((column) => (
+
+
+
+
{column.title}
+
+ {column.tasks.length}
+
+
+
+
+
+ {column.tasks.length === 0 ? (
+
+
+
{t('kanban.noTasksYet')}
+
+ {column.status === 'pending'
+ ? t('kanban.tasksWillAppear')
+ : column.status === 'in-progress'
+ ? t('kanban.moveTasksHere')
+ : column.status === 'done'
+ ? t('kanban.completedTasksHere')
+ : t('kanban.statusTasksHere')}
+
+
+ ) : (
+ column.tasks.map((task) => (
+
onTaskClick(task)}
+ showParent={showParentTasks}
+ className="w-full shadow-sm hover:shadow-md"
+ />
+ ))
+ )}
+
+
+ ))}
+
+ );
+}
+
+export default function TaskBoardContent({
+ viewMode,
+ filteredTaskCount,
+ kanbanColumns,
+ filteredTasks,
+ showParentTasks,
+ onTaskClick,
+}: TaskBoardContentProps) {
+ const { t } = useTranslation('tasks');
+
+ if (filteredTaskCount === 0) {
+ return (
+
+
+
+
{t('noMatchingTasks.title')}
+
{t('noMatchingTasks.description')}
+
+
+ );
+ }
+
+ if (viewMode === 'kanban') {
+ return ;
+ }
+
+ return (
+
+ {filteredTasks.map((task) => (
+ onTaskClick(task)}
+ showParent={showParentTasks}
+ className={viewMode === 'grid' ? 'h-full' : ''}
+ />
+ ))}
+
+ );
+}
diff --git a/src/components/task-master/view/TaskBoardToolbar.tsx b/src/components/task-master/view/TaskBoardToolbar.tsx
new file mode 100644
index 00000000..90702e3e
--- /dev/null
+++ b/src/components/task-master/view/TaskBoardToolbar.tsx
@@ -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(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 (
+ <>
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {hasProject && (
+ <>
+
+
+
+ {existingPrds.length > 0 ? (
+ <>
+
+
+ {isPrdDropdownOpen && (
+
+
+
+
+
+
+ {existingPrds.map((prd) => (
+
+ ))}
+
+
+ )}
+ >
+ ) : (
+
+ )}
+
+
+ {(hasTaskMasterConfigured || totalTaskCount > 0) && (
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/components/task-master/view/TaskCard.tsx b/src/components/task-master/view/TaskCard.tsx
new file mode 100644
index 00000000..986e01b0
--- /dev/null
+++ b/src/components/task-master/view/TaskCard.tsx
@@ -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 (
+
+
+
+
+
+ );
+ }
+
+ if (priority === 'medium') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (priority === 'low') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ );
+}
+
+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 (
+
+
+
+
+
+
+ {task.id}
+
+
+
+
+
+ {task.title}
+
+
+ {showParent && task.parentId && (
+
Task {task.parentId}
+ )}
+
+
+
{renderPriorityIcon(task.priority)}
+
+
+
+
+ {Array.isArray(task.dependencies) && task.dependencies.length > 0 && (
+
`Task ${dependency}`).join(', ')}`}>
+
+
+
Depends on: {task.dependencies.join(', ')}
+
+
+ )}
+
+
+
+
+
+
{statusStyle.statusText}
+
+
+
+
+ {progress.total > 0 && (
+
+
+
Progress:
+
+
+ {progress.completed}/{progress.total}
+
+
+
+ )}
+
+ );
+}
+
+export default memo(TaskCard);
diff --git a/src/components/task-master/view/TaskDetailModal.tsx b/src/components/task-master/view/TaskDetailModal.tsx
new file mode 100644
index 00000000..c58c6299
--- /dev/null
+++ b/src/components/task-master/view/TaskDetailModal.tsx
@@ -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(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 = {};
+
+ 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 (
+
+
+
+
+
+
+
+
+ {isEditMode ? (
+ 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"
+ />
+ ) : (
+
{task.title}
+ )}
+
+
+
+
+ {isEditMode ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {task.priority ?? 'Not set'}
+
+
+
+
+
+ {Array.isArray(task.dependencies) && task.dependencies.length > 0 ? (
+
+ {task.dependencies.map((dependency) => (
+
+ ))}
+
+ ) : (
+
No dependencies
+ )}
+
+
+
+
+
+ {isEditMode ? (
+
+
+ {task.details && (
+
+
+ {showDetails && (
+
+ )}
+
+ )}
+
+ {task.testStrategy && (
+
+
+ {showTestStrategy && (
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/task-master/view/TaskEmptyState.tsx b/src/components/task-master/view/TaskEmptyState.tsx
new file mode 100644
index 00000000..1c1e6cdf
--- /dev/null
+++ b/src/components/task-master/view/TaskEmptyState.tsx
@@ -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 (
+
+
+
+
+
+
+
{t('notConfigured.title')}
+
{t('notConfigured.description')}
+
+
+
{t('notConfigured.whatIsTitle')}
+
+
- {t('notConfigured.features.aiPowered')}
+
- {t('notConfigured.features.prdTemplates')}
+
- {t('notConfigured.features.dependencyTracking')}
+
- {t('notConfigured.features.progressVisualization')}
+
- {t('notConfigured.features.cliIntegration')}
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
{t('gettingStarted.title')}
+
{t('gettingStarted.subtitle')}
+
+
+
+
+
+
1. {t('gettingStarted.steps.createPRD.title')}
+
{t('gettingStarted.steps.createPRD.description')}
+
+
+
+ {existingPrds.length > 0 && (
+
+
{t('gettingStarted.steps.createPRD.existingPRDs')}
+
+ {existingPrds.map((prd) => (
+
+ ))}
+
+
+ )}
+
+
+
+
2. {t('gettingStarted.steps.generateTasks.title')}
+
{t('gettingStarted.steps.generateTasks.description')}
+
+
+
+
3. {t('gettingStarted.steps.analyzeTasks.title')}
+
{t('gettingStarted.steps.analyzeTasks.description')}
+
+
+
+
4. {t('gettingStarted.steps.startBuilding.title')}
+
{t('gettingStarted.steps.startBuilding.description')}
+
+
+
+
+
+
+
{t('gettingStarted.tip')}
+
+
+ );
+}
diff --git a/src/components/task-master/view/TaskMasterPanel.tsx b/src/components/task-master/view/TaskMasterPanel.tsx
new file mode 100644
index 00000000..d7445d0c
--- /dev/null
+++ b/src/components/task-master/view/TaskMasterPanel.tsx
@@ -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(null);
+ const [isTaskDetailOpen, setIsTaskDetailOpen] = useState(false);
+
+ const [isPrdEditorOpen, setIsPrdEditorOpen] = useState(false);
+ const [selectedPrd, setSelectedPrd] = useState(null);
+
+ const [prdNotification, setPrdNotification] = useState(null);
+ const notificationTimeoutRef = useRef(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 (
+ <>
+
+
+ {
+ setSelectedPrd(prd ?? null);
+ setIsPrdEditorOpen(true);
+ }}
+ existingPRDs={prdFiles}
+ onRefreshPRDs={(showNotification = false) => {
+ void refreshPrdData(showNotification);
+ }}
+ />
+
+
+
+ {
+ setIsTaskDetailOpen(false);
+ setSelectedTask(null);
+ }}
+ onStatusChange={() => {
+ void refreshTasks();
+ }}
+ onTaskClick={handleTaskClick}
+ />
+
+ {isPrdEditorOpen && (
+ {
+ 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 && (
+
+
+
+
{prdNotification}
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/CreateTaskModal.jsx b/src/components/task-master/view/modals/CreateTaskModal.tsx
similarity index 57%
rename from src/components/CreateTaskModal.jsx
rename to src/components/task-master/view/modals/CreateTaskModal.tsx
index 81af285a..bb4fa4b4 100644
--- a/src/components/CreateTaskModal.jsx
+++ b/src/components/task-master/view/modals/CreateTaskModal.tsx
@@ -1,12 +1,18 @@
-import React from 'react';
-import { X, Sparkles } from 'lucide-react';
+import { Sparkles, X } from 'lucide-react';
-const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
+type CreateTaskModalProps = {
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+export default function CreateTaskModal({ isOpen, onClose }: CreateTaskModalProps) {
+ if (!isOpen) {
+ return null;
+ }
return (
- {/* Header */}
@@ -22,67 +28,46 @@ const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
- {/* Content */}
- {/* AI-First Approach */}
-
-
- 💡 Pro Tip: Ask Claude Code Directly!
-
+
+
Pro tip: ask Claude Code directly
- You can simply ask Claude Code in the chat to create tasks for you.
- The AI assistant will automatically generate detailed tasks with research-backed insights.
+ Ask for a task in chat with context and requirements. TaskMaster can generate implementation-ready tasks.
-
-
+
Example:
- "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.
-
-
- This runs:
- task-master add-task --prompt="Implement user profile image uploads using Cloudinary" --research
-
-
- {/* Learn More Link */}
- {/* Footer */}
-
-
-
+
);
-};
-
-export default CreateTaskModal;
\ No newline at end of file
+}
diff --git a/src/components/task-master/view/modals/TaskHelpModal.tsx b/src/components/task-master/view/modals/TaskHelpModal.tsx
new file mode 100644
index 00000000..4c10c287
--- /dev/null
+++ b/src/components/task-master/view/modals/TaskHelpModal.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
{t('helpGuide.title')}
+
{t('helpGuide.subtitle')}
+
+
+
+
+
+
+
+ {steps.map((step) => (
+
+
+
+ {step.index}
+
+
+
{step.title}
+
{step.description}
+
+ {step.index === 1 && (
+
+ )}
+
+
+
+ ))}
+
+
+
{t('helpGuide.proTips.title')}
+
+ - {t('helpGuide.proTips.search')}
+ - {t('helpGuide.proTips.views')}
+ - {t('helpGuide.proTips.filters')}
+ - {t('helpGuide.proTips.details')}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/task-master/view/modals/TaskMasterSetupModal.tsx b/src/components/task-master/view/modals/TaskMasterSetupModal.tsx
new file mode 100644
index 00000000..36f8b845
--- /dev/null
+++ b/src/components/task-master/view/modals/TaskMasterSetupModal.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
{t('setupModal.title')}
+
{t('setupModal.subtitle', { projectName: project.displayName })}
+
+
+
+
+
+
+
+
+ {
+ if (exitCode === 0) {
+ setIsTaskMasterComplete(true);
+ }
+ }}
+ />
+
+
+
+
+
+
+ {isTaskMasterComplete ? (
+
+
+ {t('setupModal.completed')}
+
+ ) : (
+ t('setupModal.willStart')
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/task-master/view/shared/TaskFiltersPanel.tsx b/src/components/task-master/view/shared/TaskFiltersPanel.tsx
new file mode 100644
index 00000000..35e83047
--- /dev/null
+++ b/src/components/task-master/view/shared/TaskFiltersPanel.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t('filters.showing', { filtered: filteredTaskCount, total: totalTaskCount })}
+
+
+
+
+ );
+}
diff --git a/src/components/task-master/view/shared/TaskQuickSortBar.tsx b/src/components/task-master/view/shared/TaskQuickSortBar.tsx
new file mode 100644
index 00000000..6d970386
--- /dev/null
+++ b/src/components/task-master/view/shared/TaskQuickSortBar.tsx
@@ -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
;
+ }
+
+ return currentOrder === 'asc' ?
:
;
+}
+
+export default function TaskQuickSortBar({ sortField, sortOrder, onSortChange }: TaskQuickSortBarProps) {
+ const { t } = useTranslation('tasks');
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/contexts/TaskMasterContext.jsx b/src/contexts/TaskMasterContext.jsx
deleted file mode 100644
index 77e996f2..00000000
--- a/src/contexts/TaskMasterContext.jsx
+++ /dev/null
@@ -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 (
-
- {children}
-
- );
-};
-
-export default TaskMasterContext;
\ No newline at end of file
diff --git a/src/contexts/TaskMasterContext.ts b/src/contexts/TaskMasterContext.ts
new file mode 100644
index 00000000..c5732edf
--- /dev/null
+++ b/src/contexts/TaskMasterContext.ts
@@ -0,0 +1,6 @@
+export {
+ TaskMasterProvider,
+ useTaskMaster,
+} from '../components/task-master/context/TaskMasterContext';
+
+export { default } from '../components/task-master/context/TaskMasterContext';