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", "postcss": "8.5.6",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"typescript": "^5.8.3" "typescript": "^5.8.3",
"@tanstack/react-query": "^5.83.0"
}, },
"pnpm": { "pnpm": {
"overrides": { "overrides": {

View File

@@ -5,6 +5,10 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { CollapsibleSection } from '@/components/ui/CollapsibleSection'; import { CollapsibleSection } from '@/components/ui/CollapsibleSection';
import { Wand2, Loader2, PlusCircle } from 'lucide-react'; import { Wand2, Loader2, PlusCircle } from 'lucide-react';
import {
useUpdateTask,
useUpdateSubtask
} from '../../webview/hooks/useTaskQueries';
import type { TaskMasterTask } from '../../webview/types'; import type { TaskMasterTask } from '../../webview/types';
interface AIActionsSectionProps { interface AIActionsSectionProps {
@@ -27,42 +31,33 @@ export const AIActionsSection: React.FC<AIActionsSectionProps> = ({
onAppendingChange onAppendingChange
}) => { }) => {
const [prompt, setPrompt] = useState(''); const [prompt, setPrompt] = useState('');
const [isRegenerating, setIsRegenerating] = useState(false); const updateTask = useUpdateTask();
const [isAppending, setIsAppending] = useState(false); const updateSubtask = useUpdateSubtask();
const handleRegenerate = async () => { const handleRegenerate = async () => {
if (!currentTask || !prompt.trim()) { if (!currentTask || !prompt.trim()) {
return; return;
} }
setIsRegenerating(true);
try { try {
if (isSubtask && parentTask) { if (isSubtask && parentTask) {
await sendMessage({ await updateSubtask.mutateAsync({
type: 'updateSubtask', taskId: `${parentTask.id}.${currentTask.id}`,
data: { prompt: prompt,
taskId: `${parentTask.id}.${currentTask.id}`, options: { research: false }
prompt: prompt,
options: { research: false }
}
}); });
} else { } else {
await sendMessage({ await updateTask.mutateAsync({
type: 'updateTask', taskId: currentTask.id,
data: { updates: { description: prompt },
taskId: currentTask.id, options: { append: false, research: false }
updates: { description: prompt },
options: { append: false, research: false }
}
}); });
} }
setPrompt('');
refreshComplexityAfterAI(); refreshComplexityAfterAI();
} catch (error) { } catch (error) {
console.error('❌ TaskDetailsView: Failed to regenerate task:', error); console.error('❌ TaskDetailsView: Failed to regenerate task:', error);
} finally {
setIsRegenerating(false);
setPrompt('');
} }
}; };
@@ -71,37 +66,32 @@ export const AIActionsSection: React.FC<AIActionsSectionProps> = ({
return; return;
} }
setIsAppending(true);
try { try {
if (isSubtask && parentTask) { if (isSubtask && parentTask) {
await sendMessage({ await updateSubtask.mutateAsync({
type: 'updateSubtask', taskId: `${parentTask.id}.${currentTask.id}`,
data: { prompt: prompt,
taskId: `${parentTask.id}.${currentTask.id}`, options: { research: false }
prompt: prompt,
options: { research: false }
}
}); });
} else { } else {
await sendMessage({ await updateTask.mutateAsync({
type: 'updateTask', taskId: currentTask.id,
data: { updates: { description: prompt },
taskId: currentTask.id, options: { append: true, research: false }
updates: { description: prompt },
options: { append: true, research: false }
}
}); });
} }
setPrompt('');
refreshComplexityAfterAI(); refreshComplexityAfterAI();
} catch (error) { } catch (error) {
console.error('❌ TaskDetailsView: Failed to append to task:', 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 ( return (
<CollapsibleSection <CollapsibleSection
title="AI Actions" 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'; import type { TaskMasterTask } from '../../webview/types';
interface TaskFileData { interface TaskFileData {
@@ -17,162 +18,96 @@ export const useTaskDetails = ({
sendMessage, sendMessage,
tasks tasks
}: UseTaskDetailsProps) => { }: 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") // Parse task ID to determine if it's a subtask (e.g., "13.2")
const parseTaskId = (id: string) => { const { isSubtask, parentId, subtaskIndex, taskIdForFetch } = useMemo(() => {
const parts = id.split('.'); const parts = taskId.split('.');
if (parts.length === 2) { if (parts.length === 2) {
return { return {
isSubtask: true, isSubtask: true,
parentId: parts[0], 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 { return {
isSubtask: false, isSubtask: false,
parentId: id, parentId: taskId,
subtaskIndex: -1 subtaskIndex: -1,
taskIdForFetch: taskId
}; };
}; }, [taskId]);
// Find the current task // Use React Query to fetch full task details
useEffect(() => { const { data: fullTaskData, error: taskDetailsError } =
const { isSubtask: isSub, parentId, subtaskIndex } = parseTaskId(taskId); 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); const parent = tasks.find((t) => t.id === parentId);
if (parent && parent.subtasks && parent.subtasks[subtaskIndex]) { if (parent && parent.subtasks && parent.subtasks[subtaskIndex]) {
const subtask = parent.subtasks[subtaskIndex]; const subtask = parent.subtasks[subtaskIndex];
setCurrentTask(subtask); return { currentTask: subtask, parentTask: parent };
setParentTask(parent);
} else {
setCurrentTask(null);
setParentTask(null);
} }
} else { } else {
const task = tasks.find((t) => t.id === taskId); const task = tasks.find((t) => t.id === taskId);
if (task) { if (task) {
setCurrentTask(task); return { currentTask: task, parentTask: null };
setParentTask(null);
} else {
setCurrentTask(null);
setParentTask(null);
} }
} }
}, [taskId, tasks]); return { currentTask: null, parentTask: null };
}, [taskId, tasks, isSubtask, parentId, subtaskIndex]);
// Fetch full task details including details and testStrategy // Merge full task data from React Query with local state
useEffect(() => { const mergedCurrentTask = useMemo(() => {
const fetchTaskDetails = async () => { if (!currentTask || !fullTaskData) return currentTask;
if (!currentTask) return;
try { if (isSubtask && fullTaskData.subtasks) {
// Use the parent task ID for MCP call since get_task returns parent with subtasks // Find the specific subtask in the full data
const taskIdToFetch = const subtaskData = fullTaskData.subtasks.find(
isSubtask && parentTask ? parentTask.id : currentTask.id; (st: any) =>
st.id === currentTask.id || st.id === parseInt(currentTask.id as any)
const result = await sendMessage({ );
type: 'mcpRequest', if (subtaskData) {
tool: 'get_task', return { ...currentTask, ...subtaskData };
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');
} }
} 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(); // Get complexity score
}, [currentTask, isSubtask, parentTask, sendMessage]); const complexity = useMemo(() => {
if (mergedCurrentTask?.complexityScore !== undefined) {
// Fetch complexity score return { score: mergedCurrentTask.complexityScore };
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;
} }
return null;
}, [mergedCurrentTask]);
try { // Function to refresh data after AI operations
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]);
const refreshComplexityAfterAI = () => { const refreshComplexityAfterAI = () => {
setTimeout(() => { // React Query will automatically refetch when mutations invalidate the query
fetchComplexity(); // No need for manual refresh
}, 2000);
}; };
return { return {
currentTask, currentTask: mergedCurrentTask,
parentTask, parentTask,
isSubtask, isSubtask,
taskFileData, taskFileData,
taskFileDataError, taskFileDataError: taskDetailsError ? 'Failed to load task details' : null,
complexity, complexity,
refreshComplexityAfterAI refreshComplexityAfterAI
}; };

View File

@@ -141,6 +141,7 @@ export type KanbanProviderProps = {
children: ReactNode; children: ReactNode;
onDragEnd: (event: DragEndEvent) => void; onDragEnd: (event: DragEndEvent) => void;
onDragStart?: (event: DragEndEvent) => void; onDragStart?: (event: DragEndEvent) => void;
onDragCancel?: () => void;
className?: string; className?: string;
dragOverlay?: ReactNode; dragOverlay?: ReactNode;
}; };
@@ -149,6 +150,7 @@ export const KanbanProvider = ({
children, children,
onDragEnd, onDragEnd,
onDragStart, onDragStart,
onDragCancel,
className, className,
dragOverlay dragOverlay
}: KanbanProviderProps) => { }: KanbanProviderProps) => {
@@ -170,6 +172,7 @@ export const KanbanProvider = ({
collisionDetection={rectIntersection} collisionDetection={rectIntersection}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragCancel={onDragCancel}
> >
<div <div
className={cn( className={cn(

View File

@@ -174,9 +174,66 @@ export class WebviewManager {
break; break;
case 'updateTask': case 'updateTask':
// Handle task content updates // Handle task content updates with MCP
await this.repository.updateContent(data.taskId, data.updates); if (this.mcpClient) {
response = { success: true }; 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; break;
case 'getComplexity': case 'getComplexity':

View File

@@ -4,6 +4,7 @@
import React, { useReducer, useState, useEffect, useRef } from 'react'; import React, { useReducer, useState, useEffect, useRef } from 'react';
import { VSCodeContext } from './contexts/VSCodeContext'; import { VSCodeContext } from './contexts/VSCodeContext';
import { QueryProvider } from './providers/QueryProvider';
import { TaskMasterKanban } from './components/TaskMasterKanban'; import { TaskMasterKanban } from './components/TaskMasterKanban';
import TaskDetailsView from '@/components/TaskDetailsView'; import TaskDetailsView from '@/components/TaskDetailsView';
import { ConfigView } from '@/components/ConfigView'; import { ConfigView } from '@/components/ConfigView';
@@ -46,21 +47,7 @@ export const App: React.FC = () => {
// Notify extension that webview is ready // Notify extension that webview is ready
vscode.postMessage({ type: 'ready' }); vscode.postMessage({ type: 'ready' });
// Request initial tasks data // React Query will handle task fetching, so we only need to load tags 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
sendMessage({ type: 'getTags' }) sendMessage({ type: 'getTags' })
.then((tagsData) => { .then((tagsData) => {
if (tagsData?.tags && tagsData?.currentTag) { if (tagsData?.tags && tagsData?.currentTag) {
@@ -93,58 +80,64 @@ export const App: React.FC = () => {
}; };
return ( return (
<VSCodeContext.Provider value={contextValue}> <QueryProvider>
<ErrorBoundary <VSCodeContext.Provider value={contextValue}>
onError={(error) => { <ErrorBoundary
// Handle React errors and show appropriate toast onError={(error) => {
dispatch({ // Handle React errors and show appropriate toast
type: 'ADD_TOAST', dispatch({
payload: createToast( type: 'ADD_TOAST',
'error', payload: createToast(
'Component Error', 'error',
`A React component crashed: ${error.message}`, 'Component Error',
10000 `A React component crashed: ${error.message}`,
) 10000
}); )
}} });
> }}
{/* Conditional rendering for different views */} >
{(() => { {/* Conditional rendering for different views */}
console.log( {(() => {
'🎯 App render - currentView:', console.log(
state.currentView, '🎯 App render - currentView:',
'selectedTaskId:', state.currentView,
state.selectedTaskId 'selectedTaskId:',
); state.selectedTaskId
if (state.currentView === 'config') {
return (
<ConfigView
sendMessage={sendMessage}
onNavigateBack={() => dispatch({ type: 'NAVIGATE_TO_KANBAN' })}
/>
); );
}
if (state.currentView === 'task-details' && state.selectedTaskId) { if (state.currentView === 'config') {
return ( return (
<TaskDetailsView <ConfigView
taskId={state.selectedTaskId} sendMessage={sendMessage}
onNavigateBack={() => dispatch({ type: 'NAVIGATE_TO_KANBAN' })} onNavigateBack={() =>
onNavigateToTask={(taskId: string) => dispatch({ type: 'NAVIGATE_TO_KANBAN' })
dispatch({ type: 'NAVIGATE_TO_TASK', payload: taskId }) }
} />
/> );
); }
}
return <TaskMasterKanban />; if (state.currentView === 'task-details' && state.selectedTaskId) {
})()} return (
<ToastContainer <TaskDetailsView
notifications={state.toastNotifications} taskId={state.selectedTaskId}
onDismiss={(id) => dispatch({ type: 'REMOVE_TOAST', payload: id })} onNavigateBack={() =>
/> dispatch({ type: 'NAVIGATE_TO_KANBAN' })
</ErrorBoundary> }
</VSCodeContext.Provider> 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 ( return (
<KanbanCard <KanbanCard
id={task.id} id={task.id}
feature={{ name={task.title}
id: task.id, index={0} // Index is not used in our implementation
title: task.title, parent={task.status}
column: task.status
}}
dragging={dragging}
className="cursor-pointer p-3 transition-shadow hover:shadow-md bg-vscode-editor-background border-vscode-border group" className="cursor-pointer p-3 transition-shadow hover:shadow-md bg-vscode-editor-background border-vscode-border group"
onClick={handleCardClick} onClick={handleCardClick}
> >

View File

@@ -2,7 +2,7 @@
* Main Kanban Board Component * Main Kanban Board Component
*/ */
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback, useEffect } from 'react';
import { import {
type DragEndEvent, type DragEndEvent,
KanbanBoard, KanbanBoard,
@@ -16,15 +16,19 @@ import { PollingStatus } from './PollingStatus';
import { TagDropdown } from './TagDropdown'; import { TagDropdown } from './TagDropdown';
import { EmptyState } from './EmptyState'; import { EmptyState } from './EmptyState';
import { useVSCodeContext } from '../contexts/VSCodeContext'; import { useVSCodeContext } from '../contexts/VSCodeContext';
import {
useTasks,
useUpdateTaskStatus,
useUpdateTask
} from '../hooks/useTaskQueries';
import { kanbanStatuses, HEADER_HEIGHT } from '../constants'; import { kanbanStatuses, HEADER_HEIGHT } from '../constants';
import type { TaskMasterTask, TaskUpdates } from '../types'; import type { TaskMasterTask, TaskUpdates } from '../types';
export const TaskMasterKanban: React.FC = () => { export const TaskMasterKanban: React.FC = () => {
const { state, dispatch, sendMessage, availableHeight } = useVSCodeContext(); const { state, dispatch, sendMessage, availableHeight } = useVSCodeContext();
const { const {
tasks, loading: legacyLoading,
loading, error: legacyError,
error,
editingTask, editingTask,
polling, polling,
currentTag, currentTag,
@@ -32,6 +36,23 @@ export const TaskMasterKanban: React.FC = () => {
} = state; } = state;
const [activeTask, setActiveTask] = useState<TaskMasterTask | null>(null); 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 // Calculate header height for proper kanban board sizing
const kanbanHeight = availableHeight - HEADER_HEIGHT; const kanbanHeight = availableHeight - HEADER_HEIGHT;
@@ -60,21 +81,11 @@ export const TaskMasterKanban: React.FC = () => {
const handleUpdateTask = async (taskId: string, updates: TaskUpdates) => { const handleUpdateTask = async (taskId: string, updates: TaskUpdates) => {
console.log(`🔄 Updating task ${taskId} content:`, updates); console.log(`🔄 Updating task ${taskId} content:`, updates);
// Optimistic update
dispatch({
type: 'UPDATE_TASK_CONTENT',
payload: { taskId, updates }
});
try { try {
// Send update to extension await updateTask.mutateAsync({
await sendMessage({ taskId,
type: 'updateTask', updates,
data: { options: { append: false, research: false }
taskId,
updates,
options: { append: false, research: false }
}
}); });
console.log(`✅ Task ${taskId} content updated successfully`); console.log(`✅ Task ${taskId} content updated successfully`);
@@ -86,26 +97,6 @@ export const TaskMasterKanban: React.FC = () => {
}); });
} catch (error) { } catch (error) {
console.error(`❌ Failed to update task ${taskId}:`, 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({ dispatch({
type: 'SET_ERROR', type: 'SET_ERROR',
payload: `Failed to update task: ${error}` payload: `Failed to update task: ${error}`
@@ -115,57 +106,71 @@ export const TaskMasterKanban: React.FC = () => {
// Handle drag start // Handle drag start
const handleDragStart = useCallback( const handleDragStart = useCallback(
(taskId: string) => { (event: DragEndEvent) => {
const taskId = event.active.id as string;
const task = tasks.find((t) => t.id === taskId); const task = tasks.find((t) => t.id === taskId);
if (task) { if (task) {
setActiveTask(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 // Handle drag end
const handleDragEnd = useCallback( const handleDragEnd = useCallback(
async (event: DragEndEvent) => { async (event: DragEndEvent) => {
dispatch({ type: 'SET_USER_INTERACTING', payload: false }); const { active, over } = event;
// Reset active task
setActiveTask(null); setActiveTask(null);
const { active, over } = event; if (!over || active.id === over.id) {
if (!over || active.id === over.id) return; // Clear any temp state if drag was cancelled
setTempReorderedTasks(null);
return;
}
const taskId = active.id as string; const taskId = active.id as string;
const newStatus = over.id as TaskMasterTask['status']; const newStatus = over.id as TaskMasterTask['status'];
// Find the task // Find the task
const task = tasks.find((t) => t.id === taskId); 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 // Create the optimistically reordered tasks
dispatch({ const reorderedTasks = tasks.map((t) =>
type: 'UPDATE_TASK_STATUS', t.id === taskId ? { ...t, status: newStatus } : t
payload: { taskId, newStatus } );
});
// Set temporary state to show immediate visual feedback
setTempReorderedTasks(reorderedTasks);
try { try {
// Send update to extension // Update on server - React Query will handle optimistic updates
await sendMessage({ await updateTaskStatus.mutateAsync({ taskId, newStatus });
type: 'updateTaskStatus', // Clear temp state after mutation starts successfully
data: { taskId, newStatus } setTempReorderedTasks(null);
});
} catch (error) { } catch (error) {
// Revert on error // On error, clear temp state - React Query will revert optimistic update
dispatch({ setTempReorderedTasks(null);
type: 'UPDATE_TASK_STATUS',
payload: { taskId, newStatus: task.status }
});
dispatch({ dispatch({
type: 'SET_ERROR', type: 'SET_ERROR',
payload: `Failed to update task status: ${error}` payload: `Failed to update task status: ${error}`
}); });
} }
}, },
[tasks, sendMessage, dispatch] [tasks, updateTaskStatus, dispatch]
); );
// Handle retry connection // Handle retry connection
@@ -178,22 +183,22 @@ export const TaskMasterKanban: React.FC = () => {
async (tagName: string) => { async (tagName: string) => {
console.log('Switching to tag:', tagName); console.log('Switching to tag:', tagName);
await sendMessage({ type: 'switchTag', data: { tagName } }); await sendMessage({ type: 'switchTag', data: { tagName } });
// After switching tags, fetch the new tasks for that specific tag dispatch({
const tasksData = await sendMessage({ type: 'SET_TAG_DATA',
type: 'getTasks', payload: { currentTag: tagName, availableTags }
data: { tag: tagName }
}); });
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) { if (loading) {
return ( return (
<div <div
@@ -208,10 +213,10 @@ export const TaskMasterKanban: React.FC = () => {
); );
} }
if (error) { if (displayError) {
return ( return (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 m-4"> <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 <button
onClick={() => dispatch({ type: 'CLEAR_ERROR' })} onClick={() => dispatch({ type: 'CLEAR_ERROR' })}
className="mt-2 text-sm text-red-400 hover:text-red-300 underline" 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-shrink-0 p-4 bg-vscode-sidebar-background border-b border-vscode-border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-lg font-semibold text-vscode-foreground"> <h1 className="text-lg font-semibold text-vscode-foreground">
Task Master Kanban TaskMaster Kanban
</h1> </h1>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<TagDropdown <TagDropdown
@@ -250,7 +255,7 @@ export const TaskMasterKanban: React.FC = () => {
<button <button
onClick={() => dispatch({ type: 'NAVIGATE_TO_CONFIG' })} onClick={() => dispatch({ type: 'NAVIGATE_TO_CONFIG' })}
className="p-1.5 rounded hover:bg-vscode-button-hoverBackground transition-colors" className="p-1.5 rounded hover:bg-vscode-button-hoverBackground transition-colors"
title="Task Master Configuration" title="TaskMaster Configuration"
> >
<svg <svg
className="w-4 h-4 text-vscode-foreground/70" className="w-4 h-4 text-vscode-foreground/70"
@@ -286,6 +291,7 @@ export const TaskMasterKanban: React.FC = () => {
<KanbanProvider <KanbanProvider
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
className="kanban-container w-full h-full overflow-x-auto overflow-y-hidden" className="kanban-container w-full h-full overflow-x-auto overflow-y-hidden"
dragOverlay={ dragOverlay={
activeTask ? <TaskCard task={activeTask} dragging /> : null activeTask ? <TaskCard task={activeTask} dragging /> : null
@@ -300,7 +306,6 @@ export const TaskMasterKanban: React.FC = () => {
<KanbanBoard <KanbanBoard
key={status.id} key={status.id}
id={status.id} id={status.id}
title={status.name}
className={` className={`
w-80 flex flex-col w-80 flex flex-col
border border-vscode-border/30 border border-vscode-border/30
@@ -309,7 +314,7 @@ export const TaskMasterKanban: React.FC = () => {
`} `}
> >
<KanbanHeader <KanbanHeader
name={`${status.name} (${statusTasks.length})`} name={`${status.title} (${statusTasks.length})`}
color={status.color} color={status.color}
className="px-3 py-3 text-sm font-medium flex-shrink-0 border-b border-vscode-border/30" 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` maxHeight: `${kanbanHeight - 80}px`
}} }}
> >
<KanbanCards column={status.id}> <KanbanCards>
{statusTasks.map((task) => ( {statusTasks.map((task) => (
<TaskCard <TaskCard
key={task.id} key={task.id}

View File

@@ -4,13 +4,38 @@
import type { Status } from '@/components/ui/shadcn-io/kanban'; import type { Status } from '@/components/ui/shadcn-io/kanban';
export const kanbanStatuses: Status[] = [ export const kanbanStatuses = [
{ id: 'pending', name: 'Pending', color: 'yellow' }, {
{ id: 'in-progress', name: 'In Progress', color: 'blue' }, id: 'pending',
{ id: 'review', name: 'Review', color: 'purple' }, title: 'Pending',
{ id: 'done', name: 'Done', color: 'green' }, color: 'yellow',
{ id: 'deferred', name: 'Deferred', color: 'gray' } 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 CACHE_DURATION = 30000; // 30 seconds
export const REQUEST_TIMEOUT = 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": { "apps/extension": {
"name": "taskr",
"version": "1.1.0", "version": "1.1.0",
"dependencies": {
"@tanstack/react-query": "^5.83.0"
},
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^2.1.2",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@modelcontextprotocol/sdk": "1.13.3", "@modelcontextprotocol/sdk": "1.13.3",
@@ -126,67 +127,6 @@
"vscode": "^1.93.0" "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": { "apps/extension/node_modules/@dnd-kit/core": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
@@ -2986,57 +2926,6 @@
"node": ">=14.21.3" "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": { "node_modules/@biomejs/cli-linux-x64": {
"version": "1.9.4", "version": "1.9.4",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-1.9.4.tgz", "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": ">=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": { "node_modules/@changesets/apply-release-plan": {
"version": "7.0.12", "version": "7.0.12",
"resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.12.tgz", "resolved": "https://registry.npmjs.org/@changesets/apply-release-plan/-/apply-release-plan-7.0.12.tgz",
@@ -7103,6 +6941,32 @@
"tailwindcss": "4.1.11" "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": { "node_modules/@tokenizer/inflate": {
"version": "0.2.7", "version": "0.2.7",
"resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz",
@@ -10866,6 +10730,10 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/extension": {
"resolved": "apps/extension",
"link": true
},
"node_modules/external-editor": { "node_modules/external-editor": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz",
@@ -19331,10 +19199,6 @@
"resolved": "", "resolved": "",
"link": true "link": true
}, },
"node_modules/taskr": {
"resolved": "apps/extension",
"link": true
},
"node_modules/term-size": { "node_modules/term-size": {
"version": "2.2.1", "version": "2.2.1",
"resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz",