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",
|
"vscode:prepublish": "npm run build",
|
||||||
"build": "npm run build:js && npm run build:css",
|
"build": "npm run build:js && npm run build:css",
|
||||||
"build:js": "node ./esbuild.js --production",
|
"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": "npm exec node ./package.mjs",
|
||||||
"package:direct": "node ./package.mjs",
|
"package:direct": "node ./package.mjs",
|
||||||
"debug:env": "node ./debug-env.mjs",
|
"debug:env": "node ./debug-env.mjs",
|
||||||
"compile": "node ./esbuild.js",
|
"compile": "node ./esbuild.js",
|
||||||
"watch": "npm run watch:js & npm run watch:css",
|
"watch": "npm run watch:js & npm run watch:css",
|
||||||
"watch:js": "node ./esbuild.js --watch",
|
"watch:js": "node ./esbuild.js --watch",
|
||||||
"watch:css": "npx @tailwindcss/cli -o ./dist/index.css --watch",
|
"watch:css": "npx @tailwindcss/cli -i ./src/webview/index.css -o ./dist/index.css --watch",
|
||||||
"lint": "eslint src --ext ts,tsx",
|
|
||||||
"test": "vscode-test",
|
"test": "vscode-test",
|
||||||
"check-types": "tsc --noEmit"
|
"check-types": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
@@ -221,8 +220,6 @@
|
|||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-dom": "19.1.6",
|
"@types/react-dom": "19.1.6",
|
||||||
"@types/vscode": "^1.101.0",
|
"@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-cli": "^0.0.11",
|
||||||
"@vscode/test-electron": "^2.5.2",
|
"@vscode/test-electron": "^2.5.2",
|
||||||
"@vscode/vsce": "^2.32.0",
|
"@vscode/vsce": "^2.32.0",
|
||||||
@@ -231,7 +228,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"esbuild": "^0.25.3",
|
"esbuild": "^0.25.3",
|
||||||
"esbuild-postcss": "^0.0.4",
|
"esbuild-postcss": "^0.0.4",
|
||||||
"eslint": "^9.25.1",
|
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { execSync } from 'child_process';
|
import { execSync } from 'child_process';
|
||||||
import fs from 'fs-extra';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
|
||||||
// --- Configuration ---
|
// --- Configuration ---
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
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 { 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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||||
|
import type * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { Slot } from '@radix-ui/react-slot';
|
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';
|
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';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||||
|
import type * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||||
|
import type * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||||
|
import type * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -11,12 +11,12 @@ function ScrollArea({
|
|||||||
return (
|
return (
|
||||||
<ScrollAreaPrimitive.Root
|
<ScrollAreaPrimitive.Root
|
||||||
data-slot="scroll-area"
|
data-slot="scroll-area"
|
||||||
className={cn('relative', className)}
|
className={cn('relative overflow-hidden', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ScrollAreaPrimitive.Viewport
|
<ScrollAreaPrimitive.Viewport
|
||||||
data-slot="scroll-area-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}
|
{children}
|
||||||
</ScrollAreaPrimitive.Viewport>
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||||
|
import type * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +14,7 @@ import {
|
|||||||
useSensors
|
useSensors
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import type { DragEndEvent } from '@dnd-kit/core';
|
import type { DragEndEvent } from '@dnd-kit/core';
|
||||||
|
import type React from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
export type { DragEndEvent } from '@dnd-kit/core';
|
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';
|
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 * as vscode from 'vscode';
|
||||||
import { MCPConfig } from './mcpClient';
|
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import type { MCPConfig } from './mcpClient';
|
||||||
|
|
||||||
export interface TaskMasterConfig {
|
export interface TaskMasterConfig {
|
||||||
mcp: MCPServerConfig;
|
mcp: MCPServerConfig;
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { MCPClientManager, MCPConfig, MCPServerStatus } from './mcpClient';
|
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import {
|
||||||
|
MCPClientManager,
|
||||||
|
type MCPConfig,
|
||||||
|
type MCPServerStatus
|
||||||
|
} from './mcpClient';
|
||||||
|
|
||||||
export interface ConnectionEvent {
|
export interface ConnectionEvent {
|
||||||
type: 'connected' | 'disconnected' | 'error' | 'reconnecting';
|
type: 'connected' | 'disconnected' | 'error' | 'reconnecting';
|
||||||
@@ -135,7 +139,7 @@ export class ConnectionManager {
|
|||||||
/**
|
/**
|
||||||
* Get recent connection events
|
* Get recent connection events
|
||||||
*/
|
*/
|
||||||
getEvents(limit: number = 10): ConnectionEvent[] {
|
getEvents(limit = 10): ConnectionEvent[] {
|
||||||
return this.connectionEvents.slice(-limit);
|
return this.connectionEvents.slice(-limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +304,7 @@ export class ConnectionManager {
|
|||||||
this.reconnectAttempts++;
|
this.reconnectAttempts++;
|
||||||
|
|
||||||
const backoffMs = Math.min(
|
const backoffMs = Math.min(
|
||||||
this.reconnectBackoffMs * Math.pow(2, this.reconnectAttempts - 1),
|
this.reconnectBackoffMs * 2 ** (this.reconnectAttempts - 1),
|
||||||
this.maxBackoffMs
|
this.maxBackoffMs
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
import {
|
import {
|
||||||
shouldShowNotification,
|
|
||||||
getNotificationType,
|
getNotificationType,
|
||||||
getToastDuration
|
getToastDuration,
|
||||||
|
shouldShowNotification
|
||||||
} from './notificationPreferences';
|
} from './notificationPreferences';
|
||||||
|
|
||||||
export enum ErrorSeverity {
|
export enum ErrorSeverity {
|
||||||
@@ -169,7 +169,7 @@ export abstract class TaskMasterError extends Error {
|
|||||||
export class MCPConnectionError extends TaskMasterError {
|
export class MCPConnectionError extends TaskMasterError {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
code: string = 'MCP_CONNECTION_FAILED',
|
code = 'MCP_CONNECTION_FAILED',
|
||||||
context?: Record<string, any>,
|
context?: Record<string, any>,
|
||||||
recovery?: {
|
recovery?: {
|
||||||
automatic: boolean;
|
automatic: boolean;
|
||||||
@@ -195,7 +195,7 @@ export class MCPConnectionError extends TaskMasterError {
|
|||||||
export class ConfigurationError extends TaskMasterError {
|
export class ConfigurationError extends TaskMasterError {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
code: string = 'CONFIGURATION_INVALID',
|
code = 'CONFIGURATION_INVALID',
|
||||||
context?: Record<string, any>
|
context?: Record<string, any>
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
@@ -215,7 +215,7 @@ export class ConfigurationError extends TaskMasterError {
|
|||||||
export class TaskLoadingError extends TaskMasterError {
|
export class TaskLoadingError extends TaskMasterError {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
code: string = 'TASK_LOADING_FAILED',
|
code = 'TASK_LOADING_FAILED',
|
||||||
context?: Record<string, any>,
|
context?: Record<string, any>,
|
||||||
recovery?: {
|
recovery?: {
|
||||||
automatic: boolean;
|
automatic: boolean;
|
||||||
@@ -241,7 +241,7 @@ export class TaskLoadingError extends TaskMasterError {
|
|||||||
export class UIRenderingError extends TaskMasterError {
|
export class UIRenderingError extends TaskMasterError {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
code: string = 'UI_RENDERING_FAILED',
|
code = 'UI_RENDERING_FAILED',
|
||||||
context?: Record<string, any>
|
context?: Record<string, any>
|
||||||
) {
|
) {
|
||||||
super(
|
super(
|
||||||
@@ -261,7 +261,7 @@ export class UIRenderingError extends TaskMasterError {
|
|||||||
export class NetworkError extends TaskMasterError {
|
export class NetworkError extends TaskMasterError {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
code: string = 'NETWORK_ERROR',
|
code = 'NETWORK_ERROR',
|
||||||
context?: Record<string, any>,
|
context?: Record<string, any>,
|
||||||
recovery?: {
|
recovery?: {
|
||||||
automatic: boolean;
|
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';
|
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
|
* Logger that outputs to VS Code's output channel instead of console
|
||||||
* This prevents interference with MCP stdio communication
|
* This prevents interference with MCP stdio communication
|
||||||
*/
|
*/
|
||||||
export class ExtensionLogger {
|
export class ExtensionLogger implements ILogger {
|
||||||
private static instance: ExtensionLogger;
|
private static instance: ExtensionLogger;
|
||||||
private outputChannel: vscode.OutputChannel;
|
private outputChannel: vscode.OutputChannel;
|
||||||
private debugMode: boolean;
|
private debugMode: boolean;
|
||||||
@@ -23,7 +35,9 @@ export class ExtensionLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log(message: string, ...args: any[]): void {
|
log(message: string, ...args: any[]): void {
|
||||||
if (!this.debugMode) return;
|
if (!this.debugMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const formattedMessage = this.formatMessage(message, args);
|
const formattedMessage = this.formatMessage(message, args);
|
||||||
this.outputChannel.appendLine(`[${timestamp}] ${formattedMessage}`);
|
this.outputChannel.appendLine(`[${timestamp}] ${formattedMessage}`);
|
||||||
@@ -36,14 +50,18 @@ export class ExtensionLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
warn(message: string, ...args: any[]): void {
|
warn(message: string, ...args: any[]): void {
|
||||||
if (!this.debugMode) return;
|
if (!this.debugMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const formattedMessage = this.formatMessage(message, args);
|
const formattedMessage = this.formatMessage(message, args);
|
||||||
this.outputChannel.appendLine(`[${timestamp}] WARN: ${formattedMessage}`);
|
this.outputChannel.appendLine(`[${timestamp}] WARN: ${formattedMessage}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
debug(message: string, ...args: any[]): void {
|
debug(message: string, ...args: any[]): void {
|
||||||
if (!this.debugMode) return;
|
if (!this.debugMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const formattedMessage = this.formatMessage(message, args);
|
const formattedMessage = this.formatMessage(message, args);
|
||||||
this.outputChannel.appendLine(`[${timestamp}] DEBUG: ${formattedMessage}`);
|
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 { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { logger } from './logger';
|
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": {
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"include": ["apps/extension/**/*.ts", "apps/extension/**/*.tsx"],
|
||||||
|
"ignore": ["**/*", "!apps/extension/**/*"],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
"complexity": {
|
"complexity": {
|
||||||
"noForEach": "off",
|
"noForEach": "off",
|
||||||
"useOptionalChain": "off",
|
"useOptionalChain": "off",
|
||||||
@@ -44,7 +48,8 @@
|
|||||||
"useNumberNamespace": "off",
|
"useNumberNamespace": "off",
|
||||||
"noParameterAssign": "off",
|
"noParameterAssign": "off",
|
||||||
"useTemplate": "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",
|
"inspector": "npx @modelcontextprotocol/inspector node mcp-server/server.js",
|
||||||
"mcp-server": "node mcp-server/server.js",
|
"mcp-server": "node mcp-server/server.js",
|
||||||
"format-check": "biome format .",
|
"format-check": "biome format .",
|
||||||
"format": "biome format . --write"
|
"format": "biome format . --write",
|
||||||
|
"lint": "biome check .",
|
||||||
|
"lint:fix": "biome check . --write"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude",
|
"claude",
|
||||||
|
|||||||
Reference in New Issue
Block a user