diff --git a/apps/extension/package.json b/apps/extension/package.json index 1fac734c..d04b5e5d 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -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": { diff --git a/apps/extension/src/components/TaskDetails/AIActionsSection.tsx b/apps/extension/src/components/TaskDetails/AIActionsSection.tsx index 49bd03e5..bdc387cc 100644 --- a/apps/extension/src/components/TaskDetails/AIActionsSection.tsx +++ b/apps/extension/src/components/TaskDetails/AIActionsSection.tsx @@ -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 = ({ 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 = ({ 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 ( { - const [taskFileData, setTaskFileData] = useState({}); - const [taskFileDataError, setTaskFileDataError] = useState( - null - ); - const [complexity, setComplexity] = useState(null); - const [currentTask, setCurrentTask] = useState(null); - const [parentTask, setParentTask] = useState(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 }; diff --git a/apps/extension/src/components/ui/shadcn-io/kanban/index.tsx b/apps/extension/src/components/ui/shadcn-io/kanban/index.tsx index 3bbb55aa..5d2cf484 100644 --- a/apps/extension/src/components/ui/shadcn-io/kanban/index.tsx +++ b/apps/extension/src/components/ui/shadcn-io/kanban/index.tsx @@ -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} >
{ // 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 ( - - { - // 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 ( - dispatch({ type: 'NAVIGATE_TO_KANBAN' })} - /> + + + { + // 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 ( - dispatch({ type: 'NAVIGATE_TO_KANBAN' })} - onNavigateToTask={(taskId: string) => - dispatch({ type: 'NAVIGATE_TO_TASK', payload: taskId }) - } - /> - ); - } + if (state.currentView === 'config') { + return ( + + dispatch({ type: 'NAVIGATE_TO_KANBAN' }) + } + /> + ); + } - return ; - })()} - dispatch({ type: 'REMOVE_TOAST', payload: id })} - /> - - + if (state.currentView === 'task-details' && state.selectedTaskId) { + return ( + + dispatch({ type: 'NAVIGATE_TO_KANBAN' }) + } + onNavigateToTask={(taskId: string) => + dispatch({ type: 'NAVIGATE_TO_TASK', payload: taskId }) + } + /> + ); + } + + return ; + })()} + dispatch({ type: 'REMOVE_TOAST', payload: id })} + /> + + + ); }; diff --git a/apps/extension/src/webview/components/TaskCard.tsx b/apps/extension/src/webview/components/TaskCard.tsx index befe8100..901be08a 100644 --- a/apps/extension/src/webview/components/TaskCard.tsx +++ b/apps/extension/src/webview/components/TaskCard.tsx @@ -26,12 +26,9 @@ export const TaskCard: React.FC = ({ return ( diff --git a/apps/extension/src/webview/components/TaskMasterKanban.tsx b/apps/extension/src/webview/components/TaskMasterKanban.tsx index 3578c355..b6942cc7 100644 --- a/apps/extension/src/webview/components/TaskMasterKanban.tsx +++ b/apps/extension/src/webview/components/TaskMasterKanban.tsx @@ -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(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 (
{ ); } - if (error) { + if (displayError) { return (
-

Error: {error}

+

Error: {displayError}