Files
claude-task-master/apps/extension/src/webview/index.tsx
Ralph Khreish 722b6c5836 feat: add universal logger instead of console.log.
- fixed esbuild issues
- converted to npm instead of pnpm since root project is npm (might switch whole project to pnpm later)
2025-07-28 17:03:35 +03:00

1727 lines
46 KiB
TypeScript

import React, {
useState,
useEffect,
useReducer,
useContext,
createContext,
useCallback
} from 'react';
import { createRoot } from 'react-dom/client';
// Import shadcn Kanban components
import {
KanbanProvider,
KanbanBoard,
KanbanHeader,
KanbanCards,
KanbanCard,
type DragEndEvent,
type Status,
type Feature
} from '@/components/ui/shadcn-io/kanban';
// Import TaskDetailsView component
import TaskDetailsView from '../components/TaskDetailsView';
// TypeScript interfaces for Task Master integration
export interface TaskMasterTask {
id: string;
title: string;
description: string;
status: 'pending' | 'in-progress' | 'done' | 'deferred' | 'review';
priority: 'high' | 'medium' | 'low';
dependencies?: string[];
details?: string;
testStrategy?: string;
subtasks?: TaskMasterTask[];
complexityScore?: number;
}
interface WebviewMessage {
type: string;
requestId?: string;
data?: any;
success?: boolean;
[key: string]: any;
}
// VS Code API declaration
declare global {
interface Window {
acquireVsCodeApi?: () => {
postMessage: (message: any) => void;
setState: (state: any) => void;
getState: () => any;
};
}
}
// State management types
interface AppState {
tasks: TaskMasterTask[];
loading: boolean;
error?: string;
requestId: number;
isConnected: boolean;
connectionStatus: string;
editingTask?: { taskId: string | null; editData?: TaskMasterTask };
polling: {
isActive: boolean;
errorCount: number;
lastUpdate?: number;
isUserInteracting: boolean;
// Network status
isOfflineMode: boolean;
reconnectAttempts: number;
maxReconnectAttempts: number;
lastSuccessfulConnection?: number;
connectionStatus: 'online' | 'offline' | 'reconnecting';
};
// Toast notifications
toastNotifications: ToastNotification[];
// Navigation state
currentView: 'kanban' | 'task-details';
selectedTaskId?: string;
}
// Add interface for task updates
export interface TaskUpdates {
title?: string;
description?: string;
details?: string;
priority?: TaskMasterTask['priority'];
testStrategy?: string;
dependencies?: string[];
}
// Add state for task editing
type AppAction =
| { type: 'SET_TASKS'; payload: TaskMasterTask[] }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string }
| { type: 'CLEAR_ERROR' }
| { type: 'INCREMENT_REQUEST_ID' }
| {
type: 'UPDATE_TASK_STATUS';
payload: { taskId: string; newStatus: TaskMasterTask['status'] };
}
| {
type: 'UPDATE_TASK_CONTENT';
payload: { taskId: string; updates: TaskUpdates };
}
| {
type: 'SET_CONNECTION_STATUS';
payload: { isConnected: boolean; status: string };
}
| {
type: 'SET_EDITING_TASK';
payload: { taskId: string | null; editData?: TaskMasterTask };
}
| {
type: 'SET_POLLING_STATUS';
payload: { isActive: boolean; errorCount?: number };
}
| { type: 'SET_USER_INTERACTING'; payload: boolean }
| { type: 'TASKS_UPDATED_FROM_POLLING'; payload: TaskMasterTask[] }
| {
type: 'SET_NETWORK_STATUS';
payload: {
isOfflineMode: boolean;
connectionStatus: 'online' | 'offline' | 'reconnecting';
reconnectAttempts?: number;
maxReconnectAttempts?: number;
lastSuccessfulConnection?: number;
};
}
| { type: 'LOAD_CACHED_TASKS'; payload: TaskMasterTask[] }
| { type: 'ADD_TOAST'; payload: ToastNotification }
| { type: 'REMOVE_TOAST'; payload: string }
| { type: 'CLEAR_ALL_TOASTS' }
| { type: 'NAVIGATE_TO_TASK'; payload: string }
| { type: 'NAVIGATE_TO_KANBAN' };
// Toast notification interfaces
interface ToastNotification {
id: string;
type: 'success' | 'info' | 'warning' | 'error';
title: string;
message: string;
duration?: number;
timestamp: number;
}
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
errorInfo?: React.ErrorInfo;
}
// Error Boundary Component
class ErrorBoundary extends React.Component<
{
children: React.ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
},
ErrorBoundaryState
> {
constructor(props: {
children: React.ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('React Error Boundary caught an error:', error, errorInfo);
this.setState({ error, errorInfo });
// Notify parent component of error
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
// Send error to extension for centralized handling
if (window.acquireVsCodeApi) {
const vscode = window.acquireVsCodeApi();
vscode.postMessage({
type: 'reactError',
data: {
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: Date.now()
}
});
}
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-vscode-background">
<div className="max-w-md mx-auto text-center p-6">
<div className="w-16 h-16 mx-auto mb-4 text-red-400">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.962-.833-2.732 0L3.732 19c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-vscode-foreground mb-2">
Something went wrong
</h2>
<p className="text-vscode-foreground/70 mb-4">
The Task Master Kanban board encountered an unexpected error.
</p>
<div className="space-y-2">
<button
onClick={() =>
this.setState({
hasError: false,
error: undefined,
errorInfo: undefined
})
}
className="w-full px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md transition-colors"
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="w-full px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-md transition-colors"
>
Reload Extension
</button>
</div>
{this.state.error && (
<details className="mt-4 text-left">
<summary className="text-sm text-vscode-foreground/50 cursor-pointer">
Error Details
</summary>
<pre className="mt-2 text-xs text-vscode-foreground/70 bg-vscode-input/30 p-2 rounded overflow-auto max-h-32">
{this.state.error.message}
{this.state.error.stack && `\n\n${this.state.error.stack}`}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}
// Toast Notification Component
const ToastNotification: React.FC<{
notification: ToastNotification;
onDismiss: (id: string) => void;
}> = ({ notification, onDismiss }) => {
const [isVisible, setIsVisible] = useState(true);
const [progress, setProgress] = useState(100);
const duration = notification.duration || 5000; // 5 seconds default
useEffect(() => {
const progressInterval = setInterval(() => {
setProgress((prev) => {
const decrease = (100 / duration) * 100; // Update every 100ms
return Math.max(0, prev - decrease);
});
}, 100);
const timeoutId = setTimeout(() => {
setIsVisible(false);
setTimeout(() => onDismiss(notification.id), 300); // Wait for animation
}, duration);
return () => {
clearInterval(progressInterval);
clearTimeout(timeoutId);
};
}, [notification.id, duration, onDismiss]);
const getIcon = () => {
switch (notification.type) {
case 'success':
return (
<svg
className="w-5 h-5 text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
);
case 'warning':
return (
<svg
className="w-5 h-5 text-yellow-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.962-.833-2.732 0L3.732 19c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
);
case 'error':
return (
<svg
className="w-5 h-5 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
);
default:
return (
<svg
className="w-5 h-5 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
};
const getColorClasses = () => {
switch (notification.type) {
case 'success':
return 'bg-green-500/10 border-green-500/30 text-green-400';
case 'warning':
return 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400';
case 'error':
return 'bg-red-500/10 border-red-500/30 text-red-400';
default:
return 'bg-blue-500/10 border-blue-500/30 text-blue-400';
}
};
return (
<div
className={`
transform transition-all duration-300 ease-in-out
${isVisible ? 'translate-x-0 opacity-100' : 'translate-x-full opacity-0'}
max-w-sm w-full bg-vscode-background border rounded-lg shadow-lg p-4 relative overflow-hidden
${getColorClasses()}
`}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">{getIcon()}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-vscode-foreground">
{notification.title}
</p>
<p className="mt-1 text-sm text-vscode-foreground/70">
{notification.message}
</p>
</div>
<button
onClick={() => onDismiss(notification.id)}
className="flex-shrink-0 ml-2 text-vscode-foreground/50 hover:text-vscode-foreground transition-colors"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Progress bar */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/10">
<div
className="h-full bg-current transition-all ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
};
// Toast Container Component
const ToastContainer: React.FC<{
notifications: ToastNotification[];
onDismiss: (id: string) => void;
}> = ({ notifications, onDismiss }) => {
return (
<div className="fixed top-4 right-4 z-50 space-y-3 pointer-events-none">
<div className="space-y-3">
{notifications.map((notification) => (
<div key={notification.id} className="pointer-events-auto">
<ToastNotification
notification={notification}
onDismiss={onDismiss}
/>
</div>
))}
</div>
</div>
);
};
// Toast helper functions
const createToast = (
type: ToastNotification['type'],
title: string,
message: string,
duration?: number
): ToastNotification => {
return {
id: `toast_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type,
title,
message,
duration,
timestamp: Date.now()
};
};
const showSuccessToast =
(dispatch: React.Dispatch<AppAction>) =>
(title: string, message: string, duration?: number) => {
dispatch({
type: 'ADD_TOAST',
payload: createToast('success', title, message, duration)
});
};
const showInfoToast =
(dispatch: React.Dispatch<AppAction>) =>
(title: string, message: string, duration?: number) => {
dispatch({
type: 'ADD_TOAST',
payload: createToast('info', title, message, duration)
});
};
const showWarningToast =
(dispatch: React.Dispatch<AppAction>) =>
(title: string, message: string, duration?: number) => {
dispatch({
type: 'ADD_TOAST',
payload: createToast('warning', title, message, duration)
});
};
const showErrorToast =
(dispatch: React.Dispatch<AppAction>) =>
(title: string, message: string, duration?: number) => {
dispatch({
type: 'ADD_TOAST',
payload: createToast('error', title, message, duration)
});
};
// Kanban column configuration
const kanbanStatuses: Status[] = [
{ id: 'pending', name: 'To Do', color: '#6B7280' },
{ id: 'in-progress', name: 'In Progress', color: '#F59E0B' },
{ id: 'review', name: 'Review', color: '#8B5CF6' },
{ id: 'done', name: 'Done', color: '#10B981' },
{ id: 'deferred', name: 'Deferred', color: '#EF4444' }
];
// State reducer
const appReducer = (state: AppState, action: AppAction): AppState => {
switch (action.type) {
case 'SET_TASKS':
return {
...state,
tasks: action.payload,
loading: false,
error: undefined
};
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload, loading: false };
case 'CLEAR_ERROR':
return { ...state, error: undefined };
case 'INCREMENT_REQUEST_ID':
return { ...state, requestId: state.requestId + 1 };
case 'UPDATE_TASK_STATUS':
const updatedTasks = state.tasks.map((task) =>
task.id === action.payload.taskId
? { ...task, status: action.payload.newStatus }
: task
);
return { ...state, tasks: updatedTasks };
case 'UPDATE_TASK_CONTENT':
const updatedTasksContent = state.tasks.map((task) =>
task.id === action.payload.taskId
? { ...task, ...action.payload.updates }
: task
);
return { ...state, tasks: updatedTasksContent };
case 'SET_CONNECTION_STATUS':
return {
...state,
isConnected: action.payload.isConnected,
connectionStatus: action.payload.status
};
case 'SET_EDITING_TASK':
return { ...state, editingTask: action.payload };
case 'SET_POLLING_STATUS':
return { ...state, polling: { ...state.polling, ...action.payload } };
case 'SET_USER_INTERACTING':
return {
...state,
polling: { ...state.polling, isUserInteracting: action.payload }
};
case 'TASKS_UPDATED_FROM_POLLING':
return { ...state, tasks: action.payload };
case 'SET_NETWORK_STATUS':
return { ...state, polling: { ...state.polling, ...action.payload } };
case 'LOAD_CACHED_TASKS':
return { ...state, tasks: action.payload };
case 'ADD_TOAST':
return {
...state,
toastNotifications: [...state.toastNotifications, action.payload]
};
case 'REMOVE_TOAST':
return {
...state,
toastNotifications: state.toastNotifications.filter(
(n) => n.id !== action.payload
)
};
case 'CLEAR_ALL_TOASTS':
return { ...state, toastNotifications: [] };
case 'NAVIGATE_TO_TASK':
console.log('📍 Reducer: Navigating to task', action.payload);
return {
...state,
currentView: 'task-details',
selectedTaskId: action.payload
};
case 'NAVIGATE_TO_KANBAN':
console.log('📍 Reducer: Navigating to kanban');
return { ...state, currentView: 'kanban', selectedTaskId: undefined };
default:
return state;
}
};
// Context for VS Code API
export const VSCodeContext = createContext<{
vscode?: ReturnType<NonNullable<typeof window.acquireVsCodeApi>>;
state: AppState;
dispatch: React.Dispatch<AppAction>;
sendMessage: (message: WebviewMessage) => Promise<any>;
availableHeight: number;
// Toast notification functions
showSuccessToast: (title: string, message: string, duration?: number) => void;
showInfoToast: (title: string, message: string, duration?: number) => void;
showWarningToast: (title: string, message: string, duration?: number) => void;
showErrorToast: (title: string, message: string, duration?: number) => void;
} | null>(null);
// Priority Badge Component
const PriorityBadge: React.FC<{ priority: TaskMasterTask['priority'] }> = ({
priority
}) => {
const colorMap = {
high: 'bg-red-500/20 text-red-400 border-red-500/30',
medium: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
low: 'bg-green-500/20 text-green-400 border-green-500/30'
};
return (
<span
className={`
inline-flex items-center justify-center
px-2 py-0.5
rounded text-xs font-medium border
min-w-[50px]
${colorMap[priority]}
`}
title={priority}
>
{priority}
</span>
);
};
// Task Edit Modal Component
const TaskEditModal: React.FC<{
task: TaskMasterTask;
onSave: (taskId: string, updates: TaskUpdates) => void;
onCancel: () => void;
}> = ({ task, onSave, onCancel }) => {
const [formData, setFormData] = useState<TaskUpdates>({
title: task.title,
description: task.description,
details: task.details || '',
priority: task.priority,
testStrategy: task.testStrategy || '',
dependencies: task.dependencies || []
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Only include changed fields
const updates: TaskUpdates = {};
if (formData.title !== task.title) {
updates.title = formData.title;
}
if (formData.description !== task.description) {
updates.description = formData.description;
}
if (formData.details !== task.details) {
updates.details = formData.details;
}
if (formData.priority !== task.priority) {
updates.priority = formData.priority;
}
if (formData.testStrategy !== task.testStrategy) {
updates.testStrategy = formData.testStrategy;
}
if (
JSON.stringify(formData.dependencies) !==
JSON.stringify(task.dependencies)
) {
updates.dependencies = formData.dependencies;
}
if (Object.keys(updates).length > 0) {
onSave(task.id, updates);
} else {
onCancel(); // No changes made
}
};
const handleDependenciesChange = (value: string) => {
const deps = value
.split(',')
.map((dep) => dep.trim())
.filter((dep) => dep.length > 0);
setFormData((prev) => ({ ...prev, dependencies: deps }));
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-vscode-input border border-vscode-border rounded-lg max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<div className="p-4 border-b border-vscode-border">
<h2 className="text-lg font-semibold text-vscode-foreground">
Edit Task #{task.id}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium text-vscode-foreground mb-2">
Title
</label>
<input
type="text"
value={formData.title || ''}
onChange={(e) =>
setFormData((prev) => ({ ...prev, title: e.target.value }))
}
className="w-full px-3 py-2 bg-vscode-input border border-vscode-border rounded text-vscode-foreground focus:outline-none focus:ring-2 focus:ring-vscode-focusBorder"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-vscode-foreground mb-2">
Description
</label>
<textarea
value={formData.description || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
description: e.target.value
}))
}
className="w-full px-3 py-2 bg-input-background border border-border rounded text-input-foreground focus:outline-none focus:ring-2 focus:ring-vscode-focusBorder"
rows={3}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-vscode-foreground mb-2">
Priority
</label>
<select
value={formData.priority || 'medium'}
onChange={(e) =>
setFormData((prev) => ({
...prev,
priority: e.target.value as TaskMasterTask['priority']
}))
}
className="w-full px-3 py-2 bg-vscode-input border border-vscode-border rounded text-vscode-foreground focus:outline-none focus:ring-2 focus:ring-vscode-focusBorder"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-vscode-foreground mb-2">
Implementation Details
</label>
<textarea
value={formData.details || ''}
onChange={(e) =>
setFormData((prev) => ({ ...prev, details: e.target.value }))
}
className="w-full px-3 py-2 bg-vscode-input border border-vscode-border rounded text-vscode-foreground focus:outline-none focus:ring-2 focus:ring-vscode-focusBorder"
rows={4}
placeholder="Implementation details and notes..."
/>
</div>
<div>
<label className="block text-sm font-medium text-vscode-foreground mb-2">
Test Strategy
</label>
<textarea
value={formData.testStrategy || ''}
onChange={(e) =>
setFormData((prev) => ({
...prev,
testStrategy: e.target.value
}))
}
className="w-full px-3 py-2 bg-vscode-input border border-vscode-border rounded text-vscode-foreground focus:outline-none focus:ring-2 focus:ring-vscode-focusBorder"
rows={2}
placeholder="How to test this task..."
/>
</div>
<div>
<label className="block text-sm font-medium text-vscode-foreground mb-2">
Dependencies (comma-separated task IDs)
</label>
<input
type="text"
value={formData.dependencies?.join(', ') || ''}
onChange={(e) => handleDependenciesChange(e.target.value)}
className="w-full px-3 py-2 bg-vscode-input border border-vscode-border rounded text-link focus:outline-none focus:ring-2 focus:ring-vscode-focusBorder"
placeholder="1, 2, 3"
/>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-vscode-border">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-vscode-foreground border border-vscode-border rounded hover:bg-vscode-input/50 transition-colors"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-vscode-button-background text-vscode-button-foreground rounded hover:bg-vscode-button-hoverBackground transition-colors"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
);
};
// Task Card Component
const TaskCard: React.FC<{
task: TaskMasterTask;
index: number;
status: string;
onEdit?: (task: TaskMasterTask) => void;
onViewDetails?: (taskId: string) => void;
}> = ({ task, index, status, onEdit, onViewDetails }) => {
const handleClick = (e: React.MouseEvent) => {
onViewDetails?.(task.id);
};
const handleDoubleClick = (e: React.MouseEvent) => {
onViewDetails?.(task.id);
};
return (
<KanbanCard
id={task.id}
name={task.title}
index={index}
parent={status}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
className="
w-full
min-h-[120px]
border border-vscode-border/50
bg-card
flex-shrink-0
"
>
<div className="space-y-3 h-full flex flex-col">
<div className="flex items-start justify-between gap-2 flex-shrink-0">
<h3 className="font-medium text-sm leading-tight flex-1 min-w-0 text-vscode-foreground">
{task.title}
</h3>
<div className="flex items-center gap-1 flex-shrink-0">
<PriorityBadge priority={task.priority} />
</div>
</div>
{task.description && (
<p className="text-xs text-vscode-foreground/70 line-clamp-3 leading-relaxed flex-1 min-h-0">
{task.description}
</p>
)}
<div className="flex items-center justify-between text-xs mt-auto pt-2 flex-shrink-0 border-t border-vscode-border/20">
<span className="font-mono text-vscode-foreground/50 flex-shrink-0">
#{task.id}
</span>
{task.dependencies && task.dependencies.length > 0 && (
<span className="text-vscode-foreground/50 flex-shrink-0 ml-2">
Deps: {task.dependencies.length}
</span>
)}
</div>
</div>
</KanbanCard>
);
};
// Main Kanban Board Component
const TaskMasterKanban: React.FC = () => {
const context = useContext(VSCodeContext);
if (!context) {
throw new Error('TaskMasterKanban must be used within VSCodeContext');
}
const { state, dispatch, sendMessage, availableHeight } = context;
const {
tasks,
loading,
error,
editingTask,
polling,
currentView,
selectedTaskId
} = state;
const [activeTask, setActiveTask] = React.useState<TaskMasterTask | null>(
null
);
// Calculate header height for proper kanban board sizing
const headerHeight = 73; // Header with padding and border
const kanbanHeight = availableHeight - headerHeight;
// Group tasks by status
const tasksByStatus = kanbanStatuses.reduce(
(acc, status) => {
acc[status.id] = tasks.filter((task) => task.status === status.id);
return acc;
},
{} as Record<string, TaskMasterTask[]>
);
// Handle task update
const handleUpdateTask = async (taskId: string, updates: TaskUpdates) => {
console.log(`🔄 Updating task ${taskId} content:`, updates);
// Optimistic update
dispatch({
type: 'UPDATE_TASK_CONTENT',
payload: { taskId, updates }
});
try {
// Send update to extension
await sendMessage({
type: 'updateTask',
data: {
taskId,
updates,
options: { append: false, research: false }
}
});
console.log(`✅ Task ${taskId} content updated successfully`);
// Close the edit modal
dispatch({
type: 'SET_EDITING_TASK',
payload: { taskId: null }
});
} catch (error) {
console.error(`❌ Failed to update task ${taskId}:`, error);
// Revert the optimistic update on error
const originalTask = editingTask?.editData;
if (originalTask) {
dispatch({
type: 'UPDATE_TASK_CONTENT',
payload: {
taskId,
updates: {
title: originalTask.title,
description: originalTask.description,
details: originalTask.details,
priority: originalTask.priority,
testStrategy: originalTask.testStrategy,
dependencies: originalTask.dependencies
}
}
});
}
dispatch({
type: 'SET_ERROR',
payload: `Failed to update task: ${error}`
});
}
};
// Handle drag start - mark user as interacting and set active task
const handleDragStart = (event: DragEndEvent) => {
console.log('🖱️ User started dragging, pausing updates');
dispatch({ type: 'SET_USER_INTERACTING', payload: true });
const taskId = event.active.id as string;
const task = tasks.find((t) => t.id === taskId);
setActiveTask(task || null);
};
// Handle drag end - allow updates again after a delay
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
// Clear active task
setActiveTask(null);
// Re-enable updates after drag completes
setTimeout(() => {
console.log('✅ Drag completed, resuming updates');
dispatch({ type: 'SET_USER_INTERACTING', payload: false });
}, 1000); // 1 second delay to ensure smooth completion
if (!over) {
return;
}
const taskId = active.id as string;
const newStatus = over.id as TaskMasterTask['status'];
// Find the task that was moved
const task = tasks.find((t) => t.id === taskId);
if (!task || task.status === newStatus) {
return;
}
console.log(`🔄 Moving task ${taskId} from ${task.status} to ${newStatus}`);
// Update task status locally (optimistic update)
dispatch({
type: 'UPDATE_TASK_STATUS',
payload: { taskId, newStatus }
});
try {
// Send update to extension
await sendMessage({
type: 'updateTaskStatus',
data: { taskId, newStatus, oldStatus: task.status }
});
console.log(`✅ Task ${taskId} status updated successfully`);
} catch (error) {
console.error(`❌ Failed to update task ${taskId}:`, error);
// Revert the optimistic update on error
dispatch({
type: 'UPDATE_TASK_STATUS',
payload: { taskId, newStatus: task.status }
});
dispatch({
type: 'SET_ERROR',
payload: `Failed to update task status: ${error}`
});
}
};
// Get polling status indicator
const getPollingStatusIndicator = () => {
const {
isActive,
errorCount,
isOfflineMode,
connectionStatus,
reconnectAttempts,
maxReconnectAttempts
} = polling;
if (isOfflineMode || connectionStatus === 'offline') {
return (
<div className="flex items-center gap-2">
<div
className="flex items-center gap-1 text-red-400"
title="Offline mode - using cached data"
>
<div className="w-2 h-2 rounded-full bg-red-400"></div>
<span className="text-xs">Offline</span>
</div>
<button
onClick={async () => {
try {
await sendMessage({ type: 'attemptReconnection' });
} catch (error) {
console.error('Failed to request reconnection:', error);
}
}}
className="px-2 py-1 text-xs bg-blue-500/20 text-blue-400 border border-blue-500/30 rounded hover:bg-blue-500/30 transition-colors"
title="Attempt to reconnect"
>
Reconnect
</button>
</div>
);
} else if (connectionStatus === 'reconnecting') {
return (
<div
className="flex items-center gap-1 text-yellow-400"
title={`Reconnecting... (${reconnectAttempts}/${maxReconnectAttempts})`}
>
<div className="w-2 h-2 rounded-full bg-yellow-400 animate-pulse"></div>
<span className="text-xs">Reconnecting...</span>
</div>
);
} else if (isActive) {
return (
<div
className="flex items-center gap-1 text-green-400"
title="Auto-refresh active"
>
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
<span className="text-xs">Live</span>
</div>
);
} else if (errorCount > 0) {
return (
<div
className="flex items-center gap-1 text-yellow-400"
title="Auto-refresh paused due to errors"
>
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
<span className="text-xs">Paused</span>
</div>
);
} else {
return (
<div
className="flex items-center gap-1 text-gray-400"
title="Auto-refresh off"
>
<div className="w-2 h-2 rounded-full bg-gray-400"></div>
<span className="text-xs">Manual</span>
</div>
);
}
};
if (loading) {
return (
<div
className="flex items-center justify-center"
style={{ height: `${kanbanHeight}px` }}
>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-vscode-foreground mx-auto mb-4"></div>
<p className="text-sm text-vscode-foreground/70">Loading tasks...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 m-4">
<p className="text-red-400 text-sm">Error: {error}</p>
<button
onClick={() => dispatch({ type: 'CLEAR_ERROR' })}
className="mt-2 text-sm text-red-400 hover:text-red-300 underline"
>
Dismiss
</button>
</div>
);
}
return (
<>
<div className="flex flex-col" style={{ height: `${availableHeight}px` }}>
<div className="flex-shrink-0 p-4 bg-vscode-sidebar-background border-b border-vscode-border">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold text-vscode-foreground">
Task Master Kanban
</h1>
<div className="flex items-center gap-4">
{getPollingStatusIndicator()}
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${state.isConnected ? 'bg-green-400' : 'bg-red-400'}`}
></div>
<span className="text-xs text-vscode-foreground/70">
{state.connectionStatus}
</span>
</div>
</div>
</div>
</div>
<div
className="flex-1 px-4 py-4 overflow-hidden"
style={{ height: `${kanbanHeight}px` }}
>
<KanbanProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
className="
kanban-container
w-full h-full
overflow-x-auto overflow-y-hidden
"
dragOverlay={
activeTask ? (
<TaskCard
task={activeTask}
index={0}
status={activeTask.status}
onEdit={() => {}}
onViewDetails={() => {}}
/>
) : null
}
>
<div
className="
flex gap-4
min-w-max
h-full
pb-2
"
>
{kanbanStatuses.map((status) => {
const columnHeaderHeight = 49; // Header with padding and border
const columnPadding = 16; // p-2 = 8px top + 8px bottom
const availableColumnHeight =
kanbanHeight - columnHeaderHeight - columnPadding;
return (
<KanbanBoard
key={status.id}
id={status.id}
className="
kanban-column
flex-shrink-0
min-w-[280px] max-w-[320px] w-[280px]
h-full
flex flex-col
border border-vscode-border/30
rounded-lg
bg-vscode-sidebar-background/50
"
>
<KanbanHeader
name={`${status.name} (${tasksByStatus[status.id]?.length || 0})`}
color={status.color}
className="px-3 py-3 text-sm font-medium flex-shrink-0 border-b border-vscode-border/30"
/>
<div
className="
flex flex-col gap-2
overflow-y-auto overflow-x-hidden
p-2
scrollbar-thin scrollbar-track-transparent
"
style={{
height: `${availableColumnHeight}px`,
maxHeight: `${availableColumnHeight}px`
}}
>
<KanbanCards className="flex flex-col gap-2">
{tasksByStatus[status.id]?.map((task, index) => (
<TaskCard
key={task.id}
task={task}
index={index}
status={status.id}
onEdit={(task) => {
dispatch({
type: 'SET_EDITING_TASK',
payload: { taskId: task.id, editData: task }
});
}}
onViewDetails={(taskId) => {
console.log(
'🔍 Navigating to task details:',
taskId
);
dispatch({
type: 'NAVIGATE_TO_TASK',
payload: taskId
});
}}
/>
))}
</KanbanCards>
</div>
</KanbanBoard>
);
})}
</div>
</KanbanProvider>
</div>
</div>
{/* Task Edit Modal */}
{editingTask?.taskId && editingTask.editData && (
<TaskEditModal
task={editingTask.editData}
onSave={async (taskId, updates) => {
await handleUpdateTask(taskId, updates);
}}
onCancel={() => {
dispatch({
type: 'SET_EDITING_TASK',
payload: { taskId: null }
});
}}
/>
)}
</>
);
};
// Main App Component
const App: React.FC = () => {
const [state, dispatch] = useReducer(appReducer, {
tasks: [],
loading: true,
requestId: 0,
isConnected: false,
connectionStatus: 'Connecting...',
editingTask: { taskId: null },
polling: {
isActive: false,
errorCount: 0,
lastUpdate: undefined,
isUserInteracting: false,
// Network status
isOfflineMode: false,
reconnectAttempts: 0,
maxReconnectAttempts: 0,
lastSuccessfulConnection: undefined,
connectionStatus: 'online'
},
toastNotifications: [],
currentView: 'kanban',
selectedTaskId: undefined
});
const [vscode] = useState(() => {
return window.acquireVsCodeApi?.();
});
const [pendingRequests] = useState(
new Map<
string,
{ resolve: Function; reject: Function; timeout: NodeJS.Timeout }
>()
);
// Dynamic height calculation state
const [availableHeight, setAvailableHeight] = useState<number>(
window.innerHeight
);
// Calculate available height for kanban board
const updateAvailableHeight = useCallback(() => {
// Use window.innerHeight to get the actual available space
// This automatically accounts for VS Code panels like terminal, problems, etc.
const height = window.innerHeight;
console.log('📏 Available height updated:', height);
setAvailableHeight(height);
}, []);
// Listen to resize events to handle VS Code panel changes
useEffect(() => {
updateAvailableHeight();
const handleResize = () => {
updateAvailableHeight();
};
window.addEventListener('resize', handleResize);
// Also listen for VS Code specific events if available
const handleVisibilityChange = () => {
// Small delay to ensure VS Code has finished resizing
setTimeout(updateAvailableHeight, 100);
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('resize', handleResize);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [updateAvailableHeight]);
// Send message to extension with promise-based response handling
const sendMessage = useCallback(
async (message: WebviewMessage): Promise<any> => {
if (!vscode) {
throw new Error('VS Code API not available');
}
return new Promise((resolve, reject) => {
const requestId = `${Date.now()}-${Math.random()}`;
const messageWithId = { ...message, requestId };
// Set up timeout
const timeout = setTimeout(() => {
pendingRequests.delete(requestId);
reject(new Error('Request timeout'));
}, 10000); // 10 second timeout
// Store the promise resolvers
pendingRequests.set(requestId, { resolve, reject, timeout });
// Send the message
vscode.postMessage(messageWithId);
});
},
[vscode, pendingRequests]
);
// Handle messages from extension
useEffect(() => {
if (!vscode) {
return;
}
const handleMessage = (event: MessageEvent) => {
const message: WebviewMessage = event.data;
console.log('📨 Received message from extension:', message);
// Handle response to a pending request
if (message.requestId && pendingRequests.has(message.requestId)) {
const { resolve, reject, timeout } = pendingRequests.get(
message.requestId
)!;
clearTimeout(timeout);
pendingRequests.delete(message.requestId);
if (message.type === 'error') {
reject(new Error(message.error || 'Unknown error'));
} else {
resolve(message.data || message);
}
return;
}
// Handle different message types
switch (message.type) {
case 'init':
console.log('🚀 Extension initialized:', message.data);
dispatch({
type: 'SET_CONNECTION_STATUS',
payload: { isConnected: true, status: 'Connected' }
});
break;
case 'tasksData':
console.log('📋 Received tasks data:', message.data);
dispatch({ type: 'SET_TASKS', payload: message.data });
break;
case 'taskStatusUpdated':
console.log('✅ Task status updated:', message);
// Status is already updated optimistically, no need to update again
break;
case 'taskUpdated':
console.log('✅ Task content updated:', message);
// Content is already updated optimistically, no need to update again
break;
case 'tasksUpdated':
console.log('📡 Tasks updated from polling:', message);
// Only update if user is not currently interacting
if (!state.polling.isUserInteracting) {
dispatch({
type: 'TASKS_UPDATED_FROM_POLLING',
payload: message.data
});
dispatch({
type: 'SET_POLLING_STATUS',
payload: { isActive: true, errorCount: 0 }
});
} else {
console.log('⏸️ Skipping update due to user interaction');
}
break;
case 'pollingError':
console.log('❌ Polling error:', message);
dispatch({
type: 'SET_POLLING_STATUS',
payload: {
isActive: false,
errorCount: (state.polling.errorCount || 0) + 1
}
});
dispatch({
type: 'SET_ERROR',
payload: `Auto-refresh stopped: ${message.error}`
});
break;
case 'pollingStarted':
console.log('🔄 Polling started');
dispatch({
type: 'SET_POLLING_STATUS',
payload: { isActive: true, errorCount: 0 }
});
break;
case 'pollingStopped':
console.log('⏹️ Polling stopped');
dispatch({
type: 'SET_POLLING_STATUS',
payload: { isActive: false }
});
break;
case 'connectionStatusUpdate':
console.log('📡 Connection status update:', message);
dispatch({
type: 'SET_NETWORK_STATUS',
payload: {
isOfflineMode: message.data.isOfflineMode,
connectionStatus: message.data.status,
reconnectAttempts: message.data.reconnectAttempts,
maxReconnectAttempts: message.data.maxReconnectAttempts
}
});
break;
case 'networkOffline':
console.log('🔌 Network offline, loading cached tasks:', message);
dispatch({
type: 'SET_NETWORK_STATUS',
payload: {
isOfflineMode: true,
connectionStatus: 'offline',
reconnectAttempts: message.data.reconnectAttempts,
lastSuccessfulConnection: message.data.lastSuccessfulConnection
}
});
// Load cached tasks if available
if (message.data.cachedTasks && message.data.cachedTasks.length > 0) {
dispatch({
type: 'LOAD_CACHED_TASKS',
payload: message.data.cachedTasks
});
}
break;
case 'reconnectionAttempted':
console.log('🔄 Reconnection attempted:', message);
if (message.success) {
dispatch({
type: 'CLEAR_ERROR'
});
}
break;
case 'errorNotification':
console.log('⚠️ Error notification from extension:', message);
const errorData = message.data;
// Map error severity to toast type
let toastType: ToastNotification['type'] = 'error';
if (errorData.severity === 'low') {
toastType = 'info';
} else if (errorData.severity === 'medium') {
toastType = 'warning';
} else if (
errorData.severity === 'high' ||
errorData.severity === 'critical'
) {
toastType = 'error';
}
// Create appropriate toast based on error category
const title =
errorData.category === 'network'
? 'Network Error'
: errorData.category === 'mcp_connection'
? 'Connection Error'
: errorData.category === 'task_loading'
? 'Task Loading Error'
: errorData.category === 'ui_rendering'
? 'UI Error'
: 'Error';
dispatch({
type: 'ADD_TOAST',
payload: createToast(
toastType,
title,
errorData.message,
errorData.duration || (toastType === 'error' ? 8000 : 5000) // Use preference duration or fallback
)
});
break;
case 'error':
console.log('❌ General error from extension:', message);
const errorTitle =
message.errorType === 'connection' ? 'Connection Error' : 'Error';
const errorMessage = message.error || 'An unknown error occurred';
dispatch({
type: 'SET_ERROR',
payload: errorMessage
});
dispatch({
type: 'ADD_TOAST',
payload: createToast('error', errorTitle, errorMessage, 8000)
});
// Set offline mode for connection errors
if (message.errorType === 'connection') {
dispatch({
type: 'SET_NETWORK_STATUS',
payload: {
isOfflineMode: true,
connectionStatus: 'offline',
reconnectAttempts: 0
}
});
}
break;
case 'reactError':
console.log('🔥 React error reported to extension:', message);
// Show a toast for React errors too
dispatch({
type: 'ADD_TOAST',
payload: createToast(
'error',
'UI Error',
'A component error occurred. The extension may need to be reloaded.',
10000
)
});
break;
default:
console.log('❓ Unknown message type:', message.type);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [vscode, pendingRequests, state.polling]);
// Initialize the webview
useEffect(() => {
if (!vscode) {
console.warn('⚠️ VS Code API not available - running in standalone mode');
dispatch({
type: 'SET_CONNECTION_STATUS',
payload: { isConnected: false, status: 'Standalone Mode' }
});
return;
}
console.log('🔄 Initializing webview...');
// Notify extension that webview is ready
vscode.postMessage({ type: 'ready' });
// Request initial tasks data
sendMessage({ type: 'getTasks' })
.then((tasksData) => {
console.log('📋 Initial tasks loaded:', tasksData);
dispatch({ type: 'SET_TASKS', payload: tasksData });
})
.catch((error) => {
console.error('❌ Failed to load initial tasks:', error);
dispatch({
type: 'SET_ERROR',
payload: `Failed to load tasks: ${error.message}`
});
});
}, [vscode, sendMessage]);
const contextValue = {
vscode,
state,
dispatch,
sendMessage,
availableHeight,
// Toast notification functions
showSuccessToast: showSuccessToast(dispatch),
showInfoToast: showInfoToast(dispatch),
showWarningToast: showWarningToast(dispatch),
showErrorToast: showErrorToast(dispatch)
};
return (
<VSCodeContext.Provider value={contextValue}>
<ErrorBoundary
onError={(error, errorInfo) => {
// Handle React errors and show appropriate toast
dispatch({
type: 'ADD_TOAST',
payload: createToast(
'error',
'Component Error',
`A React component crashed: ${error.message}`,
10000
)
});
}}
>
{/* Conditional rendering for different views */}
{(() => {
console.log(
'🎯 App render - currentView:',
state.currentView,
'selectedTaskId:',
state.selectedTaskId
);
return state.currentView === 'task-details' &&
state.selectedTaskId ? (
<TaskDetailsView
taskId={state.selectedTaskId}
onNavigateBack={() => dispatch({ type: 'NAVIGATE_TO_KANBAN' })}
onNavigateToTask={(taskId: string) =>
dispatch({ type: 'NAVIGATE_TO_TASK', payload: taskId })
}
/>
) : (
<TaskMasterKanban />
);
})()}
<ToastContainer
notifications={state.toastNotifications}
onDismiss={(id) => dispatch({ type: 'REMOVE_TOAST', payload: id })}
/>
</ErrorBoundary>
</VSCodeContext.Provider>
);
};
// Initialize React app
const container = document.getElementById('root');
if (container) {
const root = createRoot(container);
root.render(<App />);
} else {
console.error('❌ Root container not found');
}