feat: add react-query for state handling, it was becoming complex

This commit is contained in:
Ralph Khreish
2025-07-31 16:56:06 +03:00
parent 6ec136e8d5
commit c99d09885f
12 changed files with 584 additions and 491 deletions

View File

@@ -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": {

View File

@@ -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"

View File

@@ -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
};

View File

@@ -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(

View File

@@ -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':

View File

@@ -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>
);
};

View File

@@ -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}
>

View File

@@ -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}

View File

@@ -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

View 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() });
}
});
}

View 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>
);
};

202
package-lock.json generated
View File

@@ -86,10 +86,11 @@
}
},
"apps/extension": {
"name": "taskr",
"version": "1.1.0",
"dependencies": {
"@tanstack/react-query": "^5.83.0"
},
"devDependencies": {
"@biomejs/biome": "^2.1.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@modelcontextprotocol/sdk": "1.13.3",
@@ -126,67 +127,6 @@
"vscode": "^1.93.0"
}
},
"apps/extension/node_modules/@biomejs/biome": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.1.2.tgz",
"integrity": "sha512-yq8ZZuKuBVDgAS76LWCfFKHSYIAgqkxVB3mGVVpOe2vSkUTs7xG46zXZeNPRNVjiJuw0SZ3+J2rXiYx0RUpfGg==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
"biome": "bin/biome"
},
"engines": {
"node": ">=14.21.3"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.1.2",
"@biomejs/cli-darwin-x64": "2.1.2",
"@biomejs/cli-linux-arm64": "2.1.2",
"@biomejs/cli-linux-arm64-musl": "2.1.2",
"@biomejs/cli-linux-x64": "2.1.2",
"@biomejs/cli-linux-x64-musl": "2.1.2",
"@biomejs/cli-win32-arm64": "2.1.2",
"@biomejs/cli-win32-x64": "2.1.2"
}
},
"apps/extension/node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.1.2.tgz",
"integrity": "sha512-leFAks64PEIjc7MY/cLjE8u5OcfBKkcDB0szxsWUB4aDfemBep1WVKt0qrEyqZBOW8LPHzrFMyDl3FhuuA0E7g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"apps/extension/node_modules/@biomejs/cli-linux-x64": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.1.2.tgz",
"integrity": "sha512-Km/UYeVowygTjpX6sGBzlizjakLoMQkxWbruVZSNE6osuSI63i4uCeIL+6q2AJlD3dxoiBJX70dn1enjQnQqwA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"apps/extension/node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
@@ -2986,57 +2926,6 @@
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.1.2.tgz",
"integrity": "sha512-Nmmv7wRX5Nj7lGmz0FjnWdflJg4zii8Ivruas6PBKzw5SJX/q+Zh2RfnO+bBnuKLXpj8kiI2x2X12otpH6a32A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.1.2.tgz",
"integrity": "sha512-NWNy2Diocav61HZiv2enTQykbPP/KrA/baS7JsLSojC7Xxh2nl9IczuvE5UID7+ksRy2e7yH7klm/WkA72G1dw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.1.2.tgz",
"integrity": "sha512-qgHvafhjH7Oca114FdOScmIKf1DlXT1LqbOrrbR30kQDLFPEOpBG0uzx6MhmsrmhGiCFCr2obDamu+czk+X0HQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz",
@@ -3053,57 +2942,6 @@
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.1.2.tgz",
"integrity": "sha512-xlB3mU14ZUa3wzLtXfmk2IMOGL+S0aHFhSix/nssWS/2XlD27q+S6f0dlQ8WOCbYoXcuz8BCM7rCn2lxdTrlQA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.1.2.tgz",
"integrity": "sha512-G8KWZli5ASOXA3yUQgx+M4pZRv3ND16h77UsdunUL17uYpcL/UC7RkWTdkfvMQvogVsAuz5JUcBDjgZHXxlKoA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.1.2.tgz",
"integrity": "sha512-9zajnk59PMpjBkty3bK2IrjUsUHvqe9HWwyAWQBjGLE7MIBjbX2vwv1XPEhmO2RRuGoTkVx3WCanHrjAytICLA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT OR Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=14.21.3"
}
},
"node_modules/@changesets/apply-release-plan": {
"version": "7.0.12",
"resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.12.tgz",
@@ -7103,6 +6941,32 @@
"tailwindcss": "4.1.11"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.83.0",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.0.tgz",
"integrity": "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.83.0",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.83.0.tgz",
"integrity": "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.83.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tokenizer/inflate": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
@@ -10866,6 +10730,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/extension": {
"resolved": "apps/extension",
"link": true
},
"node_modules/external-editor": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
@@ -19331,10 +19199,6 @@
"resolved": "",
"link": true
},
"node_modules/taskr": {
"resolved": "apps/extension",
"link": true
},
"node_modules/term-size": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz",