feat: add react-query for state handling, it was becoming complex
This commit is contained in:
@@ -269,7 +269,8 @@
|
||||
"postcss": "8.5.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "4.1.11",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.8.3",
|
||||
"@tanstack/react-query": "^5.83.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
|
||||
@@ -5,6 +5,10 @@ import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CollapsibleSection } from '@/components/ui/CollapsibleSection';
|
||||
import { Wand2, Loader2, PlusCircle } from 'lucide-react';
|
||||
import {
|
||||
useUpdateTask,
|
||||
useUpdateSubtask
|
||||
} from '../../webview/hooks/useTaskQueries';
|
||||
import type { TaskMasterTask } from '../../webview/types';
|
||||
|
||||
interface AIActionsSectionProps {
|
||||
@@ -27,42 +31,33 @@ export const AIActionsSection: React.FC<AIActionsSectionProps> = ({
|
||||
onAppendingChange
|
||||
}) => {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const [isAppending, setIsAppending] = useState(false);
|
||||
const updateTask = useUpdateTask();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!currentTask || !prompt.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRegenerating(true);
|
||||
try {
|
||||
if (isSubtask && parentTask) {
|
||||
await sendMessage({
|
||||
type: 'updateSubtask',
|
||||
data: {
|
||||
taskId: `${parentTask.id}.${currentTask.id}`,
|
||||
prompt: prompt,
|
||||
options: { research: false }
|
||||
}
|
||||
await updateSubtask.mutateAsync({
|
||||
taskId: `${parentTask.id}.${currentTask.id}`,
|
||||
prompt: prompt,
|
||||
options: { research: false }
|
||||
});
|
||||
} else {
|
||||
await sendMessage({
|
||||
type: 'updateTask',
|
||||
data: {
|
||||
taskId: currentTask.id,
|
||||
updates: { description: prompt },
|
||||
options: { append: false, research: false }
|
||||
}
|
||||
await updateTask.mutateAsync({
|
||||
taskId: currentTask.id,
|
||||
updates: { description: prompt },
|
||||
options: { append: false, research: false }
|
||||
});
|
||||
}
|
||||
|
||||
setPrompt('');
|
||||
refreshComplexityAfterAI();
|
||||
} catch (error) {
|
||||
console.error('❌ TaskDetailsView: Failed to regenerate task:', error);
|
||||
} finally {
|
||||
setIsRegenerating(false);
|
||||
setPrompt('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,37 +66,32 @@ export const AIActionsSection: React.FC<AIActionsSectionProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAppending(true);
|
||||
try {
|
||||
if (isSubtask && parentTask) {
|
||||
await sendMessage({
|
||||
type: 'updateSubtask',
|
||||
data: {
|
||||
taskId: `${parentTask.id}.${currentTask.id}`,
|
||||
prompt: prompt,
|
||||
options: { research: false }
|
||||
}
|
||||
await updateSubtask.mutateAsync({
|
||||
taskId: `${parentTask.id}.${currentTask.id}`,
|
||||
prompt: prompt,
|
||||
options: { research: false }
|
||||
});
|
||||
} else {
|
||||
await sendMessage({
|
||||
type: 'updateTask',
|
||||
data: {
|
||||
taskId: currentTask.id,
|
||||
updates: { description: prompt },
|
||||
options: { append: true, research: false }
|
||||
}
|
||||
await updateTask.mutateAsync({
|
||||
taskId: currentTask.id,
|
||||
updates: { description: prompt },
|
||||
options: { append: true, research: false }
|
||||
});
|
||||
}
|
||||
|
||||
setPrompt('');
|
||||
refreshComplexityAfterAI();
|
||||
} catch (error) {
|
||||
console.error('❌ TaskDetailsView: Failed to append to task:', error);
|
||||
} finally {
|
||||
setIsAppending(false);
|
||||
setPrompt('');
|
||||
}
|
||||
};
|
||||
|
||||
// Track loading states
|
||||
const isRegenerating = updateTask.isPending || updateSubtask.isPending;
|
||||
const isAppending = updateTask.isPending || updateSubtask.isPending;
|
||||
|
||||
return (
|
||||
<CollapsibleSection
|
||||
title="AI Actions"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useTaskDetails as useTaskDetailsQuery } from '../../webview/hooks/useTaskQueries';
|
||||
import type { TaskMasterTask } from '../../webview/types';
|
||||
|
||||
interface TaskFileData {
|
||||
@@ -17,162 +18,96 @@ export const useTaskDetails = ({
|
||||
sendMessage,
|
||||
tasks
|
||||
}: UseTaskDetailsProps) => {
|
||||
const [taskFileData, setTaskFileData] = useState<TaskFileData>({});
|
||||
const [taskFileDataError, setTaskFileDataError] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const [complexity, setComplexity] = useState<any>(null);
|
||||
const [currentTask, setCurrentTask] = useState<TaskMasterTask | null>(null);
|
||||
const [parentTask, setParentTask] = useState<TaskMasterTask | null>(null);
|
||||
|
||||
// Determine if this is a subtask
|
||||
const isSubtask = taskId.includes('.');
|
||||
|
||||
// Parse task ID to determine if it's a subtask (e.g., "13.2")
|
||||
const parseTaskId = (id: string) => {
|
||||
const parts = id.split('.');
|
||||
const { isSubtask, parentId, subtaskIndex, taskIdForFetch } = useMemo(() => {
|
||||
const parts = taskId.split('.');
|
||||
if (parts.length === 2) {
|
||||
return {
|
||||
isSubtask: true,
|
||||
parentId: parts[0],
|
||||
subtaskIndex: parseInt(parts[1]) - 1 // Convert to 0-based index
|
||||
subtaskIndex: parseInt(parts[1]) - 1, // Convert to 0-based index
|
||||
taskIdForFetch: parts[0] // Always fetch parent task for subtasks
|
||||
};
|
||||
}
|
||||
return {
|
||||
isSubtask: false,
|
||||
parentId: id,
|
||||
subtaskIndex: -1
|
||||
parentId: taskId,
|
||||
subtaskIndex: -1,
|
||||
taskIdForFetch: taskId
|
||||
};
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
// Find the current task
|
||||
useEffect(() => {
|
||||
const { isSubtask: isSub, parentId, subtaskIndex } = parseTaskId(taskId);
|
||||
// Use React Query to fetch full task details
|
||||
const { data: fullTaskData, error: taskDetailsError } =
|
||||
useTaskDetailsQuery(taskIdForFetch);
|
||||
|
||||
if (isSub) {
|
||||
// Find current task from local state for immediate display
|
||||
const { currentTask, parentTask } = useMemo(() => {
|
||||
if (isSubtask) {
|
||||
const parent = tasks.find((t) => t.id === parentId);
|
||||
if (parent && parent.subtasks && parent.subtasks[subtaskIndex]) {
|
||||
const subtask = parent.subtasks[subtaskIndex];
|
||||
setCurrentTask(subtask);
|
||||
setParentTask(parent);
|
||||
} else {
|
||||
setCurrentTask(null);
|
||||
setParentTask(null);
|
||||
return { currentTask: subtask, parentTask: parent };
|
||||
}
|
||||
} else {
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
setCurrentTask(task);
|
||||
setParentTask(null);
|
||||
} else {
|
||||
setCurrentTask(null);
|
||||
setParentTask(null);
|
||||
return { currentTask: task, parentTask: null };
|
||||
}
|
||||
}
|
||||
}, [taskId, tasks]);
|
||||
return { currentTask: null, parentTask: null };
|
||||
}, [taskId, tasks, isSubtask, parentId, subtaskIndex]);
|
||||
|
||||
// Fetch full task details including details and testStrategy
|
||||
useEffect(() => {
|
||||
const fetchTaskDetails = async () => {
|
||||
if (!currentTask) return;
|
||||
// Merge full task data from React Query with local state
|
||||
const mergedCurrentTask = useMemo(() => {
|
||||
if (!currentTask || !fullTaskData) return currentTask;
|
||||
|
||||
try {
|
||||
// Use the parent task ID for MCP call since get_task returns parent with subtasks
|
||||
const taskIdToFetch =
|
||||
isSubtask && parentTask ? parentTask.id : currentTask.id;
|
||||
|
||||
const result = await sendMessage({
|
||||
type: 'mcpRequest',
|
||||
tool: 'get_task',
|
||||
params: {
|
||||
id: taskIdToFetch
|
||||
}
|
||||
});
|
||||
|
||||
// Parse the MCP response - it comes as content[0].text JSON string
|
||||
let fullTaskData = null;
|
||||
if (result?.data?.content?.[0]?.text) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.data.content[0].text);
|
||||
fullTaskData = parsed.data;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse MCP response:', e);
|
||||
}
|
||||
} else if (result?.data?.data) {
|
||||
// Fallback if response structure is different
|
||||
fullTaskData = result.data.data;
|
||||
}
|
||||
|
||||
if (fullTaskData) {
|
||||
if (isSubtask && fullTaskData.subtasks) {
|
||||
// Find the specific subtask
|
||||
const subtaskData = fullTaskData.subtasks.find(
|
||||
(st: any) =>
|
||||
st.id === currentTask.id ||
|
||||
st.id === parseInt(currentTask.id as any)
|
||||
);
|
||||
if (subtaskData) {
|
||||
setTaskFileData({
|
||||
details: subtaskData.details || '',
|
||||
testStrategy: subtaskData.testStrategy || ''
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Use the main task data
|
||||
setTaskFileData({
|
||||
details: fullTaskData.details || '',
|
||||
testStrategy: fullTaskData.testStrategy || ''
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch task details:', error);
|
||||
setTaskFileDataError('Failed to load task details');
|
||||
if (isSubtask && fullTaskData.subtasks) {
|
||||
// Find the specific subtask in the full data
|
||||
const subtaskData = fullTaskData.subtasks.find(
|
||||
(st: any) =>
|
||||
st.id === currentTask.id || st.id === parseInt(currentTask.id as any)
|
||||
);
|
||||
if (subtaskData) {
|
||||
return { ...currentTask, ...subtaskData };
|
||||
}
|
||||
} else if (!isSubtask) {
|
||||
// Merge parent task data
|
||||
return { ...currentTask, ...fullTaskData };
|
||||
}
|
||||
|
||||
return currentTask;
|
||||
}, [currentTask, fullTaskData, isSubtask]);
|
||||
|
||||
// Extract task file data
|
||||
const taskFileData: TaskFileData = useMemo(() => {
|
||||
if (!mergedCurrentTask) return {};
|
||||
return {
|
||||
details: mergedCurrentTask.details || '',
|
||||
testStrategy: mergedCurrentTask.testStrategy || ''
|
||||
};
|
||||
}, [mergedCurrentTask]);
|
||||
|
||||
fetchTaskDetails();
|
||||
}, [currentTask, isSubtask, parentTask, sendMessage]);
|
||||
|
||||
// Fetch complexity score
|
||||
const fetchComplexity = useCallback(async () => {
|
||||
if (!currentTask) return;
|
||||
|
||||
// First check if the task already has a complexity score
|
||||
if (currentTask.complexityScore !== undefined) {
|
||||
setComplexity({ score: currentTask.complexityScore });
|
||||
return;
|
||||
// Get complexity score
|
||||
const complexity = useMemo(() => {
|
||||
if (mergedCurrentTask?.complexityScore !== undefined) {
|
||||
return { score: mergedCurrentTask.complexityScore };
|
||||
}
|
||||
return null;
|
||||
}, [mergedCurrentTask]);
|
||||
|
||||
try {
|
||||
const result = await sendMessage({
|
||||
type: 'getComplexity',
|
||||
data: { taskId: currentTask.id }
|
||||
});
|
||||
if (result) {
|
||||
setComplexity(result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ TaskDetailsView: Failed to fetch complexity:', error);
|
||||
}
|
||||
}, [currentTask, sendMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchComplexity();
|
||||
}, [fetchComplexity]);
|
||||
|
||||
// Function to refresh data after AI operations
|
||||
const refreshComplexityAfterAI = () => {
|
||||
setTimeout(() => {
|
||||
fetchComplexity();
|
||||
}, 2000);
|
||||
// React Query will automatically refetch when mutations invalidate the query
|
||||
// No need for manual refresh
|
||||
};
|
||||
|
||||
return {
|
||||
currentTask,
|
||||
currentTask: mergedCurrentTask,
|
||||
parentTask,
|
||||
isSubtask,
|
||||
taskFileData,
|
||||
taskFileDataError,
|
||||
taskFileDataError: taskDetailsError ? 'Failed to load task details' : null,
|
||||
complexity,
|
||||
refreshComplexityAfterAI
|
||||
};
|
||||
|
||||
@@ -141,6 +141,7 @@ export type KanbanProviderProps = {
|
||||
children: ReactNode;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onDragStart?: (event: DragEndEvent) => void;
|
||||
onDragCancel?: () => void;
|
||||
className?: string;
|
||||
dragOverlay?: ReactNode;
|
||||
};
|
||||
@@ -149,6 +150,7 @@ export const KanbanProvider = ({
|
||||
children,
|
||||
onDragEnd,
|
||||
onDragStart,
|
||||
onDragCancel,
|
||||
className,
|
||||
dragOverlay
|
||||
}: KanbanProviderProps) => {
|
||||
@@ -170,6 +172,7 @@ export const KanbanProvider = ({
|
||||
collisionDetection={rectIntersection}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={onDragStart}
|
||||
onDragCancel={onDragCancel}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -174,9 +174,66 @@ export class WebviewManager {
|
||||
break;
|
||||
|
||||
case 'updateTask':
|
||||
// Handle task content updates
|
||||
await this.repository.updateContent(data.taskId, data.updates);
|
||||
response = { success: true };
|
||||
// Handle task content updates with MCP
|
||||
if (this.mcpClient) {
|
||||
try {
|
||||
const { taskId, updates, options = {} } = data;
|
||||
|
||||
// Use the update_task MCP tool
|
||||
const result = await this.mcpClient.callTool('update_task', {
|
||||
id: taskId,
|
||||
prompt: updates.description || '',
|
||||
append: options.append || false,
|
||||
research: options.research || false,
|
||||
projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
});
|
||||
|
||||
// Refresh tasks after update
|
||||
await this.repository.refresh();
|
||||
const updatedTasks = await this.repository.getAll();
|
||||
this.broadcast('tasksUpdated', {
|
||||
tasks: updatedTasks,
|
||||
source: 'task-update'
|
||||
});
|
||||
response = { success: true };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to update task via MCP:', error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw new Error('MCP client not initialized');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'updateSubtask':
|
||||
// Handle subtask content updates with MCP
|
||||
if (this.mcpClient) {
|
||||
try {
|
||||
const { taskId, prompt, options = {} } = data;
|
||||
|
||||
// Use the update_subtask MCP tool
|
||||
const result = await this.mcpClient.callTool('update_subtask', {
|
||||
id: taskId,
|
||||
prompt: prompt,
|
||||
research: options.research || false,
|
||||
projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
});
|
||||
|
||||
// Refresh tasks after update
|
||||
await this.repository.refresh();
|
||||
const updatedTasks = await this.repository.getAll();
|
||||
this.broadcast('tasksUpdated', {
|
||||
tasks: updatedTasks,
|
||||
source: 'task-update'
|
||||
});
|
||||
response = { success: true };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to update subtask via MCP:', error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw new Error('MCP client not initialized');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'getComplexity':
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import React, { useReducer, useState, useEffect, useRef } from 'react';
|
||||
import { VSCodeContext } from './contexts/VSCodeContext';
|
||||
import { QueryProvider } from './providers/QueryProvider';
|
||||
import { TaskMasterKanban } from './components/TaskMasterKanban';
|
||||
import TaskDetailsView from '@/components/TaskDetailsView';
|
||||
import { ConfigView } from '@/components/ConfigView';
|
||||
@@ -46,21 +47,7 @@ export const App: React.FC = () => {
|
||||
// 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}`
|
||||
});
|
||||
});
|
||||
|
||||
// Request tags data
|
||||
// React Query will handle task fetching, so we only need to load tags data
|
||||
sendMessage({ type: 'getTags' })
|
||||
.then((tagsData) => {
|
||||
if (tagsData?.tags && tagsData?.currentTag) {
|
||||
@@ -93,58 +80,64 @@ export const App: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<VSCodeContext.Provider value={contextValue}>
|
||||
<ErrorBoundary
|
||||
onError={(error) => {
|
||||
// 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
|
||||
);
|
||||
|
||||
if (state.currentView === 'config') {
|
||||
return (
|
||||
<ConfigView
|
||||
sendMessage={sendMessage}
|
||||
onNavigateBack={() => dispatch({ type: 'NAVIGATE_TO_KANBAN' })}
|
||||
/>
|
||||
<QueryProvider>
|
||||
<VSCodeContext.Provider value={contextValue}>
|
||||
<ErrorBoundary
|
||||
onError={(error) => {
|
||||
// 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
|
||||
);
|
||||
}
|
||||
|
||||
if (state.currentView === 'task-details' && state.selectedTaskId) {
|
||||
return (
|
||||
<TaskDetailsView
|
||||
taskId={state.selectedTaskId}
|
||||
onNavigateBack={() => dispatch({ type: 'NAVIGATE_TO_KANBAN' })}
|
||||
onNavigateToTask={(taskId: string) =>
|
||||
dispatch({ type: 'NAVIGATE_TO_TASK', payload: taskId })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (state.currentView === 'config') {
|
||||
return (
|
||||
<ConfigView
|
||||
sendMessage={sendMessage}
|
||||
onNavigateBack={() =>
|
||||
dispatch({ type: 'NAVIGATE_TO_KANBAN' })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <TaskMasterKanban />;
|
||||
})()}
|
||||
<ToastContainer
|
||||
notifications={state.toastNotifications}
|
||||
onDismiss={(id) => dispatch({ type: 'REMOVE_TOAST', payload: id })}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</VSCodeContext.Provider>
|
||||
if (state.currentView === 'task-details' && state.selectedTaskId) {
|
||||
return (
|
||||
<TaskDetailsView
|
||||
taskId={state.selectedTaskId}
|
||||
onNavigateBack={() =>
|
||||
dispatch({ type: 'NAVIGATE_TO_KANBAN' })
|
||||
}
|
||||
onNavigateToTask={(taskId: string) =>
|
||||
dispatch({ type: 'NAVIGATE_TO_TASK', payload: taskId })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <TaskMasterKanban />;
|
||||
})()}
|
||||
<ToastContainer
|
||||
notifications={state.toastNotifications}
|
||||
onDismiss={(id) => dispatch({ type: 'REMOVE_TOAST', payload: id })}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</VSCodeContext.Provider>
|
||||
</QueryProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,12 +26,9 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
return (
|
||||
<KanbanCard
|
||||
id={task.id}
|
||||
feature={{
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
column: task.status
|
||||
}}
|
||||
dragging={dragging}
|
||||
name={task.title}
|
||||
index={0} // Index is not used in our implementation
|
||||
parent={task.status}
|
||||
className="cursor-pointer p-3 transition-shadow hover:shadow-md bg-vscode-editor-background border-vscode-border group"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Main Kanban Board Component
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
type DragEndEvent,
|
||||
KanbanBoard,
|
||||
@@ -16,15 +16,19 @@ import { PollingStatus } from './PollingStatus';
|
||||
import { TagDropdown } from './TagDropdown';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { useVSCodeContext } from '../contexts/VSCodeContext';
|
||||
import {
|
||||
useTasks,
|
||||
useUpdateTaskStatus,
|
||||
useUpdateTask
|
||||
} from '../hooks/useTaskQueries';
|
||||
import { kanbanStatuses, HEADER_HEIGHT } from '../constants';
|
||||
import type { TaskMasterTask, TaskUpdates } from '../types';
|
||||
|
||||
export const TaskMasterKanban: React.FC = () => {
|
||||
const { state, dispatch, sendMessage, availableHeight } = useVSCodeContext();
|
||||
const {
|
||||
tasks,
|
||||
loading,
|
||||
error,
|
||||
loading: legacyLoading,
|
||||
error: legacyError,
|
||||
editingTask,
|
||||
polling,
|
||||
currentTag,
|
||||
@@ -32,6 +36,23 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
} = state;
|
||||
const [activeTask, setActiveTask] = useState<TaskMasterTask | null>(null);
|
||||
|
||||
// Use React Query to fetch tasks
|
||||
const {
|
||||
data: serverTasks = [],
|
||||
isLoading,
|
||||
error
|
||||
} = useTasks({ tag: currentTag });
|
||||
const updateTaskStatus = useUpdateTaskStatus();
|
||||
const updateTask = useUpdateTask();
|
||||
|
||||
// Temporary state only for active drag operations
|
||||
const [tempReorderedTasks, setTempReorderedTasks] = useState<
|
||||
TaskMasterTask[] | null
|
||||
>(null);
|
||||
|
||||
// Use temp tasks only if actively set, otherwise use server tasks
|
||||
const tasks = tempReorderedTasks ?? serverTasks;
|
||||
|
||||
// Calculate header height for proper kanban board sizing
|
||||
const kanbanHeight = availableHeight - HEADER_HEIGHT;
|
||||
|
||||
@@ -60,21 +81,11 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
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 }
|
||||
}
|
||||
await updateTask.mutateAsync({
|
||||
taskId,
|
||||
updates,
|
||||
options: { append: false, research: false }
|
||||
});
|
||||
|
||||
console.log(`✅ Task ${taskId} content updated successfully`);
|
||||
@@ -86,26 +97,6 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
});
|
||||
} 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}`
|
||||
@@ -115,57 +106,71 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = useCallback(
|
||||
(taskId: string) => {
|
||||
(event: DragEndEvent) => {
|
||||
const taskId = event.active.id as string;
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
setActiveTask(task);
|
||||
dispatch({ type: 'SET_USER_INTERACTING', payload: true });
|
||||
}
|
||||
},
|
||||
[tasks, dispatch]
|
||||
[tasks]
|
||||
);
|
||||
|
||||
// Handle drag cancel
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveTask(null);
|
||||
// Clear any temporary state
|
||||
setTempReorderedTasks(null);
|
||||
}, []);
|
||||
|
||||
// Handle drag end
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
dispatch({ type: 'SET_USER_INTERACTING', payload: false });
|
||||
const { active, over } = event;
|
||||
|
||||
// Reset active task
|
||||
setActiveTask(null);
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
if (!over || active.id === over.id) {
|
||||
// Clear any temp state if drag was cancelled
|
||||
setTempReorderedTasks(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = active.id as string;
|
||||
const newStatus = over.id as TaskMasterTask['status'];
|
||||
|
||||
// Find the task
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task || task.status === newStatus) return;
|
||||
if (!task || task.status === newStatus) {
|
||||
// Clear temp state if no change needed
|
||||
setTempReorderedTasks(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optimistic update
|
||||
dispatch({
|
||||
type: 'UPDATE_TASK_STATUS',
|
||||
payload: { taskId, newStatus }
|
||||
});
|
||||
// Create the optimistically reordered tasks
|
||||
const reorderedTasks = tasks.map((t) =>
|
||||
t.id === taskId ? { ...t, status: newStatus } : t
|
||||
);
|
||||
|
||||
// Set temporary state to show immediate visual feedback
|
||||
setTempReorderedTasks(reorderedTasks);
|
||||
|
||||
try {
|
||||
// Send update to extension
|
||||
await sendMessage({
|
||||
type: 'updateTaskStatus',
|
||||
data: { taskId, newStatus }
|
||||
});
|
||||
// Update on server - React Query will handle optimistic updates
|
||||
await updateTaskStatus.mutateAsync({ taskId, newStatus });
|
||||
// Clear temp state after mutation starts successfully
|
||||
setTempReorderedTasks(null);
|
||||
} catch (error) {
|
||||
// Revert on error
|
||||
dispatch({
|
||||
type: 'UPDATE_TASK_STATUS',
|
||||
payload: { taskId, newStatus: task.status }
|
||||
});
|
||||
// On error, clear temp state - React Query will revert optimistic update
|
||||
setTempReorderedTasks(null);
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: `Failed to update task status: ${error}`
|
||||
});
|
||||
}
|
||||
},
|
||||
[tasks, sendMessage, dispatch]
|
||||
[tasks, updateTaskStatus, dispatch]
|
||||
);
|
||||
|
||||
// Handle retry connection
|
||||
@@ -178,22 +183,22 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
async (tagName: string) => {
|
||||
console.log('Switching to tag:', tagName);
|
||||
await sendMessage({ type: 'switchTag', data: { tagName } });
|
||||
// After switching tags, fetch the new tasks for that specific tag
|
||||
const tasksData = await sendMessage({
|
||||
type: 'getTasks',
|
||||
data: { tag: tagName }
|
||||
dispatch({
|
||||
type: 'SET_TAG_DATA',
|
||||
payload: { currentTag: tagName, availableTags }
|
||||
});
|
||||
console.log('Received new tasks for tag', tagName, ':', {
|
||||
tasksData,
|
||||
isArray: Array.isArray(tasksData),
|
||||
count: Array.isArray(tasksData) ? tasksData.length : 'not an array'
|
||||
});
|
||||
dispatch({ type: 'SET_TASKS', payload: tasksData });
|
||||
console.log('Dispatched SET_TASKS');
|
||||
},
|
||||
[sendMessage, dispatch]
|
||||
[sendMessage, dispatch, availableTags]
|
||||
);
|
||||
|
||||
// Use React Query loading state
|
||||
const loading = isLoading || legacyLoading;
|
||||
const displayError = error
|
||||
? error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
: legacyError;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
@@ -208,10 +213,10 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (displayError) {
|
||||
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>
|
||||
<p className="text-red-400 text-sm">Error: {displayError}</p>
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'CLEAR_ERROR' })}
|
||||
className="mt-2 text-sm text-red-400 hover:text-red-300 underline"
|
||||
@@ -228,7 +233,7 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
<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
|
||||
TaskMaster Kanban
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<TagDropdown
|
||||
@@ -250,7 +255,7 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'NAVIGATE_TO_CONFIG' })}
|
||||
className="p-1.5 rounded hover:bg-vscode-button-hoverBackground transition-colors"
|
||||
title="Task Master Configuration"
|
||||
title="TaskMaster Configuration"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-vscode-foreground/70"
|
||||
@@ -286,6 +291,7 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
<KanbanProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
className="kanban-container w-full h-full overflow-x-auto overflow-y-hidden"
|
||||
dragOverlay={
|
||||
activeTask ? <TaskCard task={activeTask} dragging /> : null
|
||||
@@ -300,7 +306,6 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
<KanbanBoard
|
||||
key={status.id}
|
||||
id={status.id}
|
||||
title={status.name}
|
||||
className={`
|
||||
w-80 flex flex-col
|
||||
border border-vscode-border/30
|
||||
@@ -309,7 +314,7 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
`}
|
||||
>
|
||||
<KanbanHeader
|
||||
name={`${status.name} (${statusTasks.length})`}
|
||||
name={`${status.title} (${statusTasks.length})`}
|
||||
color={status.color}
|
||||
className="px-3 py-3 text-sm font-medium flex-shrink-0 border-b border-vscode-border/30"
|
||||
/>
|
||||
@@ -325,7 +330,7 @@ export const TaskMasterKanban: React.FC = () => {
|
||||
maxHeight: `${kanbanHeight - 80}px`
|
||||
}}
|
||||
>
|
||||
<KanbanCards column={status.id}>
|
||||
<KanbanCards>
|
||||
{statusTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
|
||||
@@ -4,13 +4,38 @@
|
||||
|
||||
import type { Status } from '@/components/ui/shadcn-io/kanban';
|
||||
|
||||
export const kanbanStatuses: Status[] = [
|
||||
{ id: 'pending', name: 'Pending', color: 'yellow' },
|
||||
{ id: 'in-progress', name: 'In Progress', color: 'blue' },
|
||||
{ id: 'review', name: 'Review', color: 'purple' },
|
||||
{ id: 'done', name: 'Done', color: 'green' },
|
||||
{ id: 'deferred', name: 'Deferred', color: 'gray' }
|
||||
];
|
||||
export const kanbanStatuses = [
|
||||
{
|
||||
id: 'pending',
|
||||
title: 'Pending',
|
||||
color: 'yellow',
|
||||
className: 'text-yellow-600 border-yellow-600/20'
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
title: 'In Progress',
|
||||
color: 'blue',
|
||||
className: 'text-blue-600 border-blue-600/20'
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
title: 'Review',
|
||||
color: 'purple',
|
||||
className: 'text-purple-600 border-purple-600/20'
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
title: 'Done',
|
||||
color: 'green',
|
||||
className: 'text-green-600 border-green-600/20'
|
||||
},
|
||||
{
|
||||
id: 'deferred',
|
||||
title: 'Deferred',
|
||||
color: 'gray',
|
||||
className: 'text-gray-600 border-gray-600/20'
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const CACHE_DURATION = 30000; // 30 seconds
|
||||
export const REQUEST_TIMEOUT = 30000; // 30 seconds
|
||||
|
||||
189
apps/extension/src/webview/hooks/useTaskQueries.ts
Normal file
189
apps/extension/src/webview/hooks/useTaskQueries.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useVSCodeContext } from '../contexts/VSCodeContext';
|
||||
import type { TaskMasterTask, TaskUpdates } from '../types';
|
||||
|
||||
// Query keys factory
|
||||
export const taskKeys = {
|
||||
all: ['tasks'] as const,
|
||||
lists: () => [...taskKeys.all, 'list'] as const,
|
||||
list: (filters: { tag?: string; status?: string }) =>
|
||||
[...taskKeys.lists(), filters] as const,
|
||||
details: () => [...taskKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...taskKeys.details(), id] as const
|
||||
};
|
||||
|
||||
// Hook to fetch all tasks
|
||||
export function useTasks(options?: { tag?: string; status?: string }) {
|
||||
const { sendMessage } = useVSCodeContext();
|
||||
|
||||
return useQuery({
|
||||
queryKey: taskKeys.list(options || {}),
|
||||
queryFn: async () => {
|
||||
const response = await sendMessage({
|
||||
type: 'getTasks',
|
||||
data: {
|
||||
tag: options?.tag,
|
||||
withSubtasks: true
|
||||
}
|
||||
});
|
||||
return response as TaskMasterTask[];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to fetch a single task with full details
|
||||
export function useTaskDetails(taskId: string) {
|
||||
const { sendMessage } = useVSCodeContext();
|
||||
|
||||
return useQuery({
|
||||
queryKey: taskKeys.detail(taskId),
|
||||
queryFn: async () => {
|
||||
const response = await sendMessage({
|
||||
type: 'mcpRequest',
|
||||
tool: 'get_task',
|
||||
params: {
|
||||
id: taskId
|
||||
}
|
||||
});
|
||||
|
||||
// Parse the MCP response
|
||||
let fullTaskData = null;
|
||||
if (response?.data?.content?.[0]?.text) {
|
||||
try {
|
||||
const parsed = JSON.parse(response.data.content[0].text);
|
||||
fullTaskData = parsed.data;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse MCP response:', e);
|
||||
}
|
||||
} else if (response?.data?.data) {
|
||||
fullTaskData = response.data.data;
|
||||
}
|
||||
|
||||
return fullTaskData as TaskMasterTask;
|
||||
},
|
||||
enabled: !!taskId
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to update task status
|
||||
export function useUpdateTaskStatus() {
|
||||
const { sendMessage } = useVSCodeContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
taskId,
|
||||
newStatus
|
||||
}: {
|
||||
taskId: string;
|
||||
newStatus: TaskMasterTask['status'];
|
||||
}) => {
|
||||
const response = await sendMessage({
|
||||
type: 'updateTaskStatus',
|
||||
data: { taskId, newStatus }
|
||||
});
|
||||
return { taskId, newStatus, response };
|
||||
},
|
||||
// Optimistic update to prevent snap-back
|
||||
onMutate: async ({ taskId, newStatus }) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: taskKeys.all });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousTasks = queryClient.getQueriesData({
|
||||
queryKey: taskKeys.all
|
||||
});
|
||||
|
||||
// Optimistically update all task queries
|
||||
queryClient.setQueriesData({ queryKey: taskKeys.all }, (old: any) => {
|
||||
if (!old) return old;
|
||||
|
||||
// Handle both array and object responses
|
||||
if (Array.isArray(old)) {
|
||||
return old.map((task: TaskMasterTask) =>
|
||||
task.id === taskId ? { ...task, status: newStatus } : task
|
||||
);
|
||||
}
|
||||
|
||||
return old;
|
||||
});
|
||||
|
||||
// Return a context object with the snapshot
|
||||
return { previousTasks };
|
||||
},
|
||||
// If the mutation fails, roll back to the previous value
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousTasks) {
|
||||
context.previousTasks.forEach(([queryKey, data]) => {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
});
|
||||
}
|
||||
},
|
||||
// Always refetch after error or success to ensure consistency
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.all });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to update task content
|
||||
export function useUpdateTask() {
|
||||
const { sendMessage } = useVSCodeContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
taskId,
|
||||
updates,
|
||||
options = {}
|
||||
}: {
|
||||
taskId: string;
|
||||
updates: TaskUpdates | { description: string };
|
||||
options?: { append?: boolean; research?: boolean };
|
||||
}) => {
|
||||
await sendMessage({
|
||||
type: 'updateTask',
|
||||
data: { taskId, updates, options }
|
||||
});
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
// Invalidate the specific task and all lists
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: taskKeys.detail(variables.taskId)
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.lists() });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to update subtask
|
||||
export function useUpdateSubtask() {
|
||||
const { sendMessage } = useVSCodeContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
taskId,
|
||||
prompt,
|
||||
options = {}
|
||||
}: {
|
||||
taskId: string;
|
||||
prompt: string;
|
||||
options?: { research?: boolean };
|
||||
}) => {
|
||||
await sendMessage({
|
||||
type: 'updateSubtask',
|
||||
data: { taskId, prompt, options }
|
||||
});
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
// Extract parent task ID from subtask ID (e.g., "1.2" -> "1")
|
||||
const parentTaskId = variables.taskId.split('.')[0];
|
||||
// Invalidate the parent task details and all lists
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: taskKeys.detail(parentTaskId)
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.lists() });
|
||||
}
|
||||
});
|
||||
}
|
||||
34
apps/extension/src/webview/providers/QueryProvider.tsx
Normal file
34
apps/extension/src/webview/providers/QueryProvider.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// Create a stable query client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Don't refetch on window focus by default
|
||||
refetchOnWindowFocus: false,
|
||||
// Keep data fresh for 30 seconds
|
||||
staleTime: 30 * 1000,
|
||||
// Cache data for 5 minutes
|
||||
gcTime: 5 * 60 * 1000,
|
||||
// Retry failed requests 3 times
|
||||
retry: 3,
|
||||
// Retry delay exponentially backs off
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000)
|
||||
},
|
||||
mutations: {
|
||||
// Don't retry mutations by default
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface QueryProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const QueryProvider: React.FC<QueryProviderProps> = ({ children }) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user