feat(extension): complete VS Code extension with kanban board interface (#997)

---------
Co-authored-by: DavidMaliglowka <13022280+DavidMaliglowka@users.noreply.github.com>
Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
DavidMaliglowka
2025-08-01 07:04:22 -05:00
committed by GitHub
parent 60c03c548d
commit 64302dc191
101 changed files with 20608 additions and 181 deletions

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { TaskMasterKanban } from './TaskMasterKanban';
import TaskDetailsView from '@/components/TaskDetailsView';
import { ConfigView } from '@/components/ConfigView';
import { useVSCodeContext } from '../contexts/VSCodeContext';
export const AppContent: React.FC = () => {
const { state, dispatch, sendMessage } = useVSCodeContext();
if (state.currentView === 'config') {
return (
<ConfigView
sendMessage={sendMessage}
onNavigateBack={() => dispatch({ type: 'NAVIGATE_TO_KANBAN' })}
/>
);
}
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 })
}
/>
);
}
// Default to Kanban view
return <TaskMasterKanban />;
};

View File

@@ -0,0 +1,138 @@
import React from 'react';
import { ExternalLink, Terminal, MessageSquare, Plus } from 'lucide-react';
import { TaskMasterLogo } from '../../components/TaskMasterLogo';
interface EmptyStateProps {
currentTag: string;
}
export const EmptyState: React.FC<EmptyStateProps> = ({ currentTag }) => {
return (
<div className="flex items-center justify-center h-full overflow-auto">
<div className="max-w-2xl mx-auto text-center p-8">
{/* Empty state illustration */}
<div className="mb-8 max-w-96 mx-auto">
<TaskMasterLogo className="w-32 h-32 mx-auto text-vscode-foreground/20" />
</div>
<h2 className="text-2xl font-semibold mb-2 text-vscode-foreground">
No tasks in "{currentTag}" tag
</h2>
<p className="text-vscode-foreground/70 mb-8">
Get started by adding tasks to this tag using the commands below
</p>
{/* Command suggestions */}
<div className="space-y-4 text-left">
<div className="bg-vscode-editor-background/50 border border-vscode-panel-border rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<Terminal className="w-4 h-4 text-vscode-terminal-ansiGreen" />
<h3 className="font-medium">CLI Commands</h3>
</div>
<div className="space-y-2">
<div className="bg-vscode-editor-background rounded p-2 font-mono text-sm">
<span className="text-vscode-terminal-ansiYellow">
task-master
</span>{' '}
<span className="text-vscode-terminal-ansiCyan">parse-prd</span>{' '}
<span className="text-vscode-foreground/70">
&lt;path-to-prd&gt;
</span>{' '}
<span className="text-vscode-terminal-ansiMagenta">
--append
</span>
<div className="text-xs text-vscode-foreground/50 mt-1">
Parse a PRD and append tasks to current tag
</div>
</div>
<div className="bg-vscode-editor-background rounded p-2 font-mono text-sm">
<span className="text-vscode-terminal-ansiYellow">
task-master
</span>{' '}
<span className="text-vscode-terminal-ansiCyan">add-task</span>{' '}
<span className="text-vscode-terminal-ansiMagenta">
--prompt
</span>{' '}
<span className="text-vscode-foreground/70">
"Your task description"
</span>
<div className="text-xs text-vscode-foreground/50 mt-1">
Add a single task with AI assistance
</div>
</div>
<div className="bg-vscode-editor-background rounded p-2 font-mono text-sm">
<span className="text-vscode-terminal-ansiYellow">
task-master
</span>{' '}
<span className="text-vscode-terminal-ansiCyan">add-task</span>{' '}
<span className="text-vscode-terminal-ansiMagenta">--help</span>
<div className="text-xs text-vscode-foreground/50 mt-1">
View all options for adding tasks
</div>
</div>
</div>
</div>
<div className="bg-vscode-editor-background/50 border border-vscode-panel-border rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<MessageSquare className="w-4 h-4 text-vscode-textLink-foreground" />
<h3 className="font-medium">MCP Examples</h3>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-start gap-2">
<Plus className="w-4 h-4 mt-0.5 text-vscode-foreground/50" />
<div>
<div className="text-vscode-foreground">
"Add a task to tag {currentTag}: Implement user
authentication"
</div>
</div>
</div>
<div className="flex items-start gap-2">
<Plus className="w-4 h-4 mt-0.5 text-vscode-foreground/50" />
<div>
<div className="text-vscode-foreground">
"Parse this PRD and add tasks to {currentTag}: [paste PRD
content]"
</div>
</div>
</div>
<div className="flex items-start gap-2">
<Plus className="w-4 h-4 mt-0.5 text-vscode-foreground/50" />
<div>
<div className="text-vscode-foreground">
"Create 5 tasks for building a REST API in tag {currentTag}"
</div>
</div>
</div>
</div>
</div>
{/* Documentation link */}
<div className="flex justify-center pt-4">
<a
href="https://docs.task-master.dev"
className="inline-flex items-center gap-2 text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground transition-colors"
onClick={(e) => {
e.preventDefault();
// Use VS Code API to open external link
if (window.acquireVsCodeApi) {
const vscode = window.acquireVsCodeApi();
vscode.postMessage({
type: 'openExternal',
url: 'https://docs.task-master.dev'
});
}
}}
>
<ExternalLink className="w-4 h-4" />
<span className="text-sm font-medium">
View TaskMaster Documentation
</span>
</a>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,113 @@
/**
* Error Boundary Component
*/
import React from 'react';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
errorInfo?: React.ErrorInfo;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
export class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('React Error Boundary caught:', error, errorInfo);
// Log to extension
if (this.props.onError) {
this.props.onError(error, errorInfo);
}
// Send error to extension for centralized handling
if (window.acquireVsCodeApi) {
const vscode = window.acquireVsCodeApi();
vscode.postMessage({
type: 'reactError',
data: {
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
timestamp: Date.now()
}
});
}
}
render() {
if (this.state.hasError) {
return (
<div className="min-h-screen flex items-center justify-center bg-vscode-background">
<div className="max-w-md mx-auto text-center p-6">
<div className="w-16 h-16 mx-auto mb-4 text-red-400">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.962-.833-2.732 0L3.732 19c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h2 className="text-xl font-semibold text-vscode-foreground mb-2">
Something went wrong
</h2>
<p className="text-vscode-foreground/70 mb-4">
The Task Master Kanban board encountered an unexpected error.
</p>
<div className="space-y-2">
<button
onClick={() =>
this.setState({
hasError: false,
error: undefined,
errorInfo: undefined
})
}
className="w-full px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-md transition-colors"
>
Try Again
</button>
<button
onClick={() => window.location.reload()}
className="w-full px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-md transition-colors"
>
Reload Extension
</button>
</div>
{this.state.error && (
<details className="mt-4 text-left">
<summary className="text-sm text-vscode-foreground/50 cursor-pointer">
Error Details
</summary>
<pre className="mt-2 text-xs text-vscode-foreground/70 bg-vscode-input/30 p-2 rounded overflow-auto max-h-32">
{this.state.error.message}
{this.state.error.stack && `\n\n${this.state.error.stack}`}
</pre>
</details>
)}
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,84 @@
/**
* Polling Status Indicator Component
*/
import React from 'react';
import type { AppState } from '../types';
interface PollingStatusProps {
polling: AppState['polling'];
onRetry?: () => void;
}
export const PollingStatus: React.FC<PollingStatusProps> = ({
polling,
onRetry
}) => {
const {
isActive,
errorCount,
isOfflineMode,
connectionStatus,
reconnectAttempts,
maxReconnectAttempts
} = polling;
if (isOfflineMode || connectionStatus === 'offline') {
return (
<div className="flex items-center gap-2">
<div
className="flex items-center gap-1 text-red-400"
title="Offline mode - using cached data"
>
<div className="w-2 h-2 rounded-full bg-red-400" />
<span className="text-xs">Offline</span>
</div>
<button
onClick={onRetry}
className="text-xs text-blue-400 hover:underline"
title="Attempt to reconnect"
>
Retry
</button>
</div>
);
}
if (connectionStatus === 'reconnecting') {
return (
<div
className="flex items-center gap-1 text-yellow-400"
title={`Reconnecting... (${reconnectAttempts}/${maxReconnectAttempts})`}
>
<div className="w-2 h-2 rounded-full bg-yellow-400 animate-pulse" />
<span className="text-xs">Reconnecting</span>
</div>
);
}
if (errorCount > 0) {
return (
<div
className="flex items-center gap-1 text-yellow-400"
title={`${errorCount} polling error${errorCount > 1 ? 's' : ''}`}
>
<div className="w-2 h-2 rounded-full bg-yellow-400" />
<span className="text-xs">Live (errors)</span>
</div>
);
}
if (isActive) {
return (
<div
className="flex items-center gap-1 text-green-400"
title="Live updates active"
>
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
<span className="text-xs">Live</span>
</div>
);
}
return null;
};

View File

@@ -0,0 +1,30 @@
/**
* Priority Badge Component
*/
import React from 'react';
import { Badge } from '@/components/ui/badge';
import type { TaskMasterTask } from '../types';
interface PriorityBadgeProps {
priority: TaskMasterTask['priority'];
}
export const PriorityBadge: React.FC<PriorityBadgeProps> = ({ priority }) => {
if (!priority) return null;
const variants = {
high: 'destructive' as const,
medium: 'default' as const,
low: 'secondary' as const
};
return (
<Badge
variant={variants[priority] || 'secondary'}
className="text-xs font-normal px-2 py-0.5"
>
{priority}
</Badge>
);
};

View File

@@ -0,0 +1,52 @@
import React, { useState, useEffect } from 'react';
import { TaskMasterLogo } from '../../components/TaskMasterLogo';
interface SidebarViewProps {
initialConnectionStatus?: boolean;
}
// Acquire VS Code API only once globally to avoid "already acquired" error
const vscode = window.acquireVsCodeApi ? window.acquireVsCodeApi() : null;
export const SidebarView: React.FC<SidebarViewProps> = ({
initialConnectionStatus = false
}) => {
const [isConnected, setIsConnected] = useState(initialConnectionStatus);
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data;
if (message.type === 'connectionStatus') {
setIsConnected(message.data.isConnected);
}
};
window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
};
}, []);
const handleOpenBoard = () => {
vscode?.postMessage({ command: 'openBoard' });
};
return (
<div className="h-full flex items-center justify-center p-6">
<div className="text-center">
<TaskMasterLogo className="w-20 h-20 mx-auto mb-5 opacity-80 text-vscode-foreground" />
<h2 className="text-xl font-semibold mb-6 text-vscode-foreground">
TaskMaster
</h2>
<button
onClick={handleOpenBoard}
className="w-full px-4 py-2 bg-vscode-button-background text-vscode-button-foreground rounded hover:bg-vscode-button-hoverBackground transition-colors text-sm font-medium"
>
Open Kanban Board
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,141 @@
import React, { useState, useEffect, useRef } from 'react';
interface TagDropdownProps {
currentTag: string;
availableTags: string[];
onTagSwitch: (tagName: string) => Promise<void>;
sendMessage: (message: any) => Promise<any>;
dispatch: React.Dispatch<any>;
}
export const TagDropdown: React.FC<TagDropdownProps> = ({
currentTag,
availableTags,
onTagSwitch,
sendMessage,
dispatch
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Fetch tags when component mounts
useEffect(() => {
fetchTags();
}, []);
// Handle click outside to close dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [isOpen]);
const fetchTags = async () => {
try {
const result = await sendMessage({ type: 'getTags' });
if (result?.tags && result?.currentTag) {
const tagNames = result.tags.map((tag: any) => tag.name || tag);
dispatch({
type: 'SET_TAG_DATA',
payload: {
currentTag: result.currentTag,
availableTags: tagNames
}
});
}
} catch (error) {
console.error('Failed to fetch tags:', error);
}
};
const handleTagSwitch = async (tagName: string) => {
if (tagName === currentTag) {
setIsOpen(false);
return;
}
setIsLoading(true);
try {
await onTagSwitch(tagName);
dispatch({ type: 'SET_CURRENT_TAG', payload: tagName });
setIsOpen(false);
} catch (error) {
console.error('Failed to switch tag:', error);
} finally {
setIsLoading(false);
}
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded hover:bg-vscode-list-hoverBackground transition-colors"
>
<span className="font-medium">{currentTag}</span>
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{isOpen && (
<div className="absolute top-full mt-1 right-0 bg-background border border-vscode-dropdown-border rounded shadow-lg z-50 min-w-[200px] py-1">
{availableTags.map((tag) => (
<button
key={tag}
onClick={() => handleTagSwitch(tag)}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between group
${
tag === currentTag
? 'bg-vscode-list-activeSelectionBackground text-vscode-list-activeSelectionForeground'
: 'hover:bg-vscode-list-hoverBackground text-vscode-dropdown-foreground'
}`}
>
<span className="truncate pr-2">{tag}</span>
{tag === currentTag && (
<svg
className="w-4 h-4 flex-shrink-0 text-vscode-textLink-foreground"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</button>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,80 @@
/**
* Task Card Component for Kanban Board
*/
import React from 'react';
import { KanbanCard } from '@/components/ui/shadcn-io/kanban';
import { PriorityBadge } from './PriorityBadge';
import type { TaskMasterTask } from '../types';
interface TaskCardProps {
task: TaskMasterTask;
dragging?: boolean;
onViewDetails?: (taskId: string) => void;
}
export const TaskCard: React.FC<TaskCardProps> = ({
task,
dragging,
onViewDetails
}) => {
const handleCardClick = (e: React.MouseEvent) => {
e.preventDefault();
onViewDetails?.(task.id);
};
return (
<KanbanCard
id={task.id}
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}
>
<div className="space-y-3 h-full flex flex-col">
<div className="flex items-start justify-between gap-2 flex-shrink-0">
<h3 className="font-medium text-sm leading-tight flex-1 min-w-0 text-vscode-foreground">
{task.title}
</h3>
<div className="flex items-center gap-1 flex-shrink-0">
<PriorityBadge priority={task.priority} />
</div>
</div>
{task.description && (
<p className="text-xs text-vscode-foreground/70 line-clamp-3 leading-relaxed flex-1 min-h-0">
{task.description}
</p>
)}
<div className="flex items-center justify-between text-xs mt-auto pt-2 flex-shrink-0 border-t border-vscode-border/20">
<span className="font-mono text-vscode-foreground/50 flex-shrink-0">
#{task.id}
</span>
{task.dependencies && task.dependencies.length > 0 && (
<div className="flex items-center gap-1 text-vscode-foreground/50 flex-shrink-0 ml-2">
<span>Deps:</span>
<div className="flex items-center gap-1">
{task.dependencies.map((depId, index) => (
<React.Fragment key={depId}>
<button
className="font-mono hover:text-vscode-link-activeForeground hover:underline transition-colors"
onClick={(e) => {
e.stopPropagation();
onViewDetails?.(depId);
}}
>
#{depId}
</button>
{index < task.dependencies!.length - 1 && <span>,</span>}
</React.Fragment>
))}
</div>
</div>
)}
</div>
</div>
</KanbanCard>
);
};

View File

@@ -0,0 +1,242 @@
/**
* Task Edit Modal Component
*/
import React, { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import type { TaskMasterTask, TaskUpdates } from '../types';
interface TaskEditModalProps {
task: TaskMasterTask;
onSave: (taskId: string, updates: TaskUpdates) => Promise<void>;
onCancel: () => void;
}
export const TaskEditModal: React.FC<TaskEditModalProps> = ({
task,
onSave,
onCancel
}) => {
const [updates, setUpdates] = useState<TaskUpdates>({
title: task.title,
description: task.description || '',
details: task.details || '',
testStrategy: task.testStrategy || '',
priority: task.priority,
dependencies: task.dependencies || []
});
const [isSaving, setIsSaving] = useState(false);
const formRef = useRef<HTMLFormElement>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
// Focus title input on mount
useEffect(() => {
titleInputRef.current?.focus();
titleInputRef.current?.select();
}, []);
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
setIsSaving(true);
try {
await onSave(task.id, updates);
} catch (error) {
console.error('Failed to save task:', error);
} finally {
setIsSaving(false);
}
};
const hasChanges = () => {
return (
updates.title !== task.title ||
updates.description !== (task.description || '') ||
updates.details !== (task.details || '') ||
updates.testStrategy !== (task.testStrategy || '') ||
updates.priority !== task.priority ||
JSON.stringify(updates.dependencies) !==
JSON.stringify(task.dependencies || [])
);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-vscode-editor-background border border-vscode-border rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-vscode-border">
<h2 className="text-lg font-semibold">Edit Task #{task.id}</h2>
<button
onClick={onCancel}
className="text-vscode-foreground/50 hover:text-vscode-foreground transition-colors"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
{/* Form */}
<form
ref={formRef}
onSubmit={handleSubmit}
className="flex-1 overflow-y-auto p-4 space-y-4"
>
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<input
ref={titleInputRef}
id="title"
type="text"
value={updates.title || ''}
onChange={(e) =>
setUpdates({ ...updates, title: e.target.value })
}
className="w-full px-3 py-2 bg-vscode-input border border-vscode-border rounded-md text-vscode-foreground focus:outline-none focus:ring-2 focus:ring-vscode-focusBorder"
placeholder="Task title"
/>
</div>
{/* Priority */}
<div className="space-y-2">
<Label htmlFor="priority">Priority</Label>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full justify-between">
<span className="capitalize">{updates.priority}</span>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full">
<DropdownMenuItem
onClick={() => setUpdates({ ...updates, priority: 'high' })}
>
High
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setUpdates({ ...updates, priority: 'medium' })}
>
Medium
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setUpdates({ ...updates, priority: 'low' })}
>
Low
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={updates.description || ''}
onChange={(e) =>
setUpdates({ ...updates, description: e.target.value })
}
className="min-h-[80px]"
placeholder="Brief description of the task"
/>
</div>
{/* Details */}
<div className="space-y-2">
<Label htmlFor="details">Implementation Details</Label>
<Textarea
id="details"
value={updates.details || ''}
onChange={(e) =>
setUpdates({ ...updates, details: e.target.value })
}
className="min-h-[120px]"
placeholder="Technical details and implementation notes"
/>
</div>
{/* Test Strategy */}
<div className="space-y-2">
<Label htmlFor="testStrategy">Test Strategy</Label>
<Textarea
id="testStrategy"
value={updates.testStrategy || ''}
onChange={(e) =>
setUpdates({ ...updates, testStrategy: e.target.value })
}
className="min-h-[80px]"
placeholder="How to test this task"
/>
</div>
{/* Dependencies */}
<div className="space-y-2">
<Label htmlFor="dependencies">
Dependencies (comma-separated task IDs)
</Label>
<input
id="dependencies"
type="text"
value={updates.dependencies?.join(', ') || ''}
onChange={(e) =>
setUpdates({
...updates,
dependencies: e.target.value
.split(',')
.map((d) => d.trim())
.filter(Boolean)
})
}
className="w-full px-3 py-2 bg-vscode-input border border-vscode-border rounded-md text-vscode-foreground focus:outline-none focus:ring-2 focus:ring-vscode-focusBorder"
placeholder="e.g., 1, 2.1, 3"
/>
</div>
</form>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-vscode-border">
<Button variant="outline" onClick={onCancel} disabled={isSaving}>
Cancel
</Button>
<Button
onClick={() => handleSubmit()}
disabled={isSaving || !hasChanges()}
>
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,412 @@
/**
* Main Kanban Board Component
*/
import React, { useState, useCallback, useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { RefreshCw } from 'lucide-react';
import {
type DragEndEvent,
KanbanBoard,
KanbanCards,
KanbanHeader,
KanbanProvider
} from '@/components/ui/shadcn-io/kanban';
import { TaskCard } from './TaskCard';
import { TaskEditModal } from './TaskEditModal';
import { PollingStatus } from './PollingStatus';
import { TagDropdown } from './TagDropdown';
import { EmptyState } from './EmptyState';
import { useVSCodeContext } from '../contexts/VSCodeContext';
import {
useTasks,
useUpdateTaskStatus,
useUpdateTask,
taskKeys
} 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 queryClient = useQueryClient();
const {
error: legacyError,
editingTask,
polling,
currentTag,
availableTags
} = state;
const [activeTask, setActiveTask] = useState<TaskMasterTask | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
// Use React Query to fetch tasks
const {
data: serverTasks = [],
isLoading,
error,
isFetching,
isSuccess
} = useTasks({ tag: currentTag });
const updateTaskStatus = useUpdateTaskStatus();
const updateTask = useUpdateTask();
// Debug logging
console.log('🔍 TaskMasterKanban Query State:', {
isLoading,
isFetching,
isSuccess,
tasksCount: serverTasks?.length,
error
});
// 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;
// Group tasks by status
const tasksByStatus = kanbanStatuses.reduce(
(acc, status) => {
acc[status.id] = tasks.filter((task) => task.status === status.id);
return acc;
},
{} as Record<string, TaskMasterTask[]>
);
// Debug logging
console.log('TaskMasterKanban render:', {
tasksCount: tasks.length,
currentTag,
tasksByStatus: Object.entries(tasksByStatus).map(([status, tasks]) => ({
status,
count: tasks.length,
taskIds: tasks.map((t) => t.id)
})),
allTaskIds: tasks.map((t) => ({ id: t.id, title: t.title }))
});
// Handle task update
const handleUpdateTask = async (taskId: string, updates: TaskUpdates) => {
console.log(`🔄 Updating task ${taskId} content:`, updates);
try {
await updateTask.mutateAsync({
taskId,
updates,
options: { append: false, research: false }
});
console.log(`✅ Task ${taskId} content updated successfully`);
// Close the edit modal
dispatch({
type: 'SET_EDITING_TASK',
payload: { taskId: null }
});
} catch (error) {
console.error(`❌ Failed to update task ${taskId}:`, error);
dispatch({
type: 'SET_ERROR',
payload: `Failed to update task: ${error}`
});
}
};
// Handle drag start
const handleDragStart = useCallback(
(event: DragEndEvent) => {
const taskId = event.active.id as string;
const task = tasks.find((t) => t.id === taskId);
if (task) {
setActiveTask(task);
}
},
[tasks]
);
// Handle drag cancel
const handleDragCancel = useCallback(() => {
setActiveTask(null);
// Clear any temporary state
setTempReorderedTasks(null);
}, []);
// Handle drag end
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
// Reset active task
setActiveTask(null);
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) {
// Clear temp state if no change needed
setTempReorderedTasks(null);
return;
}
// 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 {
// 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) {
// 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, updateTaskStatus, dispatch]
);
// Handle retry connection
const handleRetry = useCallback(() => {
sendMessage({ type: 'retryConnection' });
}, [sendMessage]);
// Handle refresh
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
try {
// Invalidate all task queries
await queryClient.invalidateQueries({ queryKey: taskKeys.all });
} finally {
// Reset after a short delay to show the animation
setTimeout(() => setIsRefreshing(false), 500);
}
}, [queryClient]);
// Handle tag switching
const handleTagSwitch = useCallback(
async (tagName: string) => {
console.log('Switching to tag:', tagName);
await sendMessage({ type: 'switchTag', data: { tagName } });
dispatch({
type: 'SET_TAG_DATA',
payload: { currentTag: tagName, availableTags }
});
},
[sendMessage, dispatch, availableTags]
);
// Use React Query loading state
const displayError = error
? error instanceof Error
? error.message
: String(error)
: legacyError;
if (isLoading) {
return (
<div
className="flex items-center justify-center"
style={{ height: `${kanbanHeight}px` }}
>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-vscode-foreground mx-auto mb-4" />
<p className="text-sm text-vscode-foreground/70">Loading tasks...</p>
</div>
</div>
);
}
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: {displayError}</p>
<button
onClick={() => dispatch({ type: 'CLEAR_ERROR' })}
className="mt-2 text-sm text-red-400 hover:text-red-300 underline"
>
Dismiss
</button>
</div>
);
}
return (
<>
<div className="flex flex-col" style={{ height: `${availableHeight}px` }}>
<div className="flex-shrink-0 p-4 bg-vscode-sidebar-background border-b border-vscode-border">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold text-vscode-foreground">
TaskMaster Kanban
</h1>
<div className="flex items-center gap-4">
<TagDropdown
currentTag={currentTag}
availableTags={availableTags}
onTagSwitch={handleTagSwitch}
sendMessage={sendMessage}
dispatch={dispatch}
/>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="p-1.5 rounded hover:bg-vscode-button-hoverBackground transition-colors"
title="Refresh tasks"
>
<RefreshCw
className={`w-4 h-4 text-vscode-foreground/70 ${isRefreshing ? 'animate-spin' : ''}`}
/>
</button>
<PollingStatus polling={polling} onRetry={handleRetry} />
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${state.isConnected ? 'bg-green-400' : 'bg-red-400'}`}
/>
<span className="text-xs text-vscode-foreground/70">
{state.connectionStatus}
</span>
</div>
<button
onClick={() => dispatch({ type: 'NAVIGATE_TO_CONFIG' })}
className="p-1.5 rounded hover:bg-vscode-button-hoverBackground transition-colors"
title="TaskMaster Configuration"
>
<svg
className="w-4 h-4 text-vscode-foreground/70"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button>
</div>
</div>
</div>
<div
className="flex-1 px-4 py-4 overflow-hidden"
style={{ height: `${kanbanHeight}px` }}
>
{tasks.length === 0 ? (
<EmptyState currentTag={currentTag} />
) : (
<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
}
>
<div className="flex gap-4 h-full min-w-fit">
{kanbanStatuses.map((status) => {
const statusTasks = tasksByStatus[status.id] || [];
const hasScrollbar = statusTasks.length > 4;
return (
<KanbanBoard
key={status.id}
id={status.id}
className={`
w-80 flex flex-col
border border-vscode-border/30
rounded-lg
bg-vscode-sidebar-background/50
`}
>
<KanbanHeader
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"
/>
<div
className={`
flex flex-col gap-2
overflow-y-auto overflow-x-hidden
p-2
scrollbar-thin scrollbar-track-transparent
${hasScrollbar ? 'pr-1' : ''}
`}
style={{
maxHeight: `${kanbanHeight - 80}px`
}}
>
<KanbanCards>
{statusTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onViewDetails={(taskId) => {
console.log(
'🔍 Navigating to task details:',
taskId
);
dispatch({
type: 'NAVIGATE_TO_TASK',
payload: taskId
});
}}
/>
))}
</KanbanCards>
</div>
</KanbanBoard>
);
})}
</div>
</KanbanProvider>
)}
</div>
</div>
{/* Task Edit Modal */}
{editingTask?.taskId && editingTask.editData && (
<TaskEditModal
task={editingTask.editData}
onSave={handleUpdateTask}
onCancel={() => {
dispatch({
type: 'SET_EDITING_TASK',
payload: { taskId: null }
});
}}
/>
)}
</>
);
};

View File

@@ -0,0 +1,31 @@
/**
* Toast Container Component
*/
import React from 'react';
import { ToastNotification } from './ToastNotification';
import type { ToastNotification as ToastType } from '../types';
interface ToastContainerProps {
notifications: ToastType[];
onDismiss: (id: string) => void;
}
export const ToastContainer: React.FC<ToastContainerProps> = ({
notifications,
onDismiss
}) => {
return (
<div className="fixed top-4 right-4 z-50 pointer-events-none">
<div className="flex flex-col items-end pointer-events-auto">
{notifications.map((notification) => (
<ToastNotification
key={notification.id}
notification={notification}
onDismiss={onDismiss}
/>
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,167 @@
/**
* Toast Notification Component
*/
import React, { useState, useEffect } from 'react';
import type { ToastNotification as ToastType } from '../types';
interface ToastNotificationProps {
notification: ToastType;
onDismiss: (id: string) => void;
}
export const ToastNotification: React.FC<ToastNotificationProps> = ({
notification,
onDismiss
}) => {
const [isVisible, setIsVisible] = useState(true);
const [progress, setProgress] = useState(100);
const duration = notification.duration || 5000; // 5 seconds default
useEffect(() => {
const progressInterval = setInterval(() => {
setProgress((prev) => {
const decrease = (100 / duration) * 100; // Update every 100ms
return Math.max(0, prev - decrease);
});
}, 100);
const timeoutId = setTimeout(() => {
setIsVisible(false);
setTimeout(() => onDismiss(notification.id), 300); // Wait for animation
}, duration);
return () => {
clearInterval(progressInterval);
clearTimeout(timeoutId);
};
}, [notification.id, duration, onDismiss]);
const getIcon = () => {
switch (notification.type) {
case 'success':
return (
<svg
className="w-5 h-5 text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
);
case 'info':
return (
<svg
className="w-5 h-5 text-blue-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
case 'warning':
return (
<svg
className="w-5 h-5 text-yellow-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.667-2.308-1.667-3.08 0L3.34 19c-.77 1.333.192 3 1.732 3z"
/>
</svg>
);
case 'error':
return (
<svg
className="w-5 h-5 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
}
};
const bgColor = {
success: 'bg-green-900/90',
info: 'bg-blue-900/90',
warning: 'bg-yellow-900/90',
error: 'bg-red-900/90'
}[notification.type];
const borderColor = {
success: 'border-green-600',
info: 'border-blue-600',
warning: 'border-yellow-600',
error: 'border-red-600'
}[notification.type];
const progressColor = {
success: 'bg-green-400',
info: 'bg-blue-400',
warning: 'bg-yellow-400',
error: 'bg-red-400'
}[notification.type];
return (
<div
className={`${bgColor} ${borderColor} border rounded-lg shadow-lg p-4 mb-2 transition-all duration-300 ${
isVisible ? 'opacity-100 translate-x-0' : 'opacity-0 translate-x-full'
} max-w-sm w-full relative overflow-hidden`}
>
<div className="flex items-start">
<div className="flex-shrink-0">{getIcon()}</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-white">
{notification.title}
</h3>
<p className="mt-1 text-sm text-gray-300">{notification.message}</p>
</div>
<button
onClick={() => onDismiss(notification.id)}
className="ml-4 flex-shrink-0 inline-flex text-gray-400 hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white"
>
<span className="sr-only">Close</span>
<svg className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</button>
</div>
{/* Progress bar */}
<div className="absolute bottom-0 left-0 w-full h-1 bg-gray-700">
<div
className={`h-full ${progressColor} transition-all duration-100 ease-linear`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
};