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:
@@ -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'
|
||||
}
|
||||
}
|
||||
];
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
291
apps/extension/src/components/ConfigView.tsx
Normal file
291
apps/extension/src/components/ConfigView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
201
apps/extension/src/components/TaskDetails/AIActionsSection.tsx
Normal file
201
apps/extension/src/components/TaskDetails/AIActionsSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
178
apps/extension/src/components/TaskDetails/DetailsSection.tsx
Normal file
178
apps/extension/src/components/TaskDetails/DetailsSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
apps/extension/src/components/TaskDetails/PriorityBadge.tsx
Normal file
47
apps/extension/src/components/TaskDetails/PriorityBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
233
apps/extension/src/components/TaskDetails/SubtasksSection.tsx
Normal file
233
apps/extension/src/components/TaskDetails/SubtasksSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
133
apps/extension/src/components/TaskDetails/useTaskDetails.ts
Normal file
133
apps/extension/src/components/TaskDetails/useTaskDetails.ts
Normal 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
|
||||
};
|
||||
};
|
||||
1304
apps/extension/src/components/TaskDetailsView.original.tsx
Normal file
1304
apps/extension/src/components/TaskDetailsView.original.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
61
apps/extension/src/components/ui/CollapsibleSection.tsx
Normal file
61
apps/extension/src/components/ui/CollapsibleSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
141
apps/extension/src/services/config-service.ts
Normal file
141
apps/extension/src/services/config-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
167
apps/extension/src/services/error-handler.ts
Normal file
167
apps/extension/src/services/error-handler.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
129
apps/extension/src/services/notification-preferences.ts
Normal file
129
apps/extension/src/services/notification-preferences.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
92
apps/extension/src/services/polling-service.ts
Normal file
92
apps/extension/src/services/polling-service.ts
Normal 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`);
|
||||
}
|
||||
}
|
||||
67
apps/extension/src/services/polling-strategies.ts
Normal file
67
apps/extension/src/services/polling-strategies.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
138
apps/extension/src/services/task-repository.ts
Normal file
138
apps/extension/src/services/task-repository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
356
apps/extension/src/services/webview-manager.ts
Normal file
356
apps/extension/src/services/webview-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
34
apps/extension/src/utils/event-emitter.ts
Normal file
34
apps/extension/src/utils/event-emitter.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
253
apps/extension/src/utils/task-master-api/cache/cache-manager.ts
vendored
Normal file
253
apps/extension/src/utils/task-master-api/cache/cache-manager.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
471
apps/extension/src/utils/task-master-api/index.ts
Normal file
471
apps/extension/src/utils/task-master-api/index.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
98
apps/extension/src/utils/task-master-api/mcp-client.ts
Normal file
98
apps/extension/src/utils/task-master-api/mcp-client.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
156
apps/extension/src/utils/task-master-api/types/index.ts
Normal file
156
apps/extension/src/utils/task-master-api/types/index.ts
Normal 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;
|
||||
}
|
||||
@@ -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
150
apps/extension/src/webview/App.tsx
Normal file
150
apps/extension/src/webview/App.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
113
apps/extension/src/webview/components/ErrorBoundary.tsx
Normal file
113
apps/extension/src/webview/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
84
apps/extension/src/webview/components/PollingStatus.tsx
Normal file
84
apps/extension/src/webview/components/PollingStatus.tsx
Normal 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;
|
||||
};
|
||||
30
apps/extension/src/webview/components/PriorityBadge.tsx
Normal file
30
apps/extension/src/webview/components/PriorityBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
141
apps/extension/src/webview/components/TagDropdown.tsx
Normal file
141
apps/extension/src/webview/components/TagDropdown.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
67
apps/extension/src/webview/components/TaskCard.tsx
Normal file
67
apps/extension/src/webview/components/TaskCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
242
apps/extension/src/webview/components/TaskEditModal.tsx
Normal file
242
apps/extension/src/webview/components/TaskEditModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
363
apps/extension/src/webview/components/TaskMasterKanban.tsx
Normal file
363
apps/extension/src/webview/components/TaskMasterKanban.tsx
Normal 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 }
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
31
apps/extension/src/webview/components/ToastContainer.tsx
Normal file
31
apps/extension/src/webview/components/ToastContainer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
167
apps/extension/src/webview/components/ToastNotification.tsx
Normal file
167
apps/extension/src/webview/components/ToastNotification.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
apps/extension/src/webview/constants/index.ts
Normal file
17
apps/extension/src/webview/constants/index.ts
Normal 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
|
||||
32
apps/extension/src/webview/contexts/VSCodeContext.tsx
Normal file
32
apps/extension/src/webview/contexts/VSCodeContext.tsx
Normal 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;
|
||||
};
|
||||
259
apps/extension/src/webview/hooks/useVSCodeMessages.ts
Normal file
259
apps/extension/src/webview/hooks/useVSCodeMessages.ts
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
42
apps/extension/src/webview/hooks/useWebviewHeight.ts
Normal file
42
apps/extension/src/webview/hooks/useWebviewHeight.ts
Normal 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
187
apps/extension/src/webview/reducers/appReducer.ts
Normal file
187
apps/extension/src/webview/reducers/appReducer.ts
Normal 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']
|
||||
};
|
||||
120
apps/extension/src/webview/types/index.ts
Normal file
120
apps/extension/src/webview/types/index.ts
Normal 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[] };
|
||||
};
|
||||
56
apps/extension/src/webview/utils/toast.ts
Normal file
56
apps/extension/src/webview/utils/toast.ts
Normal 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)
|
||||
});
|
||||
};
|
||||
@@ -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
1522
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user