chore: refactor and cleanup

- remove eslint
- refactor code into common design patterns where needed
- refactor taskMasterApi from 1.5k lines to smaller chunks and different files
- removed taskFileReader (not used anywhere)
- added support for tag selection (wip)
- added configuration page where a user can see his config.json more visually
This commit is contained in:
Ralph Khreish
2025-07-29 19:55:38 +03:00
parent 17a95bd8c3
commit 8ad9ccd6b7
65 changed files with 7969 additions and 7231 deletions

View File

@@ -1,34 +0,0 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
export default [
{
files: ['**/*.ts']
},
{
plugins: {
'@typescript-eslint': typescriptEslint
},
languageOptions: {
parser: tsParser,
ecmaVersion: 2022,
sourceType: 'module'
},
rules: {
'@typescript-eslint/naming-convention': [
'warn',
{
selector: 'import',
format: ['camelCase', 'PascalCase']
}
],
curly: 'warn',
eqeqeq: 'warn',
'no-throw-literal': 'warn',
semi: 'warn'
}
}
];

View File

@@ -192,15 +192,14 @@
"vscode:prepublish": "npm run build",
"build": "npm run build:js && npm run build:css",
"build:js": "node ./esbuild.js --production",
"build:css": "npx @tailwindcss/cli -o ./dist/index.css --minify",
"build:css": "npx @tailwindcss/cli -i ./src/webview/index.css -o ./dist/index.css --minify",
"package": "npm exec node ./package.mjs",
"package:direct": "node ./package.mjs",
"debug:env": "node ./debug-env.mjs",
"compile": "node ./esbuild.js",
"watch": "npm run watch:js & npm run watch:css",
"watch:js": "node ./esbuild.js --watch",
"watch:css": "npx @tailwindcss/cli -o ./dist/index.css --watch",
"lint": "eslint src --ext ts,tsx",
"watch:css": "npx @tailwindcss/cli -i ./src/webview/index.css -o ./dist/index.css --watch",
"test": "vscode-test",
"check-types": "tsc --noEmit"
},
@@ -221,8 +220,6 @@
"@types/react": "19.1.8",
"@types/react-dom": "19.1.6",
"@types/vscode": "^1.101.0",
"@typescript-eslint/eslint-plugin": "^8.31.1",
"@typescript-eslint/parser": "^8.31.1",
"@vscode/test-cli": "^0.0.11",
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^2.32.0",
@@ -231,7 +228,6 @@
"clsx": "^2.1.1",
"esbuild": "^0.25.3",
"esbuild-postcss": "^0.0.4",
"eslint": "^9.25.1",
"fs-extra": "^11.3.0",
"lucide-react": "^0.525.0",
"npm-run-all": "^4.1.5",

View File

@@ -1,7 +1,7 @@
import { execSync } from 'child_process';
import fs from 'fs-extra';
import path from 'path';
import { fileURLToPath } from 'url';
import fs from 'fs-extra';
// --- Configuration ---
const __filename = fileURLToPath(import.meta.url);

View File

@@ -0,0 +1,291 @@
import { ArrowLeft, RefreshCw, Settings } from 'lucide-react';
import type React from 'react';
import { useEffect, useState } from 'react';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from './ui/card';
import { ScrollArea } from './ui/scroll-area';
import { Separator } from './ui/separator';
interface ModelConfig {
provider: string;
modelId: string;
maxTokens: number;
temperature: number;
}
interface ConfigData {
models?: {
main?: ModelConfig;
research?: ModelConfig;
fallback?: ModelConfig;
};
global?: {
defaultNumTasks?: number;
defaultSubtasks?: number;
defaultPriority?: string;
projectName?: string;
responseLanguage?: string;
};
}
interface ConfigViewProps {
sendMessage: (message: any) => Promise<any>;
onNavigateBack: () => void;
}
export const ConfigView: React.FC<ConfigViewProps> = ({
sendMessage,
onNavigateBack
}) => {
const [config, setConfig] = useState<ConfigData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadConfig = async () => {
setLoading(true);
setError(null);
try {
const response = await sendMessage({ type: 'getConfig' });
setConfig(response);
} catch (err) {
setError('Failed to load configuration');
console.error('Error loading config:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, []);
const modelLabels = {
main: {
label: 'Main Model',
icon: '🤖',
description: 'Primary model for task generation'
},
research: {
label: 'Research Model',
icon: '🔍',
description: 'Model for research-backed operations'
},
fallback: {
label: 'Fallback Model',
icon: '🔄',
description: 'Backup model if primary fails'
}
};
return (
<div className="flex flex-col h-full bg-vscode-editor-background">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-vscode-border">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onNavigateBack}
className="h-8 w-8"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="flex items-center gap-2">
<Settings className="w-5 h-5" />
<h1 className="text-lg font-semibold">Task Master Configuration</h1>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={loadConfig}
className="h-8 w-8"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
{/* Content */}
<ScrollArea className="flex-1 overflow-hidden">
<div className="p-6 pb-12">
{loading ? (
<div className="flex items-center justify-center py-8">
<RefreshCw className="w-6 h-6 animate-spin text-vscode-foreground/50" />
</div>
) : error ? (
<div className="text-red-500 text-center py-8">{error}</div>
) : config ? (
<div className="space-y-6 max-w-4xl mx-auto">
{/* Models Section */}
<Card>
<CardHeader>
<CardTitle>AI Models</CardTitle>
<CardDescription>
Models configured for different Task Master operations
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{config.models &&
Object.entries(config.models).map(([key, modelConfig]) => {
const label =
modelLabels[key as keyof typeof modelLabels];
if (!label || !modelConfig) return null;
return (
<div key={key} className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-lg">{label.icon}</span>
<div>
<h4 className="font-medium">{label.label}</h4>
<p className="text-xs text-vscode-foreground/60">
{label.description}
</p>
</div>
</div>
<div className="bg-vscode-input/20 rounded-md p-3 space-y-1">
<div className="flex justify-between">
<span className="text-sm text-vscode-foreground/80">
Provider:
</span>
<Badge variant="secondary">
{modelConfig.provider}
</Badge>
</div>
<div className="flex justify-between">
<span className="text-sm text-vscode-foreground/80">
Model:
</span>
<code className="text-xs font-mono bg-vscode-input/30 px-2 py-1 rounded">
{modelConfig.modelId}
</code>
</div>
<div className="flex justify-between">
<span className="text-sm text-vscode-foreground/80">
Max Tokens:
</span>
<span className="text-sm">
{modelConfig.maxTokens.toLocaleString()}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-vscode-foreground/80">
Temperature:
</span>
<span className="text-sm">
{modelConfig.temperature}
</span>
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
{/* Task Defaults Section */}
{config.global && (
<Card>
<CardHeader>
<CardTitle>Task Defaults</CardTitle>
<CardDescription>
Default values for new tasks and subtasks
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Default Number of Tasks
</span>
<Badge variant="outline">
{config.global.defaultNumTasks || 10}
</Badge>
</div>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Default Number of Subtasks
</span>
<Badge variant="outline">
{config.global.defaultSubtasks || 5}
</Badge>
</div>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Default Priority
</span>
<Badge
variant={
config.global.defaultPriority === 'high'
? 'destructive'
: config.global.defaultPriority === 'low'
? 'secondary'
: 'default'
}
>
{config.global.defaultPriority || 'medium'}
</Badge>
</div>
{config.global.projectName && (
<>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Project Name
</span>
<span className="text-sm text-vscode-foreground/80">
{config.global.projectName}
</span>
</div>
</>
)}
{config.global.responseLanguage && (
<>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm font-medium">
Response Language
</span>
<span className="text-sm text-vscode-foreground/80">
{config.global.responseLanguage}
</span>
</div>
</>
)}
</div>
</CardContent>
</Card>
)}
{/* Info Card */}
<Card>
<CardContent className="pt-6">
<p className="text-sm text-vscode-foreground/60">
To modify these settings, go to{' '}
<code className="bg-vscode-input/30 px-1 py-0.5 rounded">
.taskmaster/config.json
</code>{' '}
and modify them, or use the MCP.
</p>
</CardContent>
</Card>
</div>
) : (
<div className="text-center py-8 text-vscode-foreground/50">
No configuration found. Please run `task-master init` in your
project.
</div>
)}
</div>
</ScrollArea>
</div>
);
};

View File

@@ -0,0 +1,201 @@
import type React from 'react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
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 type { TaskMasterTask } from '../../webview/types';
interface AIActionsSectionProps {
currentTask: TaskMasterTask;
isSubtask: boolean;
parentTask?: TaskMasterTask | null;
sendMessage: (message: any) => Promise<any>;
refreshComplexityAfterAI: () => void;
onRegeneratingChange?: (isRegenerating: boolean) => void;
onAppendingChange?: (isAppending: boolean) => void;
}
export const AIActionsSection: React.FC<AIActionsSectionProps> = ({
currentTask,
isSubtask,
parentTask,
sendMessage,
refreshComplexityAfterAI,
onRegeneratingChange,
onAppendingChange
}) => {
const [prompt, setPrompt] = useState('');
const [isRegenerating, setIsRegenerating] = useState(false);
const [isAppending, setIsAppending] = useState(false);
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 }
}
});
} else {
await sendMessage({
type: 'updateTask',
data: {
taskId: currentTask.id,
updates: { description: prompt },
options: { append: false, research: false }
}
});
}
refreshComplexityAfterAI();
} catch (error) {
console.error('❌ TaskDetailsView: Failed to regenerate task:', error);
} finally {
setIsRegenerating(false);
setPrompt('');
}
};
const handleAppend = async () => {
if (!currentTask || !prompt.trim()) {
return;
}
setIsAppending(true);
try {
if (isSubtask && parentTask) {
await sendMessage({
type: 'updateSubtask',
data: {
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 }
}
});
}
refreshComplexityAfterAI();
} catch (error) {
console.error('❌ TaskDetailsView: Failed to append to task:', error);
} finally {
setIsAppending(false);
setPrompt('');
}
};
return (
<CollapsibleSection
title="AI Actions"
icon={Wand2}
defaultExpanded={true}
buttonClassName="text-vscode-foreground/80 hover:text-vscode-foreground"
>
<div className="space-y-4">
<div>
<Label
htmlFor="ai-prompt"
className="block text-sm font-medium text-vscode-foreground/80 mb-2"
>
Enter your prompt
</Label>
<Textarea
id="ai-prompt"
placeholder={
isSubtask
? 'Describe implementation notes, progress updates, or findings to add to this subtask...'
: 'Describe what you want to change or add to this task...'
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
className="min-h-[100px] bg-vscode-input-background border-vscode-input-border text-vscode-input-foreground placeholder-vscode-input-foreground/50 focus:border-vscode-focusBorder focus:ring-vscode-focusBorder"
disabled={isRegenerating || isAppending}
/>
</div>
<div className="flex gap-3">
{!isSubtask && (
<Button
onClick={handleRegenerate}
disabled={!prompt.trim() || isRegenerating || isAppending}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{isRegenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Regenerating...
</>
) : (
<>
<Wand2 className="w-4 h-4 mr-2" />
Regenerate Task
</>
)}
</Button>
)}
<Button
onClick={handleAppend}
disabled={!prompt.trim() || isRegenerating || isAppending}
variant={isSubtask ? 'default' : 'outline'}
className={
isSubtask
? 'bg-primary text-primary-foreground hover:bg-primary/90'
: 'bg-secondary text-secondary-foreground hover:bg-secondary/90 border-widget-border'
}
>
{isAppending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
{isSubtask ? 'Updating...' : 'Appending...'}
</>
) : (
<>
<PlusCircle className="w-4 h-4 mr-2" />
{isSubtask ? 'Add Notes to Subtask' : 'Append to Task'}
</>
)}
</Button>
</div>
<div className="text-xs text-vscode-foreground/60 space-y-1">
{isSubtask ? (
<p>
<strong>Add Notes:</strong> Appends timestamped implementation
notes, progress updates, or findings to this subtask's details
</p>
) : (
<>
<p>
<strong>Regenerate:</strong> Completely rewrites the task
description and subtasks based on your prompt
</p>
<p>
<strong>Append:</strong> Adds new content to the existing task
description based on your prompt
</p>
</>
)}
</div>
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,178 @@
import type React from 'react';
import { CollapsibleSection } from '@/components/ui/CollapsibleSection';
interface MarkdownRendererProps {
content: string;
className?: string;
}
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
content,
className = ''
}) => {
const parseMarkdown = (text: string) => {
const parts = [];
const lines = text.split('\n');
let currentBlock = [];
let inCodeBlock = false;
let codeLanguage = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('```')) {
if (inCodeBlock) {
if (currentBlock.length > 0) {
parts.push({
type: 'code',
content: currentBlock.join('\n'),
language: codeLanguage
});
currentBlock = [];
}
inCodeBlock = false;
codeLanguage = '';
} else {
if (currentBlock.length > 0) {
parts.push({
type: 'text',
content: currentBlock.join('\n')
});
currentBlock = [];
}
inCodeBlock = true;
codeLanguage = line.substring(3).trim();
}
} else {
currentBlock.push(line);
}
}
if (currentBlock.length > 0) {
parts.push({
type: inCodeBlock ? 'code' : 'text',
content: currentBlock.join('\n'),
language: codeLanguage
});
}
return parts;
};
const parts = parseMarkdown(content);
return (
<div className={className}>
{parts.map((part, index) => {
if (part.type === 'code') {
return (
<pre
key={index}
className="bg-vscode-editor-background rounded-md p-4 overflow-x-auto mb-4 border border-vscode-editor-lineHighlightBorder"
>
<code className="text-sm text-vscode-editor-foreground font-mono">
{part.content}
</code>
</pre>
);
}
return (
<div key={index} className="whitespace-pre-wrap mb-4 last:mb-0">
{part.content.split('\n').map((line, lineIndex) => {
const bulletMatch = line.match(/^(\s*)([-*])\s(.+)$/);
if (bulletMatch) {
const indent = bulletMatch[1].length;
return (
<div
key={lineIndex}
className="flex gap-2 mb-1"
style={{ paddingLeft: `${indent * 16}px` }}
>
<span className="text-vscode-foreground/60"></span>
<span className="flex-1">{bulletMatch[3]}</span>
</div>
);
}
const numberedMatch = line.match(/^(\s*)(\d+\.)\s(.+)$/);
if (numberedMatch) {
const indent = numberedMatch[1].length;
return (
<div
key={lineIndex}
className="flex gap-2 mb-1"
style={{ paddingLeft: `${indent * 16}px` }}
>
<span className="text-vscode-foreground/60 font-mono">
{numberedMatch[2]}
</span>
<span className="flex-1">{numberedMatch[3]}</span>
</div>
);
}
const headingMatch = line.match(/^(#{1,6})\s(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
const HeadingTag =
`h${Math.min(level + 2, 6)}` as keyof JSX.IntrinsicElements;
return (
<HeadingTag
key={lineIndex}
className="font-semibold text-vscode-foreground mb-2 mt-4 first:mt-0"
>
{headingMatch[2]}
</HeadingTag>
);
}
if (line.trim() === '') {
return <div key={lineIndex} className="h-2" />;
}
return (
<div key={lineIndex} className="mb-2 last:mb-0">
{line}
</div>
);
})}
</div>
);
})}
</div>
);
};
interface DetailsSectionProps {
title: string;
content?: string;
error?: string | null;
emptyMessage?: string;
defaultExpanded?: boolean;
}
export const DetailsSection: React.FC<DetailsSectionProps> = ({
title,
content,
error,
emptyMessage = 'No details available',
defaultExpanded = false
}) => {
return (
<CollapsibleSection title={title} defaultExpanded={defaultExpanded}>
<div className={title.toLowerCase().replace(/\s+/g, '-') + '-content'}>
{error ? (
<div className="text-sm text-red-400 py-2">
Error loading {title.toLowerCase()}: {error}
</div>
) : content !== undefined && content !== '' ? (
<MarkdownRenderer content={content} />
) : (
<div className="text-sm text-vscode-foreground/50 py-2">
{emptyMessage}
</div>
)}
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,47 @@
import type React from 'react';
import type { TaskMasterTask } from '../../webview/types';
// Custom Priority Badge Component with theme-adaptive styling
export const PriorityBadge: React.FC<{
priority: TaskMasterTask['priority'];
}> = ({ priority }) => {
const getPriorityColors = (priority: string) => {
switch (priority) {
case 'high':
return {
backgroundColor: 'rgba(239, 68, 68, 0.2)', // red-500 with opacity
color: '#dc2626', // red-600 - works in both themes
borderColor: 'rgba(239, 68, 68, 0.4)'
};
case 'medium':
return {
backgroundColor: 'rgba(245, 158, 11, 0.2)', // amber-500 with opacity
color: '#d97706', // amber-600 - works in both themes
borderColor: 'rgba(245, 158, 11, 0.4)'
};
case 'low':
return {
backgroundColor: 'rgba(34, 197, 94, 0.2)', // green-500 with opacity
color: '#16a34a', // green-600 - works in both themes
borderColor: 'rgba(34, 197, 94, 0.4)'
};
default:
return {
backgroundColor: 'rgba(156, 163, 175, 0.2)',
color: 'var(--vscode-foreground)',
borderColor: 'rgba(156, 163, 175, 0.4)'
};
}
};
const colors = getPriorityColors(priority || '');
return (
<span
className="inline-flex items-center px-2 py-1 text-xs font-medium rounded-md border"
style={colors}
>
{priority || 'None'}
</span>
);
};

View File

@@ -0,0 +1,233 @@
import type React from 'react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { CollapsibleSection } from '@/components/ui/CollapsibleSection';
import { Plus, Loader2, ChevronRight } from 'lucide-react';
import type { TaskMasterTask } from '../../webview/types';
interface SubtasksSectionProps {
currentTask: TaskMasterTask;
isSubtask: boolean;
sendMessage: (message: any) => Promise<any>;
onNavigateToTask: (taskId: string) => void;
}
export const SubtasksSection: React.FC<SubtasksSectionProps> = ({
currentTask,
isSubtask,
sendMessage,
onNavigateToTask
}) => {
const [isAddingSubtask, setIsAddingSubtask] = useState(false);
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [newSubtaskDescription, setNewSubtaskDescription] = useState('');
const [isSubmittingSubtask, setIsSubmittingSubtask] = useState(false);
const handleAddSubtask = async () => {
if (!currentTask || !newSubtaskTitle.trim() || isSubtask) {
return;
}
setIsSubmittingSubtask(true);
try {
await sendMessage({
type: 'addSubtask',
data: {
parentTaskId: currentTask.id,
subtaskData: {
title: newSubtaskTitle.trim(),
description: newSubtaskDescription.trim() || undefined,
status: 'pending'
}
}
});
// Reset form and close
setNewSubtaskTitle('');
setNewSubtaskDescription('');
setIsAddingSubtask(false);
} catch (error) {
console.error('❌ TaskDetailsView: Failed to add subtask:', error);
} finally {
setIsSubmittingSubtask(false);
}
};
const handleCancelAddSubtask = () => {
setIsAddingSubtask(false);
setNewSubtaskTitle('');
setNewSubtaskDescription('');
};
if (
!((currentTask.subtasks && currentTask.subtasks.length > 0) || !isSubtask)
) {
return null;
}
const rightElement = (
<>
{currentTask.subtasks && currentTask.subtasks.length > 0 && (
<span className="text-sm text-vscode-foreground/50">
{currentTask.subtasks?.filter((st) => st.status === 'done').length}/
{currentTask.subtasks?.length}
</span>
)}
{!isSubtask && (
<Button
variant="ghost"
size="sm"
className="ml-auto p-1 h-6 w-6 hover:bg-vscode-button-hoverBackground"
onClick={() => setIsAddingSubtask(true)}
title="Add subtask"
>
<Plus className="w-4 h-4" />
</Button>
)}
</>
);
return (
<CollapsibleSection
title="Sub-issues"
defaultExpanded={true}
rightElement={rightElement}
>
<div className="space-y-3">
{/* Add Subtask Form */}
{isAddingSubtask && (
<div className="bg-widget-background rounded-lg p-4 border border-widget-border">
<h4 className="text-sm font-medium text-vscode-foreground mb-3">
Add New Subtask
</h4>
<div className="space-y-3">
<div>
<Label
htmlFor="subtask-title"
className="block text-sm text-vscode-foreground/80 mb-1"
>
Title*
</Label>
<input
id="subtask-title"
type="text"
placeholder="Enter subtask title..."
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
className="w-full px-3 py-2 text-sm bg-vscode-input-background border border-vscode-input-border text-vscode-input-foreground placeholder-vscode-input-foreground/50 rounded focus:border-vscode-focusBorder focus:ring-1 focus:ring-vscode-focusBorder"
disabled={isSubmittingSubtask}
/>
</div>
<div>
<Label
htmlFor="subtask-description"
className="block text-sm text-vscode-foreground/80 mb-1"
>
Description (Optional)
</Label>
<Textarea
id="subtask-description"
placeholder="Enter subtask description..."
value={newSubtaskDescription}
onChange={(e) => setNewSubtaskDescription(e.target.value)}
className="min-h-[80px] bg-vscode-input-background border-vscode-input-border text-vscode-input-foreground placeholder-vscode-input-foreground/50 focus:border-vscode-focusBorder focus:ring-vscode-focusBorder"
disabled={isSubmittingSubtask}
/>
</div>
<div className="flex gap-3 pt-2">
<Button
onClick={handleAddSubtask}
disabled={!newSubtaskTitle.trim() || isSubmittingSubtask}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{isSubmittingSubtask ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Adding...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Add Subtask
</>
)}
</Button>
<Button
onClick={handleCancelAddSubtask}
variant="outline"
disabled={isSubmittingSubtask}
className="border-widget-border"
>
Cancel
</Button>
</div>
</div>
</div>
)}
{/* Subtasks List */}
{currentTask.subtasks && currentTask.subtasks.length > 0 && (
<div className="space-y-2">
{currentTask.subtasks.map((subtask, index) => {
const subtaskId = `${currentTask.id}.${index + 1}`;
const getStatusDotColor = (status: string) => {
switch (status) {
case 'done':
return '#22c55e';
case 'in-progress':
return '#3b82f6';
case 'review':
return '#a855f7';
case 'deferred':
return '#ef4444';
case 'cancelled':
return '#6b7280';
default:
return '#eab308';
}
};
return (
<div
key={subtask.id}
className="flex items-center gap-3 p-3 rounded-md border border-textSeparator-foreground hover:border-vscode-border/70 transition-colors cursor-pointer"
onClick={() => onNavigateToTask(subtaskId)}
>
<div
className="w-4 h-4 rounded-full flex items-center justify-center"
style={{
backgroundColor: getStatusDotColor(subtask.status)
}}
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-vscode-foreground truncate">
{subtask.title}
</p>
{subtask.description && (
<p className="text-xs text-vscode-foreground/60 truncate mt-0.5">
{subtask.description}
</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<Badge
variant="secondary"
className="text-xs bg-secondary/20 border-secondary/30 text-secondary-foreground px-2 py-0.5"
>
{subtask.status === 'pending' ? 'todo' : subtask.status}
</Badge>
</div>
</div>
);
})}
</div>
)}
</div>
</CollapsibleSection>
);
};

View File

@@ -0,0 +1,288 @@
import type React from 'react';
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { PriorityBadge } from './PriorityBadge';
import type { TaskMasterTask } from '../../webview/types';
interface TaskMetadataSidebarProps {
currentTask: TaskMasterTask;
tasks: TaskMasterTask[];
complexity: any;
isSubtask: boolean;
sendMessage: (message: any) => Promise<any>;
onStatusChange: (status: TaskMasterTask['status']) => void;
onDependencyClick: (depId: string) => void;
isRegenerating?: boolean;
isAppending?: boolean;
}
export const TaskMetadataSidebar: React.FC<TaskMetadataSidebarProps> = ({
currentTask,
tasks,
complexity,
isSubtask,
sendMessage,
onStatusChange,
onDependencyClick,
isRegenerating = false,
isAppending = false
}) => {
const [isLoadingComplexity, setIsLoadingComplexity] = useState(false);
const [mcpComplexityScore, setMcpComplexityScore] = useState<
number | undefined
>(undefined);
// Get complexity score from task
const currentComplexityScore = complexity?.score;
// Display logic - use MCP score if available, otherwise use current score
const displayComplexityScore =
mcpComplexityScore !== undefined
? mcpComplexityScore
: currentComplexityScore;
// Fetch complexity from MCP when needed
const fetchComplexityFromMCP = async (force = false) => {
if (!currentTask || (!force && currentComplexityScore !== undefined)) {
return;
}
setIsLoadingComplexity(true);
try {
const complexityResult = await sendMessage({
type: 'mcpRequest',
tool: 'complexity_report',
params: {}
});
if (complexityResult?.data?.report?.complexityAnalysis) {
const taskComplexity =
complexityResult.data.report.complexityAnalysis.tasks?.find(
(t: any) => t.id === currentTask.id
);
if (taskComplexity) {
setMcpComplexityScore(taskComplexity.complexityScore);
}
}
} catch (error) {
console.error('Failed to fetch complexity from MCP:', error);
} finally {
setIsLoadingComplexity(false);
}
};
// Handle running complexity analysis for a task
const handleRunComplexityAnalysis = async () => {
if (!currentTask) {
return;
}
setIsLoadingComplexity(true);
try {
// Run complexity analysis on this specific task
await sendMessage({
type: 'mcpRequest',
tool: 'analyze_project_complexity',
params: {
ids: currentTask.id.toString(),
research: false
}
});
// After analysis, fetch the updated complexity report
setTimeout(() => {
fetchComplexityFromMCP(true);
}, 1000);
} catch (error) {
console.error('Failed to run complexity analysis:', error);
} finally {
setIsLoadingComplexity(false);
}
};
// Effect to handle complexity on task change
useEffect(() => {
if (currentTask?.id) {
setMcpComplexityScore(undefined);
if (currentComplexityScore === undefined) {
fetchComplexityFromMCP();
}
}
}, [currentTask?.id, currentComplexityScore]);
return (
<div className="md:col-span-1 border-l border-textSeparator-foreground">
<div className="p-6">
<div className="space-y-6">
<div>
<h3 className="text-sm font-medium text-vscode-foreground/70 mb-3">
Properties
</h3>
</div>
<div className="space-y-4">
{/* Status */}
<div className="flex items-center justify-between">
<span className="text-sm text-vscode-foreground/70">Status</span>
<select
value={currentTask.status}
onChange={(e) =>
onStatusChange(e.target.value as TaskMasterTask['status'])
}
className="border rounded-md px-3 py-1 text-sm font-medium focus:ring-1 focus:border-vscode-focusBorder focus:ring-vscode-focusBorder"
style={{
backgroundColor:
currentTask.status === 'pending'
? 'rgba(156, 163, 175, 0.2)'
: currentTask.status === 'in-progress'
? 'rgba(245, 158, 11, 0.2)'
: currentTask.status === 'review'
? 'rgba(59, 130, 246, 0.2)'
: currentTask.status === 'done'
? 'rgba(34, 197, 94, 0.2)'
: currentTask.status === 'deferred'
? 'rgba(239, 68, 68, 0.2)'
: 'var(--vscode-input-background)',
color:
currentTask.status === 'pending'
? 'var(--vscode-foreground)'
: currentTask.status === 'in-progress'
? '#d97706'
: currentTask.status === 'review'
? '#2563eb'
: currentTask.status === 'done'
? '#16a34a'
: currentTask.status === 'deferred'
? '#dc2626'
: 'var(--vscode-foreground)',
borderColor:
currentTask.status === 'pending'
? 'rgba(156, 163, 175, 0.4)'
: currentTask.status === 'in-progress'
? 'rgba(245, 158, 11, 0.4)'
: currentTask.status === 'review'
? 'rgba(59, 130, 246, 0.4)'
: currentTask.status === 'done'
? 'rgba(34, 197, 94, 0.4)'
: currentTask.status === 'deferred'
? 'rgba(239, 68, 68, 0.4)'
: 'var(--vscode-input-border)'
}}
>
<option value="pending">To do</option>
<option value="in-progress">In Progress</option>
<option value="review">Review</option>
<option value="done">Done</option>
<option value="deferred">Deferred</option>
</select>
</div>
{/* Priority */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Priority</span>
<PriorityBadge priority={currentTask.priority} />
</div>
{/* Complexity Score */}
<div className="space-y-2">
<label className="text-sm font-medium text-[var(--vscode-foreground)]">
Complexity Score
</label>
{isLoadingComplexity ? (
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-[var(--vscode-descriptionForeground)]" />
<span className="text-sm text-[var(--vscode-descriptionForeground)]">
Loading...
</span>
</div>
) : displayComplexityScore !== undefined ? (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[var(--vscode-foreground)]">
{displayComplexityScore}/10
</span>
<div
className={`flex-1 rounded-full h-2 ${
displayComplexityScore >= 7
? 'bg-red-500/20'
: displayComplexityScore >= 4
? 'bg-yellow-500/20'
: 'bg-green-500/20'
}`}
>
<div
className={`h-2 rounded-full transition-all duration-300 ${
displayComplexityScore >= 7
? 'bg-red-500'
: displayComplexityScore >= 4
? 'bg-yellow-500'
: 'bg-green-500'
}`}
style={{
width: `${(displayComplexityScore || 0) * 10}%`
}}
/>
</div>
</div>
) : currentTask?.status === 'done' ||
currentTask?.status === 'deferred' ||
currentTask?.status === 'review' ? (
<div className="text-sm text-[var(--vscode-descriptionForeground)]">
N/A
</div>
) : (
<>
<div className="text-sm text-[var(--vscode-descriptionForeground)]">
No complexity score available
</div>
<div className="mt-3">
<Button
onClick={() => handleRunComplexityAnalysis()}
variant="outline"
size="sm"
className="text-xs"
disabled={isRegenerating || isAppending}
>
Run Complexity Analysis
</Button>
</div>
</>
)}
</div>
</div>
<div className="border-b border-textSeparator-foreground" />
{/* Dependencies */}
{currentTask.dependencies && currentTask.dependencies.length > 0 && (
<div>
<h4 className="text-sm font-medium text-vscode-foreground/70 mb-3">
Dependencies
</h4>
<div className="space-y-2">
{currentTask.dependencies.map((depId) => {
const depTask = tasks.find((t) => t.id === depId);
const fullTitle = `Task ${depId}: ${depTask?.title || 'Unknown Task'}`;
const truncatedTitle =
fullTitle.length > 40
? fullTitle.substring(0, 37) + '...'
: fullTitle;
return (
<div
key={depId}
className="text-sm text-link cursor-pointer hover:text-link-hover"
onClick={() => onDependencyClick(depId)}
title={fullTitle}
>
{truncatedTitle}
</div>
);
})}
</div>
</div>
)}
{/* Divider after Dependencies */}
{currentTask.dependencies && currentTask.dependencies.length > 0 && (
<div className="border-b border-textSeparator-foreground" />
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,133 @@
import { useEffect, useState, useCallback } from 'react';
import type { TaskMasterTask } from '../../webview/types';
interface TaskFileData {
details?: string;
testStrategy?: string;
}
interface UseTaskDetailsProps {
taskId: string;
sendMessage: (message: any) => Promise<any>;
tasks: TaskMasterTask[];
}
export const useTaskDetails = ({
taskId,
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('.');
if (parts.length === 2) {
return {
isSubtask: true,
parentId: parts[0],
subtaskIndex: parseInt(parts[1]) - 1 // Convert to 0-based index
};
}
return {
isSubtask: false,
parentId: id,
subtaskIndex: -1
};
};
// Find the current task
useEffect(() => {
console.log('🔍 TaskDetailsView: Looking for task:', taskId);
console.log('🔍 TaskDetailsView: Available tasks:', tasks);
const { isSubtask: isSub, parentId, subtaskIndex } = parseTaskId(taskId);
if (isSub) {
const parent = tasks.find((t) => t.id === parentId);
if (parent && parent.subtasks && parent.subtasks[subtaskIndex]) {
const subtask = parent.subtasks[subtaskIndex];
console.log('✅ TaskDetailsView: Found subtask:', subtask);
setCurrentTask(subtask);
setParentTask(parent);
// Use subtask's own details and testStrategy
setTaskFileData({
details: subtask.details || '',
testStrategy: subtask.testStrategy || ''
});
} else {
console.error('❌ TaskDetailsView: Subtask not found');
setCurrentTask(null);
setParentTask(null);
}
} else {
const task = tasks.find((t) => t.id === taskId);
if (task) {
console.log('✅ TaskDetailsView: Found task:', task);
setCurrentTask(task);
setParentTask(null);
// Use task's own details and testStrategy
setTaskFileData({
details: task.details || '',
testStrategy: task.testStrategy || ''
});
} else {
console.error('❌ TaskDetailsView: Task not found');
setCurrentTask(null);
setParentTask(null);
}
}
}, [taskId, tasks]);
// 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;
}
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]);
const refreshComplexityAfterAI = () => {
setTimeout(() => {
fetchComplexity();
}, 2000);
};
return {
currentTask,
parentTask,
isSubtask,
taskFileData,
taskFileDataError,
complexity,
refreshComplexityAfterAI
};
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,61 @@
import type React from 'react';
import { useState } from 'react';
import { Button } from './button';
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
interface CollapsibleSectionProps {
title: string;
icon?: LucideIcon;
defaultExpanded?: boolean;
className?: string;
headerClassName?: string;
contentClassName?: string;
buttonClassName?: string;
children: React.ReactNode;
rightElement?: React.ReactNode;
}
export const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
icon: Icon,
defaultExpanded = false,
className = '',
headerClassName = '',
contentClassName = '',
buttonClassName = 'text-vscode-foreground/70 hover:text-vscode-foreground',
children,
rightElement
}) => {
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
<div className={`mb-8 ${className}`}>
<div className={`flex items-center gap-2 mb-4 ${headerClassName}`}>
<Button
variant="ghost"
size="sm"
className={`p-0 h-auto ${buttonClassName}`}
onClick={() => setIsExpanded(!isExpanded)}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 mr-1" />
) : (
<ChevronRight className="w-4 h-4 mr-1" />
)}
{Icon && <Icon className="w-4 h-4 mr-1" />}
{title}
</Button>
{rightElement}
</div>
{isExpanded && (
<div
className={`bg-widget-background rounded-lg p-4 border border-widget-border ${contentClassName}`}
>
{children}
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { type VariantProps, cva } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '@/lib/utils';

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { ChevronRight, MoreHorizontal } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { type VariantProps, cva } from 'class-variance-authority';
import type * as React from 'react';
import { cn } from '../../lib/utils';

View File

@@ -1,4 +1,4 @@
import * as React from 'react';
import type * as React from 'react';
import { cn } from '@/lib/utils';

View File

@@ -1,8 +1,8 @@
'use client';
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
import type * as React from 'react';
import { cn } from '@/lib/utils';

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import type * as React from 'react';
import { cn } from '@/lib/utils';

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import type * as React from 'react';
import { cn } from '@/lib/utils';
@@ -11,12 +11,12 @@ function ScrollArea({
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn('relative', className)}
className={cn('relative overflow-hidden', className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 overflow-y-auto"
>
{children}
</ScrollAreaPrimitive.Viewport>

View File

@@ -1,7 +1,7 @@
'use client';
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import type * as React from 'react';
import { cn } from '@/lib/utils';

View File

@@ -1,6 +1,5 @@
'use client';
import React from 'react';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import {
@@ -15,6 +14,7 @@ import {
useSensors
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import type React from 'react';
import type { ReactNode } from 'react';
export type { DragEndEvent } from '@dnd-kit/core';

View File

@@ -1,4 +1,4 @@
import * as React from 'react';
import type * as React from 'react';
import { cn } from '@/lib/utils';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
/**
* Config Service
* Manages Task Master config.json file operations
*/
import * as path from 'path';
import * as fs from 'fs/promises';
import * as vscode from 'vscode';
import type { ExtensionLogger } from '../utils/logger';
export interface TaskMasterConfigJson {
anthropicApiKey?: string;
perplexityApiKey?: string;
openaiApiKey?: string;
googleApiKey?: string;
xaiApiKey?: string;
openrouterApiKey?: string;
mistralApiKey?: string;
debug?: boolean;
models?: {
main?: string;
research?: string;
fallback?: string;
};
}
export class ConfigService {
private configCache: TaskMasterConfigJson | null = null;
private lastReadTime = 0;
private readonly CACHE_DURATION = 5000; // 5 seconds
constructor(private logger: ExtensionLogger) {}
/**
* Read Task Master config.json from the workspace
*/
async readConfig(): Promise<TaskMasterConfigJson | null> {
// Check cache first
if (
this.configCache &&
Date.now() - this.lastReadTime < this.CACHE_DURATION
) {
return this.configCache;
}
try {
const workspaceRoot = this.getWorkspaceRoot();
if (!workspaceRoot) {
this.logger.warn('No workspace folder found');
return null;
}
const configPath = path.join(workspaceRoot, '.taskmaster', 'config.json');
try {
const configContent = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(configContent) as TaskMasterConfigJson;
// Cache the result
this.configCache = config;
this.lastReadTime = Date.now();
this.logger.debug('Successfully read Task Master config', {
hasModels: !!config.models,
debug: config.debug
});
return config;
} catch (error) {
if ((error as any).code === 'ENOENT') {
this.logger.debug('Task Master config.json not found');
} else {
this.logger.error('Failed to read Task Master config', error);
}
return null;
}
} catch (error) {
this.logger.error('Error accessing Task Master config', error);
return null;
}
}
/**
* Get safe config for display (with sensitive data masked)
*/
async getSafeConfig(): Promise<Record<string, any> | null> {
const config = await this.readConfig();
if (!config) {
return null;
}
// Create a safe copy with masked API keys
const safeConfig: Record<string, any> = {
...config
};
// Mask all API keys
const apiKeyFields = [
'anthropicApiKey',
'perplexityApiKey',
'openaiApiKey',
'googleApiKey',
'xaiApiKey',
'openrouterApiKey',
'mistralApiKey'
];
for (const field of apiKeyFields) {
if (safeConfig[field]) {
safeConfig[field] = this.maskApiKey(safeConfig[field]);
}
}
return safeConfig;
}
/**
* Mask API key for display
*/
private maskApiKey(key: string): string {
if (key.length <= 8) {
return '****';
}
return key.substring(0, 4) + '****' + key.substring(key.length - 4);
}
/**
* Clear cache
*/
clearCache(): void {
this.configCache = null;
this.lastReadTime = 0;
}
/**
* Get workspace root path
*/
private getWorkspaceRoot(): string | undefined {
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
}
}

View File

@@ -0,0 +1,167 @@
/**
* Error Handler Service
* Centralized error handling with categorization and recovery strategies
*/
import * as vscode from 'vscode';
import type { ExtensionLogger } from '../utils/logger';
export enum ErrorSeverity {
LOW = 'low',
MEDIUM = 'medium',
HIGH = 'high',
CRITICAL = 'critical'
}
export enum ErrorCategory {
MCP_CONNECTION = 'mcp_connection',
CONFIGURATION = 'configuration',
TASK_LOADING = 'task_loading',
NETWORK = 'network',
INTERNAL = 'internal'
}
export interface ErrorContext {
category: ErrorCategory;
severity: ErrorSeverity;
message: string;
originalError?: Error | unknown;
operation?: string;
taskId?: string;
isRecoverable?: boolean;
suggestedActions?: string[];
}
export class ErrorHandler {
private errorLog: Map<string, ErrorContext> = new Map();
private errorId = 0;
constructor(private logger: ExtensionLogger) {}
/**
* Handle an error with appropriate logging and user notification
*/
handleError(context: ErrorContext): string {
const errorId = `error_${++this.errorId}`;
this.errorLog.set(errorId, context);
// Log to extension logger
this.logError(context);
// Show user notification if appropriate
this.notifyUser(context);
return errorId;
}
/**
* Log error based on severity
*/
private logError(context: ErrorContext): void {
const logMessage = `[${context.category}] ${context.message}`;
const details = {
operation: context.operation,
taskId: context.taskId,
error: context.originalError
};
switch (context.severity) {
case ErrorSeverity.CRITICAL:
case ErrorSeverity.HIGH:
this.logger.error(logMessage, details);
break;
case ErrorSeverity.MEDIUM:
this.logger.warn(logMessage, details);
break;
case ErrorSeverity.LOW:
this.logger.debug(logMessage, details);
break;
}
}
/**
* Show user notification based on severity and category
*/
private notifyUser(context: ErrorContext): void {
// Don't show low severity errors to users
if (context.severity === ErrorSeverity.LOW) {
return;
}
// Determine notification type
const actions = context.suggestedActions || [];
switch (context.severity) {
case ErrorSeverity.CRITICAL:
vscode.window
.showErrorMessage(`Task Master: ${context.message}`, ...actions)
.then((action) => {
if (action) {
this.handleUserAction(action, context);
}
});
break;
case ErrorSeverity.HIGH:
if (context.category === ErrorCategory.MCP_CONNECTION) {
vscode.window
.showWarningMessage(
`Task Master: ${context.message}`,
'Retry',
'Settings'
)
.then((action) => {
if (action === 'Retry') {
vscode.commands.executeCommand('taskr.reconnect');
} else if (action === 'Settings') {
vscode.commands.executeCommand('taskr.openSettings');
}
});
} else {
vscode.window.showWarningMessage(`Task Master: ${context.message}`);
}
break;
case ErrorSeverity.MEDIUM:
// Only show medium errors for important categories
if (
[ErrorCategory.CONFIGURATION, ErrorCategory.TASK_LOADING].includes(
context.category
)
) {
vscode.window.showInformationMessage(
`Task Master: ${context.message}`
);
}
break;
}
}
/**
* Handle user action from notification
*/
private handleUserAction(action: string, context: ErrorContext): void {
this.logger.debug(`User selected action: ${action}`, {
errorContext: context
});
// Action handling would be implemented based on specific needs
}
/**
* Get error by ID
*/
getError(errorId: string): ErrorContext | undefined {
return this.errorLog.get(errorId);
}
/**
* Clear old errors (keep last 100)
*/
clearOldErrors(): void {
if (this.errorLog.size > 100) {
const entriesToKeep = Array.from(this.errorLog.entries()).slice(-100);
this.errorLog.clear();
entriesToKeep.forEach(([id, error]) => this.errorLog.set(id, error));
}
}
}

View File

@@ -0,0 +1,129 @@
/**
* Notification Preferences Service
* Manages user preferences for notifications
*/
import * as vscode from 'vscode';
import { ErrorCategory, ErrorSeverity } from './error-handler';
export enum NotificationLevel {
ALL = 'all',
ERRORS_ONLY = 'errors_only',
CRITICAL_ONLY = 'critical_only',
NONE = 'none'
}
interface NotificationRule {
category: ErrorCategory;
minSeverity: ErrorSeverity;
enabled: boolean;
}
export class NotificationPreferences {
private defaultRules: NotificationRule[] = [
{
category: ErrorCategory.MCP_CONNECTION,
minSeverity: ErrorSeverity.HIGH,
enabled: true
},
{
category: ErrorCategory.CONFIGURATION,
minSeverity: ErrorSeverity.MEDIUM,
enabled: true
},
{
category: ErrorCategory.TASK_LOADING,
minSeverity: ErrorSeverity.HIGH,
enabled: true
},
{
category: ErrorCategory.NETWORK,
minSeverity: ErrorSeverity.HIGH,
enabled: true
},
{
category: ErrorCategory.INTERNAL,
minSeverity: ErrorSeverity.CRITICAL,
enabled: true
}
];
/**
* Check if a notification should be shown
*/
shouldShowNotification(
category: ErrorCategory,
severity: ErrorSeverity
): boolean {
// Get user's notification level preference
const level = this.getNotificationLevel();
if (level === NotificationLevel.NONE) {
return false;
}
if (
level === NotificationLevel.CRITICAL_ONLY &&
severity !== ErrorSeverity.CRITICAL
) {
return false;
}
if (
level === NotificationLevel.ERRORS_ONLY &&
severity !== ErrorSeverity.CRITICAL &&
severity !== ErrorSeverity.HIGH
) {
return false;
}
// Check category-specific rules
const rule = this.defaultRules.find((r) => r.category === category);
if (!rule || !rule.enabled) {
return false;
}
// Check if severity meets minimum threshold
return this.compareSeverity(severity, rule.minSeverity) >= 0;
}
/**
* Get user's notification level preference
*/
private getNotificationLevel(): NotificationLevel {
const config = vscode.workspace.getConfiguration('taskmaster');
return config.get<NotificationLevel>(
'notifications.level',
NotificationLevel.ERRORS_ONLY
);
}
/**
* Compare severity levels
*/
private compareSeverity(a: ErrorSeverity, b: ErrorSeverity): number {
const severityOrder = {
[ErrorSeverity.LOW]: 0,
[ErrorSeverity.MEDIUM]: 1,
[ErrorSeverity.HIGH]: 2,
[ErrorSeverity.CRITICAL]: 3
};
return severityOrder[a] - severityOrder[b];
}
/**
* Get toast notification duration based on severity
*/
getToastDuration(severity: ErrorSeverity): number {
switch (severity) {
case ErrorSeverity.CRITICAL:
return 10000; // 10 seconds
case ErrorSeverity.HIGH:
return 7000; // 7 seconds
case ErrorSeverity.MEDIUM:
return 5000; // 5 seconds
case ErrorSeverity.LOW:
return 3000; // 3 seconds
}
}
}

View File

@@ -0,0 +1,92 @@
/**
* Polling Service - Simplified version
* Uses strategy pattern for different polling behaviors
*/
import type { ExtensionLogger } from '../utils/logger';
import type { TaskRepository } from './task-repository';
export interface PollingStrategy {
calculateNextInterval(
consecutiveNoChanges: number,
lastChangeTime?: number
): number;
getName(): string;
}
export class PollingService {
private timer?: NodeJS.Timeout;
private consecutiveNoChanges = 0;
private lastChangeTime?: number;
private lastTasksJson?: string;
constructor(
private repository: TaskRepository,
private strategy: PollingStrategy,
private logger: ExtensionLogger
) {}
start(): void {
if (this.timer) {
return;
}
this.logger.log(
`Starting polling with ${this.strategy.getName()} strategy`
);
this.scheduleNextPoll();
}
stop(): void {
if (this.timer) {
clearTimeout(this.timer);
this.timer = undefined;
this.logger.log('Polling stopped');
}
}
setStrategy(strategy: PollingStrategy): void {
this.strategy = strategy;
this.logger.log(`Changed to ${strategy.getName()} polling strategy`);
// Restart with new strategy if running
if (this.timer) {
this.stop();
this.start();
}
}
private async poll(): Promise<void> {
try {
const tasks = await this.repository.getAll();
const tasksJson = JSON.stringify(tasks);
// Check for changes
if (tasksJson !== this.lastTasksJson) {
this.consecutiveNoChanges = 0;
this.lastChangeTime = Date.now();
this.logger.debug('Tasks changed');
} else {
this.consecutiveNoChanges++;
}
this.lastTasksJson = tasksJson;
} catch (error) {
this.logger.error('Polling error', error);
}
}
private scheduleNextPoll(): void {
const interval = this.strategy.calculateNextInterval(
this.consecutiveNoChanges,
this.lastChangeTime
);
this.timer = setTimeout(async () => {
await this.poll();
this.scheduleNextPoll();
}, interval);
this.logger.debug(`Next poll in ${interval}ms`);
}
}

View File

@@ -0,0 +1,67 @@
/**
* Polling Strategies - Simplified
* Different algorithms for polling intervals
*/
import type { PollingStrategy } from './polling-service';
/**
* Fixed interval polling
*/
export class FixedIntervalStrategy implements PollingStrategy {
constructor(private interval = 10000) {}
calculateNextInterval(): number {
return this.interval;
}
getName(): string {
return 'fixed';
}
}
/**
* Adaptive polling based on activity
*/
export class AdaptivePollingStrategy implements PollingStrategy {
private readonly MIN_INTERVAL = 5000; // 5 seconds
private readonly MAX_INTERVAL = 60000; // 1 minute
private readonly BASE_INTERVAL = 10000; // 10 seconds
calculateNextInterval(consecutiveNoChanges: number): number {
// Start with base interval
let interval = this.BASE_INTERVAL;
// If no changes for a while, slow down
if (consecutiveNoChanges > 5) {
interval = Math.min(
this.MAX_INTERVAL,
this.BASE_INTERVAL * 1.5 ** (consecutiveNoChanges - 5)
);
} else if (consecutiveNoChanges === 0) {
// Recent change, poll more frequently
interval = this.MIN_INTERVAL;
}
return Math.round(interval);
}
getName(): string {
return 'adaptive';
}
}
/**
* Create polling strategy from configuration
*/
export function createPollingStrategy(config: any): PollingStrategy {
const type = config.get('polling.strategy', 'adaptive');
const interval = config.get('polling.interval', 10000);
switch (type) {
case 'fixed':
return new FixedIntervalStrategy(interval);
default:
return new AdaptivePollingStrategy();
}
}

View File

@@ -0,0 +1,138 @@
/**
* Task Repository - Simplified version
* Handles data access with caching
*/
import { EventEmitter } from '../utils/event-emitter';
import type { ExtensionLogger } from '../utils/logger';
import type { TaskMasterApi, TaskMasterTask } from '../utils/task-master-api';
// Use the TaskMasterTask type directly to ensure compatibility
export type Task = TaskMasterTask;
export class TaskRepository extends EventEmitter {
private cache: Task[] | null = null;
private cacheTimestamp = 0;
private readonly CACHE_DURATION = 30000; // 30 seconds
constructor(
private api: TaskMasterApi,
private logger: ExtensionLogger
) {
super();
}
async getAll(): Promise<Task[]> {
// Return from cache if valid
if (this.cache && Date.now() - this.cacheTimestamp < this.CACHE_DURATION) {
return this.cache;
}
try {
const result = await this.api.getTasks({ withSubtasks: true });
if (result.success && result.data) {
this.cache = result.data;
this.cacheTimestamp = Date.now();
this.emit('tasks:updated', result.data);
return result.data;
}
throw new Error(result.error || 'Failed to fetch tasks');
} catch (error) {
this.logger.error('Failed to get tasks', error);
throw error;
}
}
async getById(taskId: string): Promise<Task | null> {
// First check cache
if (this.cache) {
// Handle both main tasks and subtasks
for (const task of this.cache) {
if (task.id === taskId) {
return task;
}
// Check subtasks
if (task.subtasks) {
for (const subtask of task.subtasks) {
if (
subtask.id === taskId ||
`${task.id}.${subtask.id}` === taskId
) {
return subtask;
}
}
}
}
}
// If not in cache, fetch all and search
const tasks = await this.getAll();
for (const task of tasks) {
if (task.id === taskId) {
return task;
}
// Check subtasks
if (task.subtasks) {
for (const subtask of task.subtasks) {
if (subtask.id === taskId || `${task.id}.${subtask.id}` === taskId) {
return subtask;
}
}
}
}
return null;
}
async updateStatus(taskId: string, status: Task['status']): Promise<void> {
try {
const result = await this.api.updateTaskStatus(taskId, status);
if (!result.success) {
throw new Error(result.error || 'Failed to update status');
}
// Invalidate cache
this.cache = null;
// Fetch updated tasks
await this.getAll();
} catch (error) {
this.logger.error('Failed to update task status', error);
throw error;
}
}
async updateContent(taskId: string, updates: any): Promise<void> {
try {
const result = await this.api.updateTask(taskId, updates, {
append: false,
research: false
});
if (!result.success) {
throw new Error(result.error || 'Failed to update task');
}
// Invalidate cache
this.cache = null;
// Fetch updated tasks
await this.getAll();
} catch (error) {
this.logger.error('Failed to update task content', error);
throw error;
}
}
async refresh(): Promise<void> {
this.cache = null;
await this.getAll();
}
isConnected(): boolean {
return this.api.getConnectionStatus().isConnected;
}
}

View File

@@ -0,0 +1,356 @@
/**
* Webview Manager - Simplified
* Manages webview panels and message handling
*/
import * as vscode from 'vscode';
import type { EventEmitter } from '../utils/event-emitter';
import type { ExtensionLogger } from '../utils/logger';
import type { ConfigService } from './config-service';
import type { TaskRepository } from './task-repository';
export class WebviewManager {
private panels = new Set<vscode.WebviewPanel>();
private configService?: ConfigService;
private mcpClient?: any;
private api?: any;
constructor(
private context: vscode.ExtensionContext,
private repository: TaskRepository,
private events: EventEmitter,
private logger: ExtensionLogger
) {}
setConfigService(configService: ConfigService): void {
this.configService = configService;
}
setMCPClient(mcpClient: any): void {
this.mcpClient = mcpClient;
}
setApi(api: any): void {
this.api = api;
}
async createOrShowPanel(): Promise<void> {
// Find existing panel
const existing = Array.from(this.panels).find(
(p) => p.title === 'Task Master Kanban'
);
if (existing) {
existing.reveal();
return;
}
// Create new panel
const panel = vscode.window.createWebviewPanel(
'taskrKanban',
'Task Master Kanban',
vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [
vscode.Uri.joinPath(this.context.extensionUri, 'dist')
]
}
);
this.panels.add(panel);
panel.webview.html = this.getWebviewContent(panel.webview);
// Handle messages
panel.webview.onDidReceiveMessage(async (message) => {
await this.handleMessage(panel, message);
});
// Handle disposal
panel.onDidDispose(() => {
this.panels.delete(panel);
this.events.emit('webview:closed');
});
this.events.emit('webview:opened');
vscode.window.showInformationMessage('Task Master Kanban opened!');
}
broadcast(type: string, data: any): void {
this.panels.forEach((panel) => {
panel.webview.postMessage({ type, data });
});
}
getPanelCount(): number {
return this.panels.size;
}
dispose(): void {
this.panels.forEach((panel) => panel.dispose());
this.panels.clear();
}
private async handleMessage(
panel: vscode.WebviewPanel,
message: any
): Promise<void> {
// Validate message structure
if (!message || typeof message !== 'object') {
this.logger.error('Invalid message received:', message);
return;
}
const { type, data, requestId } = message;
this.logger.debug(`Webview message: ${type}`, message);
try {
let response: any;
switch (type) {
case 'ready':
// Webview is ready, send current connection status
const isConnected = this.mcpClient?.getStatus()?.isRunning || false;
panel.webview.postMessage({
type: 'connectionStatus',
data: {
isConnected: isConnected,
status: isConnected ? 'Connected' : 'Disconnected'
}
});
// No response needed for ready message
return;
case 'getTasks':
response = await this.repository.getAll();
break;
case 'updateTaskStatus':
await this.repository.updateStatus(data.taskId, data.newStatus);
response = { success: true };
break;
case 'getConfig':
if (this.configService) {
response = await this.configService.getSafeConfig();
} else {
response = null;
}
break;
case 'readTaskFileData':
// For now, return the task data from repository
// In the future, this could read from actual task files
const task = await this.repository.getById(data.taskId);
if (task) {
response = {
details: task.details || '',
testStrategy: task.testStrategy || ''
};
} else {
response = {
details: '',
testStrategy: ''
};
}
break;
case 'updateTask':
// Handle task content updates
await this.repository.updateContent(data.taskId, data.updates);
response = { success: true };
break;
case 'getComplexity':
// For backward compatibility - redirect to mcpRequest
this.logger.debug(
`getComplexity request for task ${data.taskId}, mcpClient available: ${!!this.mcpClient}`
);
if (this.mcpClient && data.taskId) {
try {
const complexityResult = await this.mcpClient.callTool(
'complexity_report',
{
projectRoot:
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
}
);
if (complexityResult?.report?.complexityAnalysis?.tasks) {
const task =
complexityResult.report.complexityAnalysis.tasks.find(
(t: any) => t.id === data.taskId
);
response = task ? { score: task.complexityScore } : {};
} else {
response = {};
}
} catch (error) {
this.logger.error('Failed to get complexity', error);
response = {};
}
} else {
this.logger.warn(
`Cannot get complexity: mcpClient=${!!this.mcpClient}, taskId=${data.taskId}`
);
response = {};
}
break;
case 'mcpRequest':
// Handle MCP tool calls
try {
// The tool and params come directly in the message
const tool = message.tool;
const params = message.params || {};
if (!this.mcpClient) {
throw new Error('MCP client not initialized');
}
if (!tool) {
throw new Error('Tool name not specified in mcpRequest');
}
// Add projectRoot if not provided
if (!params.projectRoot) {
params.projectRoot =
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
}
const result = await this.mcpClient.callTool(tool, params);
response = { data: result };
} catch (error) {
this.logger.error('MCP request failed:', error);
// Re-throw with cleaner error message
throw new Error(
error instanceof Error ? error.message : 'Unknown error'
);
}
break;
case 'getTags':
// Get available tags
if (this.mcpClient) {
try {
const result = await this.mcpClient.callTool('list_tags', {
projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath,
showMetadata: false
});
// The MCP response has a specific structure
// Based on the MCP SDK, the response is in result.content[0].text
let parsedData;
if (
result?.content &&
Array.isArray(result.content) &&
result.content[0]?.text
) {
try {
parsedData = JSON.parse(result.content[0].text);
} catch (e) {
this.logger.error('Failed to parse MCP response text:', e);
}
}
// Extract tags data from the parsed response
if (parsedData?.data) {
response = parsedData.data;
} else if (parsedData) {
response = parsedData;
} else if (result?.data) {
response = result.data;
} else {
response = { tags: [], currentTag: 'master' };
}
} catch (error) {
this.logger.error('Failed to get tags:', error);
response = { tags: [], currentTag: 'master' };
}
} else {
response = { tags: [], currentTag: 'master' };
}
break;
case 'switchTag':
// Switch to a different tag
if (this.mcpClient && data.tagName) {
try {
await this.mcpClient.callTool('use_tag', {
name: data.tagName,
projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
});
// Clear cache and refresh tasks for the new tag
await this.repository.refresh();
const tasks = await this.repository.getAll();
this.broadcast('tasksUpdated', { tasks, source: 'tag-switch' });
response = { success: true };
} catch (error) {
this.logger.error('Failed to switch tag:', error);
throw error;
}
} else {
throw new Error('Tag name not provided');
}
break;
default:
throw new Error(`Unknown message type: ${type}`);
}
// Send response
if (requestId) {
panel.webview.postMessage({
type: 'response',
requestId,
success: true,
data: response
});
}
} catch (error) {
this.logger.error(`Error handling message ${type}`, error);
if (requestId) {
panel.webview.postMessage({
type: 'error',
requestId,
error: error instanceof Error ? error.message : 'Unknown error'
});
}
}
}
private getWebviewContent(webview: vscode.Webview): string {
const scriptUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'index.js')
);
const styleUri = webview.asWebviewUri(
vscode.Uri.joinPath(this.context.extensionUri, 'dist', 'index.css')
);
const nonce = this.getNonce();
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}'; style-src ${webview.cspSource} 'unsafe-inline';">
<link href="${styleUri}" rel="stylesheet">
<title>Task Master Kanban</title>
</head>
<body>
<div id="root"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
private getNonce(): string {
let text = '';
const possible =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
for (let i = 0; i < 32; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
}

View File

@@ -1,6 +1,6 @@
import * as vscode from 'vscode';
import { MCPConfig } from './mcpClient';
import { logger } from './logger';
import type { MCPConfig } from './mcpClient';
export interface TaskMasterConfig {
mcp: MCPServerConfig;

View File

@@ -1,6 +1,10 @@
import * as vscode from 'vscode';
import { MCPClientManager, MCPConfig, MCPServerStatus } from './mcpClient';
import { logger } from './logger';
import {
MCPClientManager,
type MCPConfig,
type MCPServerStatus
} from './mcpClient';
export interface ConnectionEvent {
type: 'connected' | 'disconnected' | 'error' | 'reconnecting';
@@ -135,7 +139,7 @@ export class ConnectionManager {
/**
* Get recent connection events
*/
getEvents(limit: number = 10): ConnectionEvent[] {
getEvents(limit = 10): ConnectionEvent[] {
return this.connectionEvents.slice(-limit);
}
@@ -300,7 +304,7 @@ export class ConnectionManager {
this.reconnectAttempts++;
const backoffMs = Math.min(
this.reconnectBackoffMs * Math.pow(2, this.reconnectAttempts - 1),
this.reconnectBackoffMs * 2 ** (this.reconnectAttempts - 1),
this.maxBackoffMs
);

View File

@@ -1,9 +1,9 @@
import * as vscode from 'vscode';
import { logger } from './logger';
import {
shouldShowNotification,
getNotificationType,
getToastDuration
getToastDuration,
shouldShowNotification
} from './notificationPreferences';
export enum ErrorSeverity {
@@ -169,7 +169,7 @@ export abstract class TaskMasterError extends Error {
export class MCPConnectionError extends TaskMasterError {
constructor(
message: string,
code: string = 'MCP_CONNECTION_FAILED',
code = 'MCP_CONNECTION_FAILED',
context?: Record<string, any>,
recovery?: {
automatic: boolean;
@@ -195,7 +195,7 @@ export class MCPConnectionError extends TaskMasterError {
export class ConfigurationError extends TaskMasterError {
constructor(
message: string,
code: string = 'CONFIGURATION_INVALID',
code = 'CONFIGURATION_INVALID',
context?: Record<string, any>
) {
super(
@@ -215,7 +215,7 @@ export class ConfigurationError extends TaskMasterError {
export class TaskLoadingError extends TaskMasterError {
constructor(
message: string,
code: string = 'TASK_LOADING_FAILED',
code = 'TASK_LOADING_FAILED',
context?: Record<string, any>,
recovery?: {
automatic: boolean;
@@ -241,7 +241,7 @@ export class TaskLoadingError extends TaskMasterError {
export class UIRenderingError extends TaskMasterError {
constructor(
message: string,
code: string = 'UI_RENDERING_FAILED',
code = 'UI_RENDERING_FAILED',
context?: Record<string, any>
) {
super(
@@ -261,7 +261,7 @@ export class UIRenderingError extends TaskMasterError {
export class NetworkError extends TaskMasterError {
constructor(
message: string,
code: string = 'NETWORK_ERROR',
code = 'NETWORK_ERROR',
context?: Record<string, any>,
recovery?: {
automatic: boolean;

View File

@@ -0,0 +1,34 @@
/**
* Simple Event Emitter
* Lightweight alternative to complex event bus
*/
export type EventHandler = (...args: any[]) => void | Promise<void>;
export class EventEmitter {
private handlers = new Map<string, Set<EventHandler>>();
on(event: string, handler: EventHandler): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)?.add(handler);
// Return unsubscribe function
return () => this.off(event, handler);
}
off(event: string, handler: EventHandler): void {
this.handlers.get(event)?.delete(handler);
}
emit(event: string, ...args: any[]): void {
this.handlers.get(event)?.forEach((handler) => {
try {
handler(...args);
} catch (error) {
console.error(`Error in event handler for ${event}:`, error);
}
});
}
}

View File

@@ -1,10 +1,22 @@
import * as vscode from 'vscode';
/**
* Logger interface for dependency injection
*/
export interface ILogger {
log(message: string, ...args: any[]): void;
error(message: string, ...args: any[]): void;
warn(message: string, ...args: any[]): void;
debug(message: string, ...args: any[]): void;
show(): void;
dispose(): void;
}
/**
* Logger that outputs to VS Code's output channel instead of console
* This prevents interference with MCP stdio communication
*/
export class ExtensionLogger {
export class ExtensionLogger implements ILogger {
private static instance: ExtensionLogger;
private outputChannel: vscode.OutputChannel;
private debugMode: boolean;
@@ -23,7 +35,9 @@ export class ExtensionLogger {
}
log(message: string, ...args: any[]): void {
if (!this.debugMode) return;
if (!this.debugMode) {
return;
}
const timestamp = new Date().toISOString();
const formattedMessage = this.formatMessage(message, args);
this.outputChannel.appendLine(`[${timestamp}] ${formattedMessage}`);
@@ -36,14 +50,18 @@ export class ExtensionLogger {
}
warn(message: string, ...args: any[]): void {
if (!this.debugMode) return;
if (!this.debugMode) {
return;
}
const timestamp = new Date().toISOString();
const formattedMessage = this.formatMessage(message, args);
this.outputChannel.appendLine(`[${timestamp}] WARN: ${formattedMessage}`);
}
debug(message: string, ...args: any[]): void {
if (!this.debugMode) return;
if (!this.debugMode) {
return;
}
const timestamp = new Date().toISOString();
const formattedMessage = this.formatMessage(message, args);
this.outputChannel.appendLine(`[${timestamp}] DEBUG: ${formattedMessage}`);

View File

@@ -1,5 +1,5 @@
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import * as vscode from 'vscode';
import { logger } from './logger';

View File

@@ -0,0 +1,253 @@
/**
* Cache Manager
* Handles all caching logic with LRU eviction and analytics
*/
import type { ExtensionLogger } from '../../logger';
import type { CacheAnalytics, CacheConfig, CacheEntry } from '../types';
export class CacheManager {
private cache = new Map<string, CacheEntry>();
private analytics: CacheAnalytics = {
hits: 0,
misses: 0,
evictions: 0,
refreshes: 0,
totalSize: 0,
averageAccessTime: 0,
hitRate: 0
};
private backgroundRefreshTimer?: NodeJS.Timeout;
constructor(
private config: CacheConfig & { cacheDuration: number },
private logger: ExtensionLogger
) {
if (config.enableBackgroundRefresh) {
this.initializeBackgroundRefresh();
}
}
/**
* Get data from cache if not expired
*/
get(key: string): any {
const startTime = Date.now();
const cached = this.cache.get(key);
if (cached) {
const isExpired =
Date.now() - cached.timestamp >=
(cached.ttl || this.config.cacheDuration);
if (!isExpired) {
// Update access statistics
cached.accessCount++;
cached.lastAccessed = Date.now();
if (this.config.enableAnalytics) {
this.analytics.hits++;
}
const accessTime = Date.now() - startTime;
this.logger.debug(
`Cache hit for ${key} (${accessTime}ms, ${cached.accessCount} accesses)`
);
return cached.data;
} else {
// Remove expired entry
this.cache.delete(key);
this.logger.debug(`Cache entry expired and removed: ${key}`);
}
}
if (this.config.enableAnalytics) {
this.analytics.misses++;
}
this.logger.debug(`Cache miss for ${key}`);
return null;
}
/**
* Set data in cache with LRU eviction
*/
set(
key: string,
data: any,
options?: { ttl?: number; tags?: string[] }
): void {
const now = Date.now();
const dataSize = this.estimateDataSize(data);
// Create cache entry
const entry: CacheEntry = {
data,
timestamp: now,
accessCount: 1,
lastAccessed: now,
size: dataSize,
ttl: options?.ttl,
tags: options?.tags || [key.split('_')[0]]
};
// Check if we need to evict entries (LRU strategy)
if (this.cache.size >= this.config.maxSize) {
this.evictLRUEntries(Math.max(1, Math.floor(this.config.maxSize * 0.1)));
}
this.cache.set(key, entry);
this.logger.debug(
`Cached data for ${key} (size: ${dataSize} bytes, TTL: ${entry.ttl || this.config.cacheDuration}ms)`
);
// Trigger prefetch if enabled
if (this.config.enablePrefetch) {
this.scheduleRelatedDataPrefetch(key, data);
}
}
/**
* Clear cache entries matching a pattern
*/
clearPattern(pattern: string): void {
let evictedCount = 0;
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key);
evictedCount++;
}
}
if (evictedCount > 0) {
this.analytics.evictions += evictedCount;
this.logger.debug(
`Evicted ${evictedCount} cache entries matching pattern: ${pattern}`
);
}
}
/**
* Clear all cached data
*/
clear(): void {
this.cache.clear();
this.resetAnalytics();
}
/**
* Get cache analytics
*/
getAnalytics(): CacheAnalytics {
this.updateAnalytics();
return { ...this.analytics };
}
/**
* Get frequently accessed entries for background refresh
*/
getRefreshCandidates(): Array<[string, CacheEntry]> {
return Array.from(this.cache.entries())
.filter(([key, entry]) => {
const age = Date.now() - entry.timestamp;
const isNearExpiration = age > this.config.cacheDuration * 0.7;
const isFrequentlyAccessed = entry.accessCount >= 3;
return (
isNearExpiration && isFrequentlyAccessed && key.includes('get_tasks')
);
})
.sort((a, b) => b[1].accessCount - a[1].accessCount)
.slice(0, 5);
}
/**
* Update refresh count for analytics
*/
incrementRefreshes(): void {
this.analytics.refreshes++;
}
/**
* Cleanup resources
*/
destroy(): void {
if (this.backgroundRefreshTimer) {
clearInterval(this.backgroundRefreshTimer);
this.backgroundRefreshTimer = undefined;
}
this.clear();
}
private initializeBackgroundRefresh(): void {
if (this.backgroundRefreshTimer) {
clearInterval(this.backgroundRefreshTimer);
}
const interval = this.config.refreshInterval;
this.backgroundRefreshTimer = setInterval(() => {
// Background refresh is handled by the main API class
// This just maintains the timer
}, interval);
this.logger.debug(
`Cache background refresh initialized with ${interval}ms interval`
);
}
private evictLRUEntries(count: number): void {
const entries = Array.from(this.cache.entries())
.sort((a, b) => a[1].lastAccessed - b[1].lastAccessed)
.slice(0, count);
for (const [key] of entries) {
this.cache.delete(key);
this.analytics.evictions++;
}
if (entries.length > 0) {
this.logger.debug(`Evicted ${entries.length} LRU cache entries`);
}
}
private estimateDataSize(data: any): number {
try {
return JSON.stringify(data).length * 2; // Rough estimate
} catch {
return 1000; // Default fallback
}
}
private scheduleRelatedDataPrefetch(key: string, data: any): void {
if (key.includes('get_tasks') && Array.isArray(data)) {
this.logger.debug(
`Scheduled prefetch for ${data.length} tasks related to ${key}`
);
}
}
private resetAnalytics(): void {
this.analytics = {
hits: 0,
misses: 0,
evictions: 0,
refreshes: 0,
totalSize: 0,
averageAccessTime: 0,
hitRate: 0
};
}
private updateAnalytics(): void {
const total = this.analytics.hits + this.analytics.misses;
this.analytics.hitRate = total > 0 ? this.analytics.hits / total : 0;
this.analytics.totalSize = this.cache.size;
if (this.cache.size > 0) {
const totalAccessTime = Array.from(this.cache.values()).reduce(
(sum, entry) => sum + (entry.lastAccessed - entry.timestamp),
0
);
this.analytics.averageAccessTime = totalAccessTime / this.cache.size;
}
}
}

View File

@@ -0,0 +1,471 @@
/**
* Task Master API
* Main API class that coordinates all modules
*/
import * as vscode from 'vscode';
import { ExtensionLogger } from '../logger';
import type { MCPClientManager } from '../mcpClient';
import { CacheManager } from './cache/cache-manager';
import { MCPClient } from './mcp-client';
import { TaskTransformer } from './transformers/task-transformer';
import type {
AddSubtaskOptions,
CacheConfig,
GetTasksOptions,
SubtaskData,
TaskMasterApiConfig,
TaskMasterApiResponse,
TaskMasterTask,
TaskUpdate,
UpdateSubtaskOptions,
UpdateTaskOptions,
UpdateTaskStatusOptions
} from './types';
// Re-export types for backward compatibility
export * from './types';
export class TaskMasterApi {
private mcpWrapper: MCPClient;
private cache: CacheManager;
private transformer: TaskTransformer;
private config: TaskMasterApiConfig;
private logger: ExtensionLogger;
private readonly defaultCacheConfig: CacheConfig = {
maxSize: 100,
enableBackgroundRefresh: true,
refreshInterval: 5 * 60 * 1000, // 5 minutes
enableAnalytics: true,
enablePrefetch: true,
compressionEnabled: false,
persistToDisk: false
};
private readonly defaultConfig: TaskMasterApiConfig = {
timeout: 30000,
retryAttempts: 3,
cacheDuration: 5 * 60 * 1000, // 5 minutes
cache: this.defaultCacheConfig
};
constructor(
mcpClient: MCPClientManager,
config?: Partial<TaskMasterApiConfig>
) {
this.logger = ExtensionLogger.getInstance();
// Merge config - ensure cache is always fully defined
const mergedCache: CacheConfig = {
maxSize: config?.cache?.maxSize ?? this.defaultCacheConfig.maxSize,
enableBackgroundRefresh:
config?.cache?.enableBackgroundRefresh ??
this.defaultCacheConfig.enableBackgroundRefresh,
refreshInterval:
config?.cache?.refreshInterval ??
this.defaultCacheConfig.refreshInterval,
enableAnalytics:
config?.cache?.enableAnalytics ??
this.defaultCacheConfig.enableAnalytics,
enablePrefetch:
config?.cache?.enablePrefetch ?? this.defaultCacheConfig.enablePrefetch,
compressionEnabled:
config?.cache?.compressionEnabled ??
this.defaultCacheConfig.compressionEnabled,
persistToDisk:
config?.cache?.persistToDisk ?? this.defaultCacheConfig.persistToDisk
};
this.config = {
...this.defaultConfig,
...config,
cache: mergedCache
};
// Initialize modules
this.mcpWrapper = new MCPClient(mcpClient, this.logger, {
timeout: this.config.timeout,
retryAttempts: this.config.retryAttempts
});
this.cache = new CacheManager(
{ ...mergedCache, cacheDuration: this.config.cacheDuration },
this.logger
);
this.transformer = new TaskTransformer(this.logger);
// Start background refresh if enabled
if (this.config.cache?.enableBackgroundRefresh) {
this.startBackgroundRefresh();
}
this.logger.log('TaskMasterApi: Initialized with modular architecture');
}
/**
* Get tasks from Task Master
*/
async getTasks(
options?: GetTasksOptions
): Promise<TaskMasterApiResponse<TaskMasterTask[]>> {
const startTime = Date.now();
const cacheKey = `get_tasks_${JSON.stringify(options || {})}`;
try {
// Check cache first
const cached = this.cache.get(cacheKey);
if (cached) {
return {
success: true,
data: cached,
requestDuration: Date.now() - startTime
};
}
// Prepare MCP tool arguments
const mcpArgs: Record<string, unknown> = {
projectRoot: options?.projectRoot || this.getWorkspaceRoot(),
withSubtasks: options?.withSubtasks ?? true
};
if (options?.status) {
mcpArgs.status = options.status;
}
if (options?.tag) {
mcpArgs.tag = options.tag;
}
this.logger.log('Calling get_tasks with args:', mcpArgs);
// Call MCP tool
const mcpResponse = await this.mcpWrapper.callTool('get_tasks', mcpArgs);
// Transform response
const transformedTasks =
this.transformer.transformMCPTasksResponse(mcpResponse);
// Cache the result
this.cache.set(cacheKey, transformedTasks);
return {
success: true,
data: transformedTasks,
requestDuration: Date.now() - startTime
};
} catch (error) {
this.logger.error('Error getting tasks:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
requestDuration: Date.now() - startTime
};
}
}
/**
* Update task status
*/
async updateTaskStatus(
taskId: string,
status: string,
options?: UpdateTaskStatusOptions
): Promise<TaskMasterApiResponse<boolean>> {
const startTime = Date.now();
try {
const mcpArgs: Record<string, unknown> = {
id: taskId,
status: status,
projectRoot: options?.projectRoot || this.getWorkspaceRoot()
};
this.logger.log('Calling set_task_status with args:', mcpArgs);
await this.mcpWrapper.callTool('set_task_status', mcpArgs);
// Clear relevant caches
this.cache.clearPattern('get_tasks');
return {
success: true,
data: true,
requestDuration: Date.now() - startTime
};
} catch (error) {
this.logger.error('Error updating task status:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
requestDuration: Date.now() - startTime
};
}
}
/**
* Update task content
*/
async updateTask(
taskId: string,
updates: TaskUpdate,
options?: UpdateTaskOptions
): Promise<TaskMasterApiResponse<boolean>> {
const startTime = Date.now();
try {
// Build update prompt
const updateFields: string[] = [];
if (updates.title !== undefined) {
updateFields.push(`Title: ${updates.title}`);
}
if (updates.description !== undefined) {
updateFields.push(`Description: ${updates.description}`);
}
if (updates.details !== undefined) {
updateFields.push(`Details: ${updates.details}`);
}
if (updates.priority !== undefined) {
updateFields.push(`Priority: ${updates.priority}`);
}
if (updates.testStrategy !== undefined) {
updateFields.push(`Test Strategy: ${updates.testStrategy}`);
}
if (updates.dependencies !== undefined) {
updateFields.push(`Dependencies: ${updates.dependencies.join(', ')}`);
}
const prompt = `Update task with the following changes:\n${updateFields.join('\n')}`;
const mcpArgs: Record<string, unknown> = {
id: taskId,
prompt: prompt,
projectRoot: options?.projectRoot || this.getWorkspaceRoot()
};
if (options?.append !== undefined) {
mcpArgs.append = options.append;
}
if (options?.research !== undefined) {
mcpArgs.research = options.research;
}
this.logger.log('Calling update_task with args:', mcpArgs);
await this.mcpWrapper.callTool('update_task', mcpArgs);
// Clear relevant caches
this.cache.clearPattern('get_tasks');
return {
success: true,
data: true,
requestDuration: Date.now() - startTime
};
} catch (error) {
this.logger.error('Error updating task:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
requestDuration: Date.now() - startTime
};
}
}
/**
* Update subtask content
*/
async updateSubtask(
taskId: string,
prompt: string,
options?: UpdateSubtaskOptions
): Promise<TaskMasterApiResponse<boolean>> {
const startTime = Date.now();
try {
const mcpArgs: Record<string, unknown> = {
id: taskId,
prompt: prompt,
projectRoot: options?.projectRoot || this.getWorkspaceRoot()
};
if (options?.research !== undefined) {
mcpArgs.research = options.research;
}
this.logger.log('Calling update_subtask with args:', mcpArgs);
await this.mcpWrapper.callTool('update_subtask', mcpArgs);
// Clear relevant caches
this.cache.clearPattern('get_tasks');
return {
success: true,
data: true,
requestDuration: Date.now() - startTime
};
} catch (error) {
this.logger.error('Error updating subtask:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
requestDuration: Date.now() - startTime
};
}
}
/**
* Add a new subtask
*/
async addSubtask(
parentTaskId: string,
subtaskData: SubtaskData,
options?: AddSubtaskOptions
): Promise<TaskMasterApiResponse<boolean>> {
const startTime = Date.now();
try {
const mcpArgs: Record<string, unknown> = {
id: parentTaskId,
title: subtaskData.title,
projectRoot: options?.projectRoot || this.getWorkspaceRoot()
};
if (subtaskData.description) {
mcpArgs.description = subtaskData.description;
}
if (subtaskData.dependencies && subtaskData.dependencies.length > 0) {
mcpArgs.dependencies = subtaskData.dependencies.join(',');
}
if (subtaskData.status) {
mcpArgs.status = subtaskData.status;
}
this.logger.log('Calling add_subtask with args:', mcpArgs);
await this.mcpWrapper.callTool('add_subtask', mcpArgs);
// Clear relevant caches
this.cache.clearPattern('get_tasks');
return {
success: true,
data: true,
requestDuration: Date.now() - startTime
};
} catch (error) {
this.logger.error('Error adding subtask:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
requestDuration: Date.now() - startTime
};
}
}
/**
* Get connection status
*/
getConnectionStatus(): { isConnected: boolean; error?: string } {
const status = this.mcpWrapper.getStatus();
return {
isConnected: status.isRunning,
error: status.error
};
}
/**
* Test connection
*/
async testConnection(): Promise<TaskMasterApiResponse<boolean>> {
const startTime = Date.now();
try {
const isConnected = await this.mcpWrapper.testConnection();
return {
success: true,
data: isConnected,
requestDuration: Date.now() - startTime
};
} catch (error) {
this.logger.error('Connection test failed:', error);
return {
success: false,
error:
error instanceof Error ? error.message : 'Connection test failed',
requestDuration: Date.now() - startTime
};
}
}
/**
* Clear all cached data
*/
clearCache(): void {
this.cache.clear();
}
/**
* Get cache analytics
*/
getCacheAnalytics() {
return this.cache.getAnalytics();
}
/**
* Cleanup resources
*/
destroy(): void {
this.cache.destroy();
this.logger.log('TaskMasterApi: Destroyed and cleaned up resources');
}
/**
* Start background refresh
*/
private startBackgroundRefresh(): void {
const interval = this.config.cache?.refreshInterval || 5 * 60 * 1000;
setInterval(() => {
this.performBackgroundRefresh();
}, interval);
}
/**
* Perform background refresh of frequently accessed cache entries
*/
private async performBackgroundRefresh(): Promise<void> {
if (!this.config.cache?.enableBackgroundRefresh) {
return;
}
this.logger.log('Starting background cache refresh');
const candidates = this.cache.getRefreshCandidates();
let refreshedCount = 0;
for (const [key, entry] of candidates) {
try {
const optionsMatch = key.match(/get_tasks_(.+)/);
if (optionsMatch) {
const options = JSON.parse(optionsMatch[1]);
await this.getTasks(options);
refreshedCount++;
this.cache.incrementRefreshes();
}
} catch (error) {
this.logger.warn(`Background refresh failed for key ${key}:`, error);
}
}
this.logger.log(
`Background refresh completed, refreshed ${refreshedCount} entries`
);
}
/**
* Get workspace root path
*/
private getWorkspaceRoot(): string {
return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
}
}

View File

@@ -0,0 +1,98 @@
/**
* MCP Client Wrapper
* Handles MCP tool calls with retry logic
*/
import type { ExtensionLogger } from '../logger';
import type { MCPClientManager } from '../mcpClient';
export class MCPClient {
constructor(
private mcpClient: MCPClientManager,
private logger: ExtensionLogger,
private config: { timeout: number; retryAttempts: number }
) {}
/**
* Call MCP tool with retry logic
*/
async callTool(
toolName: string,
args: Record<string, unknown>
): Promise<any> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= this.config.retryAttempts; attempt++) {
try {
const rawResponse = await this.mcpClient.callTool(toolName, args);
this.logger.debug(
`Raw MCP response for ${toolName}:`,
JSON.stringify(rawResponse, null, 2)
);
// Parse MCP response format
if (
rawResponse &&
rawResponse.content &&
Array.isArray(rawResponse.content) &&
rawResponse.content[0]
) {
const contentItem = rawResponse.content[0];
if (contentItem.type === 'text' && contentItem.text) {
try {
const parsedData = JSON.parse(contentItem.text);
this.logger.debug(`Parsed MCP data for ${toolName}:`, parsedData);
return parsedData;
} catch (parseError) {
this.logger.error(
`Failed to parse MCP response text for ${toolName}:`,
parseError
);
this.logger.error(`Raw text was:`, contentItem.text);
return rawResponse; // Fall back to original response
}
}
}
// If not in expected format, return as-is
this.logger.warn(
`Unexpected MCP response format for ${toolName}, returning raw response`
);
return rawResponse;
} catch (error) {
lastError = error instanceof Error ? error : new Error('Unknown error');
this.logger.warn(
`Attempt ${attempt}/${this.config.retryAttempts} failed for ${toolName}:`,
lastError.message
);
if (attempt < this.config.retryAttempts) {
// Exponential backoff
const delay = Math.min(1000 * 2 ** (attempt - 1), 5000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
throw (
lastError ||
new Error(
`Failed to call ${toolName} after ${this.config.retryAttempts} attempts`
)
);
}
/**
* Get connection status
*/
getStatus(): { isRunning: boolean; error?: string } {
return this.mcpClient.getStatus();
}
/**
* Test connection
*/
async testConnection(): Promise<boolean> {
return this.mcpClient.testConnection();
}
}

View File

@@ -0,0 +1,477 @@
/**
* Task Transformer
* Handles transformation and validation of MCP responses to internal format
*/
import type { ExtensionLogger } from '../../logger';
import { MCPTaskResponse, type TaskMasterTask } from '../types';
export class TaskTransformer {
constructor(private logger: ExtensionLogger) {}
/**
* Transform MCP tasks response to internal format
*/
transformMCPTasksResponse(mcpResponse: any): TaskMasterTask[] {
const transformStartTime = Date.now();
try {
// Validate response structure
const validationResult = this.validateMCPResponse(mcpResponse);
if (!validationResult.isValid) {
this.logger.warn(
'MCP response validation failed:',
validationResult.errors
);
return [];
}
// Handle different response structures
let tasks = [];
if (Array.isArray(mcpResponse)) {
tasks = mcpResponse;
} else if (mcpResponse.data) {
if (Array.isArray(mcpResponse.data)) {
tasks = mcpResponse.data;
} else if (
mcpResponse.data.tasks &&
Array.isArray(mcpResponse.data.tasks)
) {
tasks = mcpResponse.data.tasks;
}
} else if (mcpResponse.tasks && Array.isArray(mcpResponse.tasks)) {
tasks = mcpResponse.tasks;
}
this.logger.log(`Transforming ${tasks.length} tasks from MCP response`, {
responseStructure: {
isArray: Array.isArray(mcpResponse),
hasData: !!mcpResponse.data,
dataIsArray: Array.isArray(mcpResponse.data),
hasDataTasks: !!mcpResponse.data?.tasks,
hasTasks: !!mcpResponse.tasks
}
});
const transformedTasks: TaskMasterTask[] = [];
const transformationErrors: Array<{
taskId: any;
error: string;
task: any;
}> = [];
for (let i = 0; i < tasks.length; i++) {
try {
const task = tasks[i];
const transformedTask = this.transformSingleTask(task, i);
if (transformedTask) {
transformedTasks.push(transformedTask);
}
} catch (error) {
const errorMsg =
error instanceof Error
? error.message
: 'Unknown transformation error';
transformationErrors.push({
taskId: tasks[i]?.id || `unknown_${i}`,
error: errorMsg,
task: tasks[i]
});
this.logger.error(
`Failed to transform task at index ${i}:`,
errorMsg,
tasks[i]
);
}
}
// Log transformation summary
const transformDuration = Date.now() - transformStartTime;
this.logger.log(`Transformation completed in ${transformDuration}ms`, {
totalTasks: tasks.length,
successfulTransformations: transformedTasks.length,
errors: transformationErrors.length,
errorSummary: transformationErrors.map((e) => ({
id: e.taskId,
error: e.error
}))
});
return transformedTasks;
} catch (error) {
this.logger.error(
'Critical error during response transformation:',
error
);
return [];
}
}
/**
* Validate MCP response structure
*/
private validateMCPResponse(mcpResponse: any): {
isValid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (!mcpResponse) {
errors.push('Response is null or undefined');
return { isValid: false, errors };
}
// Arrays are valid responses
if (Array.isArray(mcpResponse)) {
return { isValid: true, errors };
}
if (typeof mcpResponse !== 'object') {
errors.push('Response is not an object or array');
return { isValid: false, errors };
}
if (mcpResponse.error) {
errors.push(`MCP error: ${mcpResponse.error}`);
}
// Check for valid task structure
const hasValidTasksStructure =
(mcpResponse.data && Array.isArray(mcpResponse.data)) ||
(mcpResponse.data?.tasks && Array.isArray(mcpResponse.data.tasks)) ||
(mcpResponse.tasks && Array.isArray(mcpResponse.tasks));
if (!hasValidTasksStructure && !mcpResponse.error) {
errors.push('Response does not contain a valid tasks array structure');
}
return { isValid: errors.length === 0, errors };
}
/**
* Transform a single task with validation
*/
private transformSingleTask(task: any, index: number): TaskMasterTask | null {
if (!task || typeof task !== 'object') {
this.logger.warn(`Task at index ${index} is not a valid object:`, task);
return null;
}
try {
// Validate required fields
const taskId = this.validateAndNormalizeId(task.id, index);
const title =
this.validateAndNormalizeString(
task.title,
'Untitled Task',
`title for task ${taskId}`
) || 'Untitled Task';
const description =
this.validateAndNormalizeString(
task.description,
'',
`description for task ${taskId}`
) || '';
// Normalize and validate status/priority
const status = this.normalizeStatus(task.status);
const priority = this.normalizePriority(task.priority);
// Handle optional fields
const details = this.validateAndNormalizeString(
task.details,
undefined,
`details for task ${taskId}`
);
const testStrategy = this.validateAndNormalizeString(
task.testStrategy,
undefined,
`testStrategy for task ${taskId}`
);
// Handle complexity score
const complexityScore =
typeof task.complexityScore === 'number'
? task.complexityScore
: undefined;
// Transform dependencies
const dependencies = this.transformDependencies(
task.dependencies,
taskId
);
// Transform subtasks
const subtasks = this.transformSubtasks(task.subtasks, taskId);
const transformedTask: TaskMasterTask = {
id: taskId,
title,
description,
status,
priority,
details,
testStrategy,
complexityScore,
dependencies,
subtasks
};
// Log successful transformation for complex tasks
if (
(subtasks && subtasks.length > 0) ||
dependencies.length > 0 ||
complexityScore !== undefined
) {
this.logger.debug(`Successfully transformed complex task ${taskId}:`, {
subtaskCount: subtasks?.length ?? 0,
dependencyCount: dependencies.length,
status,
priority,
complexityScore
});
}
return transformedTask;
} catch (error) {
this.logger.error(
`Error transforming task at index ${index}:`,
error,
task
);
return null;
}
}
private validateAndNormalizeId(id: any, fallbackIndex: number): string {
if (id === null || id === undefined) {
const generatedId = `generated_${fallbackIndex}_${Date.now()}`;
this.logger.warn(`Task missing ID, generated: ${generatedId}`);
return generatedId;
}
const stringId = String(id).trim();
if (stringId === '') {
const generatedId = `empty_${fallbackIndex}_${Date.now()}`;
this.logger.warn(`Task has empty ID, generated: ${generatedId}`);
return generatedId;
}
return stringId;
}
private validateAndNormalizeString(
value: any,
defaultValue: string | undefined,
fieldName: string
): string | undefined {
if (value === null || value === undefined) {
return defaultValue;
}
if (typeof value !== 'string') {
this.logger.warn(`${fieldName} is not a string, converting:`, value);
return String(value).trim() || defaultValue;
}
const trimmed = value.trim();
if (trimmed === '' && defaultValue !== undefined) {
return defaultValue;
}
return trimmed || defaultValue;
}
private transformDependencies(dependencies: any, taskId: string): string[] {
if (!dependencies) {
return [];
}
if (!Array.isArray(dependencies)) {
this.logger.warn(
`Dependencies for task ${taskId} is not an array:`,
dependencies
);
return [];
}
const validDependencies: string[] = [];
for (let i = 0; i < dependencies.length; i++) {
const dep = dependencies[i];
if (dep === null || dep === undefined) {
this.logger.warn(`Null dependency at index ${i} for task ${taskId}`);
continue;
}
const stringDep = String(dep).trim();
if (stringDep === '') {
this.logger.warn(`Empty dependency at index ${i} for task ${taskId}`);
continue;
}
// Check for self-dependency
if (stringDep === taskId) {
this.logger.warn(
`Self-dependency detected for task ${taskId}, skipping`
);
continue;
}
validDependencies.push(stringDep);
}
return validDependencies;
}
private transformSubtasks(
subtasks: any,
parentTaskId: string
): TaskMasterTask['subtasks'] {
if (!subtasks) {
return [];
}
if (!Array.isArray(subtasks)) {
this.logger.warn(
`Subtasks for task ${parentTaskId} is not an array:`,
subtasks
);
return [];
}
const validSubtasks = [];
for (let i = 0; i < subtasks.length; i++) {
try {
const subtask = subtasks[i];
if (!subtask || typeof subtask !== 'object') {
this.logger.warn(
`Invalid subtask at index ${i} for task ${parentTaskId}:`,
subtask
);
continue;
}
const transformedSubtask = {
id: typeof subtask.id === 'number' ? subtask.id : i + 1,
title:
this.validateAndNormalizeString(
subtask.title,
`Subtask ${i + 1}`,
`subtask title for parent ${parentTaskId}`
) || `Subtask ${i + 1}`,
description: this.validateAndNormalizeString(
subtask.description,
undefined,
`subtask description for parent ${parentTaskId}`
),
status:
this.validateAndNormalizeString(
subtask.status,
'pending',
`subtask status for parent ${parentTaskId}`
) || 'pending',
details: this.validateAndNormalizeString(
subtask.details,
undefined,
`subtask details for parent ${parentTaskId}`
),
dependencies: subtask.dependencies || []
};
validSubtasks.push(transformedSubtask);
} catch (error) {
this.logger.error(
`Error transforming subtask at index ${i} for task ${parentTaskId}:`,
error
);
}
}
return validSubtasks;
}
private normalizeStatus(status: string): TaskMasterTask['status'] {
const original = status;
const normalized = status?.toLowerCase()?.trim() || 'pending';
const statusMap: Record<string, TaskMasterTask['status']> = {
pending: 'pending',
'in-progress': 'in-progress',
in_progress: 'in-progress',
inprogress: 'in-progress',
progress: 'in-progress',
working: 'in-progress',
active: 'in-progress',
review: 'review',
reviewing: 'review',
'in-review': 'review',
in_review: 'review',
done: 'done',
completed: 'done',
complete: 'done',
finished: 'done',
closed: 'done',
resolved: 'done',
blocked: 'deferred',
block: 'deferred',
stuck: 'deferred',
waiting: 'deferred',
cancelled: 'cancelled',
canceled: 'cancelled',
cancel: 'cancelled',
abandoned: 'cancelled',
deferred: 'deferred',
defer: 'deferred',
postponed: 'deferred',
later: 'deferred'
};
const result = statusMap[normalized] || 'pending';
if (original && original !== result) {
this.logger.debug(`Normalized status '${original}' -> '${result}'`);
}
return result;
}
private normalizePriority(priority: string): TaskMasterTask['priority'] {
const original = priority;
const normalized = priority?.toLowerCase()?.trim() || 'medium';
let result: TaskMasterTask['priority'] = 'medium';
if (
normalized.includes('high') ||
normalized.includes('urgent') ||
normalized.includes('critical') ||
normalized.includes('important') ||
normalized === 'h' ||
normalized === '3'
) {
result = 'high';
} else if (
normalized.includes('low') ||
normalized.includes('minor') ||
normalized.includes('trivial') ||
normalized === 'l' ||
normalized === '1'
) {
result = 'low';
} else if (
normalized.includes('medium') ||
normalized.includes('normal') ||
normalized.includes('standard') ||
normalized === 'm' ||
normalized === '2'
) {
result = 'medium';
}
if (original && original !== result) {
this.logger.debug(`Normalized priority '${original}' -> '${result}'`);
}
return result;
}
}

View File

@@ -0,0 +1,156 @@
/**
* Task Master API Types
* All type definitions for the Task Master API
*/
// MCP Response Types
export interface MCPTaskResponse {
data?: {
tasks?: Array<{
id: number | string;
title: string;
description: string;
status: string;
priority: string;
details?: string;
testStrategy?: string;
dependencies?: Array<number | string>;
complexityScore?: number;
subtasks?: Array<{
id: number;
title: string;
description?: string;
status: string;
details?: string;
dependencies?: Array<number | string>;
}>;
}>;
tag?: {
currentTag: string;
availableTags: string[];
};
};
version?: {
version: string;
name: string;
};
error?: string;
}
// Internal Task Interface
export interface TaskMasterTask {
id: string;
title: string;
description: string;
status:
| 'pending'
| 'in-progress'
| 'review'
| 'done'
| 'deferred'
| 'cancelled';
priority: 'high' | 'medium' | 'low';
details?: string;
testStrategy?: string;
dependencies?: string[];
complexityScore?: number;
subtasks?: Array<{
id: number;
title: string;
description?: string;
status: string;
details?: string;
dependencies?: Array<number | string>;
}>;
}
// API Response Wrapper
export interface TaskMasterApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
requestDuration?: number;
}
// API Configuration
export interface TaskMasterApiConfig {
timeout: number;
retryAttempts: number;
cacheDuration: number;
projectRoot?: string;
cache?: CacheConfig;
}
export interface CacheConfig {
maxSize: number;
enableBackgroundRefresh: boolean;
refreshInterval: number;
enableAnalytics: boolean;
enablePrefetch: boolean;
compressionEnabled: boolean;
persistToDisk: boolean;
}
// Cache Types
export interface CacheEntry {
data: any;
timestamp: number;
accessCount: number;
lastAccessed: number;
size: number;
ttl?: number;
tags: string[];
}
export interface CacheAnalytics {
hits: number;
misses: number;
evictions: number;
refreshes: number;
totalSize: number;
averageAccessTime: number;
hitRate: number;
}
// Method Options
export interface GetTasksOptions {
status?: string;
withSubtasks?: boolean;
tag?: string;
projectRoot?: string;
}
export interface UpdateTaskStatusOptions {
projectRoot?: string;
}
export interface UpdateTaskOptions {
projectRoot?: string;
append?: boolean;
research?: boolean;
}
export interface UpdateSubtaskOptions {
projectRoot?: string;
research?: boolean;
}
export interface AddSubtaskOptions {
projectRoot?: string;
}
export interface TaskUpdate {
title?: string;
description?: string;
details?: string;
priority?: 'high' | 'medium' | 'low';
testStrategy?: string;
dependencies?: string[];
}
export interface SubtaskData {
title: string;
description?: string;
dependencies?: string[];
status?: string;
}

View File

@@ -1,215 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import { logger } from './logger';
export interface TaskFileData {
details?: string;
testStrategy?: string;
}
export interface TasksJsonStructure {
[tagName: string]: {
tasks: TaskWithDetails[];
metadata: {
createdAt: string;
description?: string;
};
};
}
export interface TaskWithDetails {
id: string | number;
title: string;
description: string;
status: string;
priority: string;
dependencies?: (string | number)[];
details?: string;
testStrategy?: string;
subtasks?: TaskWithDetails[];
}
/**
* Reads tasks.json file directly and extracts implementation details and test strategy
* @param taskId - The ID of the task to read (e.g., "1" or "1.2" for subtasks)
* @param tagName - The tag/context name (defaults to "master")
* @returns TaskFileData with details and testStrategy fields
*/
export async function readTaskFileData(
taskId: string,
tagName: string = 'master'
): Promise<TaskFileData> {
try {
// Check if we're in a VS Code webview context
if (typeof window !== 'undefined' && (window as any).vscode) {
// Use VS Code API to read the file
const vscode = (window as any).vscode;
// Request file content from the extension
return new Promise((resolve, reject) => {
const messageId = Date.now().toString();
// Listen for response
const messageHandler = (event: MessageEvent) => {
const message = event.data;
if (
message.type === 'taskFileData' &&
message.messageId === messageId
) {
window.removeEventListener('message', messageHandler);
if (message.error) {
reject(new Error(message.error));
} else {
resolve(message.data);
}
}
};
window.addEventListener('message', messageHandler);
// Send request to extension
vscode.postMessage({
type: 'readTaskFileData',
messageId,
taskId,
tagName
});
// Timeout after 5 seconds
setTimeout(() => {
window.removeEventListener('message', messageHandler);
reject(new Error('Timeout reading task file data'));
}, 5000);
});
} else {
// Fallback for non-VS Code environments
return { details: undefined, testStrategy: undefined };
}
} catch (error) {
logger.error('Error reading task file data:', error);
return { details: undefined, testStrategy: undefined };
}
}
/**
* Finds a task by ID within a tasks array, supporting subtask notation (e.g., "1.2")
* @param tasks - Array of tasks to search
* @param taskId - ID to search for
* @returns The task object if found, undefined otherwise
*/
export function findTaskById(
tasks: TaskWithDetails[],
taskId: string
): TaskWithDetails | undefined {
// Check if this is a subtask ID with dotted notation (e.g., "1.2")
if (taskId.includes('.')) {
const [parentId, subtaskId] = taskId.split('.');
logger.log('🔍 Looking for subtask:', { parentId, subtaskId, taskId });
// Find the parent task first
const parentTask = tasks.find((task) => String(task.id) === parentId);
if (!parentTask || !parentTask.subtasks) {
logger.log('❌ Parent task not found or has no subtasks:', parentId);
return undefined;
}
logger.log(
'📋 Parent task found with',
parentTask.subtasks.length,
'subtasks'
);
logger.log(
'🔍 Subtask IDs in parent:',
parentTask.subtasks.map((st) => st.id)
);
// Find the subtask within the parent
const subtask = parentTask.subtasks.find(
(st) => String(st.id) === subtaskId
);
if (subtask) {
logger.log('✅ Subtask found:', subtask.id);
} else {
logger.log('❌ Subtask not found:', subtaskId);
}
return subtask;
}
// For regular task IDs (not dotted notation)
for (const task of tasks) {
// Convert both to strings for comparison to handle string vs number IDs
if (String(task.id) === String(taskId)) {
return task;
}
}
return undefined;
}
/**
* Parses tasks.json content and extracts task file data (details and testStrategy only)
* @param content - Raw tasks.json content
* @param taskId - Task ID to find
* @param tagName - Tag name to use
* @param workspacePath - Path to workspace root (not used anymore but kept for compatibility)
* @returns TaskFileData with details and testStrategy only
*/
export function parseTaskFileData(
content: string,
taskId: string,
tagName: string,
workspacePath?: string
): TaskFileData {
logger.log('🔍 parseTaskFileData called with:', {
taskId,
tagName,
contentLength: content.length
});
try {
const tasksJson: TasksJsonStructure = JSON.parse(content);
logger.log('📊 Available tags:', Object.keys(tasksJson));
// Get the tag data
const tagData = tasksJson[tagName];
if (!tagData || !tagData.tasks) {
logger.log('❌ Tag not found or no tasks in tag:', tagName);
return { details: undefined, testStrategy: undefined };
}
logger.log('📋 Tag found with', tagData.tasks.length, 'tasks');
logger.log(
'🔍 Available task IDs:',
tagData.tasks.map((t) => t.id)
);
// Find the task
const task = findTaskById(tagData.tasks, taskId);
if (!task) {
logger.log('❌ Task not found:', taskId);
return { details: undefined, testStrategy: undefined };
}
logger.log('✅ Task found:', task.id);
logger.log(
'📝 Task has details:',
!!task.details,
'length:',
task.details?.length
);
logger.log(
'🧪 Task has testStrategy:',
!!task.testStrategy,
'length:',
task.testStrategy?.length
);
return {
details: task.details,
testStrategy: task.testStrategy
};
} catch (error) {
logger.error('❌ Error parsing tasks.json:', error);
return { details: undefined, testStrategy: undefined };
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,150 @@
/**
* Main App Component
*/
import React, { useReducer, useState, useEffect, useRef } from 'react';
import { VSCodeContext } from './contexts/VSCodeContext';
import { TaskMasterKanban } from './components/TaskMasterKanban';
import TaskDetailsView from '@/components/TaskDetailsView';
import { ConfigView } from '@/components/ConfigView';
import { ToastContainer } from './components/ToastContainer';
import { ErrorBoundary } from './components/ErrorBoundary';
import { appReducer, initialState } from './reducers/appReducer';
import { useWebviewHeight } from './hooks/useWebviewHeight';
import { useVSCodeMessages } from './hooks/useVSCodeMessages';
import {
showSuccessToast,
showInfoToast,
showWarningToast,
showErrorToast,
createToast
} from './utils/toast';
export const App: React.FC = () => {
const [state, dispatch] = useReducer(appReducer, initialState);
const [vscode] = useState(() => window.acquireVsCodeApi?.());
const availableHeight = useWebviewHeight();
const { sendMessage } = useVSCodeMessages(vscode, state, dispatch);
const hasInitialized = useRef(false);
// Initialize the webview
useEffect(() => {
if (hasInitialized.current) return;
hasInitialized.current = true;
if (!vscode) {
console.warn('⚠️ VS Code API not available - running in standalone mode');
dispatch({
type: 'SET_CONNECTION_STATUS',
payload: { isConnected: false, status: 'Standalone Mode' }
});
return;
}
console.log('🔄 Initializing webview...');
// 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
sendMessage({ type: 'getTags' })
.then((tagsData) => {
if (tagsData?.tags && tagsData?.currentTag) {
const tagNames = tagsData.tags.map((tag: any) => tag.name || tag);
dispatch({
type: 'SET_TAG_DATA',
payload: {
currentTag: tagsData.currentTag,
availableTags: tagNames
}
});
}
})
.catch((error) => {
console.error('❌ Failed to load tags:', error);
});
}, [vscode, sendMessage, dispatch]);
const contextValue = {
vscode,
state,
dispatch,
sendMessage,
availableHeight,
// Toast notification functions
showSuccessToast: showSuccessToast(dispatch),
showInfoToast: showInfoToast(dispatch),
showWarningToast: showWarningToast(dispatch),
showErrorToast: showErrorToast(dispatch)
};
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' })}
/>
);
}
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>
);
};

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: 'warning' 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,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,67 @@
/**
* 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}
feature={{
id: task.id,
title: task.title,
column: task.status
}}
dragging={dragging}
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 && (
<span className="text-vscode-foreground/50 flex-shrink-0 ml-2">
Deps: {task.dependencies.length}
</span>
)}
</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,363 @@
/**
* Main Kanban Board Component
*/
import React, { useState, useCallback } from '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 { useVSCodeContext } from '../contexts/VSCodeContext';
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,
editingTask,
polling,
currentTag,
availableTags
} = state;
const [activeTask, setActiveTask] = useState<TaskMasterTask | null>(null);
// 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);
// 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 }
}
});
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);
// 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}`
});
}
};
// Handle drag start
const handleDragStart = useCallback(
(taskId: string) => {
const task = tasks.find((t) => t.id === taskId);
if (task) {
setActiveTask(task);
dispatch({ type: 'SET_USER_INTERACTING', payload: true });
}
},
[tasks, dispatch]
);
// Handle drag end
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
dispatch({ type: 'SET_USER_INTERACTING', payload: false });
setActiveTask(null);
const { active, over } = event;
if (!over || active.id === over.id) 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;
// Optimistic update
dispatch({
type: 'UPDATE_TASK_STATUS',
payload: { taskId, newStatus }
});
try {
// Send update to extension
await sendMessage({
type: 'updateTaskStatus',
data: { taskId, newStatus }
});
} catch (error) {
// Revert on error
dispatch({
type: 'UPDATE_TASK_STATUS',
payload: { taskId, newStatus: task.status }
});
dispatch({
type: 'SET_ERROR',
payload: `Failed to update task status: ${error}`
});
}
},
[tasks, sendMessage, dispatch]
);
// Handle retry connection
const handleRetry = useCallback(() => {
sendMessage({ type: 'retryConnection' });
}, [sendMessage]);
// Handle tag switching
const handleTagSwitch = useCallback(
async (tagName: string) => {
console.log('Switching to tag:', tagName);
await sendMessage({ type: 'switchTag', data: { tagName } });
// After switching tags, fetch the new tasks
const tasksData = await sendMessage({ type: 'getTasks' });
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]
);
if (loading) {
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 (error) {
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>
<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">
Task Master Kanban
</h1>
<div className="flex items-center gap-4">
<TagDropdown
currentTag={currentTag}
availableTags={availableTags}
onTagSwitch={handleTagSwitch}
sendMessage={sendMessage}
dispatch={dispatch}
/>
<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="Task Master 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` }}
>
<KanbanProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
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}
title={status.name}
className={`
w-80 flex flex-col
border border-vscode-border/30
rounded-lg
bg-vscode-sidebar-background/50
`}
>
<KanbanHeader
name={`${status.name} (${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 column={status.id}>
{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>
);
};

View File

@@ -0,0 +1,17 @@
/**
* Application constants
*/
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 CACHE_DURATION = 30000; // 30 seconds
export const REQUEST_TIMEOUT = 30000; // 30 seconds
export const HEADER_HEIGHT = 73; // Header with padding and border

View File

@@ -0,0 +1,32 @@
/**
* VS Code API Context
* Provides access to VS Code API and webview state
*/
import React, { createContext, useContext } from 'react';
import type { AppState, AppAction, ToastNotification } from '../types';
export interface VSCodeContextValue {
vscode?: ReturnType<NonNullable<typeof window.acquireVsCodeApi>>;
state: AppState;
dispatch: React.Dispatch<AppAction>;
sendMessage: (message: any) => Promise<any>;
availableHeight: number;
// Toast notification functions
showSuccessToast: (title: string, message: string, duration?: number) => void;
showInfoToast: (title: string, message: string, duration?: number) => void;
showWarningToast: (title: string, message: string, duration?: number) => void;
showErrorToast: (title: string, message: string, duration?: number) => void;
}
export const VSCodeContext = createContext<VSCodeContextValue | undefined>(
undefined
);
export const useVSCodeContext = () => {
const context = useContext(VSCodeContext);
if (!context) {
throw new Error('useVSCodeContext must be used within VSCodeProvider');
}
return context;
};

View File

@@ -0,0 +1,259 @@
/**
* Hook for handling VS Code messages
*/
import { useEffect, useCallback, useRef } from 'react';
import type { AppState, AppAction } from '../types';
import { createToast } from '../utils/toast';
import { REQUEST_TIMEOUT } from '../constants';
interface PendingRequest {
resolve: Function;
reject: Function;
timeout: NodeJS.Timeout;
}
let requestCounter = 0;
export const useVSCodeMessages = (
vscode: ReturnType<NonNullable<typeof window.acquireVsCodeApi>> | undefined,
state: AppState,
dispatch: React.Dispatch<AppAction>
) => {
const pendingRequestsRef = useRef(new Map<string, PendingRequest>());
const sendMessage = useCallback(
(message: any): Promise<any> => {
if (!vscode) {
return Promise.reject(new Error('VS Code API not available'));
}
return new Promise((resolve, reject) => {
const requestId = `req_${++requestCounter}_${Date.now()}`;
const timeout = setTimeout(() => {
pendingRequestsRef.current.delete(requestId);
reject(new Error('Request timeout'));
}, REQUEST_TIMEOUT);
pendingRequestsRef.current.set(requestId, { resolve, reject, timeout });
vscode.postMessage({
...message,
requestId
});
});
},
[vscode]
);
useEffect(() => {
if (!vscode) return;
const handleMessage = (event: MessageEvent) => {
const message = event.data;
console.log('📥 Received message:', message.type, message);
// Handle request/response pattern
if (message.requestId) {
const pending = pendingRequestsRef.current.get(message.requestId);
if (pending) {
clearTimeout(pending.timeout);
pendingRequestsRef.current.delete(message.requestId);
if (message.type === 'response') {
// Check for explicit success field, default to true if data exists
const isSuccess =
message.success !== undefined
? message.success
: message.data !== undefined;
if (isSuccess) {
pending.resolve(message.data);
} else {
pending.reject(new Error(message.error || 'Request failed'));
}
} else if (message.type === 'error') {
pending.reject(new Error(message.error || 'Request failed'));
}
}
return;
}
// Handle other message types
switch (message.type) {
case 'connectionStatus':
dispatch({
type: 'SET_CONNECTION_STATUS',
payload: {
isConnected: message.data?.isConnected || false,
status: message.data?.status || 'Unknown'
}
});
break;
case 'tasksData':
console.log('📋 Received tasks data:', message.data);
dispatch({ type: 'SET_TASKS', payload: message.data });
break;
case 'tasksUpdated':
console.log('📋 Tasks updated:', message.data);
// Extract tasks from the data object
const tasks = message.data?.tasks || message.data;
if (Array.isArray(tasks)) {
dispatch({ type: 'SET_TASKS', payload: tasks });
}
break;
case 'taskStatusUpdated':
console.log('✅ Task status updated:', message);
break;
case 'taskUpdated':
console.log('✅ Task content updated:', message);
break;
case 'pollingStatus':
dispatch({
type: 'SET_POLLING_STATUS',
payload: {
isActive: message.isActive,
errorCount: message.errorCount || 0
}
});
break;
case 'pollingUpdate':
console.log('🔄 Polling update received:', {
tasksCount: message.data?.length,
userInteracting: state.polling.isUserInteracting,
offlineMode: state.polling.isOfflineMode
});
if (
!state.polling.isUserInteracting &&
!state.polling.isOfflineMode
) {
dispatch({
type: 'TASKS_UPDATED_FROM_POLLING',
payload: message.data
});
}
break;
case 'networkStatus':
dispatch({
type: 'SET_NETWORK_STATUS',
payload: message.data
});
break;
case 'cachedTasks':
console.log('📦 Received cached tasks:', message.data);
dispatch({
type: 'LOAD_CACHED_TASKS',
payload: message.data
});
break;
case 'errorNotification':
handleErrorNotification(message, dispatch);
break;
case 'error':
handleGeneralError(message, dispatch);
break;
case 'reactError':
console.log('🔥 React error reported to extension:', message);
dispatch({
type: 'ADD_TOAST',
payload: createToast(
'error',
'UI Error',
'A component error occurred. The extension may need to be reloaded.',
10000
)
});
break;
default:
console.log('❓ Unknown message type:', message.type);
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [vscode, state.polling, dispatch]);
return { sendMessage };
};
function handleErrorNotification(
message: any,
dispatch: React.Dispatch<AppAction>
) {
console.log('📨 Error notification received:', message);
const errorData = message.data;
// Map severity to toast type
let toastType: 'error' | 'warning' | 'info' = 'error';
if (errorData.severity === 'high' || errorData.severity === 'critical') {
toastType = 'error';
} else if (errorData.severity === 'medium') {
toastType = 'warning';
} else {
toastType = 'info';
}
// Create appropriate toast based on error category
const title =
errorData.category === 'network'
? 'Network Error'
: errorData.category === 'mcp_connection'
? 'Connection Error'
: errorData.category === 'task_loading'
? 'Task Loading Error'
: errorData.category === 'ui_rendering'
? 'UI Error'
: 'Error';
dispatch({
type: 'ADD_TOAST',
payload: createToast(
toastType,
title,
errorData.message,
errorData.duration || (toastType === 'error' ? 8000 : 5000)
)
});
}
function handleGeneralError(message: any, dispatch: React.Dispatch<AppAction>) {
console.log('❌ General error from extension:', message);
const errorTitle =
message.errorType === 'connection' ? 'Connection Error' : 'Error';
const errorMessage = message.error || 'An unknown error occurred';
dispatch({
type: 'SET_ERROR',
payload: errorMessage
});
dispatch({
type: 'ADD_TOAST',
payload: createToast('error', errorTitle, errorMessage, 8000)
});
// Set offline mode for connection errors
if (message.errorType === 'connection') {
dispatch({
type: 'SET_NETWORK_STATUS',
payload: {
isOfflineMode: true,
connectionStatus: 'offline',
reconnectAttempts: 0
}
});
}
}

View File

@@ -0,0 +1,42 @@
/**
* Hook for managing webview height
*/
import { useState, useEffect, useCallback } from 'react';
export const useWebviewHeight = () => {
const [availableHeight, setAvailableHeight] = useState<number>(
window.innerHeight
);
const updateAvailableHeight = useCallback(() => {
const height = window.innerHeight;
console.log('📏 Available height updated:', height);
setAvailableHeight(height);
}, []);
useEffect(() => {
updateAvailableHeight();
const handleResize = () => {
updateAvailableHeight();
};
window.addEventListener('resize', handleResize);
// Also listen for VS Code specific events if available
const handleVisibilityChange = () => {
// Small delay to ensure VS Code has finished resizing
setTimeout(updateAvailableHeight, 100);
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('resize', handleResize);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [updateAvailableHeight]);
return availableHeight;
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,187 @@
/**
* Main application state reducer
*/
import type { AppState, AppAction } from '../types';
export const appReducer = (state: AppState, action: AppAction): AppState => {
console.log('Reducer action:', action.type, action.payload);
switch (action.type) {
case 'SET_TASKS':
const newTasks = Array.isArray(action.payload) ? action.payload : [];
console.log('SET_TASKS reducer - updating tasks:', {
oldCount: state.tasks.length,
newCount: newTasks.length,
newTasks
});
return {
...state,
tasks: newTasks,
loading: false,
error: undefined
};
case 'SET_LOADING':
return { ...state, loading: action.payload };
case 'SET_ERROR':
return { ...state, error: action.payload, loading: false };
case 'CLEAR_ERROR':
return { ...state, error: undefined };
case 'INCREMENT_REQUEST_ID':
return { ...state, requestId: state.requestId + 1 };
case 'UPDATE_TASK_STATUS': {
const { taskId, newStatus } = action.payload;
return {
...state,
tasks: state.tasks.map((task) =>
task.id === taskId ? { ...task, status: newStatus } : task
)
};
}
case 'UPDATE_TASK_CONTENT': {
const { taskId, updates } = action.payload;
return {
...state,
tasks: state.tasks.map((task) =>
task.id === taskId ? { ...task, ...updates } : task
)
};
}
case 'SET_CONNECTION_STATUS':
return {
...state,
isConnected: action.payload.isConnected,
connectionStatus: action.payload.status
};
case 'SET_EDITING_TASK':
return {
...state,
editingTask: action.payload
};
case 'SET_POLLING_STATUS':
return {
...state,
polling: {
...state.polling,
isActive: action.payload.isActive,
errorCount: action.payload.errorCount ?? state.polling.errorCount,
lastUpdate: action.payload.isActive
? Date.now()
: state.polling.lastUpdate
}
};
case 'SET_USER_INTERACTING':
return {
...state,
polling: {
...state.polling,
isUserInteracting: action.payload
}
};
case 'TASKS_UPDATED_FROM_POLLING':
return {
...state,
tasks: Array.isArray(action.payload) ? action.payload : [],
polling: {
...state.polling,
lastUpdate: Date.now()
}
};
case 'SET_NETWORK_STATUS':
return {
...state,
polling: {
...state.polling,
isOfflineMode: action.payload.isOfflineMode,
connectionStatus: action.payload.connectionStatus,
reconnectAttempts:
action.payload.reconnectAttempts !== undefined
? action.payload.reconnectAttempts
: state.polling.reconnectAttempts,
maxReconnectAttempts:
action.payload.maxReconnectAttempts !== undefined
? action.payload.maxReconnectAttempts
: state.polling.maxReconnectAttempts,
lastSuccessfulConnection:
action.payload.lastSuccessfulConnection !== undefined
? action.payload.lastSuccessfulConnection
: state.polling.lastSuccessfulConnection
}
};
case 'LOAD_CACHED_TASKS':
return {
...state,
tasks: Array.isArray(action.payload) ? action.payload : []
};
case 'ADD_TOAST':
return {
...state,
toastNotifications: [...state.toastNotifications, action.payload]
};
case 'REMOVE_TOAST':
return {
...state,
toastNotifications: state.toastNotifications.filter(
(notification) => notification.id !== action.payload
)
};
case 'CLEAR_ALL_TOASTS':
return { ...state, toastNotifications: [] };
case 'NAVIGATE_TO_TASK':
console.log('📍 Reducer: Navigating to task:', action.payload);
return {
...state,
currentView: 'task-details',
selectedTaskId: action.payload
};
case 'NAVIGATE_TO_KANBAN':
console.log('📍 Reducer: Navigating to kanban');
return { ...state, currentView: 'kanban', selectedTaskId: undefined };
case 'NAVIGATE_TO_CONFIG':
console.log('📍 Reducer: Navigating to config');
return { ...state, currentView: 'config', selectedTaskId: undefined };
case 'SET_CURRENT_TAG':
return {
...state,
currentTag: action.payload
};
case 'SET_AVAILABLE_TAGS':
return {
...state,
availableTags: action.payload
};
case 'SET_TAG_DATA':
return {
...state,
currentTag: action.payload.currentTag,
availableTags: action.payload.availableTags
};
default:
return state;
}
};
export const initialState: AppState = {
tasks: [],
loading: true,
requestId: 0,
isConnected: false,
connectionStatus: 'Connecting...',
editingTask: { taskId: null },
polling: {
isActive: false,
errorCount: 0,
lastUpdate: undefined,
isUserInteracting: false,
isOfflineMode: false,
reconnectAttempts: 0,
maxReconnectAttempts: 0,
lastSuccessfulConnection: undefined,
connectionStatus: 'online'
},
toastNotifications: [],
currentView: 'kanban',
selectedTaskId: undefined,
// Tag-related state
currentTag: 'master',
availableTags: ['master']
};

View File

@@ -0,0 +1,120 @@
/**
* Shared types for the webview application
*/
export interface TaskMasterTask {
id: string;
title: string;
description: string;
status: 'pending' | 'in-progress' | 'done' | 'deferred' | 'review';
priority: 'high' | 'medium' | 'low';
dependencies?: string[];
details?: string;
testStrategy?: string;
subtasks?: TaskMasterTask[];
complexityScore?: number;
}
export interface TaskUpdates {
title?: string;
description?: string;
details?: string;
priority?: TaskMasterTask['priority'];
testStrategy?: string;
dependencies?: string[];
}
export interface WebviewMessage {
type: string;
requestId?: string;
data?: any;
success?: boolean;
[key: string]: any;
}
export interface ToastNotification {
id: string;
type: 'success' | 'info' | 'warning' | 'error';
title: string;
message: string;
duration?: number;
}
export interface AppState {
tasks: TaskMasterTask[];
loading: boolean;
error?: string;
requestId: number;
isConnected: boolean;
connectionStatus: string;
editingTask?: { taskId: string | null; editData?: TaskMasterTask };
polling: {
isActive: boolean;
errorCount: number;
lastUpdate?: number;
isUserInteracting: boolean;
isOfflineMode: boolean;
reconnectAttempts: number;
maxReconnectAttempts: number;
lastSuccessfulConnection?: number;
connectionStatus: 'online' | 'offline' | 'reconnecting';
};
toastNotifications: ToastNotification[];
currentView: 'kanban' | 'task-details' | 'config';
selectedTaskId?: string;
// Tag-related state
currentTag: string;
availableTags: string[];
}
export type AppAction =
| { type: 'SET_TASKS'; payload: TaskMasterTask[] }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string }
| { type: 'CLEAR_ERROR' }
| { type: 'INCREMENT_REQUEST_ID' }
| {
type: 'UPDATE_TASK_STATUS';
payload: { taskId: string; newStatus: TaskMasterTask['status'] };
}
| {
type: 'UPDATE_TASK_CONTENT';
payload: { taskId: string; updates: TaskUpdates };
}
| {
type: 'SET_CONNECTION_STATUS';
payload: { isConnected: boolean; status: string };
}
| {
type: 'SET_EDITING_TASK';
payload: { taskId: string | null; editData?: TaskMasterTask };
}
| {
type: 'SET_POLLING_STATUS';
payload: { isActive: boolean; errorCount?: number };
}
| { type: 'SET_USER_INTERACTING'; payload: boolean }
| { type: 'TASKS_UPDATED_FROM_POLLING'; payload: TaskMasterTask[] }
| {
type: 'SET_NETWORK_STATUS';
payload: {
isOfflineMode: boolean;
connectionStatus: 'online' | 'offline' | 'reconnecting';
reconnectAttempts?: number;
maxReconnectAttempts?: number;
lastSuccessfulConnection?: number;
};
}
| { type: 'LOAD_CACHED_TASKS'; payload: TaskMasterTask[] }
| { type: 'ADD_TOAST'; payload: ToastNotification }
| { type: 'REMOVE_TOAST'; payload: string }
| { type: 'CLEAR_ALL_TOASTS' }
| { type: 'NAVIGATE_TO_TASK'; payload: string }
| { type: 'NAVIGATE_TO_KANBAN' }
| { type: 'NAVIGATE_TO_CONFIG' }
| { type: 'SET_CURRENT_TAG'; payload: string }
| { type: 'SET_AVAILABLE_TAGS'; payload: string[] }
| {
type: 'SET_TAG_DATA';
payload: { currentTag: string; availableTags: string[] };
};

View File

@@ -0,0 +1,56 @@
/**
* Toast notification utilities
*/
import type { ToastNotification, AppAction } from '../types';
let toastIdCounter = 0;
export const createToast = (
type: ToastNotification['type'],
title: string,
message: string,
duration?: number
): ToastNotification => ({
id: `toast-${++toastIdCounter}`,
type,
title,
message,
duration
});
export const showSuccessToast =
(dispatch: React.Dispatch<AppAction>) =>
(title: string, message: string, duration?: number) => {
dispatch({
type: 'ADD_TOAST',
payload: createToast('success', title, message, duration)
});
};
export const showInfoToast =
(dispatch: React.Dispatch<AppAction>) =>
(title: string, message: string, duration?: number) => {
dispatch({
type: 'ADD_TOAST',
payload: createToast('info', title, message, duration)
});
};
export const showWarningToast =
(dispatch: React.Dispatch<AppAction>) =>
(title: string, message: string, duration?: number) => {
dispatch({
type: 'ADD_TOAST',
payload: createToast('warning', title, message, duration)
});
};
export const showErrorToast =
(dispatch: React.Dispatch<AppAction>) =>
(title: string, message: string, duration?: number) => {
dispatch({
type: 'ADD_TOAST',
payload: createToast('error', title, message, duration)
});
};

View File

@@ -24,7 +24,11 @@
}
},
"linter": {
"enabled": true,
"include": ["apps/extension/**/*.ts", "apps/extension/**/*.tsx"],
"ignore": ["**/*", "!apps/extension/**/*"],
"rules": {
"recommended": true,
"complexity": {
"noForEach": "off",
"useOptionalChain": "off",
@@ -44,7 +48,8 @@
"useNumberNamespace": "off",
"noParameterAssign": "off",
"useTemplate": "off",
"noUnusedTemplateLiteral": "off"
"noUnusedTemplateLiteral": "off",
"noNonNullAssertion": "warn"
}
}
}

1522
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,9 @@
"inspector": "npx @modelcontextprotocol/inspector node mcp-server/server.js",
"mcp-server": "node mcp-server/server.js",
"format-check": "biome format .",
"format": "biome format . --write"
"format": "biome format . --write",
"lint": "biome check .",
"lint:fix": "biome check . --write"
},
"keywords": [
"claude",