- fixed esbuild issues - converted to npm instead of pnpm since root project is npm (might switch whole project to pnpm later)
1727 lines
46 KiB
TypeScript
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');
|
|
}
|