feat(extension): complete VS Code extension with kanban board interface (#997)
--------- Co-authored-by: DavidMaliglowka <13022280+DavidMaliglowka@users.noreply.github.com> Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
42
.changeset/sour-pans-beam.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
"extension": minor
|
||||
---
|
||||
|
||||
🎉 **Introducing TaskMaster Extension!**
|
||||
|
||||
We're thrilled to launch the first version of our Code extension, bringing the power of TaskMaster directly into your favorite code editor. While this is our initial release and we've kept things focused, it already packs powerful features to supercharge your development workflow.
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
### 📋 Visual Task Management
|
||||
- **Kanban Board View**: Visualize all your tasks in an intuitive board layout directly in VS Code
|
||||
- **Drag & Drop**: Easily change task status by dragging cards between columns
|
||||
- **Real-time Updates**: See changes instantly as you work through your project
|
||||
|
||||
### 🏷️ Multi-Context Support
|
||||
- **Tag Switching**: Seamlessly switch between different project contexts/tags
|
||||
- **Isolated Workflows**: Keep different features or experiments organized separately
|
||||
|
||||
### 🤖 AI-Powered Task Updates
|
||||
- **Smart Updates**: Use TaskMaster's AI capabilities to update tasks and subtasks
|
||||
- **Context-Aware**: Leverages your existing TaskMaster configuration and models
|
||||
|
||||
### 📊 Rich Task Information
|
||||
- **Complexity Scores**: See task complexity ratings at a glance
|
||||
- **Subtask Visualization**: Expand tasks to view and manage subtasks
|
||||
- **Dependency Graphs**: Understand task relationships and dependencies visually
|
||||
|
||||
### ⚙️ Configuration Management
|
||||
- **Visual Config Editor**: View and understand your `.taskmaster/config.json` settings
|
||||
- **Easy Access**: No more manual JSON editing for common configuration tasks
|
||||
|
||||
### 🚀 Quick Actions
|
||||
- **Status Updates**: Change task status with a single click
|
||||
- **Task Details**: Access full task information without leaving VS Code
|
||||
- **Integrated Commands**: All TaskMaster commands available through the command palette
|
||||
|
||||
## 🎯 What's Next?
|
||||
|
||||
This is just the beginning! We wanted to get a solid foundation into your hands quickly. The extension will evolve rapidly with your feedback, adding more advanced features, better visualizations, and deeper integration with your development workflow.
|
||||
|
||||
Thank you for being part of the TaskMaster journey. Your workflow has never looked better! 🚀
|
||||
21
.github/scripts/release.sh
vendored
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Starting release process..."
|
||||
|
||||
# Double-check we're not in pre-release mode (safety net)
|
||||
if [ -f .changeset/pre.json ]; then
|
||||
echo "⚠️ Warning: pre.json still exists. Removing it..."
|
||||
rm -f .changeset/pre.json
|
||||
fi
|
||||
|
||||
# Check if the extension version has changed and tag it
|
||||
# This prevents changeset from trying to publish the private package
|
||||
node .github/scripts/tag-extension.mjs
|
||||
|
||||
# Run changeset publish for npm packages
|
||||
npx changeset publish
|
||||
|
||||
echo "✅ Release process completed!"
|
||||
|
||||
# The extension tag (if created) will trigger the extension-release workflow
|
||||
77
.github/scripts/tag-extension.mjs
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env node
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Read the extension's package.json
|
||||
const extensionDir = join(__dirname, '..', 'apps', 'extension');
|
||||
const pkgPath = join(extensionDir, 'package.json');
|
||||
|
||||
let pkg;
|
||||
try {
|
||||
const pkgContent = readFileSync(pkgPath, 'utf8');
|
||||
pkg = JSON.parse(pkgContent);
|
||||
} catch (error) {
|
||||
console.error('Failed to read package.json:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Read root package.json for repository info
|
||||
const rootPkgPath = join(__dirname, '..', 'package.json');
|
||||
let rootPkg;
|
||||
try {
|
||||
const rootPkgContent = readFileSync(rootPkgPath, 'utf8');
|
||||
rootPkg = JSON.parse(rootPkgContent);
|
||||
} catch (error) {
|
||||
console.error('Failed to read root package.json:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Ensure we have required fields
|
||||
assert(pkg.name, 'package.json must have a name field');
|
||||
assert(pkg.version, 'package.json must have a version field');
|
||||
assert(rootPkg.repository, 'root package.json must have a repository field');
|
||||
|
||||
const tag = `${pkg.name}@${pkg.version}`;
|
||||
|
||||
// Get repository URL from root package.json
|
||||
const repoUrl = rootPkg.repository.url;
|
||||
|
||||
const { status, stdout, error } = spawnSync('git', ['ls-remote', repoUrl, tag]);
|
||||
|
||||
assert.equal(status, 0, error);
|
||||
|
||||
const exists = String(stdout).trim() !== '';
|
||||
|
||||
if (!exists) {
|
||||
console.log(`Creating new extension tag: ${tag}`);
|
||||
|
||||
// Create the tag
|
||||
const tagResult = spawnSync('git', ['tag', tag]);
|
||||
if (tagResult.status !== 0) {
|
||||
console.error(
|
||||
'Failed to create tag:',
|
||||
tagResult.error || tagResult.stderr.toString()
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Push the tag
|
||||
const pushResult = spawnSync('git', ['push', 'origin', tag]);
|
||||
if (pushResult.status !== 0) {
|
||||
console.error(
|
||||
'Failed to push tag:',
|
||||
pushResult.error || pushResult.stderr.toString()
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`✅ Successfully created and pushed tag: ${tag}`);
|
||||
} else {
|
||||
console.log(`Extension tag already exists: ${tag}`);
|
||||
}
|
||||
143
.github/workflows/extension-ci.yml
vendored
Normal file
@@ -0,0 +1,143 @@
|
||||
name: Extension CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- next
|
||||
paths:
|
||||
- 'apps/extension/**'
|
||||
- '.github/workflows/extension-ci.yml'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- next
|
||||
paths:
|
||||
- 'apps/extension/**'
|
||||
- '.github/workflows/extension-ci.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
*/*/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install Extension Dependencies
|
||||
working-directory: apps/extension
|
||||
run: npm ci
|
||||
timeout-minutes: 5
|
||||
|
||||
typecheck:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
*/*/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install if cache miss
|
||||
working-directory: apps/extension
|
||||
run: npm ci
|
||||
timeout-minutes: 3
|
||||
|
||||
- name: Type Check Extension
|
||||
working-directory: apps/extension
|
||||
run: npm run check-types
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
build:
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
- name: Restore node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
*/*/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install if cache miss
|
||||
working-directory: apps/extension
|
||||
run: npm ci
|
||||
timeout-minutes: 3
|
||||
|
||||
- name: Build Extension
|
||||
working-directory: apps/extension
|
||||
run: npm run build
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
- name: Package Extension
|
||||
working-directory: apps/extension
|
||||
run: npm run package
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
- name: Verify Package Contents
|
||||
working-directory: apps/extension
|
||||
run: |
|
||||
echo "Checking vsix-build contents..."
|
||||
ls -la vsix-build/
|
||||
echo "Checking dist contents..."
|
||||
ls -la vsix-build/dist/
|
||||
echo "Checking package.json exists..."
|
||||
test -f vsix-build/package.json
|
||||
|
||||
- name: Create VSIX Package (Test)
|
||||
working-directory: apps/extension/vsix-build
|
||||
run: npx vsce package --no-dependencies
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
- name: Upload Extension Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: extension-package
|
||||
path: |
|
||||
apps/extension/vsix-build/*.vsix
|
||||
apps/extension/dist/
|
||||
retention-days: 30
|
||||
|
||||
137
.github/workflows/extension-release.yml
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
name: Extension Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "extension@*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency: extension-release-${{ github.ref }}
|
||||
|
||||
jobs:
|
||||
publish-extension:
|
||||
runs-on: ubuntu-latest
|
||||
environment: extension-release
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
*/*/node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- name: Install Extension Dependencies
|
||||
working-directory: apps/extension
|
||||
run: npm ci
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Type Check Extension
|
||||
working-directory: apps/extension
|
||||
run: npm run check-types
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
- name: Build Extension
|
||||
working-directory: apps/extension
|
||||
run: npm run build
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
- name: Package Extension
|
||||
working-directory: apps/extension
|
||||
run: npm run package
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
- name: Create VSIX Package
|
||||
working-directory: apps/extension/vsix-build
|
||||
run: npx vsce package --no-dependencies
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
- name: Get VSIX filename
|
||||
id: vsix-info
|
||||
working-directory: apps/extension/vsix-build
|
||||
run: |
|
||||
VSIX_FILE=$(find . -maxdepth 1 -name "*.vsix" -type f | head -n1 | xargs basename)
|
||||
if [ -z "$VSIX_FILE" ]; then
|
||||
echo "Error: No VSIX file found"
|
||||
exit 1
|
||||
fi
|
||||
echo "vsix-filename=$VSIX_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "Found VSIX: $VSIX_FILE"
|
||||
|
||||
- name: Publish to VS Code Marketplace
|
||||
working-directory: apps/extension/vsix-build
|
||||
run: npx vsce publish --packagePath "${{ steps.vsix-info.outputs.vsix-filename }}"
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
FORCE_COLOR: 1
|
||||
|
||||
- name: Install Open VSX CLI
|
||||
run: npm install -g ovsx
|
||||
|
||||
- name: Publish to Open VSX Registry
|
||||
working-directory: apps/extension/vsix-build
|
||||
run: ovsx publish "${{ steps.vsix-info.outputs.vsix-filename }}"
|
||||
env:
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
FORCE_COLOR: 1
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
release_name: Extension ${{ github.ref_name }}
|
||||
body: |
|
||||
VS Code Extension Release ${{ github.ref_name }}
|
||||
|
||||
**Marketplaces:**
|
||||
- [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=Hamster.task-master-hamster)
|
||||
- [Open VSX Registry](https://open-vsx.org/extension/Hamster/task-master-hamster)
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
- name: Upload VSIX to Release
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||
asset_path: apps/extension/vsix-build/${{ steps.vsix-info.outputs.vsix-filename }}
|
||||
asset_name: ${{ steps.vsix-info.outputs.vsix-filename }}
|
||||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Build Artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: extension-release-${{ github.ref_name }}
|
||||
path: |
|
||||
apps/extension/vsix-build/*.vsix
|
||||
apps/extension/dist/
|
||||
retention-days: 90
|
||||
|
||||
notify-success:
|
||||
needs: publish-extension
|
||||
if: success()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Success Notification
|
||||
run: |
|
||||
echo "🎉 Extension ${{ github.ref_name }} successfully published!"
|
||||
echo "📦 Available on VS Code Marketplace"
|
||||
echo "🌍 Available on Open VSX Registry"
|
||||
echo "🏷️ GitHub release created: ${{ github.ref_name }}"
|
||||
29
.github/workflows/release.yml
vendored
@@ -6,6 +6,11 @@ on:
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -33,13 +38,31 @@ jobs:
|
||||
run: npm ci
|
||||
timeout-minutes: 2
|
||||
|
||||
- name: Exit pre-release mode (safety check)
|
||||
run: npx changeset pre exit || true
|
||||
- name: Exit pre-release mode and clean up
|
||||
run: |
|
||||
echo "🔄 Ensuring we're not in pre-release mode for main branch..."
|
||||
|
||||
# Exit pre-release mode if we're in it
|
||||
npx changeset pre exit || echo "Not in pre-release mode"
|
||||
|
||||
# Remove pre.json file if it exists (belt and suspenders approach)
|
||||
if [ -f .changeset/pre.json ]; then
|
||||
echo "🧹 Removing pre.json file..."
|
||||
rm -f .changeset/pre.json
|
||||
fi
|
||||
|
||||
# Verify the file is gone
|
||||
if [ ! -f .changeset/pre.json ]; then
|
||||
echo "✅ pre.json successfully removed"
|
||||
else
|
||||
echo "❌ Failed to remove pre.json"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Create Release Pull Request or Publish to npm
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
publish: npm run release
|
||||
publish: ./.github/scripts/release.sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
7
.gitignore
vendored
@@ -87,3 +87,10 @@ dev-debug.log
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# VS Code extension test files
|
||||
.vscode-test/
|
||||
apps/extension/.vscode-test/
|
||||
|
||||
# apps/extension
|
||||
apps/extension/vsix-build/
|
||||
25
apps/extension/.vscodeignore
Normal file
@@ -0,0 +1,25 @@
|
||||
# Ignore everything by default
|
||||
*
|
||||
|
||||
# Only include specific essential files
|
||||
!package.json
|
||||
!README.md
|
||||
!CHANGELOG.md
|
||||
!LICENSE
|
||||
!icon.png
|
||||
!assets/**
|
||||
|
||||
# Include only the built files we need (not source maps)
|
||||
!dist/extension.js
|
||||
!dist/index.js
|
||||
!dist/index.css
|
||||
|
||||
# Exclude development documentation
|
||||
docs/extension-CI-setup.md
|
||||
docs/extension-DEV-guide.md
|
||||
|
||||
# Exclude
|
||||
assets/.DS_Store
|
||||
assets/banner.png
|
||||
|
||||
|
||||
1
apps/extension/CHANGELOG.md
Normal file
@@ -0,0 +1 @@
|
||||
# Change Log
|
||||
21
apps/extension/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 David Maliglowka
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
204
apps/extension/README.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Official Taskmaster AI Extension
|
||||
|
||||
Transform your AI-driven development workflow with a beautiful, interactive Kanban board directly in VS Code. Seamlessly manage tasks from [Taskmaster AI](https://github.com/eyaltoledano/claude-task-master) projects with real-time synchronization and intelligent task management.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 🎯 What is Taskmaster AI?
|
||||
|
||||
Taskmaster AI is an intelligent task management system designed for AI-assisted development. It helps you break down complex projects into manageable tasks, track progress, and leverage AI to enhance your development workflow.
|
||||
|
||||
## ✨ Key Features
|
||||
|
||||
### 📊 **Interactive Kanban Board**
|
||||
- **Drag & Drop Interface** - Effortlessly move tasks between status columns
|
||||
- **Real-time Sync** - Changes instantly reflect in your Taskmaster project files
|
||||
- **Multiple Views** - Board view and detailed task sidebar
|
||||
- **Smart Columns** - Pending, In Progress, Review, Done, Deferred, and Cancelled
|
||||
|
||||

|
||||
|
||||
### 🤖 **AI-Powered Features**
|
||||
- **Task Content Generation** - Regenerate task descriptions using AI
|
||||
- **Smart Task Updates** - Append findings and progress notes automatically
|
||||
- **MCP Integration** - Seamless connection to Taskmaster AI via Model Context Protocol
|
||||
- **Intelligent Caching** - Smart performance optimization with background refresh
|
||||
|
||||

|
||||
|
||||
### 🚀 **Performance & Usability**
|
||||
- **Offline Support** - Continue working even when disconnected
|
||||
- **Auto-refresh** - Automatic polling for task changes with smart frequency
|
||||
- **VS Code Native** - Perfectly integrated with VS Code themes and UI
|
||||
- **Modern Interface** - Built with ShadCN UI components and Tailwind CSS
|
||||
|
||||
## 🛠️ Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **VS Code** 1.90.0 or higher
|
||||
2. **Node.js** 18.0 or higher (for Taskmaster MCP server)
|
||||
|
||||
### Install the Extension
|
||||
|
||||
1. **From VS Code Marketplace:**
|
||||
- Click the **Install** button above
|
||||
- The extension will be automatically added to your VS Code instance
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. **Initialize Taskmaster Project**
|
||||
If you don't have a Taskmaster project yet:
|
||||
```bash
|
||||
cd your-project
|
||||
npm i -g task-master-ai
|
||||
task-master init
|
||||
```
|
||||
|
||||
### 2. **Open Kanban Board**
|
||||
- **Command Palette** (Ctrl+Shift+P): `Taskmaster Kanban: Show Board`
|
||||
- **Or** the extension automatically activates when you have a `.taskmaster` folder in your workspace
|
||||
|
||||
### 3. **MCP Server Setup**
|
||||
The extension automatically handles the Taskmaster MCP server connection:
|
||||
- **No manual installation required** - The extension spawns the MCP server automatically
|
||||
- **Uses npx by default** - Automatically downloads Taskmaster AI when needed
|
||||
- **Configurable** - You can customize the MCP server command in settings if needed
|
||||
|
||||
### 4. **Start Managing Tasks**
|
||||
- **Drag tasks** between columns to change status
|
||||
- **Click tasks** to view detailed information
|
||||
- **Use AI features** to enhance task content
|
||||
- **Add subtasks** with the + button on parent tasks
|
||||
|
||||
## 📋 Usage Guide
|
||||
|
||||
### Task Management
|
||||
|
||||
| Action | How to Do It |
|
||||
|--------|--------------|
|
||||
| **View Kanban Board** | `Ctrl/Cmd + Shift + P` → "Taskmaster: Show Board" |
|
||||
| **Change Task Status** | Drag task card to different column |
|
||||
| **View Task Details** | Click on any task card |
|
||||
| **Edit Task Content** | Click task → Use edit buttons in details panel |
|
||||
| **Add Subtasks** | Click the + button on parent task cards |
|
||||
| **Use AI Features** | Open task details → Click AI action buttons |
|
||||
|
||||
### Understanding Task Statuses
|
||||
|
||||
- 📋 **Pending** - Tasks ready to be started
|
||||
- 🚀 **In Progress** - Currently being worked on
|
||||
- 👀 **Review** - Awaiting review or feedback
|
||||
- ✅ **Done** - Completed tasks
|
||||
- ⏸️ **Deferred** - Postponed for later
|
||||
|
||||
### **AI-Powered Task Management**
|
||||
|
||||
The extension integrates seamlessly with Taskmaster AI via MCP to provide:
|
||||
- **Smart Task Generation** - AI creates detailed implementation plans
|
||||
- **Progress Tracking** - Append timestamped notes and findings
|
||||
- **Content Enhancement** - Regenerate task descriptions for clarity
|
||||
- **Research Integration** - Get up-to-date information for your tasks
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Access settings via **File → Preferences → Settings** and search for "Taskmaster":
|
||||
|
||||
### **MCP Connection Settings**
|
||||
- **MCP Server Command** - Path to task-master-ai executable (default: `npx`)
|
||||
- **MCP Server Args** - Arguments for the server command (default: `-y`, `--package=task-master-ai`, `task-master-ai`)
|
||||
- **Connection Timeout** - Server response timeout (default: 30s)
|
||||
- **Auto Refresh** - Enable automatic task updates (default: enabled)
|
||||
|
||||
### **UI Preferences**
|
||||
- **Theme** - Auto, Light, or Dark mode
|
||||
- **Show Completed Tasks** - Display done tasks in board (default: enabled)
|
||||
- **Task Display Limit** - Maximum tasks to show (default: 100)
|
||||
|
||||
### **Performance Options**
|
||||
- **Cache Duration** - How long to cache task data (default: 5s)
|
||||
- **Concurrent Requests** - Max simultaneous API calls (default: 5)
|
||||
|
||||
## 🔧 Troubleshooting
|
||||
|
||||
### **Extension Not Loading**
|
||||
1. Ensure Node.js 18+ is installed
|
||||
2. Check workspace contains `.taskmaster` folder
|
||||
3. Restart VS Code
|
||||
4. Check Output panel (View → Output → Taskmaster Kanban)
|
||||
|
||||
### **MCP Connection Issues**
|
||||
1. **Command not found**: Ensure Node.js and npx are in your PATH
|
||||
2. **Timeout errors**: Increase timeout in settings
|
||||
3. **Permission errors**: Check Node.js permissions
|
||||
4. **Network issues**: Verify internet connection for npx downloads
|
||||
|
||||
### **Tasks Not Updating**
|
||||
1. Check MCP connection status in status bar
|
||||
2. Verify `.taskmaster/tasks/tasks.json` exists
|
||||
3. Try manual refresh: `Taskmaster Kanban: Check Connection`
|
||||
4. Review error logs in Output panel
|
||||
|
||||
### **Performance Issues**
|
||||
1. Reduce task display limit in settings
|
||||
2. Increase cache duration
|
||||
3. Disable auto-refresh if needed
|
||||
4. Close other VS Code extensions temporarily
|
||||
|
||||
## 🆘 Support & Resources
|
||||
|
||||
### **Getting Help**
|
||||
- 📖 **Documentation**: [Taskmaster AI Docs](https://github.com/eyaltoledano/claude-task-master)
|
||||
- 🐛 **Report Issues**: [GitHub Issues](https://github.com/eyaltoledano/claude-task-master/issues)
|
||||
- 💬 **Discussions**: [GitHub Discussions](https://github.com/eyaltoledano/claude-task-master/discussions)
|
||||
- 🐛 **Report Issues**: [GitHub Issues](https://github.com/eyaltoledano/claude-task-master/issues)
|
||||
|
||||
## 🎯 Tips for Best Results
|
||||
|
||||
### **Project Organization**
|
||||
- Use descriptive task titles
|
||||
- Add detailed implementation notes
|
||||
- Set appropriate task dependencies
|
||||
- Leverage AI features for complex tasks
|
||||
|
||||
### **Workflow Optimization**
|
||||
- Review task details before starting work
|
||||
- Use subtasks for complex features
|
||||
- Update task status as you progress
|
||||
- Add findings and learnings to task notes
|
||||
|
||||
### **Collaboration**
|
||||
- Keep task descriptions updated
|
||||
- Use consistent status conventions
|
||||
- Document decisions in task details
|
||||
- Share knowledge through task notes
|
||||
|
||||
---
|
||||
|
||||
## 🏆 Why Taskmaster Kanban?
|
||||
|
||||
✅ **Visual workflow management** for your Taskmaster projects
|
||||
✅ **AI-powered task enhancement** built right in
|
||||
✅ **Real-time synchronization** keeps everything in sync
|
||||
✅ **Native VS Code integration** feels like part of the editor
|
||||
✅ **Free and open source** with active development
|
||||
|
||||
**Transform your development workflow today!** 🚀
|
||||
|
||||
---
|
||||
|
||||
*Originally Made with ❤️ by [David Maliglowka](https://x.com/DavidMaliglowka)*
|
||||
|
||||
## Support
|
||||
|
||||
This is an open-source project maintained in my spare time. While I strive to fix bugs and improve the extension, support is provided on a best-effort basis. Feel free to:
|
||||
- Report issues on [GitHub](https://github.com/eyaltoledano/claude-task-master/issues)
|
||||
- Submit pull requests with improvements
|
||||
- Fork the project if you need specific modifications
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This extension is provided "as is" without any warranties. Use at your own risk. The author is not responsible for any issues, data loss, or damages that may occur from using this extension. Please backup your work regularly and test thoroughly before using in important projects.
|
||||
BIN
apps/extension/assets/banner.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
3
apps/extension/assets/icon-dark.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 224 291" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M101.635 286.568L71.4839 256.414C65.6092 250.539 65.6092 241.03 71.4839 235.155L142.52 164.11C144.474 162.156 147.643 162.156 149.61 164.11L176.216 190.719C178.17 192.673 181.339 192.673 183.305 190.719L189.719 184.305C191.673 182.35 191.673 179.181 189.719 177.214L163.113 150.605C161.159 148.651 161.159 145.481 163.113 143.514L191.26 115.365C193.214 113.41 193.214 110.241 191.26 108.274L182.316 99.3291C180.362 97.3748 177.193 97.3748 175.226 99.3291L55.8638 218.706C49.989 224.581 40.4816 224.581 34.6068 218.706L4.4061 188.501C-1.4687 182.626 -1.4687 173.117 4.4061 167.242L23.8342 147.811C25.7883 145.857 25.7883 142.688 23.8342 140.721L4.78187 121.666C-1.09293 115.791 -1.09293 106.282 4.78187 100.406L34.7195 70.4527C40.5943 64.5772 50.1017 64.5772 55.9765 70.4527L75.555 90.0335C77.5091 91.9879 80.6782 91.9879 82.6448 90.0335L124.144 48.5292C126.098 46.5749 126.098 43.4054 124.144 41.4385L115.463 32.7568C113.509 30.8025 110.34 30.8025 108.374 32.7568L99.8683 41.2632C97.9143 43.2175 94.7451 43.2175 92.7785 41.2632L82.1438 30.6271C80.1897 28.6728 80.1897 25.5033 82.1438 23.5364L101.271 4.40662C107.146 -1.46887 116.653 -1.46887 122.528 4.40662L152.478 34.3604C158.353 40.2359 158.353 49.7444 152.478 55.6199L82.6323 125.474C80.6782 127.429 77.5091 127.429 75.5425 125.474L48.8741 98.8029C46.9201 96.8486 43.7509 96.8486 41.7843 98.8029L33.1036 107.485C31.1496 109.439 31.1496 112.608 33.1036 114.575L59.2458 140.721C61.1999 142.675 61.1999 145.844 59.2458 147.811L32.7404 174.32C30.7863 176.274 30.7863 179.444 32.7404 181.411L41.6841 190.355C43.6382 192.31 46.8073 192.31 48.7739 190.355L168.136 70.9789C174.011 65.1034 183.518 65.1034 189.393 70.9789L219.594 101.183C225.469 107.059 225.469 116.567 219.594 122.443L198.537 143.502C196.583 145.456 196.583 148.626 198.537 150.592L218.053 170.111C223.928 175.986 223.928 185.495 218.053 191.37L190.37 219.056C184.495 224.932 174.988 224.932 169.113 219.056L149.597 199.538C147.643 197.584 144.474 197.584 142.508 199.538L99.8057 242.245C97.8516 244.2 97.8516 247.369 99.8057 249.336L108.699 258.231C110.653 260.185 113.823 260.185 115.789 258.231L122.954 251.065C124.908 249.11 128.077 249.11 130.044 251.065L140.679 261.701C142.633 263.655 142.633 266.825 140.679 268.791L122.879 286.593C117.004 292.469 107.497 292.469 101.622 286.593L101.635 286.568Z" fill="#CCCCCC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
3
apps/extension/assets/icon-light.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 224 291" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M101.635 286.568L71.4839 256.414C65.6092 250.539 65.6092 241.03 71.4839 235.155L142.52 164.11C144.474 162.156 147.643 162.156 149.61 164.11L176.216 190.719C178.17 192.673 181.339 192.673 183.305 190.719L189.719 184.305C191.673 182.35 191.673 179.181 189.719 177.214L163.113 150.605C161.159 148.651 161.159 145.481 163.113 143.514L191.26 115.365C193.214 113.41 193.214 110.241 191.26 108.274L182.316 99.3291C180.362 97.3748 177.193 97.3748 175.226 99.3291L55.8638 218.706C49.989 224.581 40.4816 224.581 34.6068 218.706L4.4061 188.501C-1.4687 182.626 -1.4687 173.117 4.4061 167.242L23.8342 147.811C25.7883 145.857 25.7883 142.688 23.8342 140.721L4.78187 121.666C-1.09293 115.791 -1.09293 106.282 4.78187 100.406L34.7195 70.4527C40.5943 64.5772 50.1017 64.5772 55.9765 70.4527L75.555 90.0335C77.5091 91.9879 80.6782 91.9879 82.6448 90.0335L124.144 48.5292C126.098 46.5749 126.098 43.4054 124.144 41.4385L115.463 32.7568C113.509 30.8025 110.34 30.8025 108.374 32.7568L99.8683 41.2632C97.9143 43.2175 94.7451 43.2175 92.7785 41.2632L82.1438 30.6271C80.1897 28.6728 80.1897 25.5033 82.1438 23.5364L101.271 4.40662C107.146 -1.46887 116.653 -1.46887 122.528 4.40662L152.478 34.3604C158.353 40.2359 158.353 49.7444 152.478 55.6199L82.6323 125.474C80.6782 127.429 77.5091 127.429 75.5425 125.474L48.8741 98.8029C46.9201 96.8486 43.7509 96.8486 41.7843 98.8029L33.1036 107.485C31.1496 109.439 31.1496 112.608 33.1036 114.575L59.2458 140.721C61.1999 142.675 61.1999 145.844 59.2458 147.811L32.7404 174.32C30.7863 176.274 30.7863 179.444 32.7404 181.411L41.6841 190.355C43.6382 192.31 46.8073 192.31 48.7739 190.355L168.136 70.9789C174.011 65.1034 183.518 65.1034 189.393 70.9789L219.594 101.183C225.469 107.059 225.469 116.567 219.594 122.443L198.537 143.502C196.583 145.456 196.583 148.626 198.537 150.592L218.053 170.111C223.928 175.986 223.928 185.495 218.053 191.37L190.37 219.056C184.495 224.932 174.988 224.932 169.113 219.056L149.597 199.538C147.643 197.584 144.474 197.584 142.508 199.538L99.8057 242.245C97.8516 244.2 97.8516 247.369 99.8057 249.336L108.699 258.231C110.653 260.185 113.823 260.185 115.789 258.231L122.954 251.065C124.908 249.11 128.077 249.11 130.044 251.065L140.679 261.701C142.633 263.655 142.633 266.825 140.679 268.791L122.879 286.593C117.004 292.469 107.497 292.469 101.622 286.593L101.635 286.568Z" fill="#424242"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
BIN
apps/extension/assets/icon.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
apps/extension/assets/screenshots/kanban-board.png
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
apps/extension/assets/screenshots/task-details.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
3
apps/extension/assets/sidebar-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 224 291" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M101.635 286.568L71.4839 256.414C65.6092 250.539 65.6092 241.03 71.4839 235.155L142.52 164.11C144.474 162.156 147.643 162.156 149.61 164.11L176.216 190.719C178.17 192.673 181.339 192.673 183.305 190.719L189.719 184.305C191.673 182.35 191.673 179.181 189.719 177.214L163.113 150.605C161.159 148.651 161.159 145.481 163.113 143.514L191.26 115.365C193.214 113.41 193.214 110.241 191.26 108.274L182.316 99.3291C180.362 97.3748 177.193 97.3748 175.226 99.3291L55.8638 218.706C49.989 224.581 40.4816 224.581 34.6068 218.706L4.4061 188.501C-1.4687 182.626 -1.4687 173.117 4.4061 167.242L23.8342 147.811C25.7883 145.857 25.7883 142.688 23.8342 140.721L4.78187 121.666C-1.09293 115.791 -1.09293 106.282 4.78187 100.406L34.7195 70.4527C40.5943 64.5772 50.1017 64.5772 55.9765 70.4527L75.555 90.0335C77.5091 91.9879 80.6782 91.9879 82.6448 90.0335L124.144 48.5292C126.098 46.5749 126.098 43.4054 124.144 41.4385L115.463 32.7568C113.509 30.8025 110.34 30.8025 108.374 32.7568L99.8683 41.2632C97.9143 43.2175 94.7451 43.2175 92.7785 41.2632L82.1438 30.6271C80.1897 28.6728 80.1897 25.5033 82.1438 23.5364L101.271 4.40662C107.146 -1.46887 116.653 -1.46887 122.528 4.40662L152.478 34.3604C158.353 40.2359 158.353 49.7444 152.478 55.6199L82.6323 125.474C80.6782 127.429 77.5091 127.429 75.5425 125.474L48.8741 98.8029C46.9201 96.8486 43.7509 96.8486 41.7843 98.8029L33.1036 107.485C31.1496 109.439 31.1496 112.608 33.1036 114.575L59.2458 140.721C61.1999 142.675 61.1999 145.844 59.2458 147.811L32.7404 174.32C30.7863 176.274 30.7863 179.444 32.7404 181.411L41.6841 190.355C43.6382 192.31 46.8073 192.31 48.7739 190.355L168.136 70.9789C174.011 65.1034 183.518 65.1034 189.393 70.9789L219.594 101.183C225.469 107.059 225.469 116.567 219.594 122.443L198.537 143.502C196.583 145.456 196.583 148.626 198.537 150.592L218.053 170.111C223.928 175.986 223.928 185.495 218.053 191.37L190.37 219.056C184.495 224.932 174.988 224.932 169.113 219.056L149.597 199.538C147.643 197.584 144.474 197.584 142.508 199.538L99.8057 242.245C97.8516 244.2 97.8516 247.369 99.8057 249.336L108.699 258.231C110.653 260.185 113.823 260.185 115.789 258.231L122.954 251.065C124.908 249.11 128.077 249.11 130.044 251.065L140.679 261.701C142.633 263.655 142.633 266.825 140.679 268.791L122.879 286.593C117.004 292.469 107.497 292.469 101.622 286.593L101.635 286.568Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
18
apps/extension/components.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/webview/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib"
|
||||
},
|
||||
"iconLibrary": "lucide-react"
|
||||
}
|
||||
222
apps/extension/docs/extension-CI-setup.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# VS Code Extension CI/CD Setup
|
||||
|
||||
This document explains the CI/CD setup for the Task Master VS Code extension using automated changesets.
|
||||
|
||||
## 🔄 Workflows Overview
|
||||
|
||||
### 1. Extension CI (`extension-ci.yml`)
|
||||
|
||||
#### Triggers
|
||||
|
||||
- Push to `main` or `next` branches (only when extension files change)
|
||||
- Pull requests to `main` or `next` (only when extension files change)
|
||||
|
||||
#### What it does
|
||||
|
||||
- ✅ Lints and type-checks the extension code
|
||||
- 🔨 Builds the extension (`npm run build`)
|
||||
- 📦 Creates a clean package (`npm run package`)
|
||||
- 🧪 Runs tests with VS Code test framework
|
||||
- 📋 Creates a test VSIX package to verify packaging works
|
||||
- 💾 Uploads build artifacts for inspection
|
||||
|
||||
### 2. Version & Publish (`version.yml`)
|
||||
|
||||
**Triggers:**
|
||||
- Push to `main` branch
|
||||
|
||||
**What it does:**
|
||||
- 🔍 Detects changeset files for pending releases
|
||||
- 📝 Creates "Version Packages" PR with updated versions and CHANGELOG
|
||||
- 🤖 When Version PR is merged, automatically:
|
||||
- 🔨 Builds and packages the extension
|
||||
- 🏷️ Creates git tags with changeset automation
|
||||
- 📤 Publishes to VS Code Marketplace
|
||||
- 🌍 Publishes to Open VSX Registry
|
||||
- 📊 Updates package versions and CHANGELOG
|
||||
|
||||
## 🚀 Changeset Workflow
|
||||
|
||||
### Creating Changes
|
||||
When making changes to the extension:
|
||||
|
||||
1. **Make your code changes**
|
||||
2. **Create a changeset**:
|
||||
```bash
|
||||
# From project root
|
||||
npx changeset add
|
||||
```
|
||||
3. **Select the extension package**: Choose `taskr-kanban` when prompted
|
||||
4. **Select version bump type**:
|
||||
- `patch`: Bug fixes, minor updates
|
||||
- `minor`: New features, backwards compatible
|
||||
- `major`: Breaking changes
|
||||
5. **Write a summary**: Describe what changed for users
|
||||
6. **Commit changeset file** along with your code changes
|
||||
7. **Push to feature branch** and create PR
|
||||
|
||||
### Automated Publishing Process
|
||||
1. **PR with changeset** gets merged to `main`
|
||||
2. **Version workflow** detects changesets and creates "Version Packages" PR
|
||||
3. **Review and merge** the Version PR
|
||||
4. **Automated publishing** happens immediately:
|
||||
- Extension is built using 3-file packaging system
|
||||
- VSIX package is created and tested
|
||||
- Published to VS Code Marketplace (if `VSCE_PAT` is set)
|
||||
- Published to Open VSX Registry (if `OVSX_PAT` is set)
|
||||
- Git tags are created: `taskr-kanban@1.0.1`
|
||||
- CHANGELOG is updated automatically
|
||||
|
||||
## 🔑 Required Secrets
|
||||
|
||||
To use the automated publishing, you need to set up these GitHub repository secrets:
|
||||
|
||||
### `VSCE_PAT` (VS Code Marketplace Personal Access Token)
|
||||
1. Go to [Azure DevOps](https://dev.azure.com/)
|
||||
2. Sign in with your Microsoft account
|
||||
3. Create a Personal Access Token:
|
||||
- **Name**: VS Code Extension Publishing
|
||||
- **Organization**: All accessible organizations
|
||||
- **Expiration**: Custom (recommend 1 year)
|
||||
- **Scopes**: Custom defined → **Marketplace** → **Manage**
|
||||
4. Copy the token and add it to GitHub Secrets as `VSCE_PAT`
|
||||
|
||||
### `OVSX_PAT` (Open VSX Registry Personal Access Token)
|
||||
1. Go to [Open VSX Registry](https://open-vsx.org/)
|
||||
2. Sign in with your GitHub account
|
||||
3. Go to your [User Settings](https://open-vsx.org/user-settings/tokens)
|
||||
4. Create a new Access Token:
|
||||
- **Description**: VS Code Extension Publishing
|
||||
- **Scopes**: Leave default (full access)
|
||||
5. Copy the token and add it to GitHub Secrets as `OVSX_PAT`
|
||||
|
||||
### `GITHUB_TOKEN` (automatically provided)
|
||||
This is automatically available in GitHub Actions - no setup required.
|
||||
|
||||
## 📋 Version Management
|
||||
|
||||
### Changeset-Based Versioning
|
||||
Versions are automatically managed by changesets:
|
||||
|
||||
- **No manual version updates needed** - changesets handle this automatically
|
||||
- **Semantic versioning** is enforced based on changeset types
|
||||
- **Changelog generation** happens automatically
|
||||
- **Git tagging** is handled by the automation
|
||||
|
||||
### Critical Fields Sync
|
||||
The automation ensures these fields stay in sync between `package.json` and `package.publish.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0.2", // ✅ AUTO-SYNCED
|
||||
"publisher": "Hamster", // ⚠️ MUST MATCH MANUALLY
|
||||
"displayName": "taskr: Task Master Kanban", // ⚠️ MUST MATCH MANUALLY
|
||||
"description": "...", // ⚠️ MUST MATCH MANUALLY
|
||||
"engines": { "vscode": "^1.93.0" }, // ⚠️ MUST MATCH MANUALLY
|
||||
"categories": [...], // ⚠️ MUST MATCH MANUALLY
|
||||
"contributes": { ... } // ⚠️ MUST MATCH MANUALLY
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: Only `version` is automatically synced. Other fields must be manually kept in sync.
|
||||
|
||||
## 🔍 Monitoring Builds
|
||||
|
||||
### CI Status
|
||||
|
||||
- **Green ✅**: Extension builds and tests successfully
|
||||
- **Red ❌**: Build/test failures - check logs for details
|
||||
- **Yellow 🟡**: Partial success - some jobs may have warnings
|
||||
|
||||
### Version PR Status
|
||||
|
||||
- **Version PR Created**: Changesets detected, review and merge to publish
|
||||
- **No Version PR**: No changesets found, no releases pending
|
||||
- **Version PR Merged**: Automated publishing triggered
|
||||
|
||||
### Release Status
|
||||
|
||||
- **Published 🎉**: Extension live on VS Code Marketplace and Open VSX
|
||||
- **Skipped ℹ️**: No changesets found, no release needed
|
||||
- **Failed ❌**: Check logs - often missing secrets or build issues
|
||||
|
||||
### Artifacts
|
||||
|
||||
Workflows upload artifacts that you can download:
|
||||
|
||||
- **CI**: Test results, built files, and VSIX package
|
||||
- **Version**: Final VSIX package and published extension
|
||||
|
||||
## 🛠️ Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### No Version PR Created
|
||||
|
||||
- **Check**: Changeset files exist in `.changeset/` directory
|
||||
- **Check**: Changeset refers to `taskr-kanban` package name
|
||||
- **Check**: Changes were pushed to `main` branch
|
||||
- **Solution**: Create changeset with `npx changeset add`
|
||||
|
||||
#### Version PR Not Publishing
|
||||
|
||||
- **Check**: Version PR was actually merged (not just closed)
|
||||
- **Check**: Required secrets (`VSCE_PAT`, `OVSX_PAT`) are set
|
||||
- **Check**: No build failures in workflow logs
|
||||
- **Solution**: Re-run workflow or check secret configuration
|
||||
|
||||
#### `VSCE_PAT` is not set Error
|
||||
|
||||
- Ensure `VSCE_PAT` secret is added to repository
|
||||
- Check token hasn't expired
|
||||
- Verify token has Marketplace → Manage permissions
|
||||
|
||||
#### `OVSX_PAT` is not set Error
|
||||
|
||||
- Ensure `OVSX_PAT` secret is added to repository
|
||||
- Check token hasn't expired
|
||||
- Verify you're signed in to Open VSX Registry with GitHub
|
||||
|
||||
#### Build Failures
|
||||
|
||||
- Check extension code compiles locally: `cd apps/extension && npm run build`
|
||||
- Verify tests pass locally: `npm run test`
|
||||
- Check for TypeScript errors: `npm run check-types`
|
||||
|
||||
#### Packaging Failures
|
||||
|
||||
- Ensure clean package builds: `npm run package`
|
||||
- Check vsix-build structure is correct
|
||||
- Verify `package.publish.json` has correct `repository` field
|
||||
|
||||
#### Changeset Issues
|
||||
|
||||
- **Wrong package name**: Ensure changeset refers to `taskr-kanban`
|
||||
- **Invalid format**: Check changeset markdown format is correct
|
||||
- **Merge conflicts**: Resolve any conflicts in changeset files
|
||||
|
||||
## 📁 File Structure Impact
|
||||
|
||||
The CI workflows respect the 3-file packaging system:
|
||||
- **Development**: Uses `package.json` for dependencies and scripts
|
||||
- **Release**: Uses `package.publish.json` for clean marketplace package
|
||||
- **Build**: Uses `package.mjs` to create `vsix-build/` for final packaging
|
||||
- **Changesets**: Automatically manage versions across the system
|
||||
|
||||
## 🌍 Dual Registry Publishing
|
||||
|
||||
Your extension will be automatically published to both:
|
||||
- **VS Code Marketplace** - For official VS Code users
|
||||
- **Open VSX Registry** - For Cursor, Windsurf, VSCodium, Gitpod, Eclipse Theia, and other compatible editors
|
||||
|
||||
## 🎯 Benefits of Changeset Automation
|
||||
|
||||
- ✅ **Automated versioning**: No manual version bumps needed
|
||||
- ✅ **Generated changelogs**: Automatic, accurate release notes
|
||||
- ✅ **Semantic versioning**: Enforced through changeset types
|
||||
- ✅ **Git tagging**: Proper tags for extension releases
|
||||
- ✅ **Conflict prevention**: Clear separation of extension vs. main package versions
|
||||
- ✅ **Review process**: Version changes are reviewable via PR
|
||||
- ✅ **Rollback capability**: Easy to revert if issues arise
|
||||
|
||||
This ensures clean, predictable, and fully automated publishing to both registries! 🚀
|
||||
256
apps/extension/docs/extension-development-guide.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# VS Code Extension Development Guide
|
||||
|
||||
## 📁 File Structure Overview
|
||||
|
||||
This VS Code extension uses a **3-file packaging system** to avoid dependency conflicts during publishing:
|
||||
|
||||
```
|
||||
apps/extension/
|
||||
├── package.json # Development configuration
|
||||
├── package.publish.json # Clean publishing configuration
|
||||
├── package.mjs # Build script for packaging
|
||||
├── .vscodeignore # Files to exclude from extension package
|
||||
└── vsix-build/ # Generated clean package directory
|
||||
```
|
||||
|
||||
## 📋 File Purposes
|
||||
|
||||
### `package.json` (Development)
|
||||
- **Purpose**: Development environment with all build tools
|
||||
- **Contains**:
|
||||
- All `devDependencies` needed for building
|
||||
- Development scripts (`build`, `watch`, `lint`, etc.)
|
||||
- Development package name: `"taskr"`
|
||||
- **Used for**: Local development, building, testing
|
||||
|
||||
### `package.publish.json` (Publishing)
|
||||
- **Purpose**: Clean distribution version for VS Code Marketplace
|
||||
- **Contains**:
|
||||
- **No devDependencies** (avoids dependency conflicts)
|
||||
- Publishing metadata (`keywords`, `repository`, `categories`)
|
||||
- Marketplace package name: `"taskr-kanban"`
|
||||
- VS Code extension configuration
|
||||
- **Used for**: Final extension packaging
|
||||
|
||||
### `package.mjs` (Build Script)
|
||||
- **Purpose**: Creates clean package for distribution
|
||||
- **Process**:
|
||||
1. Builds the extension (`build:js` + `build:css`)
|
||||
2. Creates clean `vsix-build/` directory
|
||||
3. Copies only essential files (no source code)
|
||||
4. Renames `package.publish.json` → `package.json`
|
||||
5. Ready for `vsce package`
|
||||
|
||||
## 🚀 Development Workflow
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development with hot reload
|
||||
npm run watch
|
||||
|
||||
# Run just JavaScript build
|
||||
npm run build:js
|
||||
|
||||
# Run just CSS build
|
||||
npm run build:css
|
||||
|
||||
# Full production build
|
||||
npm run build
|
||||
|
||||
# Type checking
|
||||
npm run check-types
|
||||
|
||||
# Linting
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### Testing in VS Code
|
||||
1. Press `F5` in VS Code to launch Extension Development Host
|
||||
2. Test your extension functionality in the new window
|
||||
3. Use `Developer: Reload Window` to reload after changes
|
||||
|
||||
## 📦 Production Packaging
|
||||
|
||||
### Step 1: Build Clean Package
|
||||
```bash
|
||||
npm run package
|
||||
```
|
||||
This creates `vsix-build/` with clean distribution files.
|
||||
|
||||
### Step 2: Create VSIX
|
||||
```bash
|
||||
cd vsix-build
|
||||
npx vsce package --no-dependencies
|
||||
```
|
||||
Creates: `taskr-kanban-1.0.1.vsix`
|
||||
|
||||
### Alternative: One Command
|
||||
```bash
|
||||
npm run package && cd vsix-build && npx vsce package --no-dependencies
|
||||
```
|
||||
|
||||
## 🔄 Keeping Files in Sync
|
||||
|
||||
### Critical Fields to Sync Between Files
|
||||
|
||||
When updating extension metadata, ensure these fields match between `package.json` and `package.publish.json`:
|
||||
|
||||
#### Version & Identity
|
||||
```json
|
||||
{
|
||||
"version": "1.0.1", // ⚠️ MUST MATCH
|
||||
"publisher": "Hamster", // ⚠️ MUST MATCH
|
||||
"displayName": "taskr: Task Master Kanban", // ⚠️ MUST MATCH
|
||||
"description": "A visual Kanban board...", // ⚠️ MUST MATCH
|
||||
}
|
||||
```
|
||||
|
||||
#### VS Code Configuration
|
||||
```json
|
||||
{
|
||||
"engines": { "vscode": "^1.101.0" }, // ⚠️ MUST MATCH
|
||||
"categories": [...], // ⚠️ MUST MATCH
|
||||
"activationEvents": [...], // ⚠️ MUST MATCH
|
||||
"main": "./dist/extension.js", // ⚠️ MUST MATCH
|
||||
"contributes": { ... } // ⚠️ MUST MATCH EXACTLY
|
||||
}
|
||||
```
|
||||
|
||||
### Key Differences (Should NOT Match)
|
||||
```json
|
||||
// package.json (dev)
|
||||
{
|
||||
"name": "taskr", // ✅ Short dev name
|
||||
"devDependencies": { ... }, // ✅ Only in dev file
|
||||
"scripts": { ... } // ✅ Build scripts
|
||||
}
|
||||
|
||||
// package.publish.json (publishing)
|
||||
{
|
||||
"name": "taskr-kanban", // ✅ Marketplace name
|
||||
"keywords": [...], // ✅ Only in publish file
|
||||
"repository": "https://github.com/...", // ✅ Only in publish file
|
||||
// NO devDependencies // ✅ Clean for publishing
|
||||
// NO build scripts // ✅ Not needed in package
|
||||
}
|
||||
```
|
||||
|
||||
## 🤖 Automated Release Process
|
||||
|
||||
### Changesets Workflow
|
||||
This extension uses [Changesets](https://github.com/changesets/changesets) for automated version management and publishing.
|
||||
|
||||
#### Adding Changes
|
||||
When making changes to the extension:
|
||||
|
||||
1. **Make your code changes**
|
||||
2. **Create a changeset**:
|
||||
```bash
|
||||
# From project root
|
||||
npx changeset add
|
||||
```
|
||||
3. **Select the extension package**: Choose `taskr-kanban` when prompted
|
||||
4. **Select version bump type**:
|
||||
- `patch`: Bug fixes, minor updates
|
||||
- `minor`: New features, backwards compatible
|
||||
- `major`: Breaking changes
|
||||
5. **Write a summary**: Describe what changed for users
|
||||
|
||||
#### Automated Publishing
|
||||
The automation workflow runs on pushes to `main`:
|
||||
|
||||
1. **Version Workflow** (`.github/workflows/version.yml`):
|
||||
- Detects when changesets exist
|
||||
- Creates a "Version Packages" PR with updated versions and CHANGELOG
|
||||
- When the PR is merged, automatically publishes the extension
|
||||
|
||||
2. **Release Process** (`scripts/release.sh`):
|
||||
- Builds the extension using the 3-file packaging system
|
||||
- Creates VSIX package
|
||||
- Publishes to VS Code Marketplace (if `VSCE_PAT` is set)
|
||||
- Publishes to Open VSX Registry (if `OVSX_PAT` is set)
|
||||
- Creates git tags for the extension version
|
||||
|
||||
#### Required Secrets
|
||||
For automated publishing, these secrets must be set in the repository:
|
||||
|
||||
- `VSCE_PAT`: Personal Access Token for VS Code Marketplace
|
||||
- `OVSX_PAT`: Personal Access Token for Open VSX Registry
|
||||
- `GITHUB_TOKEN`: Automatically provided by GitHub Actions
|
||||
|
||||
#### Manual Release
|
||||
If needed, you can manually trigger a release:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
./scripts/release.sh
|
||||
```
|
||||
|
||||
### Extension Tagging
|
||||
The extension uses a separate tagging strategy from the main package:
|
||||
|
||||
- **Extension tags**: `taskr-kanban@1.0.1`
|
||||
- **Main package tags**: `task-master-ai@2.1.0`
|
||||
|
||||
This allows independent versioning and prevents conflicts in the monorepo.
|
||||
|
||||
## 🔍 Troubleshooting
|
||||
|
||||
### Dependency Conflicts
|
||||
**Problem**: `vsce package` fails with missing dependencies
|
||||
**Solution**: Use the 3-file system - never run `vsce package` from root
|
||||
|
||||
### Build Failures
|
||||
**Problem**: Extension not working after build
|
||||
**Check**:
|
||||
1. All files copied to `vsix-build/dist/`
|
||||
2. `package.publish.json` has correct `main` field
|
||||
3. VS Code engine version compatibility
|
||||
|
||||
### Sync Issues
|
||||
**Problem**: Extension works locally but fails when packaged
|
||||
**Check**: Ensure critical fields are synced between package files
|
||||
|
||||
### Changeset Issues
|
||||
**Problem**: Version workflow not triggering
|
||||
**Check**:
|
||||
1. Changeset files exist in `.changeset/`
|
||||
2. Package name in changeset matches `package.publish.json`
|
||||
3. Changes are pushed to `main` branch
|
||||
|
||||
**Problem**: Publishing fails
|
||||
**Check**:
|
||||
1. Required secrets are set in repository settings
|
||||
2. `package.publish.json` has correct repository URL
|
||||
3. Build process completes successfully
|
||||
|
||||
## 📝 Version Release Checklist
|
||||
|
||||
### Manual Releases
|
||||
1. **Create changeset**: `npx changeset add`
|
||||
2. **Update critical fields** in both `package.json` and `package.publish.json`
|
||||
3. **Test locally** with `F5` in VS Code
|
||||
4. **Commit and push** to trigger automated workflow
|
||||
|
||||
### Automated Releases (Recommended)
|
||||
1. **Create changeset**: `npx changeset add`
|
||||
2. **Push to feature branch** and create PR
|
||||
3. **Merge PR** - this triggers version PR creation
|
||||
4. **Review and merge version PR** - this triggers automated publishing
|
||||
|
||||
## 🎯 Why This System?
|
||||
|
||||
- **Avoids dependency conflicts**: VS Code doesn't see dev dependencies
|
||||
- **Clean distribution**: Only essential files in final package
|
||||
- **Faster packaging**: No dependency resolution during `vsce package`
|
||||
- **Maintainable**: Clear separation of dev vs. production configs
|
||||
- **Reliable**: Consistent, conflict-free packaging process
|
||||
- **Automated**: Changesets handle versioning and publishing automatically
|
||||
- **Traceable**: Clear changelog and git tags for every release
|
||||
|
||||
---
|
||||
|
||||
**Remember**: Always use `npx changeset add` for changes, then push to trigger automated releases! 🚀
|
||||
173
apps/extension/esbuild.js
Normal file
@@ -0,0 +1,173 @@
|
||||
const esbuild = require('esbuild');
|
||||
const path = require('path');
|
||||
|
||||
const production = process.argv.includes('--production');
|
||||
const watch = process.argv.includes('--watch');
|
||||
|
||||
/**
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const esbuildProblemMatcherPlugin = {
|
||||
name: 'esbuild-problem-matcher',
|
||||
|
||||
setup(build) {
|
||||
build.onStart(() => {
|
||||
console.log('[watch] build started');
|
||||
});
|
||||
build.onEnd((result) => {
|
||||
result.errors.forEach(({ text, location }) => {
|
||||
console.error(`✘ [ERROR] ${text}`);
|
||||
console.error(
|
||||
` ${location.file}:${location.line}:${location.column}:`
|
||||
);
|
||||
});
|
||||
console.log('[watch] build finished');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {import('esbuild').Plugin}
|
||||
*/
|
||||
const aliasPlugin = {
|
||||
name: 'alias',
|
||||
setup(build) {
|
||||
// Handle @/ aliases for shadcn/ui
|
||||
build.onResolve({ filter: /^@\// }, (args) => {
|
||||
const resolvedPath = path.resolve(__dirname, 'src', args.path.slice(2));
|
||||
|
||||
// Try to resolve with common TypeScript extensions
|
||||
const fs = require('fs');
|
||||
const extensions = ['.tsx', '.ts', '.jsx', '.js'];
|
||||
|
||||
// Check if it's a file first
|
||||
for (const ext of extensions) {
|
||||
const fullPath = resolvedPath + ext;
|
||||
if (fs.existsSync(fullPath)) {
|
||||
return { path: fullPath };
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a directory with index file
|
||||
for (const ext of extensions) {
|
||||
const indexPath = path.join(resolvedPath, 'index' + ext);
|
||||
if (fs.existsSync(indexPath)) {
|
||||
return { path: indexPath };
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to original behavior
|
||||
return { path: resolvedPath };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
async function main() {
|
||||
// Build configuration for the VS Code extension
|
||||
const extensionCtx = await esbuild.context({
|
||||
entryPoints: ['src/extension.ts'],
|
||||
bundle: true,
|
||||
format: 'cjs',
|
||||
minify: production,
|
||||
sourcemap: !production ? 'inline' : false,
|
||||
sourcesContent: !production,
|
||||
platform: 'node',
|
||||
outdir: 'dist',
|
||||
external: ['vscode'],
|
||||
logLevel: 'silent',
|
||||
// Add production optimizations
|
||||
...(production && {
|
||||
drop: ['debugger'],
|
||||
pure: ['console.log', 'console.debug', 'console.trace']
|
||||
}),
|
||||
plugins: [esbuildProblemMatcherPlugin, aliasPlugin]
|
||||
});
|
||||
|
||||
// Build configuration for the React webview
|
||||
const webviewCtx = await esbuild.context({
|
||||
entryPoints: ['src/webview/index.tsx'],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
globalName: 'App',
|
||||
minify: production,
|
||||
sourcemap: !production ? 'inline' : false,
|
||||
sourcesContent: !production,
|
||||
platform: 'browser',
|
||||
outdir: 'dist',
|
||||
logLevel: 'silent',
|
||||
target: ['es2020'],
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'react',
|
||||
external: ['*.css'],
|
||||
// Bundle React with webview since it's not available in the runtime
|
||||
// This prevents the multiple React instances issue
|
||||
// Ensure React is resolved from the workspace root to avoid duplicates
|
||||
alias: {
|
||||
react: path.resolve(__dirname, 'node_modules/react'),
|
||||
'react-dom': path.resolve(__dirname, 'node_modules/react-dom')
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': production ? '"production"' : '"development"',
|
||||
global: 'globalThis'
|
||||
},
|
||||
// Add production optimizations for webview too
|
||||
...(production && {
|
||||
drop: ['debugger'],
|
||||
pure: ['console.log', 'console.debug', 'console.trace']
|
||||
}),
|
||||
plugins: [esbuildProblemMatcherPlugin, aliasPlugin]
|
||||
});
|
||||
|
||||
// Build configuration for the React sidebar
|
||||
const sidebarCtx = await esbuild.context({
|
||||
entryPoints: ['src/webview/sidebar.tsx'],
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
globalName: 'SidebarApp',
|
||||
minify: production,
|
||||
sourcemap: !production ? 'inline' : false,
|
||||
sourcesContent: !production,
|
||||
platform: 'browser',
|
||||
outdir: 'dist',
|
||||
logLevel: 'silent',
|
||||
target: ['es2020'],
|
||||
jsx: 'automatic',
|
||||
jsxImportSource: 'react',
|
||||
external: ['*.css'],
|
||||
alias: {
|
||||
react: path.resolve(__dirname, 'node_modules/react'),
|
||||
'react-dom': path.resolve(__dirname, 'node_modules/react-dom')
|
||||
},
|
||||
define: {
|
||||
'process.env.NODE_ENV': production ? '"production"' : '"development"',
|
||||
global: 'globalThis'
|
||||
},
|
||||
...(production && {
|
||||
drop: ['debugger'],
|
||||
pure: ['console.log', 'console.debug', 'console.trace']
|
||||
}),
|
||||
plugins: [esbuildProblemMatcherPlugin, aliasPlugin]
|
||||
});
|
||||
|
||||
if (watch) {
|
||||
await Promise.all([
|
||||
extensionCtx.watch(),
|
||||
webviewCtx.watch(),
|
||||
sidebarCtx.watch()
|
||||
]);
|
||||
} else {
|
||||
await Promise.all([
|
||||
extensionCtx.rebuild(),
|
||||
webviewCtx.rebuild(),
|
||||
sidebarCtx.rebuild()
|
||||
]);
|
||||
await extensionCtx.dispose();
|
||||
await webviewCtx.dispose();
|
||||
await sidebarCtx.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,16 +1,281 @@
|
||||
{
|
||||
"name": "extension",
|
||||
"private": true,
|
||||
"version": "0.20.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"displayName": "TaskMaster",
|
||||
"description": "A visual Kanban board interface for TaskMaster projects in VS Code",
|
||||
"version": "0.22.3",
|
||||
"publisher": "Hamster",
|
||||
"icon": "assets/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.93.0"
|
||||
},
|
||||
"categories": ["AI", "Visualization", "Education", "Other"],
|
||||
"main": "./dist/extension.js",
|
||||
"activationEvents": ["onStartupFinished", "workspaceContains:.taskmaster/**"],
|
||||
"contributes": {
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
"id": "taskmaster",
|
||||
"title": "TaskMaster",
|
||||
"icon": "assets/sidebar-icon.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"taskmaster": [
|
||||
{
|
||||
"id": "taskmaster.welcome",
|
||||
"name": "TaskMaster",
|
||||
"type": "webview"
|
||||
}
|
||||
]
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"command": "tm.showKanbanBoard",
|
||||
"title": "TaskMaster: Show Board",
|
||||
"icon": "$(checklist)"
|
||||
},
|
||||
{
|
||||
"command": "tm.checkConnection",
|
||||
"title": "TaskMaster: Check Connection"
|
||||
},
|
||||
{
|
||||
"command": "tm.reconnect",
|
||||
"title": "TaskMaster: Reconnect"
|
||||
},
|
||||
{
|
||||
"command": "tm.openSettings",
|
||||
"title": "TaskMaster: Open Settings"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"view/title": [
|
||||
{
|
||||
"command": "tm.showKanbanBoard",
|
||||
"when": "view == taskmaster.welcome",
|
||||
"group": "navigation"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"title": "TaskMaster Kanban",
|
||||
"properties": {
|
||||
"taskmaster.mcp.command": {
|
||||
"type": "string",
|
||||
"default": "npx",
|
||||
"description": "The command or absolute path to execute for the MCP server (e.g., 'npx' or '/usr/local/bin/task-master-ai')."
|
||||
},
|
||||
"taskmaster.mcp.args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["task-master-ai"],
|
||||
"description": "An array of arguments to pass to the MCP server command."
|
||||
},
|
||||
"taskmaster.mcp.cwd": {
|
||||
"type": "string",
|
||||
"description": "Working directory for the TaskMaster MCP server (defaults to workspace root)"
|
||||
},
|
||||
"taskmaster.mcp.env": {
|
||||
"type": "object",
|
||||
"description": "Environment variables for the TaskMaster MCP server"
|
||||
},
|
||||
"taskmaster.mcp.timeout": {
|
||||
"type": "number",
|
||||
"default": 30000,
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"description": "Connection timeout in milliseconds"
|
||||
},
|
||||
"taskmaster.mcp.maxReconnectAttempts": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"description": "Maximum number of reconnection attempts"
|
||||
},
|
||||
"taskmaster.mcp.reconnectBackoffMs": {
|
||||
"type": "number",
|
||||
"default": 1000,
|
||||
"minimum": 100,
|
||||
"maximum": 10000,
|
||||
"description": "Initial reconnection backoff delay in milliseconds"
|
||||
},
|
||||
"taskmaster.mcp.maxBackoffMs": {
|
||||
"type": "number",
|
||||
"default": 30000,
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"description": "Maximum reconnection backoff delay in milliseconds"
|
||||
},
|
||||
"taskmaster.mcp.healthCheckIntervalMs": {
|
||||
"type": "number",
|
||||
"default": 15000,
|
||||
"minimum": 5000,
|
||||
"maximum": 60000,
|
||||
"description": "Health check interval in milliseconds"
|
||||
},
|
||||
"taskmaster.mcp.requestTimeoutMs": {
|
||||
"type": "number",
|
||||
"default": 300000,
|
||||
"minimum": 30000,
|
||||
"maximum": 600000,
|
||||
"description": "MCP request timeout in milliseconds (default: 5 minutes)"
|
||||
},
|
||||
"taskmaster.ui.autoRefresh": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically refresh tasks from the server"
|
||||
},
|
||||
"taskmaster.ui.refreshIntervalMs": {
|
||||
"type": "number",
|
||||
"default": 10000,
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"description": "Auto-refresh interval in milliseconds"
|
||||
},
|
||||
"taskmaster.ui.theme": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "light", "dark"],
|
||||
"default": "auto",
|
||||
"description": "UI theme preference"
|
||||
},
|
||||
"taskmaster.ui.showCompletedTasks": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show completed tasks in the Kanban board"
|
||||
},
|
||||
"taskmaster.ui.taskDisplayLimit": {
|
||||
"type": "number",
|
||||
"default": 100,
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"description": "Maximum number of tasks to display"
|
||||
},
|
||||
"taskmaster.ui.showPriority": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show task priority indicators"
|
||||
},
|
||||
"taskmaster.ui.showTaskIds": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show task IDs in the interface"
|
||||
},
|
||||
"taskmaster.performance.maxConcurrentRequests": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"description": "Maximum number of concurrent MCP requests"
|
||||
},
|
||||
"taskmaster.performance.requestTimeoutMs": {
|
||||
"type": "number",
|
||||
"default": 30000,
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"description": "Request timeout in milliseconds"
|
||||
},
|
||||
"taskmaster.performance.cacheTasksMs": {
|
||||
"type": "number",
|
||||
"default": 5000,
|
||||
"minimum": 0,
|
||||
"maximum": 60000,
|
||||
"description": "Task cache duration in milliseconds"
|
||||
},
|
||||
"taskmaster.performance.lazyLoadThreshold": {
|
||||
"type": "number",
|
||||
"default": 50,
|
||||
"minimum": 10,
|
||||
"maximum": 500,
|
||||
"description": "Number of tasks before enabling lazy loading"
|
||||
},
|
||||
"taskmaster.debug.enableLogging": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable debug logging"
|
||||
},
|
||||
"taskmaster.debug.logLevel": {
|
||||
"type": "string",
|
||||
"enum": ["error", "warn", "info", "debug"],
|
||||
"default": "info",
|
||||
"description": "Logging level"
|
||||
},
|
||||
"taskmaster.debug.enableConnectionMetrics": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable connection performance metrics"
|
||||
},
|
||||
"taskmaster.debug.saveEventLogs": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Save event logs to files"
|
||||
},
|
||||
"taskmaster.debug.maxEventLogSize": {
|
||||
"type": "number",
|
||||
"default": 1000,
|
||||
"minimum": 10,
|
||||
"maximum": 10000,
|
||||
"description": "Maximum number of events to keep in memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"vscode:prepublish": "npm run build",
|
||||
"build": "npm run build:js && npm run build:css",
|
||||
"build:js": "node ./esbuild.js --production",
|
||||
"build:css": "npx @tailwindcss/cli -i ./src/webview/index.css -o ./dist/index.css --minify",
|
||||
"package": "npm exec node ./package.mjs",
|
||||
"package:direct": "node ./package.mjs",
|
||||
"debug:env": "node ./debug-env.mjs",
|
||||
"compile": "node ./esbuild.js",
|
||||
"watch": "npm run watch:js & npm run watch:css",
|
||||
"watch:js": "node ./esbuild.js --watch",
|
||||
"watch:css": "npx @tailwindcss/cli -i ./src/webview/index.css -o ./dist/index.css --watch",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3"
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@modelcontextprotocol/sdk": "1.13.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-portal": "^1.1.9",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/node": "20.x",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/react-dom": "19.1.6",
|
||||
"@types/vscode": "^1.101.0",
|
||||
"@vscode/test-cli": "^0.0.11",
|
||||
"@vscode/test-electron": "^2.5.2",
|
||||
"@vscode/vsce": "^2.32.0",
|
||||
"autoprefixer": "10.4.21",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"esbuild": "^0.25.3",
|
||||
"esbuild-postcss": "^0.0.4",
|
||||
"fs-extra": "^11.3.0",
|
||||
"lucide-react": "^0.525.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "8.5.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "4.1.11",
|
||||
"typescript": "^5.8.3",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"glob@<8": "^10.4.5",
|
||||
"inflight": "npm:@tootallnate/once@2"
|
||||
}
|
||||
}
|
||||
|
||||
136
apps/extension/package.mjs
Normal file
@@ -0,0 +1,136 @@
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
// --- Configuration ---
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const packageDir = path.resolve(__dirname, 'vsix-build');
|
||||
// --- End Configuration ---
|
||||
|
||||
try {
|
||||
console.log('🚀 Starting packaging process...');
|
||||
|
||||
// 1. Build Project
|
||||
console.log('\nBuilding JavaScript...');
|
||||
execSync('npm run build:js', { stdio: 'inherit' });
|
||||
console.log('\nBuilding CSS...');
|
||||
execSync('npm run build:css', { stdio: 'inherit' });
|
||||
|
||||
// 2. Prepare Clean Directory
|
||||
console.log(`\nPreparing clean directory at: ${packageDir}`);
|
||||
fs.emptyDirSync(packageDir);
|
||||
|
||||
// 3. Copy Build Artifacts (excluding source maps)
|
||||
console.log('Copying build artifacts...');
|
||||
const distDir = path.resolve(__dirname, 'dist');
|
||||
const targetDistDir = path.resolve(packageDir, 'dist');
|
||||
fs.ensureDirSync(targetDistDir);
|
||||
|
||||
// Only copy the files we need (exclude .map files)
|
||||
const filesToCopy = ['extension.js', 'index.js', 'index.css', 'sidebar.js'];
|
||||
for (const file of filesToCopy) {
|
||||
const srcFile = path.resolve(distDir, file);
|
||||
const destFile = path.resolve(targetDistDir, file);
|
||||
if (fs.existsSync(srcFile)) {
|
||||
fs.copySync(srcFile, destFile);
|
||||
console.log(` - Copied dist/${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Copy additional files
|
||||
const additionalFiles = ['README.md', 'CHANGELOG.md', 'AGENTS.md'];
|
||||
for (const file of additionalFiles) {
|
||||
if (fs.existsSync(path.resolve(__dirname, file))) {
|
||||
fs.copySync(
|
||||
path.resolve(__dirname, file),
|
||||
path.resolve(packageDir, file)
|
||||
);
|
||||
console.log(` - Copied ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Sync versions and prepare the final package.json
|
||||
console.log('Syncing versions and preparing the final package.json...');
|
||||
|
||||
// Read current versions
|
||||
const devPackagePath = path.resolve(__dirname, 'package.json');
|
||||
const publishPackagePath = path.resolve(__dirname, 'package.publish.json');
|
||||
|
||||
const devPackage = JSON.parse(fs.readFileSync(devPackagePath, 'utf8'));
|
||||
const publishPackage = JSON.parse(
|
||||
fs.readFileSync(publishPackagePath, 'utf8')
|
||||
);
|
||||
|
||||
// Check if versions are in sync
|
||||
if (devPackage.version !== publishPackage.version) {
|
||||
console.log(
|
||||
` - Version sync needed: ${publishPackage.version} → ${devPackage.version}`
|
||||
);
|
||||
publishPackage.version = devPackage.version;
|
||||
|
||||
// Update the source package.publish.json file
|
||||
fs.writeFileSync(
|
||||
publishPackagePath,
|
||||
JSON.stringify(publishPackage, null, '\t') + '\n'
|
||||
);
|
||||
console.log(
|
||||
` - Updated package.publish.json version to ${devPackage.version}`
|
||||
);
|
||||
} else {
|
||||
console.log(` - Versions already in sync: ${devPackage.version}`);
|
||||
}
|
||||
|
||||
// Copy the (now synced) package.publish.json as package.json
|
||||
fs.copySync(publishPackagePath, path.resolve(packageDir, 'package.json'));
|
||||
console.log(' - Copied package.publish.json as package.json');
|
||||
|
||||
// 6. Copy .vscodeignore if it exists
|
||||
if (fs.existsSync(path.resolve(__dirname, '.vscodeignore'))) {
|
||||
fs.copySync(
|
||||
path.resolve(__dirname, '.vscodeignore'),
|
||||
path.resolve(packageDir, '.vscodeignore')
|
||||
);
|
||||
console.log(' - Copied .vscodeignore');
|
||||
}
|
||||
|
||||
// 7. Copy LICENSE if it exists
|
||||
if (fs.existsSync(path.resolve(__dirname, 'LICENSE'))) {
|
||||
fs.copySync(
|
||||
path.resolve(__dirname, 'LICENSE'),
|
||||
path.resolve(packageDir, 'LICENSE')
|
||||
);
|
||||
console.log(' - Copied LICENSE');
|
||||
}
|
||||
|
||||
// 7a. Copy assets directory if it exists
|
||||
const assetsDir = path.resolve(__dirname, 'assets');
|
||||
if (fs.existsSync(assetsDir)) {
|
||||
const targetAssetsDir = path.resolve(packageDir, 'assets');
|
||||
fs.copySync(assetsDir, targetAssetsDir);
|
||||
console.log(' - Copied assets directory');
|
||||
}
|
||||
|
||||
// Small delay to ensure file system operations complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// 8. Final step - manual packaging
|
||||
console.log('\n✅ Build preparation complete!');
|
||||
console.log('\nTo create the VSIX package, run:');
|
||||
console.log(
|
||||
'\x1b[36m%s\x1b[0m',
|
||||
`cd vsix-build && npx vsce package --no-dependencies`
|
||||
);
|
||||
|
||||
// Use the synced version for output
|
||||
const finalVersion = devPackage.version;
|
||||
console.log(
|
||||
`\nYour extension will be packaged to: vsix-build/task-master-${finalVersion}.vsix`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('\n❌ Packaging failed!');
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
250
apps/extension/package.publish.json
Normal file
@@ -0,0 +1,250 @@
|
||||
{
|
||||
"name": "task-master-hamster",
|
||||
"displayName": "Taskmaster AI",
|
||||
"description": "A visual Kanban board interface for Taskmaster projects in VS Code",
|
||||
"version": "0.22.3",
|
||||
"publisher": "Hamster",
|
||||
"icon": "assets/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.93.0"
|
||||
},
|
||||
"categories": ["AI", "Visualization", "Education", "Other"],
|
||||
"keywords": [
|
||||
"kanban",
|
||||
"kanban board",
|
||||
"productivity",
|
||||
"todo",
|
||||
"task tracking",
|
||||
"project management",
|
||||
"task-master",
|
||||
"task management",
|
||||
"agile",
|
||||
"scrum",
|
||||
"ai",
|
||||
"mcp",
|
||||
"model context protocol",
|
||||
"dashboard",
|
||||
"chatgpt",
|
||||
"claude",
|
||||
"openai",
|
||||
"anthropic",
|
||||
"task",
|
||||
"npm",
|
||||
"intellicode",
|
||||
"react",
|
||||
"typescript",
|
||||
"php",
|
||||
"python",
|
||||
"node",
|
||||
"planner",
|
||||
"organizer",
|
||||
"workflow",
|
||||
"boards",
|
||||
"cards"
|
||||
],
|
||||
"repository": "https://github.com/eyaltoledano/claude-task-master",
|
||||
"activationEvents": ["onStartupFinished", "workspaceContains:.taskmaster/**"],
|
||||
"main": "./dist/extension.js",
|
||||
"contributes": {
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
"id": "taskmaster",
|
||||
"title": "Taskmaster",
|
||||
"icon": "assets/sidebar-icon.svg"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"taskmaster": [
|
||||
{
|
||||
"id": "taskmaster.welcome",
|
||||
"name": "Taskmaster",
|
||||
"type": "webview"
|
||||
}
|
||||
]
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"command": "tm.showKanbanBoard",
|
||||
"title": "Taskmaster: Show Board"
|
||||
},
|
||||
{
|
||||
"command": "tm.checkConnection",
|
||||
"title": "Taskmaster: Check Connection"
|
||||
},
|
||||
{
|
||||
"command": "tm.reconnect",
|
||||
"title": "Taskmaster: Reconnect"
|
||||
},
|
||||
{
|
||||
"command": "tm.openSettings",
|
||||
"title": "Taskmaster: Open Settings"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
"title": "Taskmaster Kanban",
|
||||
"properties": {
|
||||
"taskmaster.mcp.command": {
|
||||
"type": "string",
|
||||
"default": "npx",
|
||||
"description": "The command or absolute path to execute for the MCP server (e.g., 'npx' or '/usr/local/bin/task-master-ai')."
|
||||
},
|
||||
"taskmaster.mcp.args": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": ["-y", "--package=task-master-ai", "task-master-ai"],
|
||||
"description": "An array of arguments to pass to the MCP server command."
|
||||
},
|
||||
"taskmaster.mcp.cwd": {
|
||||
"type": "string",
|
||||
"description": "Working directory for the Task Master MCP server (defaults to workspace root)"
|
||||
},
|
||||
"taskmaster.mcp.env": {
|
||||
"type": "object",
|
||||
"description": "Environment variables for the Task Master MCP server"
|
||||
},
|
||||
"taskmaster.mcp.timeout": {
|
||||
"type": "number",
|
||||
"default": 30000,
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"description": "Connection timeout in milliseconds"
|
||||
},
|
||||
"taskmaster.mcp.maxReconnectAttempts": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"description": "Maximum number of reconnection attempts"
|
||||
},
|
||||
"taskmaster.mcp.reconnectBackoffMs": {
|
||||
"type": "number",
|
||||
"default": 1000,
|
||||
"minimum": 100,
|
||||
"maximum": 10000,
|
||||
"description": "Initial reconnection backoff delay in milliseconds"
|
||||
},
|
||||
"taskmaster.mcp.maxBackoffMs": {
|
||||
"type": "number",
|
||||
"default": 30000,
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"description": "Maximum reconnection backoff delay in milliseconds"
|
||||
},
|
||||
"taskmaster.mcp.healthCheckIntervalMs": {
|
||||
"type": "number",
|
||||
"default": 15000,
|
||||
"minimum": 5000,
|
||||
"maximum": 60000,
|
||||
"description": "Health check interval in milliseconds"
|
||||
},
|
||||
"taskmaster.mcp.requestTimeoutMs": {
|
||||
"type": "number",
|
||||
"default": 300000,
|
||||
"minimum": 30000,
|
||||
"maximum": 600000,
|
||||
"description": "MCP request timeout in milliseconds (default: 5 minutes)"
|
||||
},
|
||||
"taskmaster.ui.autoRefresh": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically refresh tasks from the server"
|
||||
},
|
||||
"taskmaster.ui.refreshIntervalMs": {
|
||||
"type": "number",
|
||||
"default": 10000,
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"description": "Auto-refresh interval in milliseconds"
|
||||
},
|
||||
"taskmaster.ui.theme": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "light", "dark"],
|
||||
"default": "auto",
|
||||
"description": "UI theme preference"
|
||||
},
|
||||
"taskmaster.ui.showCompletedTasks": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show completed tasks in the Kanban board"
|
||||
},
|
||||
"taskmaster.ui.taskDisplayLimit": {
|
||||
"type": "number",
|
||||
"default": 100,
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"description": "Maximum number of tasks to display"
|
||||
},
|
||||
"taskmaster.ui.showPriority": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show task priority indicators"
|
||||
},
|
||||
"taskmaster.ui.showTaskIds": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show task IDs in the interface"
|
||||
},
|
||||
"taskmaster.performance.maxConcurrentRequests": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 20,
|
||||
"description": "Maximum number of concurrent MCP requests"
|
||||
},
|
||||
"taskmaster.performance.requestTimeoutMs": {
|
||||
"type": "number",
|
||||
"default": 30000,
|
||||
"minimum": 1000,
|
||||
"maximum": 300000,
|
||||
"description": "Request timeout in milliseconds"
|
||||
},
|
||||
"taskmaster.performance.cacheTasksMs": {
|
||||
"type": "number",
|
||||
"default": 5000,
|
||||
"minimum": 0,
|
||||
"maximum": 60000,
|
||||
"description": "Task cache duration in milliseconds"
|
||||
},
|
||||
"taskmaster.performance.lazyLoadThreshold": {
|
||||
"type": "number",
|
||||
"default": 50,
|
||||
"minimum": 10,
|
||||
"maximum": 500,
|
||||
"description": "Number of tasks before enabling lazy loading"
|
||||
},
|
||||
"taskmaster.debug.enableLogging": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable debug logging"
|
||||
},
|
||||
"taskmaster.debug.logLevel": {
|
||||
"type": "string",
|
||||
"enum": ["error", "warn", "info", "debug"],
|
||||
"default": "info",
|
||||
"description": "Logging level"
|
||||
},
|
||||
"taskmaster.debug.enableConnectionMetrics": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable connection performance metrics"
|
||||
},
|
||||
"taskmaster.debug.saveEventLogs": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Save event logs to files"
|
||||
},
|
||||
"taskmaster.debug.maxEventLogSize": {
|
||||
"type": "number",
|
||||
"default": 1000,
|
||||
"minimum": 10,
|
||||
"maximum": 10000,
|
||||
"description": "Maximum number of events to keep in memory"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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, useCallback } 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 = useCallback(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);
|
||||
}
|
||||
}, [sendMessage]);
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
}, [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>
|
||||
);
|
||||
};
|
||||
207
apps/extension/src/components/TaskDetails/AIActionsSection.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
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 {
|
||||
useUpdateTask,
|
||||
useUpdateSubtask
|
||||
} from '../../webview/hooks/useTaskQueries';
|
||||
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 [lastAction, setLastAction] = useState<'regenerate' | 'append' | null>(
|
||||
null
|
||||
);
|
||||
const updateTask = useUpdateTask();
|
||||
const updateSubtask = useUpdateSubtask();
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
if (!currentTask || !prompt.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLastAction('regenerate');
|
||||
onRegeneratingChange?.(true);
|
||||
|
||||
try {
|
||||
if (isSubtask && parentTask) {
|
||||
await updateSubtask.mutateAsync({
|
||||
taskId: `${parentTask.id}.${currentTask.id}`,
|
||||
prompt: prompt,
|
||||
options: { research: false }
|
||||
});
|
||||
} else {
|
||||
await updateTask.mutateAsync({
|
||||
taskId: currentTask.id,
|
||||
updates: { description: prompt },
|
||||
options: { append: false, research: false }
|
||||
});
|
||||
}
|
||||
|
||||
setPrompt('');
|
||||
refreshComplexityAfterAI();
|
||||
} catch (error) {
|
||||
console.error('❌ TaskDetailsView: Failed to regenerate task:', error);
|
||||
} finally {
|
||||
setLastAction(null);
|
||||
onRegeneratingChange?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAppend = async () => {
|
||||
if (!currentTask || !prompt.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLastAction('append');
|
||||
onAppendingChange?.(true);
|
||||
|
||||
try {
|
||||
if (isSubtask && parentTask) {
|
||||
await updateSubtask.mutateAsync({
|
||||
taskId: `${parentTask.id}.${currentTask.id}`,
|
||||
prompt: prompt,
|
||||
options: { research: false }
|
||||
});
|
||||
} else {
|
||||
await updateTask.mutateAsync({
|
||||
taskId: currentTask.id,
|
||||
updates: { description: prompt },
|
||||
options: { append: true, research: false }
|
||||
});
|
||||
}
|
||||
|
||||
setPrompt('');
|
||||
refreshComplexityAfterAI();
|
||||
} catch (error) {
|
||||
console.error('❌ TaskDetailsView: Failed to append to task:', error);
|
||||
} finally {
|
||||
setLastAction(null);
|
||||
onAppendingChange?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Track loading states based on the last action
|
||||
const isLoading = updateTask.isPending || updateSubtask.isPending;
|
||||
const isRegenerating = isLoading && lastAction === 'regenerate';
|
||||
const isAppending = isLoading && lastAction === 'append';
|
||||
|
||||
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
|
||||
implementation details based on your prompt
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
);
|
||||
};
|
||||
204
apps/extension/src/components/TaskDetails/DetailsSection.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
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 headingLevel = Math.min(level + 2, 6);
|
||||
const headingClassName =
|
||||
'font-semibold text-vscode-foreground mb-2 mt-4 first:mt-0';
|
||||
|
||||
switch (headingLevel) {
|
||||
case 3:
|
||||
return (
|
||||
<h3 key={lineIndex} className={headingClassName}>
|
||||
{headingMatch[2]}
|
||||
</h3>
|
||||
);
|
||||
case 4:
|
||||
return (
|
||||
<h4 key={lineIndex} className={headingClassName}>
|
||||
{headingMatch[2]}
|
||||
</h4>
|
||||
);
|
||||
case 5:
|
||||
return (
|
||||
<h5 key={lineIndex} className={headingClassName}>
|
||||
{headingMatch[2]}
|
||||
</h5>
|
||||
);
|
||||
case 6:
|
||||
return (
|
||||
<h6 key={lineIndex} className={headingClassName}>
|
||||
{headingMatch[2]}
|
||||
</h6>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<h3 key={lineIndex} className={headingClassName}>
|
||||
{headingMatch[2]}
|
||||
</h3>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -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>
|
||||
);
|
||||
};
|
||||
218
apps/extension/src/components/TaskDetails/SubtasksSection.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
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 } from 'lucide-react';
|
||||
import type { TaskMasterTask } from '../../webview/types';
|
||||
import { getStatusDotColor } from '../constants';
|
||||
|
||||
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}`;
|
||||
|
||||
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,291 @@
|
||||
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) => {
|
||||
// Convert both to string for comparison since depId might be string or number
|
||||
const depTask = tasks.find(
|
||||
(t) => String(t.id) === String(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>
|
||||
);
|
||||
};
|
||||
116
apps/extension/src/components/TaskDetails/useTaskDetails.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTaskDetails as useTaskDetailsQuery } from '../../webview/hooks/useTaskQueries';
|
||||
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) => {
|
||||
// Parse task ID to determine if it's a subtask (e.g., "13.2")
|
||||
const { isSubtask, parentId, subtaskIndex, taskIdForFetch } = useMemo(() => {
|
||||
// Ensure taskId is a string
|
||||
const taskIdStr = String(taskId);
|
||||
const parts = taskIdStr.split('.');
|
||||
if (parts.length === 2) {
|
||||
return {
|
||||
isSubtask: true,
|
||||
parentId: parts[0],
|
||||
subtaskIndex: parseInt(parts[1]) - 1, // Convert to 0-based index
|
||||
taskIdForFetch: parts[0] // Always fetch parent task for subtasks
|
||||
};
|
||||
}
|
||||
return {
|
||||
isSubtask: false,
|
||||
parentId: taskIdStr,
|
||||
subtaskIndex: -1,
|
||||
taskIdForFetch: taskIdStr
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
// Use React Query to fetch full task details
|
||||
const { data: fullTaskData, error: taskDetailsError } =
|
||||
useTaskDetailsQuery(taskIdForFetch);
|
||||
|
||||
// Find current task from local state for immediate display
|
||||
const { currentTask, parentTask } = useMemo(() => {
|
||||
if (isSubtask) {
|
||||
const parent = tasks.find((t) => t.id === parentId);
|
||||
if (parent && parent.subtasks && parent.subtasks[subtaskIndex]) {
|
||||
const subtask = parent.subtasks[subtaskIndex];
|
||||
return { currentTask: subtask, parentTask: parent };
|
||||
}
|
||||
} else {
|
||||
const task = tasks.find((t) => t.id === String(taskId));
|
||||
if (task) {
|
||||
return { currentTask: task, parentTask: null };
|
||||
}
|
||||
}
|
||||
return { currentTask: null, parentTask: null };
|
||||
}, [taskId, tasks, isSubtask, parentId, subtaskIndex]);
|
||||
|
||||
// Merge full task data from React Query with local state
|
||||
const mergedCurrentTask = useMemo(() => {
|
||||
if (!currentTask || !fullTaskData) return currentTask;
|
||||
|
||||
if (isSubtask && fullTaskData.subtasks) {
|
||||
// Find the specific subtask in the full data
|
||||
const subtaskData = fullTaskData.subtasks.find(
|
||||
(st: any) =>
|
||||
st.id === currentTask.id || st.id === parseInt(currentTask.id as any)
|
||||
);
|
||||
if (subtaskData) {
|
||||
return { ...currentTask, ...subtaskData };
|
||||
}
|
||||
} else if (!isSubtask) {
|
||||
// Merge parent task data
|
||||
return { ...currentTask, ...fullTaskData };
|
||||
}
|
||||
|
||||
return currentTask;
|
||||
}, [currentTask, fullTaskData, isSubtask]);
|
||||
|
||||
// Extract task file data
|
||||
const taskFileData: TaskFileData = useMemo(() => {
|
||||
if (!mergedCurrentTask) return {};
|
||||
return {
|
||||
details: mergedCurrentTask.details || '',
|
||||
testStrategy: mergedCurrentTask.testStrategy || ''
|
||||
};
|
||||
}, [mergedCurrentTask]);
|
||||
|
||||
// Get complexity score
|
||||
const complexity = useMemo(() => {
|
||||
if (mergedCurrentTask?.complexityScore !== undefined) {
|
||||
return { score: mergedCurrentTask.complexityScore };
|
||||
}
|
||||
return null;
|
||||
}, [mergedCurrentTask]);
|
||||
|
||||
// Function to refresh data after AI operations
|
||||
const refreshComplexityAfterAI = () => {
|
||||
// React Query will automatically refetch when mutations invalidate the query
|
||||
// No need for manual refresh
|
||||
};
|
||||
|
||||
return {
|
||||
currentTask: mergedCurrentTask,
|
||||
parentTask,
|
||||
isSubtask,
|
||||
taskFileData,
|
||||
taskFileDataError: taskDetailsError ? 'Failed to load task details' : null,
|
||||
complexity,
|
||||
refreshComplexityAfterAI
|
||||
};
|
||||
};
|
||||
218
apps/extension/src/components/TaskDetailsView.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import type React from 'react';
|
||||
import { useContext, useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbSeparator
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import { VSCodeContext } from '../webview/contexts/VSCodeContext';
|
||||
import { AIActionsSection } from './TaskDetails/AIActionsSection';
|
||||
import { SubtasksSection } from './TaskDetails/SubtasksSection';
|
||||
import { TaskMetadataSidebar } from './TaskDetails/TaskMetadataSidebar';
|
||||
import { DetailsSection } from './TaskDetails/DetailsSection';
|
||||
import { useTaskDetails } from './TaskDetails/useTaskDetails';
|
||||
import { useTasks, taskKeys } from '../webview/hooks/useTaskQueries';
|
||||
import type { TaskMasterTask } from '../webview/types';
|
||||
|
||||
interface TaskDetailsViewProps {
|
||||
taskId: string;
|
||||
onNavigateBack: () => void;
|
||||
onNavigateToTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const TaskDetailsView: React.FC<TaskDetailsViewProps> = ({
|
||||
taskId,
|
||||
onNavigateBack,
|
||||
onNavigateToTask
|
||||
}) => {
|
||||
const context = useContext(VSCodeContext);
|
||||
if (!context) {
|
||||
throw new Error('TaskDetailsView must be used within VSCodeProvider');
|
||||
}
|
||||
|
||||
const { state, sendMessage } = context;
|
||||
const { currentTag } = state;
|
||||
const queryClient = useQueryClient();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Use React Query to fetch all tasks
|
||||
const { data: allTasks = [] } = useTasks({ tag: currentTag });
|
||||
|
||||
const {
|
||||
currentTask,
|
||||
parentTask,
|
||||
isSubtask,
|
||||
taskFileData,
|
||||
taskFileDataError,
|
||||
complexity,
|
||||
refreshComplexityAfterAI
|
||||
} = useTaskDetails({ taskId, sendMessage, tasks: allTasks });
|
||||
|
||||
const handleStatusChange = async (newStatus: TaskMasterTask['status']) => {
|
||||
if (!currentTask) return;
|
||||
|
||||
try {
|
||||
await sendMessage({
|
||||
type: 'updateTaskStatus',
|
||||
data: {
|
||||
taskId:
|
||||
isSubtask && parentTask
|
||||
? `${parentTask.id}.${currentTask.id}`
|
||||
: currentTask.id,
|
||||
newStatus: newStatus
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ TaskDetailsView: Failed to update task status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDependencyClick = (depId: string) => {
|
||||
onNavigateToTask(depId);
|
||||
};
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Invalidate all task queries
|
||||
await queryClient.invalidateQueries({ queryKey: taskKeys.all });
|
||||
} finally {
|
||||
// Reset after a short delay to show the animation
|
||||
setTimeout(() => setIsRefreshing(false), 500);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
if (!currentTask) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-vscode-foreground/70 mb-4">
|
||||
Task not found
|
||||
</p>
|
||||
<Button onClick={onNavigateBack} variant="outline">
|
||||
Back to Kanban Board
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-3 gap-6 p-6 overflow-auto">
|
||||
{/* Left column - Main content (2/3 width) */}
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{/* Breadcrumb navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink
|
||||
onClick={onNavigateBack}
|
||||
className="cursor-pointer hover:text-vscode-foreground text-link"
|
||||
>
|
||||
Kanban Board
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
{isSubtask && parentTask && (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink
|
||||
onClick={() => onNavigateToTask(parentTask.id)}
|
||||
className="cursor-pointer hover:text-vscode-foreground"
|
||||
>
|
||||
{parentTask.title}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<span className="text-vscode-foreground">
|
||||
{currentTask.title}
|
||||
</span>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="p-1.5 rounded hover:bg-vscode-button-hoverBackground transition-colors"
|
||||
title="Refresh task details"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 text-vscode-foreground/70 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task title */}
|
||||
<h1 className="text-2xl font-bold tracking-tight text-vscode-foreground">
|
||||
{currentTask.title}
|
||||
</h1>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-8">
|
||||
<p className="text-vscode-foreground/80 leading-relaxed">
|
||||
{currentTask.description || 'No description available.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* AI Actions */}
|
||||
<AIActionsSection
|
||||
currentTask={currentTask}
|
||||
isSubtask={isSubtask}
|
||||
parentTask={parentTask}
|
||||
sendMessage={sendMessage}
|
||||
refreshComplexityAfterAI={refreshComplexityAfterAI}
|
||||
/>
|
||||
|
||||
{/* Implementation Details */}
|
||||
<DetailsSection
|
||||
title="Implementation Details"
|
||||
content={taskFileData.details}
|
||||
error={taskFileDataError}
|
||||
emptyMessage="No implementation details available"
|
||||
defaultExpanded={false}
|
||||
/>
|
||||
|
||||
{/* Test Strategy */}
|
||||
<DetailsSection
|
||||
title="Test Strategy"
|
||||
content={taskFileData.testStrategy}
|
||||
error={taskFileDataError}
|
||||
emptyMessage="No test strategy available"
|
||||
defaultExpanded={false}
|
||||
/>
|
||||
|
||||
{/* Subtasks */}
|
||||
<SubtasksSection
|
||||
currentTask={currentTask}
|
||||
isSubtask={isSubtask}
|
||||
sendMessage={sendMessage}
|
||||
onNavigateToTask={onNavigateToTask}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column - Metadata (1/3 width) */}
|
||||
<TaskMetadataSidebar
|
||||
currentTask={currentTask}
|
||||
tasks={allTasks}
|
||||
complexity={complexity}
|
||||
isSubtask={isSubtask}
|
||||
sendMessage={sendMessage}
|
||||
onStatusChange={handleStatusChange}
|
||||
onDependencyClick={handleDependencyClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDetailsView;
|
||||
23
apps/extension/src/components/TaskMasterLogo.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
interface TaskMasterLogoProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const TaskMasterLogo: React.FC<TaskMasterLogoProps> = ({
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 224 291"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M101.635 286.568L71.4839 256.414C65.6092 250.539 65.6092 241.03 71.4839 235.155L142.52 164.11C144.474 162.156 147.643 162.156 149.61 164.11L176.216 190.719C178.17 192.673 181.339 192.673 183.305 190.719L189.719 184.305C191.673 182.35 191.673 179.181 189.719 177.214L163.113 150.605C161.159 148.651 161.159 145.481 163.113 143.514L191.26 115.365C193.214 113.41 193.214 110.241 191.26 108.274L182.316 99.3291C180.362 97.3748 177.193 97.3748 175.226 99.3291L55.8638 218.706C49.989 224.581 40.4816 224.581 34.6068 218.706L4.4061 188.501C-1.4687 182.626 -1.4687 173.117 4.4061 167.242L23.8342 147.811C25.7883 145.857 25.7883 142.688 23.8342 140.721L4.78187 121.666C-1.09293 115.791 -1.09293 106.282 4.78187 100.406L34.7195 70.4527C40.5943 64.5772 50.1017 64.5772 55.9765 70.4527L75.555 90.0335C77.5091 91.9879 80.6782 91.9879 82.6448 90.0335L124.144 48.5292C126.098 46.5749 126.098 43.4054 124.144 41.4385L115.463 32.7568C113.509 30.8025 110.34 30.8025 108.374 32.7568L99.8683 41.2632C97.9143 43.2175 94.7451 43.2175 92.7785 41.2632L82.1438 30.6271C80.1897 28.6728 80.1897 25.5033 82.1438 23.5364L101.271 4.40662C107.146 -1.46887 116.653 -1.46887 122.528 4.40662L152.478 34.3604C158.353 40.2359 158.353 49.7444 152.478 55.6199L82.6323 125.474C80.6782 127.429 77.5091 127.429 75.5425 125.474L48.8741 98.8029C46.9201 96.8486 43.7509 96.8486 41.7843 98.8029L33.1036 107.485C31.1496 109.439 31.1496 112.608 33.1036 114.575L59.2458 140.721C61.1999 142.675 61.1999 145.844 59.2458 147.811L32.7404 174.32C30.7863 176.274 30.7863 179.444 32.7404 181.411L41.6841 190.355C43.6382 192.31 46.8073 192.31 48.7739 190.355L168.136 70.9789C174.011 65.1034 183.518 65.1034 189.393 70.9789L219.594 101.183C225.469 107.059 225.469 116.567 219.594 122.443L198.537 143.502C196.583 145.456 196.583 148.626 198.537 150.592L218.053 170.111C223.928 175.986 223.928 185.495 218.053 191.37L190.37 219.056C184.495 224.932 174.988 224.932 169.113 219.056L149.597 199.538C147.643 197.584 144.474 197.584 142.508 199.538L99.8057 242.245C97.8516 244.2 97.8516 247.369 99.8057 249.336L108.699 258.231C110.653 260.185 113.823 260.185 115.789 258.231L122.954 251.065C124.908 249.11 128.077 249.11 130.044 251.065L140.679 261.701C142.633 263.655 142.633 266.825 140.679 268.791L122.879 286.593C117.004 292.469 107.497 292.469 101.622 286.593L101.635 286.568Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
26
apps/extension/src/components/constants.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Shared constants for TaskDetails components
|
||||
*/
|
||||
|
||||
/**
|
||||
* Status color definitions for visual indicators
|
||||
*/
|
||||
export const STATUS_DOT_COLORS = {
|
||||
done: '#22c55e', // Green
|
||||
'in-progress': '#3b82f6', // Blue
|
||||
review: '#a855f7', // Purple
|
||||
deferred: '#ef4444', // Red
|
||||
cancelled: '#6b7280', // Gray
|
||||
pending: '#eab308' // Yellow (default)
|
||||
} as const;
|
||||
|
||||
export type TaskStatus = keyof typeof STATUS_DOT_COLORS;
|
||||
|
||||
/**
|
||||
* Get the color for a status dot indicator
|
||||
* @param status - The task status
|
||||
* @returns The hex color code for the status
|
||||
*/
|
||||
export function getStatusDotColor(status: string): string {
|
||||
return STATUS_DOT_COLORS[status as TaskStatus] || STATUS_DOT_COLORS.pending;
|
||||
}
|
||||
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>
|
||||
);
|
||||
};
|
||||
46
apps/extension/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'span'> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : 'span';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
109
apps/extension/src/components/ui/breadcrumb.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { ChevronRight, MoreHorizontal } from 'lucide-react';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<'ol'>) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
'text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn('inline-flex items-center gap-1.5', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'a'> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'a';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn('hover:text-foreground transition-colors', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn('text-foreground font-normal', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'>) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('[&>svg]:size-3.5', className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn('flex size-9 items-center justify-center', className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis
|
||||
};
|
||||
59
apps/extension/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { type VariantProps, cva } from 'class-variance-authority';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
92
apps/extension/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn('px-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent
|
||||
};
|
||||
31
apps/extension/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
257
apps/extension/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
'use client';
|
||||
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from 'lucide-react';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DROPDOWN_MENU_ITEM_CLASSES =
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
|
||||
|
||||
const DROPDOWN_MENU_SUB_CONTENT_CLASSES =
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg';
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: 'default' | 'destructive';
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(DROPDOWN_MENU_ITEM_CLASSES, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-medium data-[inset]:pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn('bg-border -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'span'>) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
'text-muted-foreground ml-auto text-xs tracking-widest',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(DROPDOWN_MENU_SUB_CONTENT_CLASSES, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent
|
||||
};
|
||||
22
apps/extension/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
56
apps/extension/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn('relative overflow-hidden', className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1 overflow-y-auto"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'flex touch-none p-px transition-colors select-none',
|
||||
orientation === 'vertical' &&
|
||||
'h-full w-2.5 border-l border-l-transparent',
|
||||
orientation === 'horizontal' &&
|
||||
'h-2.5 flex-col border-t border-t-transparent',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
28
apps/extension/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator';
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Separator };
|
||||
188
apps/extension/src/components/ui/shadcn-io/kanban/index.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
'use client';
|
||||
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
rectIntersection,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from '@dnd-kit/core';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import type React from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type { DragEndEvent } from '@dnd-kit/core';
|
||||
|
||||
export type Status = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
};
|
||||
|
||||
export type Feature = {
|
||||
id: string;
|
||||
name: string;
|
||||
startAt: Date;
|
||||
endAt: Date;
|
||||
status: Status;
|
||||
};
|
||||
|
||||
export type KanbanBoardProps = {
|
||||
id: Status['id'];
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
|
||||
const { isOver, setNodeRef } = useDroppable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline transition-all',
|
||||
isOver ? 'outline-primary' : 'outline-transparent',
|
||||
className
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type KanbanCardProps = Pick<Feature, 'id' | 'name'> & {
|
||||
index: number;
|
||||
parent: string;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
onClick?: (event: React.MouseEvent) => void;
|
||||
onDoubleClick?: (event: React.MouseEvent) => void;
|
||||
};
|
||||
|
||||
export const KanbanCard = ({
|
||||
id,
|
||||
name,
|
||||
index,
|
||||
parent,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
onDoubleClick
|
||||
}: KanbanCardProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id,
|
||||
data: { index, parent }
|
||||
});
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'rounded-md p-3 shadow-sm',
|
||||
isDragging && 'cursor-grabbing opacity-0',
|
||||
!isDragging && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
transform: transform
|
||||
? `translateX(${transform.x}px) translateY(${transform.y}px)`
|
||||
: 'none'
|
||||
}}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={(e) => !isDragging && onClick?.(e)}
|
||||
onDoubleClick={onDoubleClick}
|
||||
ref={setNodeRef}
|
||||
>
|
||||
{children ?? <p className="m-0 font-medium text-sm">{name}</p>}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export type KanbanCardsProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const KanbanCards = ({ children, className }: KanbanCardsProps) => (
|
||||
<div className={cn('flex flex-1 flex-col gap-2', className)}>{children}</div>
|
||||
);
|
||||
|
||||
export type KanbanHeaderProps =
|
||||
| {
|
||||
children: ReactNode;
|
||||
}
|
||||
| {
|
||||
name: Status['name'];
|
||||
color: Status['color'];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const KanbanHeader = (props: KanbanHeaderProps) =>
|
||||
'children' in props ? (
|
||||
props.children
|
||||
) : (
|
||||
<div className={cn('flex shrink-0 items-center gap-2', props.className)}>
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: props.color }}
|
||||
/>
|
||||
<p className="m-0 font-semibold text-sm">{props.name}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type KanbanProviderProps = {
|
||||
children: ReactNode;
|
||||
onDragEnd: (event: DragEndEvent) => void;
|
||||
onDragStart?: (event: DragEndEvent) => void;
|
||||
onDragCancel?: () => void;
|
||||
className?: string;
|
||||
dragOverlay?: ReactNode;
|
||||
};
|
||||
|
||||
export const KanbanProvider = ({
|
||||
children,
|
||||
onDragEnd,
|
||||
onDragStart,
|
||||
onDragCancel,
|
||||
className,
|
||||
dragOverlay
|
||||
}: KanbanProviderProps) => {
|
||||
// Configure sensors with activation constraints to prevent accidental drags
|
||||
const sensors = useSensors(
|
||||
// Only start a drag if you've moved more than 8px
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 }
|
||||
}),
|
||||
// On touch devices, require a short press + small move
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 150, tolerance: 5 }
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={rectIntersection}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={onDragStart}
|
||||
onDragCancel={onDragCancel}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'grid w-full auto-cols-fr grid-flow-col gap-4',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<DragOverlay>{dragOverlay}</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
18
apps/extension/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
219
apps/extension/src/extension.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* TaskMaster Extension - Simplified Architecture
|
||||
* Only using patterns where they add real value
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { ConfigService } from './services/config-service';
|
||||
import { PollingService } from './services/polling-service';
|
||||
import { createPollingStrategy } from './services/polling-strategies';
|
||||
import { TaskRepository } from './services/task-repository';
|
||||
import { WebviewManager } from './services/webview-manager';
|
||||
import { EventEmitter } from './utils/event-emitter';
|
||||
import { ExtensionLogger } from './utils/logger';
|
||||
import {
|
||||
MCPClientManager,
|
||||
createMCPConfigFromSettings
|
||||
} from './utils/mcpClient';
|
||||
import { TaskMasterApi } from './utils/task-master-api';
|
||||
import { SidebarWebviewManager } from './services/sidebar-webview-manager';
|
||||
|
||||
let logger: ExtensionLogger;
|
||||
let mcpClient: MCPClientManager;
|
||||
let api: TaskMasterApi;
|
||||
let repository: TaskRepository;
|
||||
let pollingService: PollingService;
|
||||
let webviewManager: WebviewManager;
|
||||
let events: EventEmitter;
|
||||
let configService: ConfigService;
|
||||
let sidebarManager: SidebarWebviewManager;
|
||||
|
||||
export async function activate(context: vscode.ExtensionContext) {
|
||||
try {
|
||||
// Initialize logger (needed to prevent MCP stdio issues)
|
||||
logger = ExtensionLogger.getInstance();
|
||||
logger.log('🎉 TaskMaster Extension activating...');
|
||||
|
||||
// Simple event emitter for webview communication
|
||||
events = new EventEmitter();
|
||||
|
||||
// Initialize MCP client
|
||||
mcpClient = new MCPClientManager(createMCPConfigFromSettings());
|
||||
|
||||
// Initialize API
|
||||
api = new TaskMasterApi(mcpClient);
|
||||
|
||||
// Repository with caching (actually useful for performance)
|
||||
repository = new TaskRepository(api, logger);
|
||||
|
||||
// Config service for TaskMaster config.json
|
||||
configService = new ConfigService(logger);
|
||||
|
||||
// Polling service with strategy pattern (makes sense for different polling behaviors)
|
||||
const strategy = createPollingStrategy(
|
||||
vscode.workspace.getConfiguration('taskmaster')
|
||||
);
|
||||
pollingService = new PollingService(repository, strategy, logger);
|
||||
|
||||
// Webview manager (cleaner than global panel array) - create before connection
|
||||
webviewManager = new WebviewManager(context, repository, events, logger);
|
||||
webviewManager.setConfigService(configService);
|
||||
|
||||
// Sidebar webview manager
|
||||
sidebarManager = new SidebarWebviewManager(context.extensionUri);
|
||||
|
||||
// Initialize connection
|
||||
await initializeConnection();
|
||||
|
||||
// Set MCP client and API after connection
|
||||
webviewManager.setMCPClient(mcpClient);
|
||||
webviewManager.setApi(api);
|
||||
sidebarManager.setApi(api);
|
||||
|
||||
// Register commands
|
||||
registerCommands(context);
|
||||
|
||||
// Handle polling lifecycle
|
||||
events.on('webview:opened', () => {
|
||||
if (webviewManager.getPanelCount() === 1) {
|
||||
pollingService.start();
|
||||
}
|
||||
});
|
||||
|
||||
events.on('webview:closed', () => {
|
||||
if (webviewManager.getPanelCount() === 0) {
|
||||
pollingService.stop();
|
||||
}
|
||||
});
|
||||
|
||||
// Forward repository updates to webviews
|
||||
repository.on('tasks:updated', (tasks) => {
|
||||
webviewManager.broadcast('tasksUpdated', { tasks, source: 'polling' });
|
||||
});
|
||||
|
||||
logger.log('✅ TaskMaster Extension activated');
|
||||
} catch (error) {
|
||||
logger?.error('Failed to activate', error);
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to activate TaskMaster: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeConnection() {
|
||||
try {
|
||||
logger.log('🔗 Connecting to TaskMaster...');
|
||||
|
||||
// Notify webviews that we're connecting
|
||||
if (webviewManager) {
|
||||
webviewManager.broadcast('connectionStatus', {
|
||||
isConnected: false,
|
||||
status: 'Connecting...'
|
||||
});
|
||||
}
|
||||
|
||||
await mcpClient.connect();
|
||||
|
||||
const testResult = await api.testConnection();
|
||||
|
||||
if (testResult.success) {
|
||||
logger.log('✅ Connected to TaskMaster');
|
||||
vscode.window.showInformationMessage('TaskMaster connected!');
|
||||
|
||||
// Notify webviews that we're connected
|
||||
if (webviewManager) {
|
||||
webviewManager.broadcast('connectionStatus', {
|
||||
isConnected: true,
|
||||
status: 'Connected'
|
||||
});
|
||||
}
|
||||
if (sidebarManager) {
|
||||
sidebarManager.updateConnectionStatus();
|
||||
}
|
||||
} else {
|
||||
throw new Error(testResult.error || 'Connection test failed');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Connection failed', error);
|
||||
|
||||
// Notify webviews that connection failed
|
||||
if (webviewManager) {
|
||||
webviewManager.broadcast('connectionStatus', {
|
||||
isConnected: false,
|
||||
status: 'Disconnected'
|
||||
});
|
||||
}
|
||||
if (sidebarManager) {
|
||||
sidebarManager.updateConnectionStatus();
|
||||
}
|
||||
|
||||
handleConnectionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleConnectionError(error: any) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
if (message.includes('ENOENT') && message.includes('npx')) {
|
||||
vscode.window
|
||||
.showWarningMessage(
|
||||
'TaskMaster: npx not found. Please ensure Node.js is installed.',
|
||||
'Open Settings'
|
||||
)
|
||||
.then((action) => {
|
||||
if (action === 'Open Settings') {
|
||||
vscode.commands.executeCommand(
|
||||
'workbench.action.openSettings',
|
||||
'@ext:Hamster.task-master-hamster taskmaster'
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
vscode.window.showWarningMessage(
|
||||
`TaskMaster connection failed: ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function registerCommands(context: vscode.ExtensionContext) {
|
||||
// Main command
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('tm.showKanbanBoard', async () => {
|
||||
await webviewManager.createOrShowPanel();
|
||||
})
|
||||
);
|
||||
|
||||
// Utility commands
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('tm.refreshTasks', async () => {
|
||||
await repository.refresh();
|
||||
vscode.window.showInformationMessage('Tasks refreshed!');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('tm.openSettings', () => {
|
||||
vscode.commands.executeCommand(
|
||||
'workbench.action.openSettings',
|
||||
'@ext:Hamster.task-master-hamster taskmaster'
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// Register sidebar view provider
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(
|
||||
'taskmaster.welcome',
|
||||
sidebarManager
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
logger?.log('👋 TaskMaster Extension deactivating...');
|
||||
pollingService?.stop();
|
||||
webviewManager?.dispose();
|
||||
api?.destroy();
|
||||
mcpClient?.disconnect();
|
||||
}
|
||||
6
apps/extension/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
147
apps/extension/src/services/config-service.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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
|
||||
* Shows only the last 4 characters for better security
|
||||
*/
|
||||
private maskApiKey(key: string): string {
|
||||
if (key.length <= 4) {
|
||||
return '****';
|
||||
}
|
||||
const visibleChars = 4;
|
||||
const maskedLength = key.length - visibleChars;
|
||||
return (
|
||||
'*'.repeat(Math.min(maskedLength, 12)) +
|
||||
key.substring(key.length - visibleChars)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
330
apps/extension/src/services/error-handler.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/**
|
||||
* Validate if an action is allowed
|
||||
*/
|
||||
private isValidAction(action: string): boolean {
|
||||
// Define predefined valid actions
|
||||
const predefinedActions = [
|
||||
'Retry',
|
||||
'Settings',
|
||||
'Reload',
|
||||
'Dismiss',
|
||||
'View Logs',
|
||||
'Report Issue'
|
||||
];
|
||||
|
||||
// Check if it's a predefined action or a TaskMaster command
|
||||
return predefinedActions.includes(action) || action.startsWith('tm.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and validate suggested actions
|
||||
*/
|
||||
private getValidActions(actions: string[]): string[] {
|
||||
return actions.filter((action) => this.isValidAction(action));
|
||||
}
|
||||
|
||||
private notifyUser(context: ErrorContext): void {
|
||||
// Don't show low severity errors to users
|
||||
if (context.severity === ErrorSeverity.LOW) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and filter suggested actions
|
||||
const rawActions = context.suggestedActions || [];
|
||||
const actions = this.getValidActions(rawActions);
|
||||
|
||||
// Log if any actions were filtered out
|
||||
if (rawActions.length !== actions.length) {
|
||||
this.logger.warn('Invalid actions filtered out:', {
|
||||
original: rawActions,
|
||||
filtered: actions,
|
||||
removed: rawActions.filter((a) => !actions.includes(a))
|
||||
});
|
||||
}
|
||||
|
||||
switch (context.severity) {
|
||||
case ErrorSeverity.CRITICAL:
|
||||
vscode.window
|
||||
.showErrorMessage(`TaskMaster: ${context.message}`, ...actions)
|
||||
.then((action) => {
|
||||
if (action) {
|
||||
this.handleUserAction(action, context);
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case ErrorSeverity.HIGH:
|
||||
if (context.category === ErrorCategory.MCP_CONNECTION) {
|
||||
// Use validated actions or default actions for MCP connection
|
||||
const mcpActions =
|
||||
actions.length > 0 ? actions : ['Retry', 'Settings'];
|
||||
vscode.window
|
||||
.showWarningMessage(`TaskMaster: ${context.message}`, ...mcpActions)
|
||||
.then((action) => {
|
||||
if (action === 'Retry') {
|
||||
vscode.commands.executeCommand('tm.reconnect');
|
||||
} else if (action === 'Settings') {
|
||||
vscode.commands.executeCommand('tm.openSettings');
|
||||
} else if (action) {
|
||||
this.handleUserAction(action, context);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Show warning with validated actions
|
||||
if (actions.length > 0) {
|
||||
vscode.window
|
||||
.showWarningMessage(`TaskMaster: ${context.message}`, ...actions)
|
||||
.then((action) => {
|
||||
if (action) {
|
||||
this.handleUserAction(action, context);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
vscode.window.showWarningMessage(`TaskMaster: ${context.message}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ErrorSeverity.MEDIUM:
|
||||
// Only show medium errors for important categories
|
||||
if (
|
||||
[ErrorCategory.CONFIGURATION, ErrorCategory.TASK_LOADING].includes(
|
||||
context.category
|
||||
)
|
||||
) {
|
||||
if (actions.length > 0) {
|
||||
vscode.window
|
||||
.showInformationMessage(
|
||||
`TaskMaster: ${context.message}`,
|
||||
...actions
|
||||
)
|
||||
.then((action) => {
|
||||
if (action) {
|
||||
this.handleUserAction(action, context);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
vscode.window.showInformationMessage(
|
||||
`TaskMaster: ${context.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle user action from notification
|
||||
*/
|
||||
private handleUserAction(action: string, context: ErrorContext): void {
|
||||
this.logger.debug(`User selected action: ${action}`, {
|
||||
errorContext: context
|
||||
});
|
||||
|
||||
// Handle predefined actions
|
||||
switch (action) {
|
||||
case 'Retry':
|
||||
if (context.category === ErrorCategory.MCP_CONNECTION) {
|
||||
vscode.commands.executeCommand('tm.reconnect');
|
||||
} else {
|
||||
vscode.commands.executeCommand('tm.refreshTasks');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Settings':
|
||||
vscode.commands.executeCommand('tm.openSettings');
|
||||
break;
|
||||
|
||||
case 'Reload':
|
||||
vscode.commands.executeCommand('workbench.action.reloadWindow');
|
||||
break;
|
||||
|
||||
case 'View Logs':
|
||||
// Show error details in a modal dialog instead of output channel
|
||||
this.showErrorDetails(context);
|
||||
break;
|
||||
|
||||
case 'Report Issue':
|
||||
const issueUrl = this.generateIssueUrl(context);
|
||||
vscode.env.openExternal(vscode.Uri.parse(issueUrl));
|
||||
break;
|
||||
|
||||
case 'Dismiss':
|
||||
// No action needed
|
||||
break;
|
||||
|
||||
default:
|
||||
// Handle TaskMaster commands (tm.*)
|
||||
if (action.startsWith('tm.')) {
|
||||
void vscode.commands.executeCommand(action).then(
|
||||
() => {},
|
||||
(error: unknown) => {
|
||||
this.logger.error(`Failed to execute command: ${action}`, error);
|
||||
}
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show detailed error information in a modal dialog
|
||||
*/
|
||||
private showErrorDetails(context: ErrorContext): void {
|
||||
const details = [
|
||||
`**Error Details**`,
|
||||
``,
|
||||
`Category: ${context.category}`,
|
||||
`Severity: ${context.severity}`,
|
||||
`Message: ${context.message}`,
|
||||
context.operation ? `Operation: ${context.operation}` : '',
|
||||
context.taskId ? `Task ID: ${context.taskId}` : '',
|
||||
context.originalError ? `\nOriginal Error:\n${context.originalError}` : ''
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
|
||||
vscode.window.showInformationMessage(details, {
|
||||
modal: true,
|
||||
detail: details
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate GitHub issue URL with pre-filled information
|
||||
*/
|
||||
private generateIssueUrl(context: ErrorContext): string {
|
||||
const title = encodeURIComponent(`[Extension Error] ${context.message}`);
|
||||
const body = encodeURIComponent(
|
||||
[
|
||||
`**Error Details:**`,
|
||||
`- Category: ${context.category}`,
|
||||
`- Severity: ${context.severity}`,
|
||||
`- Message: ${context.message}`,
|
||||
context.operation ? `- Operation: ${context.operation}` : '',
|
||||
context.taskId ? `- Task ID: ${context.taskId}` : '',
|
||||
``,
|
||||
`**Context:**`,
|
||||
'```json',
|
||||
JSON.stringify(context, null, 2),
|
||||
'```',
|
||||
``,
|
||||
`**Environment:**`,
|
||||
`- VS Code Version: ${vscode.version}`,
|
||||
`- Extension Version: ${vscode.extensions.getExtension('Hamster.taskmaster')?.packageJSON.version || 'Unknown'}`,
|
||||
``,
|
||||
`**Steps to Reproduce:**`,
|
||||
`1. [Please describe the steps that led to this error]`,
|
||||
``,
|
||||
`**Expected Behavior:**`,
|
||||
`[What should have happened instead]`
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
);
|
||||
|
||||
return `https://github.com/eyaltoledano/claude-task-master/issues/new?title=${title}&body=${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
90
apps/extension/src/services/sidebar-webview-manager.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { TaskMasterApi } from '../utils/task-master-api';
|
||||
|
||||
export class SidebarWebviewManager implements vscode.WebviewViewProvider {
|
||||
private webviewView?: vscode.WebviewView;
|
||||
private api?: TaskMasterApi;
|
||||
|
||||
constructor(private readonly extensionUri: vscode.Uri) {}
|
||||
|
||||
setApi(api: TaskMasterApi): void {
|
||||
this.api = api;
|
||||
// Update connection status if webview exists
|
||||
if (this.webviewView) {
|
||||
this.updateConnectionStatus();
|
||||
}
|
||||
}
|
||||
|
||||
resolveWebviewView(
|
||||
webviewView: vscode.WebviewView,
|
||||
context: vscode.WebviewViewResolveContext,
|
||||
token: vscode.CancellationToken
|
||||
): void {
|
||||
this.webviewView = webviewView;
|
||||
|
||||
webviewView.webview.options = {
|
||||
enableScripts: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist'),
|
||||
vscode.Uri.joinPath(this.extensionUri, 'assets')
|
||||
]
|
||||
};
|
||||
|
||||
webviewView.webview.html = this.getHtmlContent(webviewView.webview);
|
||||
|
||||
// Handle messages from the webview
|
||||
webviewView.webview.onDidReceiveMessage((message) => {
|
||||
if (message.command === 'openBoard') {
|
||||
vscode.commands.executeCommand('tm.showKanbanBoard');
|
||||
}
|
||||
});
|
||||
|
||||
// Update connection status on load
|
||||
this.updateConnectionStatus();
|
||||
}
|
||||
|
||||
updateConnectionStatus(): void {
|
||||
if (!this.webviewView || !this.api) return;
|
||||
|
||||
const status = this.api.getConnectionStatus();
|
||||
this.webviewView.webview.postMessage({
|
||||
type: 'connectionStatus',
|
||||
data: status
|
||||
});
|
||||
}
|
||||
|
||||
private getHtmlContent(webview: vscode.Webview): string {
|
||||
const scriptUri = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.extensionUri, 'dist', 'sidebar.js')
|
||||
);
|
||||
const styleUri = webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.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>TaskMaster</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;
|
||||
}
|
||||
}
|
||||
168
apps/extension/src/services/task-repository.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 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(options?: {
|
||||
tag?: string;
|
||||
withSubtasks?: boolean;
|
||||
}): Promise<Task[]> {
|
||||
// If a tag is specified, always fetch fresh data
|
||||
const shouldUseCache =
|
||||
!options?.tag &&
|
||||
this.cache &&
|
||||
Date.now() - this.cacheTimestamp < this.CACHE_DURATION;
|
||||
|
||||
if (shouldUseCache) {
|
||||
return this.cache || [];
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.api.getTasks({
|
||||
withSubtasks: options?.withSubtasks ?? true,
|
||||
tag: options?.tag
|
||||
});
|
||||
|
||||
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.toString() === taskId ||
|
||||
`${task.id}.${subtask.id}` === taskId
|
||||
) {
|
||||
return {
|
||||
...subtask,
|
||||
id: subtask.id.toString(),
|
||||
description: subtask.description || '',
|
||||
status: (subtask.status ||
|
||||
'pending') as TaskMasterTask['status'],
|
||||
priority: 'medium' as const,
|
||||
dependencies:
|
||||
subtask.dependencies?.map((d) => d.toString()) || []
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.toString() === taskId ||
|
||||
`${task.id}.${subtask.id}` === taskId
|
||||
) {
|
||||
return {
|
||||
...subtask,
|
||||
id: subtask.id.toString(),
|
||||
description: subtask.description || '',
|
||||
status: (subtask.status || 'pending') as TaskMasterTask['status'],
|
||||
priority: 'medium' as const,
|
||||
dependencies: subtask.dependencies?.map((d) => d.toString()) || []
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
424
apps/extension/src/services/webview-manager.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* 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 === 'TaskMaster Kanban'
|
||||
);
|
||||
if (existing) {
|
||||
existing.reveal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new panel
|
||||
const panel = vscode.window.createWebviewPanel(
|
||||
'taskrKanban',
|
||||
'TaskMaster Kanban',
|
||||
vscode.ViewColumn.One,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [
|
||||
vscode.Uri.joinPath(this.context.extensionUri, 'dist')
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
// Set the icon for the webview tab
|
||||
panel.iconPath = {
|
||||
light: vscode.Uri.joinPath(
|
||||
this.context.extensionUri,
|
||||
'assets',
|
||||
'icon-light.svg'
|
||||
),
|
||||
dark: vscode.Uri.joinPath(
|
||||
this.context.extensionUri,
|
||||
'assets',
|
||||
'icon-dark.svg'
|
||||
)
|
||||
};
|
||||
|
||||
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('TaskMaster 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':
|
||||
// Pass options to getAll including tag if specified
|
||||
response = await this.repository.getAll({
|
||||
tag: data?.tag,
|
||||
withSubtasks: data?.withSubtasks ?? true
|
||||
});
|
||||
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 with MCP
|
||||
if (this.mcpClient) {
|
||||
try {
|
||||
const { taskId, updates, options = {} } = data;
|
||||
|
||||
// Use the update_task MCP tool
|
||||
await this.mcpClient.callTool('update_task', {
|
||||
id: String(taskId),
|
||||
prompt: updates.description || '',
|
||||
append: options.append || false,
|
||||
research: options.research || false,
|
||||
projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
});
|
||||
|
||||
response = { success: true };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to update task via MCP:', error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw new Error('MCP client not initialized');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'updateSubtask':
|
||||
// Handle subtask content updates with MCP
|
||||
if (this.mcpClient) {
|
||||
try {
|
||||
const { taskId, prompt, options = {} } = data;
|
||||
|
||||
// Use the update_subtask MCP tool
|
||||
await this.mcpClient.callTool('update_subtask', {
|
||||
id: String(taskId),
|
||||
prompt: prompt,
|
||||
research: options.research || false,
|
||||
projectRoot: vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
});
|
||||
|
||||
response = { success: true };
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to update subtask via MCP:', error);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw new Error('MCP client not initialized');
|
||||
}
|
||||
break;
|
||||
|
||||
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 fetch tasks for the new tag
|
||||
await this.repository.refresh();
|
||||
const tasks = await this.repository.getAll({ tag: data.tagName });
|
||||
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;
|
||||
|
||||
case 'openExternal':
|
||||
// Open external URL
|
||||
if (message.url) {
|
||||
vscode.env.openExternal(vscode.Uri.parse(message.url));
|
||||
}
|
||||
return;
|
||||
|
||||
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>TaskMaster 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;
|
||||
}
|
||||
}
|
||||
15
apps/extension/src/test/extension.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as assert from 'assert';
|
||||
|
||||
// You can import and use all API from the 'vscode' module
|
||||
// as well as import your extension to test it
|
||||
import * as vscode from 'vscode';
|
||||
// import * as myExtension from '../../extension';
|
||||
|
||||
suite('Extension Test Suite', () => {
|
||||
vscode.window.showInformationMessage('Start all tests.');
|
||||
|
||||
test('Sample test', () => {
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(5));
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(0));
|
||||
});
|
||||
});
|
||||
514
apps/extension/src/utils/configManager.ts
Normal file
@@ -0,0 +1,514 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { logger } from './logger';
|
||||
import type { MCPConfig } from './mcpClient';
|
||||
|
||||
export interface TaskMasterConfig {
|
||||
mcp: MCPServerConfig;
|
||||
ui: UIConfig;
|
||||
performance: PerformanceConfig;
|
||||
debug: DebugConfig;
|
||||
}
|
||||
|
||||
export interface MCPServerConfig {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeout: number;
|
||||
maxReconnectAttempts: number;
|
||||
reconnectBackoffMs: number;
|
||||
maxBackoffMs: number;
|
||||
healthCheckIntervalMs: number;
|
||||
}
|
||||
|
||||
export interface UIConfig {
|
||||
autoRefresh: boolean;
|
||||
refreshIntervalMs: number;
|
||||
theme: 'auto' | 'light' | 'dark';
|
||||
showCompletedTasks: boolean;
|
||||
taskDisplayLimit: number;
|
||||
showPriority: boolean;
|
||||
showTaskIds: boolean;
|
||||
}
|
||||
|
||||
export interface PerformanceConfig {
|
||||
maxConcurrentRequests: number;
|
||||
requestTimeoutMs: number;
|
||||
cacheTasksMs: number;
|
||||
lazyLoadThreshold: number;
|
||||
}
|
||||
|
||||
export interface DebugConfig {
|
||||
enableLogging: boolean;
|
||||
logLevel: 'error' | 'warn' | 'info' | 'debug';
|
||||
enableConnectionMetrics: boolean;
|
||||
saveEventLogs: boolean;
|
||||
maxEventLogSize: number;
|
||||
}
|
||||
|
||||
export interface ConfigValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class ConfigManager {
|
||||
private static instance: ConfigManager | null = null;
|
||||
private config: TaskMasterConfig;
|
||||
private configListeners: ((config: TaskMasterConfig) => void)[] = [];
|
||||
|
||||
private constructor() {
|
||||
this.config = this.loadConfig();
|
||||
this.setupConfigWatcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*/
|
||||
static getInstance(): ConfigManager {
|
||||
if (!ConfigManager.instance) {
|
||||
ConfigManager.instance = new ConfigManager();
|
||||
}
|
||||
return ConfigManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): TaskMasterConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP configuration for the client
|
||||
*/
|
||||
getMCPConfig(): MCPConfig {
|
||||
const mcpConfig = this.config.mcp;
|
||||
return {
|
||||
command: mcpConfig.command,
|
||||
args: mcpConfig.args,
|
||||
cwd: mcpConfig.cwd,
|
||||
env: mcpConfig.env
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration (programmatically)
|
||||
*/
|
||||
async updateConfig(updates: Partial<TaskMasterConfig>): Promise<void> {
|
||||
const newConfig = this.mergeConfig(this.config, updates);
|
||||
const validation = this.validateConfig(newConfig);
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new Error(
|
||||
`Configuration validation failed: ${validation.errors.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
// Update VS Code settings
|
||||
const vsConfig = vscode.workspace.getConfiguration('taskmaster');
|
||||
|
||||
if (updates.mcp) {
|
||||
if (updates.mcp.command !== undefined) {
|
||||
await vsConfig.update(
|
||||
'mcp.command',
|
||||
updates.mcp.command,
|
||||
vscode.ConfigurationTarget.Workspace
|
||||
);
|
||||
}
|
||||
if (updates.mcp.args !== undefined) {
|
||||
await vsConfig.update(
|
||||
'mcp.args',
|
||||
updates.mcp.args,
|
||||
vscode.ConfigurationTarget.Workspace
|
||||
);
|
||||
}
|
||||
if (updates.mcp.cwd !== undefined) {
|
||||
await vsConfig.update(
|
||||
'mcp.cwd',
|
||||
updates.mcp.cwd,
|
||||
vscode.ConfigurationTarget.Workspace
|
||||
);
|
||||
}
|
||||
if (updates.mcp.timeout !== undefined) {
|
||||
await vsConfig.update(
|
||||
'mcp.timeout',
|
||||
updates.mcp.timeout,
|
||||
vscode.ConfigurationTarget.Workspace
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.ui) {
|
||||
if (updates.ui.autoRefresh !== undefined) {
|
||||
await vsConfig.update(
|
||||
'ui.autoRefresh',
|
||||
updates.ui.autoRefresh,
|
||||
vscode.ConfigurationTarget.Workspace
|
||||
);
|
||||
}
|
||||
if (updates.ui.theme !== undefined) {
|
||||
await vsConfig.update(
|
||||
'ui.theme',
|
||||
updates.ui.theme,
|
||||
vscode.ConfigurationTarget.Workspace
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.debug) {
|
||||
if (updates.debug.enableLogging !== undefined) {
|
||||
await vsConfig.update(
|
||||
'debug.enableLogging',
|
||||
updates.debug.enableLogging,
|
||||
vscode.ConfigurationTarget.Workspace
|
||||
);
|
||||
}
|
||||
if (updates.debug.logLevel !== undefined) {
|
||||
await vsConfig.update(
|
||||
'debug.logLevel',
|
||||
updates.debug.logLevel,
|
||||
vscode.ConfigurationTarget.Workspace
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.config = newConfig;
|
||||
this.notifyConfigChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate configuration
|
||||
*/
|
||||
validateConfig(config: TaskMasterConfig): ConfigValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate MCP configuration
|
||||
if (!config.mcp.command || config.mcp.command.trim() === '') {
|
||||
errors.push('MCP command cannot be empty');
|
||||
}
|
||||
|
||||
if (config.mcp.timeout < 1000) {
|
||||
warnings.push(
|
||||
'MCP timeout is very low (< 1s), this may cause connection issues'
|
||||
);
|
||||
} else if (config.mcp.timeout > 60000) {
|
||||
warnings.push(
|
||||
'MCP timeout is very high (> 60s), this may cause slow responses'
|
||||
);
|
||||
}
|
||||
|
||||
if (config.mcp.maxReconnectAttempts < 1) {
|
||||
errors.push('Max reconnect attempts must be at least 1');
|
||||
} else if (config.mcp.maxReconnectAttempts > 10) {
|
||||
warnings.push(
|
||||
'Max reconnect attempts is very high, this may cause long delays'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate UI configuration
|
||||
if (config.ui.refreshIntervalMs < 1000) {
|
||||
warnings.push(
|
||||
'UI refresh interval is very low (< 1s), this may impact performance'
|
||||
);
|
||||
}
|
||||
|
||||
if (config.ui.taskDisplayLimit < 1) {
|
||||
errors.push('Task display limit must be at least 1');
|
||||
} else if (config.ui.taskDisplayLimit > 1000) {
|
||||
warnings.push(
|
||||
'Task display limit is very high, this may impact performance'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate performance configuration
|
||||
if (config.performance.maxConcurrentRequests < 1) {
|
||||
errors.push('Max concurrent requests must be at least 1');
|
||||
} else if (config.performance.maxConcurrentRequests > 20) {
|
||||
warnings.push(
|
||||
'Max concurrent requests is very high, this may overwhelm the server'
|
||||
);
|
||||
}
|
||||
|
||||
if (config.performance.requestTimeoutMs < 1000) {
|
||||
warnings.push(
|
||||
'Request timeout is very low (< 1s), this may cause premature timeouts'
|
||||
);
|
||||
}
|
||||
|
||||
// Validate debug configuration
|
||||
if (config.debug.maxEventLogSize < 10) {
|
||||
errors.push('Max event log size must be at least 10');
|
||||
} else if (config.debug.maxEventLogSize > 10000) {
|
||||
warnings.push(
|
||||
'Max event log size is very high, this may consume significant memory'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset configuration to defaults
|
||||
*/
|
||||
async resetToDefaults(): Promise<void> {
|
||||
const defaultConfig = this.getDefaultConfig();
|
||||
await this.updateConfig(defaultConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export configuration to JSON
|
||||
*/
|
||||
exportConfig(): string {
|
||||
return JSON.stringify(this.config, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import configuration from JSON
|
||||
*/
|
||||
async importConfig(jsonConfig: string): Promise<void> {
|
||||
try {
|
||||
const importedConfig = JSON.parse(jsonConfig) as TaskMasterConfig;
|
||||
const validation = this.validateConfig(importedConfig);
|
||||
|
||||
if (!validation.isValid) {
|
||||
throw new Error(
|
||||
`Invalid configuration: ${validation.errors.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (validation.warnings.length > 0) {
|
||||
const proceed = await vscode.window.showWarningMessage(
|
||||
`Configuration has warnings: ${validation.warnings.join(', ')}. Import anyway?`,
|
||||
'Yes',
|
||||
'No'
|
||||
);
|
||||
|
||||
if (proceed !== 'Yes') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateConfig(importedConfig);
|
||||
vscode.window.showInformationMessage(
|
||||
'Configuration imported successfully'
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to import configuration: ${errorMessage}`
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add configuration change listener
|
||||
*/
|
||||
onConfigChange(listener: (config: TaskMasterConfig) => void): void {
|
||||
this.configListeners.push(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove configuration change listener
|
||||
*/
|
||||
removeConfigListener(listener: (config: TaskMasterConfig) => void): void {
|
||||
const index = this.configListeners.indexOf(listener);
|
||||
if (index !== -1) {
|
||||
this.configListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load configuration from VS Code settings
|
||||
*/
|
||||
private loadConfig(): TaskMasterConfig {
|
||||
const vsConfig = vscode.workspace.getConfiguration('taskmaster');
|
||||
const defaultConfig = this.getDefaultConfig();
|
||||
|
||||
return {
|
||||
mcp: {
|
||||
command: vsConfig.get('mcp.command', defaultConfig.mcp.command),
|
||||
args: vsConfig.get('mcp.args', defaultConfig.mcp.args),
|
||||
cwd: vsConfig.get('mcp.cwd', defaultConfig.mcp.cwd),
|
||||
env: vsConfig.get('mcp.env', defaultConfig.mcp.env),
|
||||
timeout: vsConfig.get('mcp.timeout', defaultConfig.mcp.timeout),
|
||||
maxReconnectAttempts: vsConfig.get(
|
||||
'mcp.maxReconnectAttempts',
|
||||
defaultConfig.mcp.maxReconnectAttempts
|
||||
),
|
||||
reconnectBackoffMs: vsConfig.get(
|
||||
'mcp.reconnectBackoffMs',
|
||||
defaultConfig.mcp.reconnectBackoffMs
|
||||
),
|
||||
maxBackoffMs: vsConfig.get(
|
||||
'mcp.maxBackoffMs',
|
||||
defaultConfig.mcp.maxBackoffMs
|
||||
),
|
||||
healthCheckIntervalMs: vsConfig.get(
|
||||
'mcp.healthCheckIntervalMs',
|
||||
defaultConfig.mcp.healthCheckIntervalMs
|
||||
)
|
||||
},
|
||||
ui: {
|
||||
autoRefresh: vsConfig.get(
|
||||
'ui.autoRefresh',
|
||||
defaultConfig.ui.autoRefresh
|
||||
),
|
||||
refreshIntervalMs: vsConfig.get(
|
||||
'ui.refreshIntervalMs',
|
||||
defaultConfig.ui.refreshIntervalMs
|
||||
),
|
||||
theme: vsConfig.get('ui.theme', defaultConfig.ui.theme),
|
||||
showCompletedTasks: vsConfig.get(
|
||||
'ui.showCompletedTasks',
|
||||
defaultConfig.ui.showCompletedTasks
|
||||
),
|
||||
taskDisplayLimit: vsConfig.get(
|
||||
'ui.taskDisplayLimit',
|
||||
defaultConfig.ui.taskDisplayLimit
|
||||
),
|
||||
showPriority: vsConfig.get(
|
||||
'ui.showPriority',
|
||||
defaultConfig.ui.showPriority
|
||||
),
|
||||
showTaskIds: vsConfig.get(
|
||||
'ui.showTaskIds',
|
||||
defaultConfig.ui.showTaskIds
|
||||
)
|
||||
},
|
||||
performance: {
|
||||
maxConcurrentRequests: vsConfig.get(
|
||||
'performance.maxConcurrentRequests',
|
||||
defaultConfig.performance.maxConcurrentRequests
|
||||
),
|
||||
requestTimeoutMs: vsConfig.get(
|
||||
'performance.requestTimeoutMs',
|
||||
defaultConfig.performance.requestTimeoutMs
|
||||
),
|
||||
cacheTasksMs: vsConfig.get(
|
||||
'performance.cacheTasksMs',
|
||||
defaultConfig.performance.cacheTasksMs
|
||||
),
|
||||
lazyLoadThreshold: vsConfig.get(
|
||||
'performance.lazyLoadThreshold',
|
||||
defaultConfig.performance.lazyLoadThreshold
|
||||
)
|
||||
},
|
||||
debug: {
|
||||
enableLogging: vsConfig.get(
|
||||
'debug.enableLogging',
|
||||
defaultConfig.debug.enableLogging
|
||||
),
|
||||
logLevel: vsConfig.get('debug.logLevel', defaultConfig.debug.logLevel),
|
||||
enableConnectionMetrics: vsConfig.get(
|
||||
'debug.enableConnectionMetrics',
|
||||
defaultConfig.debug.enableConnectionMetrics
|
||||
),
|
||||
saveEventLogs: vsConfig.get(
|
||||
'debug.saveEventLogs',
|
||||
defaultConfig.debug.saveEventLogs
|
||||
),
|
||||
maxEventLogSize: vsConfig.get(
|
||||
'debug.maxEventLogSize',
|
||||
defaultConfig.debug.maxEventLogSize
|
||||
)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default configuration
|
||||
*/
|
||||
private getDefaultConfig(): TaskMasterConfig {
|
||||
return {
|
||||
mcp: {
|
||||
command: 'npx',
|
||||
args: ['task-master-ai'],
|
||||
cwd: vscode.workspace.rootPath || '',
|
||||
env: undefined,
|
||||
timeout: 30000,
|
||||
maxReconnectAttempts: 5,
|
||||
reconnectBackoffMs: 1000,
|
||||
maxBackoffMs: 30000,
|
||||
healthCheckIntervalMs: 15000
|
||||
},
|
||||
ui: {
|
||||
autoRefresh: true,
|
||||
refreshIntervalMs: 10000,
|
||||
theme: 'auto',
|
||||
showCompletedTasks: true,
|
||||
taskDisplayLimit: 100,
|
||||
showPriority: true,
|
||||
showTaskIds: true
|
||||
},
|
||||
performance: {
|
||||
maxConcurrentRequests: 5,
|
||||
requestTimeoutMs: 30000,
|
||||
cacheTasksMs: 5000,
|
||||
lazyLoadThreshold: 50
|
||||
},
|
||||
debug: {
|
||||
enableLogging: true,
|
||||
logLevel: 'info',
|
||||
enableConnectionMetrics: true,
|
||||
saveEventLogs: false,
|
||||
maxEventLogSize: 1000
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup configuration watcher
|
||||
*/
|
||||
private setupConfigWatcher(): void {
|
||||
vscode.workspace.onDidChangeConfiguration((event) => {
|
||||
if (event.affectsConfiguration('taskmaster')) {
|
||||
logger.log('Task Master configuration changed, reloading...');
|
||||
this.config = this.loadConfig();
|
||||
this.notifyConfigChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge configurations
|
||||
*/
|
||||
private mergeConfig(
|
||||
baseConfig: TaskMasterConfig,
|
||||
updates: Partial<TaskMasterConfig>
|
||||
): TaskMasterConfig {
|
||||
return {
|
||||
mcp: { ...baseConfig.mcp, ...updates.mcp },
|
||||
ui: { ...baseConfig.ui, ...updates.ui },
|
||||
performance: { ...baseConfig.performance, ...updates.performance },
|
||||
debug: { ...baseConfig.debug, ...updates.debug }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify configuration change listeners
|
||||
*/
|
||||
private notifyConfigChange(): void {
|
||||
this.configListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(this.config);
|
||||
} catch (error) {
|
||||
logger.error('Error in configuration change listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function to get configuration manager instance
|
||||
*/
|
||||
export function getConfigManager(): ConfigManager {
|
||||
return ConfigManager.getInstance();
|
||||
}
|
||||
387
apps/extension/src/utils/connectionManager.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { logger } from './logger';
|
||||
import {
|
||||
MCPClientManager,
|
||||
type MCPConfig,
|
||||
type MCPServerStatus
|
||||
} from './mcpClient';
|
||||
|
||||
export interface ConnectionEvent {
|
||||
type: 'connected' | 'disconnected' | 'error' | 'reconnecting';
|
||||
timestamp: Date;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface ConnectionHealth {
|
||||
isHealthy: boolean;
|
||||
lastSuccessfulCall?: Date;
|
||||
consecutiveFailures: number;
|
||||
averageResponseTime: number;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export class ConnectionManager {
|
||||
private mcpClient: MCPClientManager | null = null;
|
||||
private config: MCPConfig;
|
||||
private connectionEvents: ConnectionEvent[] = [];
|
||||
private health: ConnectionHealth = {
|
||||
isHealthy: false,
|
||||
consecutiveFailures: 0,
|
||||
averageResponseTime: 0,
|
||||
uptime: 0
|
||||
};
|
||||
private startTime: Date | null = null;
|
||||
private healthCheckInterval: NodeJS.Timeout | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectBackoffMs = 1000; // Start with 1 second
|
||||
private maxBackoffMs = 30000; // Max 30 seconds
|
||||
private isReconnecting = false;
|
||||
|
||||
// Event handlers
|
||||
private onConnectionChange?: (
|
||||
status: MCPServerStatus,
|
||||
health: ConnectionHealth
|
||||
) => void;
|
||||
private onConnectionEvent?: (event: ConnectionEvent) => void;
|
||||
|
||||
constructor(config: MCPConfig) {
|
||||
this.config = config;
|
||||
this.mcpClient = new MCPClientManager(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set event handlers
|
||||
*/
|
||||
setEventHandlers(handlers: {
|
||||
onConnectionChange?: (
|
||||
status: MCPServerStatus,
|
||||
health: ConnectionHealth
|
||||
) => void;
|
||||
onConnectionEvent?: (event: ConnectionEvent) => void;
|
||||
}) {
|
||||
this.onConnectionChange = handlers.onConnectionChange;
|
||||
this.onConnectionEvent = handlers.onConnectionEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect with automatic retry and health monitoring
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
if (!this.mcpClient) {
|
||||
throw new Error('MCP client not initialized');
|
||||
}
|
||||
|
||||
this.logEvent({ type: 'reconnecting', timestamp: new Date() });
|
||||
|
||||
await this.mcpClient.connect();
|
||||
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectBackoffMs = 1000;
|
||||
this.isReconnecting = false;
|
||||
this.startTime = new Date();
|
||||
|
||||
this.updateHealth();
|
||||
this.startHealthMonitoring();
|
||||
|
||||
this.logEvent({ type: 'connected', timestamp: new Date() });
|
||||
|
||||
logger.log('Connection manager: Successfully connected');
|
||||
} catch (error) {
|
||||
this.logEvent({
|
||||
type: 'error',
|
||||
timestamp: new Date(),
|
||||
data: {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
}
|
||||
});
|
||||
|
||||
await this.handleConnectionFailure(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect and stop health monitoring
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
this.stopHealthMonitoring();
|
||||
this.isReconnecting = false;
|
||||
|
||||
if (this.mcpClient) {
|
||||
await this.mcpClient.disconnect();
|
||||
}
|
||||
|
||||
this.health.isHealthy = false;
|
||||
this.startTime = null;
|
||||
|
||||
this.logEvent({ type: 'disconnected', timestamp: new Date() });
|
||||
|
||||
this.notifyConnectionChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current connection status
|
||||
*/
|
||||
getStatus(): MCPServerStatus {
|
||||
return this.mcpClient?.getStatus() || { isRunning: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection health metrics
|
||||
*/
|
||||
getHealth(): ConnectionHealth {
|
||||
this.updateHealth();
|
||||
return { ...this.health };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recent connection events
|
||||
*/
|
||||
getEvents(limit = 10): ConnectionEvent[] {
|
||||
return this.connectionEvents.slice(-limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection with performance monitoring
|
||||
*/
|
||||
async testConnection(): Promise<{
|
||||
success: boolean;
|
||||
responseTime: number;
|
||||
error?: string;
|
||||
}> {
|
||||
if (!this.mcpClient) {
|
||||
return {
|
||||
success: false,
|
||||
responseTime: 0,
|
||||
error: 'Client not initialized'
|
||||
};
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const success = await this.mcpClient.testConnection();
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
if (success) {
|
||||
this.health.lastSuccessfulCall = new Date();
|
||||
this.health.consecutiveFailures = 0;
|
||||
this.updateAverageResponseTime(responseTime);
|
||||
} else {
|
||||
this.health.consecutiveFailures++;
|
||||
}
|
||||
|
||||
this.updateHealth();
|
||||
this.notifyConnectionChange();
|
||||
|
||||
return { success, responseTime };
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
this.health.consecutiveFailures++;
|
||||
this.updateHealth();
|
||||
this.notifyConnectionChange();
|
||||
|
||||
return {
|
||||
success: false,
|
||||
responseTime,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call MCP tool with automatic retry and health monitoring
|
||||
*/
|
||||
async callTool(
|
||||
toolName: string,
|
||||
arguments_: Record<string, unknown>
|
||||
): Promise<any> {
|
||||
if (!this.mcpClient) {
|
||||
throw new Error('MCP client not initialized');
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const result = await this.mcpClient.callTool(toolName, arguments_);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
this.health.lastSuccessfulCall = new Date();
|
||||
this.health.consecutiveFailures = 0;
|
||||
this.updateAverageResponseTime(responseTime);
|
||||
this.updateHealth();
|
||||
this.notifyConnectionChange();
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.health.consecutiveFailures++;
|
||||
this.updateHealth();
|
||||
|
||||
// Attempt reconnection if connection seems lost
|
||||
if (this.health.consecutiveFailures >= 3 && !this.isReconnecting) {
|
||||
logger.log(
|
||||
'Multiple consecutive failures detected, attempting reconnection...'
|
||||
);
|
||||
this.reconnectWithBackoff().catch((err) => {
|
||||
logger.error('Reconnection failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
this.notifyConnectionChange();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration and reconnect
|
||||
*/
|
||||
async updateConfig(newConfig: MCPConfig): Promise<void> {
|
||||
this.config = newConfig;
|
||||
|
||||
await this.disconnect();
|
||||
this.mcpClient = new MCPClientManager(newConfig);
|
||||
|
||||
// Attempt to reconnect with new config
|
||||
try {
|
||||
await this.connect();
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect with new configuration:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start health monitoring
|
||||
*/
|
||||
private startHealthMonitoring(): void {
|
||||
this.stopHealthMonitoring();
|
||||
|
||||
this.healthCheckInterval = setInterval(async () => {
|
||||
try {
|
||||
await this.testConnection();
|
||||
} catch (error) {
|
||||
logger.error('Health check failed:', error);
|
||||
}
|
||||
}, 15000); // Check every 15 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop health monitoring
|
||||
*/
|
||||
private stopHealthMonitoring(): void {
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval);
|
||||
this.healthCheckInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle connection failure with exponential backoff
|
||||
*/
|
||||
private async handleConnectionFailure(error: any): Promise<void> {
|
||||
this.health.consecutiveFailures++;
|
||||
this.updateHealth();
|
||||
this.notifyConnectionChange();
|
||||
|
||||
if (
|
||||
this.reconnectAttempts < this.maxReconnectAttempts &&
|
||||
!this.isReconnecting
|
||||
) {
|
||||
await this.reconnectWithBackoff();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect with exponential backoff
|
||||
*/
|
||||
private async reconnectWithBackoff(): Promise<void> {
|
||||
if (this.isReconnecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isReconnecting = true;
|
||||
this.reconnectAttempts++;
|
||||
|
||||
const backoffMs = Math.min(
|
||||
this.reconnectBackoffMs * 2 ** (this.reconnectAttempts - 1),
|
||||
this.maxBackoffMs
|
||||
);
|
||||
|
||||
logger.log(
|
||||
`Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${backoffMs}ms...`
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, backoffMs));
|
||||
|
||||
try {
|
||||
await this.connect();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Reconnection attempt ${this.reconnectAttempts} failed:`,
|
||||
error
|
||||
);
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
this.isReconnecting = false;
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to reconnect to Task Master after ${this.maxReconnectAttempts} attempts. Please check your configuration and try manually reconnecting.`
|
||||
);
|
||||
} else {
|
||||
// Try again
|
||||
await this.reconnectWithBackoff();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update health metrics
|
||||
*/
|
||||
private updateHealth(): void {
|
||||
const status = this.getStatus();
|
||||
this.health.isHealthy =
|
||||
status.isRunning && this.health.consecutiveFailures < 3;
|
||||
|
||||
if (this.startTime) {
|
||||
this.health.uptime = Date.now() - this.startTime.getTime();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update average response time
|
||||
*/
|
||||
private updateAverageResponseTime(responseTime: number): void {
|
||||
// Simple moving average calculation
|
||||
if (this.health.averageResponseTime === 0) {
|
||||
this.health.averageResponseTime = responseTime;
|
||||
} else {
|
||||
this.health.averageResponseTime =
|
||||
this.health.averageResponseTime * 0.8 + responseTime * 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log connection event
|
||||
*/
|
||||
private logEvent(event: ConnectionEvent): void {
|
||||
this.connectionEvents.push(event);
|
||||
|
||||
// Keep only last 100 events
|
||||
if (this.connectionEvents.length > 100) {
|
||||
this.connectionEvents = this.connectionEvents.slice(-100);
|
||||
}
|
||||
|
||||
if (this.onConnectionEvent) {
|
||||
this.onConnectionEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify connection change
|
||||
*/
|
||||
private notifyConnectionChange(): void {
|
||||
if (this.onConnectionChange) {
|
||||
this.onConnectionChange(this.getStatus(), this.getHealth());
|
||||
}
|
||||
}
|
||||
}
|
||||
858
apps/extension/src/utils/errorHandler.ts
Normal file
@@ -0,0 +1,858 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { logger } from './logger';
|
||||
import {
|
||||
getNotificationType,
|
||||
getToastDuration,
|
||||
shouldShowNotification
|
||||
} from './notificationPreferences';
|
||||
|
||||
export enum ErrorSeverity {
|
||||
LOW = 'low',
|
||||
MEDIUM = 'medium',
|
||||
HIGH = 'high',
|
||||
CRITICAL = 'critical'
|
||||
}
|
||||
|
||||
export enum ErrorCategory {
|
||||
MCP_CONNECTION = 'mcp_connection',
|
||||
CONFIGURATION = 'configuration',
|
||||
TASK_LOADING = 'task_loading',
|
||||
UI_RENDERING = 'ui_rendering',
|
||||
VALIDATION = 'validation',
|
||||
NETWORK = 'network',
|
||||
INTERNAL = 'internal',
|
||||
TASK_MASTER_API = 'TASK_MASTER_API',
|
||||
DATA_VALIDATION = 'DATA_VALIDATION',
|
||||
DATA_PARSING = 'DATA_PARSING',
|
||||
TASK_DATA_CORRUPTION = 'TASK_DATA_CORRUPTION',
|
||||
VSCODE_API = 'VSCODE_API',
|
||||
WEBVIEW = 'WEBVIEW',
|
||||
EXTENSION_HOST = 'EXTENSION_HOST',
|
||||
USER_INTERACTION = 'USER_INTERACTION',
|
||||
DRAG_DROP = 'DRAG_DROP',
|
||||
COMPONENT_RENDER = 'COMPONENT_RENDER',
|
||||
PERMISSION = 'PERMISSION',
|
||||
FILE_SYSTEM = 'FILE_SYSTEM',
|
||||
UNKNOWN = 'UNKNOWN'
|
||||
}
|
||||
|
||||
export enum NotificationType {
|
||||
VSCODE_INFO = 'VSCODE_INFO',
|
||||
VSCODE_WARNING = 'VSCODE_WARNING',
|
||||
VSCODE_ERROR = 'VSCODE_ERROR',
|
||||
TOAST_SUCCESS = 'TOAST_SUCCESS',
|
||||
TOAST_INFO = 'TOAST_INFO',
|
||||
TOAST_WARNING = 'TOAST_WARNING',
|
||||
TOAST_ERROR = 'TOAST_ERROR',
|
||||
CONSOLE_ONLY = 'CONSOLE_ONLY',
|
||||
SILENT = 'SILENT'
|
||||
}
|
||||
|
||||
export interface ErrorContext {
|
||||
// Core error information
|
||||
category: ErrorCategory;
|
||||
severity: ErrorSeverity;
|
||||
message: string;
|
||||
originalError?: Error | unknown;
|
||||
|
||||
// Contextual information
|
||||
operation?: string; // What operation was being performed
|
||||
taskId?: string; // Related task ID if applicable
|
||||
userId?: string; // User context if applicable
|
||||
sessionId?: string; // Session context
|
||||
|
||||
// Technical details
|
||||
stackTrace?: string;
|
||||
userAgent?: string;
|
||||
timestamp?: number;
|
||||
|
||||
// Recovery information
|
||||
isRecoverable?: boolean;
|
||||
suggestedActions?: string[];
|
||||
documentationLink?: string;
|
||||
|
||||
// Notification preferences
|
||||
notificationType?: NotificationType;
|
||||
showToUser?: boolean;
|
||||
logToConsole?: boolean;
|
||||
logToFile?: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorDetails {
|
||||
code: string;
|
||||
message: string;
|
||||
category: ErrorCategory;
|
||||
severity: ErrorSeverity;
|
||||
timestamp: Date;
|
||||
context?: Record<string, any>;
|
||||
stack?: string;
|
||||
userAction?: string;
|
||||
recovery?: {
|
||||
automatic: boolean;
|
||||
action?: () => Promise<void>;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ErrorLogEntry {
|
||||
id: string;
|
||||
error: ErrorDetails;
|
||||
resolved: boolean;
|
||||
resolvedAt?: Date;
|
||||
attempts: number;
|
||||
lastAttempt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for all Task Master errors
|
||||
*/
|
||||
export abstract class TaskMasterError extends Error {
|
||||
public readonly code: string;
|
||||
public readonly category: ErrorCategory;
|
||||
public readonly severity: ErrorSeverity;
|
||||
public readonly timestamp: Date;
|
||||
public readonly context?: Record<string, any>;
|
||||
public readonly userAction?: string;
|
||||
public readonly recovery?: {
|
||||
automatic: boolean;
|
||||
action?: () => Promise<void>;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
category: ErrorCategory,
|
||||
severity: ErrorSeverity = ErrorSeverity.MEDIUM,
|
||||
context?: Record<string, any>,
|
||||
userAction?: string,
|
||||
recovery?: {
|
||||
automatic: boolean;
|
||||
action?: () => Promise<void>;
|
||||
description?: string;
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
this.code = code;
|
||||
this.category = category;
|
||||
this.severity = severity;
|
||||
this.timestamp = new Date();
|
||||
this.context = context;
|
||||
this.userAction = userAction;
|
||||
this.recovery = recovery;
|
||||
|
||||
// Capture stack trace
|
||||
if (Error.captureStackTrace) {
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
public toErrorDetails(): ErrorDetails {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
category: this.category,
|
||||
severity: this.severity,
|
||||
timestamp: this.timestamp,
|
||||
context: this.context,
|
||||
stack: this.stack,
|
||||
userAction: this.userAction,
|
||||
recovery: this.recovery
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP Connection related errors
|
||||
*/
|
||||
export class MCPConnectionError extends TaskMasterError {
|
||||
constructor(
|
||||
message: string,
|
||||
code = 'MCP_CONNECTION_FAILED',
|
||||
context?: Record<string, any>,
|
||||
recovery?: {
|
||||
automatic: boolean;
|
||||
action?: () => Promise<void>;
|
||||
description?: string;
|
||||
}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorCategory.MCP_CONNECTION,
|
||||
ErrorSeverity.HIGH,
|
||||
context,
|
||||
'Check your Task Master configuration and ensure the MCP server is accessible.',
|
||||
recovery
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration related errors
|
||||
*/
|
||||
export class ConfigurationError extends TaskMasterError {
|
||||
constructor(
|
||||
message: string,
|
||||
code = 'CONFIGURATION_INVALID',
|
||||
context?: Record<string, any>
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorCategory.CONFIGURATION,
|
||||
ErrorSeverity.MEDIUM,
|
||||
context,
|
||||
'Check your Task Master configuration in VS Code settings.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Task loading related errors
|
||||
*/
|
||||
export class TaskLoadingError extends TaskMasterError {
|
||||
constructor(
|
||||
message: string,
|
||||
code = 'TASK_LOADING_FAILED',
|
||||
context?: Record<string, any>,
|
||||
recovery?: {
|
||||
automatic: boolean;
|
||||
action?: () => Promise<void>;
|
||||
description?: string;
|
||||
}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorCategory.TASK_LOADING,
|
||||
ErrorSeverity.MEDIUM,
|
||||
context,
|
||||
'Try refreshing the task list or check your project configuration.',
|
||||
recovery
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* UI rendering related errors
|
||||
*/
|
||||
export class UIRenderingError extends TaskMasterError {
|
||||
constructor(
|
||||
message: string,
|
||||
code = 'UI_RENDERING_FAILED',
|
||||
context?: Record<string, any>
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorCategory.UI_RENDERING,
|
||||
ErrorSeverity.LOW,
|
||||
context,
|
||||
'Try closing and reopening the Kanban board.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network related errors
|
||||
*/
|
||||
export class NetworkError extends TaskMasterError {
|
||||
constructor(
|
||||
message: string,
|
||||
code = 'NETWORK_ERROR',
|
||||
context?: Record<string, any>,
|
||||
recovery?: {
|
||||
automatic: boolean;
|
||||
action?: () => Promise<void>;
|
||||
description?: string;
|
||||
}
|
||||
) {
|
||||
super(
|
||||
message,
|
||||
code,
|
||||
ErrorCategory.NETWORK,
|
||||
ErrorSeverity.MEDIUM,
|
||||
context,
|
||||
'Check your network connection and firewall settings.',
|
||||
recovery
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized error handler
|
||||
*/
|
||||
export class ErrorHandler {
|
||||
private static instance: ErrorHandler | null = null;
|
||||
private errorLog: ErrorLogEntry[] = [];
|
||||
private maxLogSize = 1000;
|
||||
private errorListeners: ((error: ErrorDetails) => void)[] = [];
|
||||
|
||||
private constructor() {
|
||||
this.setupGlobalErrorHandlers();
|
||||
}
|
||||
|
||||
static getInstance(): ErrorHandler {
|
||||
if (!ErrorHandler.instance) {
|
||||
ErrorHandler.instance = new ErrorHandler();
|
||||
}
|
||||
return ErrorHandler.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error with comprehensive logging and recovery
|
||||
*/
|
||||
async handleError(
|
||||
error: Error | TaskMasterError,
|
||||
context?: Record<string, any>
|
||||
): Promise<void> {
|
||||
const errorDetails = this.createErrorDetails(error, context);
|
||||
const logEntry = this.logError(errorDetails);
|
||||
|
||||
// Notify listeners
|
||||
this.notifyErrorListeners(errorDetails);
|
||||
|
||||
// Show user notification based on severity
|
||||
await this.showUserNotification(errorDetails);
|
||||
|
||||
// Attempt recovery if available
|
||||
if (errorDetails.recovery?.automatic && errorDetails.recovery.action) {
|
||||
try {
|
||||
await errorDetails.recovery.action();
|
||||
this.markErrorResolved(logEntry.id);
|
||||
} catch (recoveryError) {
|
||||
logger.error('Error recovery failed:', recoveryError);
|
||||
logEntry.attempts++;
|
||||
logEntry.lastAttempt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Log to console with appropriate level
|
||||
this.logToConsole(errorDetails);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle critical errors that should stop execution
|
||||
*/
|
||||
async handleCriticalError(
|
||||
error: Error | TaskMasterError,
|
||||
context?: Record<string, any>
|
||||
): Promise<void> {
|
||||
const errorDetails = this.createErrorDetails(error, context);
|
||||
errorDetails.severity = ErrorSeverity.CRITICAL;
|
||||
|
||||
await this.handleError(error, context);
|
||||
|
||||
// Show critical error dialog
|
||||
const action = await vscode.window.showErrorMessage(
|
||||
`Critical Error in Task Master: ${errorDetails.message}`,
|
||||
'View Details',
|
||||
'Report Issue',
|
||||
'Restart Extension'
|
||||
);
|
||||
|
||||
switch (action) {
|
||||
case 'View Details':
|
||||
await this.showErrorDetails(errorDetails);
|
||||
break;
|
||||
case 'Report Issue':
|
||||
await this.openIssueReport(errorDetails);
|
||||
break;
|
||||
case 'Restart Extension':
|
||||
await vscode.commands.executeCommand('workbench.action.reloadWindow');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add error event listener
|
||||
*/
|
||||
onError(listener: (error: ErrorDetails) => void): void {
|
||||
this.errorListeners.push(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove error event listener
|
||||
*/
|
||||
removeErrorListener(listener: (error: ErrorDetails) => void): void {
|
||||
const index = this.errorListeners.indexOf(listener);
|
||||
if (index !== -1) {
|
||||
this.errorListeners.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error log
|
||||
*/
|
||||
getErrorLog(
|
||||
category?: ErrorCategory,
|
||||
severity?: ErrorSeverity
|
||||
): ErrorLogEntry[] {
|
||||
let filteredLog = this.errorLog;
|
||||
|
||||
if (category) {
|
||||
filteredLog = filteredLog.filter(
|
||||
(entry) => entry.error.category === category
|
||||
);
|
||||
}
|
||||
|
||||
if (severity) {
|
||||
filteredLog = filteredLog.filter(
|
||||
(entry) => entry.error.severity === severity
|
||||
);
|
||||
}
|
||||
|
||||
return filteredLog.slice().reverse(); // Most recent first
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error log
|
||||
*/
|
||||
clearErrorLog(): void {
|
||||
this.errorLog = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Export error log for debugging
|
||||
*/
|
||||
exportErrorLog(): string {
|
||||
return JSON.stringify(this.errorLog, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error details from error instance
|
||||
*/
|
||||
private createErrorDetails(
|
||||
error: Error | TaskMasterError,
|
||||
context?: Record<string, any>
|
||||
): ErrorDetails {
|
||||
if (error instanceof TaskMasterError) {
|
||||
const details = error.toErrorDetails();
|
||||
if (context) {
|
||||
details.context = { ...details.context, ...context };
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
// Handle standard Error objects
|
||||
return {
|
||||
code: 'UNKNOWN_ERROR',
|
||||
message: error.message || 'An unknown error occurred',
|
||||
category: ErrorCategory.INTERNAL,
|
||||
severity: ErrorSeverity.MEDIUM,
|
||||
timestamp: new Date(),
|
||||
context: { ...context, errorName: error.name },
|
||||
stack: error.stack
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log error to internal log
|
||||
*/
|
||||
private logError(errorDetails: ErrorDetails): ErrorLogEntry {
|
||||
const logEntry: ErrorLogEntry = {
|
||||
id: `err_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
error: errorDetails,
|
||||
resolved: false,
|
||||
attempts: 0
|
||||
};
|
||||
|
||||
this.errorLog.push(logEntry);
|
||||
|
||||
// Maintain log size limit
|
||||
if (this.errorLog.length > this.maxLogSize) {
|
||||
this.errorLog = this.errorLog.slice(-this.maxLogSize);
|
||||
}
|
||||
|
||||
return logEntry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark error as resolved
|
||||
*/
|
||||
private markErrorResolved(errorId: string): void {
|
||||
const entry = this.errorLog.find((e) => e.id === errorId);
|
||||
if (entry) {
|
||||
entry.resolved = true;
|
||||
entry.resolvedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show user notification based on error severity and user preferences
|
||||
*/
|
||||
private async showUserNotification(
|
||||
errorDetails: ErrorDetails
|
||||
): Promise<void> {
|
||||
// Check if user wants to see this notification
|
||||
if (!shouldShowNotification(errorDetails.category, errorDetails.severity)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notificationType = getNotificationType(
|
||||
errorDetails.category,
|
||||
errorDetails.severity
|
||||
);
|
||||
const message = errorDetails.userAction
|
||||
? `${errorDetails.message} ${errorDetails.userAction}`
|
||||
: errorDetails.message;
|
||||
|
||||
// Handle different notification types based on user preferences
|
||||
switch (notificationType) {
|
||||
case 'VSCODE_ERROR':
|
||||
await vscode.window.showErrorMessage(message);
|
||||
break;
|
||||
case 'VSCODE_WARNING':
|
||||
await vscode.window.showWarningMessage(message);
|
||||
break;
|
||||
case 'VSCODE_INFO':
|
||||
await vscode.window.showInformationMessage(message);
|
||||
break;
|
||||
case 'TOAST_SUCCESS':
|
||||
case 'TOAST_INFO':
|
||||
case 'TOAST_WARNING':
|
||||
case 'TOAST_ERROR':
|
||||
// These will be handled by the webview toast system
|
||||
// The error listener in extension.ts will send these to webview
|
||||
break;
|
||||
case 'CONSOLE_ONLY':
|
||||
case 'SILENT':
|
||||
// No user notification, just console logging
|
||||
break;
|
||||
default:
|
||||
// Fallback to severity-based notifications
|
||||
switch (errorDetails.severity) {
|
||||
case ErrorSeverity.CRITICAL:
|
||||
await vscode.window.showErrorMessage(message);
|
||||
break;
|
||||
case ErrorSeverity.HIGH:
|
||||
await vscode.window.showErrorMessage(message);
|
||||
break;
|
||||
case ErrorSeverity.MEDIUM:
|
||||
await vscode.window.showWarningMessage(message);
|
||||
break;
|
||||
case ErrorSeverity.LOW:
|
||||
await vscode.window.showInformationMessage(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log to console with appropriate level
|
||||
*/
|
||||
private logToConsole(errorDetails: ErrorDetails): void {
|
||||
const logMessage = `[${errorDetails.category}] ${errorDetails.code}: ${errorDetails.message}`;
|
||||
|
||||
switch (errorDetails.severity) {
|
||||
case ErrorSeverity.CRITICAL:
|
||||
case ErrorSeverity.HIGH:
|
||||
logger.error(logMessage, errorDetails);
|
||||
break;
|
||||
case ErrorSeverity.MEDIUM:
|
||||
logger.warn(logMessage, errorDetails);
|
||||
break;
|
||||
case ErrorSeverity.LOW:
|
||||
console.info(logMessage, errorDetails);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show detailed error information
|
||||
*/
|
||||
private async showErrorDetails(errorDetails: ErrorDetails): Promise<void> {
|
||||
const details = [
|
||||
`Error Code: ${errorDetails.code}`,
|
||||
`Category: ${errorDetails.category}`,
|
||||
`Severity: ${errorDetails.severity}`,
|
||||
`Time: ${errorDetails.timestamp.toISOString()}`,
|
||||
`Message: ${errorDetails.message}`
|
||||
];
|
||||
|
||||
if (errorDetails.context) {
|
||||
details.push(`Context: ${JSON.stringify(errorDetails.context, null, 2)}`);
|
||||
}
|
||||
|
||||
if (errorDetails.stack) {
|
||||
details.push(`Stack Trace: ${errorDetails.stack}`);
|
||||
}
|
||||
|
||||
const content = details.join('\n\n');
|
||||
|
||||
// Create temporary document to show error details
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
content,
|
||||
language: 'plaintext'
|
||||
});
|
||||
|
||||
await vscode.window.showTextDocument(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open GitHub issue report
|
||||
*/
|
||||
private async openIssueReport(errorDetails: ErrorDetails): Promise<void> {
|
||||
const issueTitle = encodeURIComponent(
|
||||
`Error: ${errorDetails.code} - ${errorDetails.message}`
|
||||
);
|
||||
const issueBody = encodeURIComponent(`
|
||||
**Error Details:**
|
||||
- Code: ${errorDetails.code}
|
||||
- Category: ${errorDetails.category}
|
||||
- Severity: ${errorDetails.severity}
|
||||
- Time: ${errorDetails.timestamp.toISOString()}
|
||||
|
||||
**Message:**
|
||||
${errorDetails.message}
|
||||
|
||||
**Context:**
|
||||
${errorDetails.context ? JSON.stringify(errorDetails.context, null, 2) : 'None'}
|
||||
|
||||
**Steps to Reproduce:**
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected Behavior:**
|
||||
|
||||
|
||||
**Additional Notes:**
|
||||
|
||||
`);
|
||||
|
||||
const issueUrl = `https://github.com/eyaltoledano/claude-task-master/issues/new?title=${issueTitle}&body=${issueBody}`;
|
||||
await vscode.env.openExternal(vscode.Uri.parse(issueUrl));
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify error listeners
|
||||
*/
|
||||
private notifyErrorListeners(errorDetails: ErrorDetails): void {
|
||||
this.errorListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(errorDetails);
|
||||
} catch (error) {
|
||||
logger.error('Error in error listener:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup global error handlers
|
||||
*/
|
||||
private setupGlobalErrorHandlers(): void {
|
||||
// Handle unhandled promise rejections
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
// Create a concrete error class for internal errors
|
||||
class InternalError extends TaskMasterError {
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
severity: ErrorSeverity,
|
||||
context?: Record<string, any>
|
||||
) {
|
||||
super(message, code, ErrorCategory.INTERNAL, severity, context);
|
||||
}
|
||||
}
|
||||
|
||||
const error = new InternalError(
|
||||
'Unhandled Promise Rejection',
|
||||
'UNHANDLED_REJECTION',
|
||||
ErrorSeverity.HIGH,
|
||||
{ reason: String(reason), promise: String(promise) }
|
||||
);
|
||||
this.handleError(error);
|
||||
});
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on('uncaughtException', (error) => {
|
||||
// Create a concrete error class for internal errors
|
||||
class InternalError extends TaskMasterError {
|
||||
constructor(
|
||||
message: string,
|
||||
code: string,
|
||||
severity: ErrorSeverity,
|
||||
context?: Record<string, any>
|
||||
) {
|
||||
super(message, code, ErrorCategory.INTERNAL, severity, context);
|
||||
}
|
||||
}
|
||||
|
||||
const taskMasterError = new InternalError(
|
||||
'Uncaught Exception',
|
||||
'UNCAUGHT_EXCEPTION',
|
||||
ErrorSeverity.CRITICAL,
|
||||
{ originalError: error.message, stack: error.stack }
|
||||
);
|
||||
this.handleCriticalError(taskMasterError);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility functions for error handling
|
||||
*/
|
||||
export function getErrorHandler(): ErrorHandler {
|
||||
return ErrorHandler.getInstance();
|
||||
}
|
||||
|
||||
export function createRecoveryAction(
|
||||
action: () => Promise<void>,
|
||||
description: string
|
||||
) {
|
||||
return {
|
||||
automatic: false,
|
||||
action,
|
||||
description
|
||||
};
|
||||
}
|
||||
|
||||
export function createAutoRecoveryAction(
|
||||
action: () => Promise<void>,
|
||||
description: string
|
||||
) {
|
||||
return {
|
||||
automatic: true,
|
||||
action,
|
||||
description
|
||||
};
|
||||
}
|
||||
|
||||
// Default error categorization rules
|
||||
export const ERROR_CATEGORIZATION_RULES: Record<string, ErrorCategory> = {
|
||||
// Network patterns
|
||||
ECONNREFUSED: ErrorCategory.NETWORK,
|
||||
ENOTFOUND: ErrorCategory.NETWORK,
|
||||
ETIMEDOUT: ErrorCategory.NETWORK,
|
||||
'Network request failed': ErrorCategory.NETWORK,
|
||||
'fetch failed': ErrorCategory.NETWORK,
|
||||
|
||||
// MCP patterns
|
||||
MCP: ErrorCategory.MCP_CONNECTION,
|
||||
'Task Master': ErrorCategory.TASK_MASTER_API,
|
||||
polling: ErrorCategory.TASK_MASTER_API,
|
||||
|
||||
// VS Code patterns
|
||||
vscode: ErrorCategory.VSCODE_API,
|
||||
webview: ErrorCategory.WEBVIEW,
|
||||
extension: ErrorCategory.EXTENSION_HOST,
|
||||
|
||||
// Data patterns
|
||||
JSON: ErrorCategory.DATA_PARSING,
|
||||
parse: ErrorCategory.DATA_PARSING,
|
||||
validation: ErrorCategory.DATA_VALIDATION,
|
||||
invalid: ErrorCategory.DATA_VALIDATION,
|
||||
|
||||
// Permission patterns
|
||||
EACCES: ErrorCategory.PERMISSION,
|
||||
EPERM: ErrorCategory.PERMISSION,
|
||||
permission: ErrorCategory.PERMISSION,
|
||||
|
||||
// File system patterns
|
||||
ENOENT: ErrorCategory.FILE_SYSTEM,
|
||||
EISDIR: ErrorCategory.FILE_SYSTEM,
|
||||
file: ErrorCategory.FILE_SYSTEM
|
||||
};
|
||||
|
||||
// Severity mapping based on error categories
|
||||
export const CATEGORY_SEVERITY_MAPPING: Record<ErrorCategory, ErrorSeverity> = {
|
||||
[ErrorCategory.NETWORK]: ErrorSeverity.MEDIUM,
|
||||
[ErrorCategory.MCP_CONNECTION]: ErrorSeverity.HIGH,
|
||||
[ErrorCategory.TASK_MASTER_API]: ErrorSeverity.HIGH,
|
||||
[ErrorCategory.DATA_VALIDATION]: ErrorSeverity.MEDIUM,
|
||||
[ErrorCategory.DATA_PARSING]: ErrorSeverity.HIGH,
|
||||
[ErrorCategory.TASK_DATA_CORRUPTION]: ErrorSeverity.CRITICAL,
|
||||
[ErrorCategory.VSCODE_API]: ErrorSeverity.HIGH,
|
||||
[ErrorCategory.WEBVIEW]: ErrorSeverity.MEDIUM,
|
||||
[ErrorCategory.EXTENSION_HOST]: ErrorSeverity.CRITICAL,
|
||||
[ErrorCategory.USER_INTERACTION]: ErrorSeverity.LOW,
|
||||
[ErrorCategory.DRAG_DROP]: ErrorSeverity.MEDIUM,
|
||||
[ErrorCategory.COMPONENT_RENDER]: ErrorSeverity.MEDIUM,
|
||||
[ErrorCategory.PERMISSION]: ErrorSeverity.CRITICAL,
|
||||
[ErrorCategory.FILE_SYSTEM]: ErrorSeverity.HIGH,
|
||||
[ErrorCategory.CONFIGURATION]: ErrorSeverity.MEDIUM,
|
||||
[ErrorCategory.UNKNOWN]: ErrorSeverity.HIGH,
|
||||
// Legacy mappings for existing categories
|
||||
[ErrorCategory.TASK_LOADING]: ErrorSeverity.HIGH,
|
||||
[ErrorCategory.UI_RENDERING]: ErrorSeverity.MEDIUM,
|
||||
[ErrorCategory.VALIDATION]: ErrorSeverity.MEDIUM,
|
||||
[ErrorCategory.INTERNAL]: ErrorSeverity.HIGH
|
||||
};
|
||||
|
||||
// Notification type mapping based on severity
|
||||
export const SEVERITY_NOTIFICATION_MAPPING: Record<
|
||||
ErrorSeverity,
|
||||
NotificationType
|
||||
> = {
|
||||
[ErrorSeverity.LOW]: NotificationType.TOAST_INFO,
|
||||
[ErrorSeverity.MEDIUM]: NotificationType.TOAST_WARNING,
|
||||
[ErrorSeverity.HIGH]: NotificationType.VSCODE_WARNING,
|
||||
[ErrorSeverity.CRITICAL]: NotificationType.VSCODE_ERROR
|
||||
};
|
||||
|
||||
/**
|
||||
* Automatically categorize an error based on its message and type
|
||||
*/
|
||||
export function categorizeError(
|
||||
error: Error | unknown,
|
||||
operation?: string
|
||||
): ErrorCategory {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
const searchText =
|
||||
`${errorMessage} ${errorStack || ''} ${operation || ''}`.toLowerCase();
|
||||
|
||||
for (const [pattern, category] of Object.entries(
|
||||
ERROR_CATEGORIZATION_RULES
|
||||
)) {
|
||||
if (searchText.includes(pattern.toLowerCase())) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
return ErrorCategory.UNKNOWN;
|
||||
}
|
||||
|
||||
export function getSuggestedSeverity(category: ErrorCategory): ErrorSeverity {
|
||||
return CATEGORY_SEVERITY_MAPPING[category] || ErrorSeverity.HIGH;
|
||||
}
|
||||
|
||||
export function getSuggestedNotificationType(
|
||||
severity: ErrorSeverity
|
||||
): NotificationType {
|
||||
return (
|
||||
SEVERITY_NOTIFICATION_MAPPING[severity] || NotificationType.CONSOLE_ONLY
|
||||
);
|
||||
}
|
||||
|
||||
export function createErrorContext(
|
||||
error: Error | unknown,
|
||||
operation?: string,
|
||||
overrides?: Partial<ErrorContext>
|
||||
): ErrorContext {
|
||||
const category = categorizeError(error, operation);
|
||||
const severity = getSuggestedSeverity(category);
|
||||
const notificationType = getSuggestedNotificationType(severity);
|
||||
|
||||
const baseContext: ErrorContext = {
|
||||
category,
|
||||
severity,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
originalError: error,
|
||||
operation,
|
||||
timestamp: Date.now(),
|
||||
stackTrace: error instanceof Error ? error.stack : undefined,
|
||||
isRecoverable: severity !== ErrorSeverity.CRITICAL,
|
||||
notificationType,
|
||||
showToUser:
|
||||
severity === ErrorSeverity.HIGH || severity === ErrorSeverity.CRITICAL,
|
||||
logToConsole: true,
|
||||
logToFile:
|
||||
severity === ErrorSeverity.HIGH || severity === ErrorSeverity.CRITICAL
|
||||
};
|
||||
|
||||
return { ...baseContext, ...overrides };
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
104
apps/extension/src/utils/logger.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
/**
|
||||
* Logger interface for dependency injection
|
||||
*/
|
||||
export interface ILogger {
|
||||
log(message: string, ...args: any[]): void;
|
||||
error(message: string, ...args: any[]): void;
|
||||
warn(message: string, ...args: any[]): void;
|
||||
debug(message: string, ...args: any[]): void;
|
||||
show(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger that outputs to VS Code's output channel instead of console
|
||||
* This prevents interference with MCP stdio communication
|
||||
*/
|
||||
export class ExtensionLogger implements ILogger {
|
||||
private static instance: ExtensionLogger;
|
||||
private outputChannel: vscode.OutputChannel;
|
||||
private debugMode: boolean;
|
||||
|
||||
private constructor() {
|
||||
this.outputChannel = vscode.window.createOutputChannel('TaskMaster');
|
||||
const config = vscode.workspace.getConfiguration('taskmaster');
|
||||
this.debugMode = config.get<boolean>('debug.enableLogging', true);
|
||||
}
|
||||
|
||||
static getInstance(): ExtensionLogger {
|
||||
if (!ExtensionLogger.instance) {
|
||||
ExtensionLogger.instance = new ExtensionLogger();
|
||||
}
|
||||
return ExtensionLogger.instance;
|
||||
}
|
||||
|
||||
log(message: string, ...args: any[]): void {
|
||||
if (!this.debugMode) {
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedMessage = this.formatMessage(message, args);
|
||||
this.outputChannel.appendLine(`[${timestamp}] ${formattedMessage}`);
|
||||
}
|
||||
|
||||
error(message: string, ...args: any[]): void {
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedMessage = this.formatMessage(message, args);
|
||||
this.outputChannel.appendLine(`[${timestamp}] ERROR: ${formattedMessage}`);
|
||||
}
|
||||
|
||||
warn(message: string, ...args: any[]): void {
|
||||
if (!this.debugMode) {
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedMessage = this.formatMessage(message, args);
|
||||
this.outputChannel.appendLine(`[${timestamp}] WARN: ${formattedMessage}`);
|
||||
}
|
||||
|
||||
debug(message: string, ...args: any[]): void {
|
||||
if (!this.debugMode) {
|
||||
return;
|
||||
}
|
||||
const timestamp = new Date().toISOString();
|
||||
const formattedMessage = this.formatMessage(message, args);
|
||||
this.outputChannel.appendLine(`[${timestamp}] DEBUG: ${formattedMessage}`);
|
||||
}
|
||||
|
||||
private formatMessage(message: string, args: any[]): string {
|
||||
if (args.length === 0) {
|
||||
return message;
|
||||
}
|
||||
|
||||
// Convert objects to JSON for better readability
|
||||
const formattedArgs = args.map((arg) => {
|
||||
if (typeof arg === 'object' && arg !== null) {
|
||||
try {
|
||||
return JSON.stringify(arg, null, 2);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
return String(arg);
|
||||
});
|
||||
|
||||
return `${message} ${formattedArgs.join(' ')}`;
|
||||
}
|
||||
|
||||
show(): void {
|
||||
this.outputChannel.show();
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.outputChannel.dispose();
|
||||
}
|
||||
|
||||
setDebugMode(enabled: boolean): void {
|
||||
this.debugMode = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance for convenience
|
||||
export const logger = ExtensionLogger.getInstance();
|
||||
390
apps/extension/src/utils/mcpClient.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||
import * as vscode from 'vscode';
|
||||
import { logger } from './logger';
|
||||
|
||||
export interface MCPConfig {
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface MCPServerStatus {
|
||||
isRunning: boolean;
|
||||
pid?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class MCPClientManager {
|
||||
private client: Client | null = null;
|
||||
private transport: StdioClientTransport | null = null;
|
||||
private config: MCPConfig;
|
||||
private status: MCPServerStatus = { isRunning: false };
|
||||
private connectionPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(config: MCPConfig) {
|
||||
logger.log(
|
||||
'🔍 DEBUGGING: MCPClientManager constructor called with config:',
|
||||
config
|
||||
);
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current server status
|
||||
*/
|
||||
getStatus(): MCPServerStatus {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the MCP server process and establish client connection
|
||||
*/
|
||||
async connect(): Promise<void> {
|
||||
if (this.connectionPromise) {
|
||||
return this.connectionPromise;
|
||||
}
|
||||
|
||||
this.connectionPromise = this._doConnect();
|
||||
return this.connectionPromise;
|
||||
}
|
||||
|
||||
private async _doConnect(): Promise<void> {
|
||||
try {
|
||||
// Clean up any existing connections
|
||||
await this.disconnect();
|
||||
|
||||
// Create the transport - it will handle spawning the server process internally
|
||||
logger.log(
|
||||
`Starting MCP server: ${this.config.command} ${this.config.args?.join(' ') || ''}`
|
||||
);
|
||||
logger.log('🔍 DEBUGGING: Transport config cwd:', this.config.cwd);
|
||||
logger.log('🔍 DEBUGGING: Process cwd before spawn:', process.cwd());
|
||||
|
||||
// Test if the target directory and .taskmaster exist
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
try {
|
||||
const targetDir = this.config.cwd;
|
||||
const taskmasterDir = path.join(targetDir, '.taskmaster');
|
||||
const tasksFile = path.join(taskmasterDir, 'tasks', 'tasks.json');
|
||||
|
||||
logger.log(
|
||||
'🔍 DEBUGGING: Checking target directory:',
|
||||
targetDir,
|
||||
'exists:',
|
||||
fs.existsSync(targetDir)
|
||||
);
|
||||
logger.log(
|
||||
'🔍 DEBUGGING: Checking .taskmaster dir:',
|
||||
taskmasterDir,
|
||||
'exists:',
|
||||
fs.existsSync(taskmasterDir)
|
||||
);
|
||||
logger.log(
|
||||
'🔍 DEBUGGING: Checking tasks.json:',
|
||||
tasksFile,
|
||||
'exists:',
|
||||
fs.existsSync(tasksFile)
|
||||
);
|
||||
|
||||
if (fs.existsSync(tasksFile)) {
|
||||
const stats = fs.statSync(tasksFile);
|
||||
logger.log('🔍 DEBUGGING: tasks.json size:', stats.size, 'bytes');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.log('🔍 DEBUGGING: Error checking filesystem:', error);
|
||||
}
|
||||
|
||||
this.transport = new StdioClientTransport({
|
||||
command: this.config.command,
|
||||
args: this.config.args || [],
|
||||
cwd: this.config.cwd,
|
||||
env: {
|
||||
...(Object.fromEntries(
|
||||
Object.entries(process.env).filter(([, v]) => v !== undefined)
|
||||
) as Record<string, string>),
|
||||
...this.config.env
|
||||
}
|
||||
});
|
||||
|
||||
logger.log('🔍 DEBUGGING: Transport created, checking process...');
|
||||
|
||||
// Set up transport event handlers
|
||||
this.transport.onerror = (error: Error) => {
|
||||
logger.error('❌ MCP transport error:', error);
|
||||
logger.error('Transport error details:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
code: (error as any).code,
|
||||
errno: (error as any).errno,
|
||||
syscall: (error as any).syscall
|
||||
});
|
||||
this.status = { isRunning: false, error: error.message };
|
||||
vscode.window.showErrorMessage(
|
||||
`TaskMaster MCP transport error: ${error.message}`
|
||||
);
|
||||
};
|
||||
|
||||
this.transport.onclose = () => {
|
||||
logger.log('🔌 MCP transport closed');
|
||||
this.status = { isRunning: false };
|
||||
this.client = null;
|
||||
this.transport = null;
|
||||
};
|
||||
|
||||
// Add message handler like the working debug script
|
||||
this.transport.onmessage = (message: any) => {
|
||||
logger.log('📤 MCP server message:', message);
|
||||
};
|
||||
|
||||
// Create the client
|
||||
this.client = new Client(
|
||||
{
|
||||
name: 'taskr-vscode-extension',
|
||||
version: '1.0.0'
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Connect the client to the transport (this automatically starts the transport)
|
||||
logger.log('🔄 Attempting MCP client connection...');
|
||||
logger.log('MCP config:', {
|
||||
command: this.config.command,
|
||||
args: this.config.args,
|
||||
cwd: this.config.cwd
|
||||
});
|
||||
logger.log('Current working directory:', process.cwd());
|
||||
logger.log(
|
||||
'VS Code workspace folders:',
|
||||
vscode.workspace.workspaceFolders?.map((f) => f.uri.fsPath)
|
||||
);
|
||||
|
||||
// Check if process was created before connecting
|
||||
if (this.transport && (this.transport as any).process) {
|
||||
const proc = (this.transport as any).process;
|
||||
logger.log('📝 MCP server process PID:', proc.pid);
|
||||
logger.log('📝 Process working directory will be:', this.config.cwd);
|
||||
|
||||
proc.on('exit', (code: number, signal: string) => {
|
||||
logger.log(
|
||||
`🔚 MCP server process exited with code ${code}, signal ${signal}`
|
||||
);
|
||||
if (code !== 0) {
|
||||
logger.log('❌ Non-zero exit code indicates server failure');
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error: Error) => {
|
||||
logger.log('❌ MCP server process error:', error);
|
||||
});
|
||||
|
||||
// Listen to stderr to see server-side errors
|
||||
if (proc.stderr) {
|
||||
proc.stderr.on('data', (data: Buffer) => {
|
||||
logger.log('📥 MCP server stderr:', data.toString());
|
||||
});
|
||||
}
|
||||
|
||||
// Listen to stdout for server messages
|
||||
if (proc.stdout) {
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
logger.log('📤 MCP server stdout:', data.toString());
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.log('⚠️ No process found in transport before connection');
|
||||
}
|
||||
|
||||
await this.client.connect(this.transport);
|
||||
|
||||
// Update status
|
||||
this.status = {
|
||||
isRunning: true,
|
||||
pid: this.transport.pid || undefined
|
||||
};
|
||||
|
||||
logger.log('MCP client connected successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect to MCP server:', error);
|
||||
this.status = {
|
||||
isRunning: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
};
|
||||
|
||||
// Clean up on error
|
||||
await this.disconnect();
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
this.connectionPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the MCP server and clean up resources
|
||||
*/
|
||||
async disconnect(): Promise<void> {
|
||||
logger.log('Disconnecting from MCP server');
|
||||
|
||||
if (this.client) {
|
||||
try {
|
||||
await this.client.close();
|
||||
} catch (error) {
|
||||
logger.error('Error closing MCP client:', error);
|
||||
}
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
if (this.transport) {
|
||||
try {
|
||||
await this.transport.close();
|
||||
} catch (error) {
|
||||
logger.error('Error closing MCP transport:', error);
|
||||
}
|
||||
this.transport = null;
|
||||
}
|
||||
|
||||
this.status = { isRunning: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the MCP client instance (if connected)
|
||||
*/
|
||||
getClient(): Client | null {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an MCP tool
|
||||
*/
|
||||
async callTool(
|
||||
toolName: string,
|
||||
arguments_: Record<string, unknown>
|
||||
): Promise<any> {
|
||||
if (!this.client) {
|
||||
throw new Error('MCP client is not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
// Use the configured timeout or default to 5 minutes
|
||||
const timeout = this.config.timeout || 300000; // 5 minutes default
|
||||
|
||||
logger.log(`Calling MCP tool "${toolName}" with timeout: ${timeout}ms`);
|
||||
|
||||
const result = await this.client.callTool(
|
||||
{
|
||||
name: toolName,
|
||||
arguments: arguments_
|
||||
},
|
||||
undefined,
|
||||
{
|
||||
timeout: timeout
|
||||
}
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`Error calling MCP tool "${toolName}":`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the connection by calling a simple MCP tool
|
||||
*/
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
// Try to list available tools as a connection test
|
||||
if (!this.client) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// listTools is a simple metadata request, no need for extended timeout
|
||||
const result = await this.client.listTools();
|
||||
logger.log(
|
||||
'Available MCP tools:',
|
||||
result.tools?.map((t) => t.name) || []
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Connection test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stderr stream from the transport (if available)
|
||||
*/
|
||||
getStderr(): NodeJS.ReadableStream | null {
|
||||
const stderr = this.transport?.stderr;
|
||||
return stderr ? (stderr as unknown as NodeJS.ReadableStream) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the process ID of the spawned server
|
||||
*/
|
||||
getPid(): number | null {
|
||||
return this.transport?.pid || null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create MCP configuration from VS Code settings
|
||||
*/
|
||||
export function createMCPConfigFromSettings(): MCPConfig {
|
||||
logger.log(
|
||||
'🔍 DEBUGGING: createMCPConfigFromSettings called at',
|
||||
new Date().toISOString()
|
||||
);
|
||||
const config = vscode.workspace.getConfiguration('taskmaster');
|
||||
|
||||
let command = config.get<string>('mcp.command', 'npx');
|
||||
const args = config.get<string[]>('mcp.args', ['task-master-ai']);
|
||||
|
||||
// Use proper VS Code workspace detection
|
||||
const defaultCwd =
|
||||
vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || process.cwd();
|
||||
const cwd = config.get<string>('mcp.cwd', defaultCwd);
|
||||
const env = config.get<Record<string, string>>('mcp.env');
|
||||
const timeout = config.get<number>('mcp.requestTimeoutMs', 300000);
|
||||
|
||||
logger.log('✅ Using workspace directory:', defaultCwd);
|
||||
|
||||
// If using default 'npx', try to find the full path on macOS/Linux
|
||||
if (command === 'npx') {
|
||||
const fs = require('fs');
|
||||
const npxPaths = [
|
||||
'/opt/homebrew/bin/npx', // Homebrew on Apple Silicon
|
||||
'/usr/local/bin/npx', // Homebrew on Intel
|
||||
'/usr/bin/npx', // System npm
|
||||
'npx' // Final fallback to PATH
|
||||
];
|
||||
|
||||
for (const path of npxPaths) {
|
||||
try {
|
||||
if (path === 'npx' || fs.existsSync(path)) {
|
||||
command = path;
|
||||
logger.log(`✅ Using npx at: ${path}`);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue to next path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
command,
|
||||
args,
|
||||
cwd: cwd || defaultCwd,
|
||||
env,
|
||||
timeout
|
||||
};
|
||||
}
|
||||
463
apps/extension/src/utils/notificationPreferences.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ErrorCategory, ErrorSeverity, NotificationType } from './errorHandler';
|
||||
import { logger } from './logger';
|
||||
|
||||
export interface NotificationPreferences {
|
||||
// Global notification toggles
|
||||
enableToastNotifications: boolean;
|
||||
enableVSCodeNotifications: boolean;
|
||||
enableConsoleLogging: boolean;
|
||||
|
||||
// Toast notification settings
|
||||
toastDuration: {
|
||||
info: number;
|
||||
warning: number;
|
||||
error: number;
|
||||
};
|
||||
|
||||
// Category-based preferences
|
||||
categoryPreferences: Record<
|
||||
ErrorCategory,
|
||||
{
|
||||
showToUser: boolean;
|
||||
notificationType: NotificationType;
|
||||
logToConsole: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
// Severity-based preferences
|
||||
severityPreferences: Record<
|
||||
ErrorSeverity,
|
||||
{
|
||||
showToUser: boolean;
|
||||
notificationType: NotificationType;
|
||||
minToastDuration: number;
|
||||
}
|
||||
>;
|
||||
|
||||
// Advanced settings
|
||||
maxToastCount: number;
|
||||
enableErrorTracking: boolean;
|
||||
enableDetailedErrorInfo: boolean;
|
||||
}
|
||||
|
||||
export class NotificationPreferencesManager {
|
||||
private static instance: NotificationPreferencesManager | null = null;
|
||||
private readonly configSection = 'taskMasterKanban';
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): NotificationPreferencesManager {
|
||||
if (!NotificationPreferencesManager.instance) {
|
||||
NotificationPreferencesManager.instance =
|
||||
new NotificationPreferencesManager();
|
||||
}
|
||||
return NotificationPreferencesManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current notification preferences from VS Code settings
|
||||
*/
|
||||
getPreferences(): NotificationPreferences {
|
||||
const config = vscode.workspace.getConfiguration(this.configSection);
|
||||
|
||||
return {
|
||||
enableToastNotifications: config.get('notifications.enableToast', true),
|
||||
enableVSCodeNotifications: config.get('notifications.enableVSCode', true),
|
||||
enableConsoleLogging: config.get('notifications.enableConsole', true),
|
||||
|
||||
toastDuration: {
|
||||
info: config.get('notifications.toastDuration.info', 5000),
|
||||
warning: config.get('notifications.toastDuration.warning', 7000),
|
||||
error: config.get('notifications.toastDuration.error', 10000)
|
||||
},
|
||||
|
||||
categoryPreferences: this.getCategoryPreferences(config),
|
||||
severityPreferences: this.getSeverityPreferences(config),
|
||||
|
||||
maxToastCount: config.get('notifications.maxToastCount', 5),
|
||||
enableErrorTracking: config.get(
|
||||
'notifications.enableErrorTracking',
|
||||
true
|
||||
),
|
||||
enableDetailedErrorInfo: config.get(
|
||||
'notifications.enableDetailedErrorInfo',
|
||||
false
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update notification preferences in VS Code settings
|
||||
*/
|
||||
async updatePreferences(
|
||||
preferences: Partial<NotificationPreferences>
|
||||
): Promise<void> {
|
||||
const config = vscode.workspace.getConfiguration(this.configSection);
|
||||
|
||||
if (preferences.enableToastNotifications !== undefined) {
|
||||
await config.update(
|
||||
'notifications.enableToast',
|
||||
preferences.enableToastNotifications,
|
||||
vscode.ConfigurationTarget.Global
|
||||
);
|
||||
}
|
||||
|
||||
if (preferences.enableVSCodeNotifications !== undefined) {
|
||||
await config.update(
|
||||
'notifications.enableVSCode',
|
||||
preferences.enableVSCodeNotifications,
|
||||
vscode.ConfigurationTarget.Global
|
||||
);
|
||||
}
|
||||
|
||||
if (preferences.enableConsoleLogging !== undefined) {
|
||||
await config.update(
|
||||
'notifications.enableConsole',
|
||||
preferences.enableConsoleLogging,
|
||||
vscode.ConfigurationTarget.Global
|
||||
);
|
||||
}
|
||||
|
||||
if (preferences.toastDuration) {
|
||||
await config.update(
|
||||
'notifications.toastDuration',
|
||||
preferences.toastDuration,
|
||||
vscode.ConfigurationTarget.Global
|
||||
);
|
||||
}
|
||||
|
||||
if (preferences.maxToastCount !== undefined) {
|
||||
await config.update(
|
||||
'notifications.maxToastCount',
|
||||
preferences.maxToastCount,
|
||||
vscode.ConfigurationTarget.Global
|
||||
);
|
||||
}
|
||||
|
||||
if (preferences.enableErrorTracking !== undefined) {
|
||||
await config.update(
|
||||
'notifications.enableErrorTracking',
|
||||
preferences.enableErrorTracking,
|
||||
vscode.ConfigurationTarget.Global
|
||||
);
|
||||
}
|
||||
|
||||
if (preferences.enableDetailedErrorInfo !== undefined) {
|
||||
await config.update(
|
||||
'notifications.enableDetailedErrorInfo',
|
||||
preferences.enableDetailedErrorInfo,
|
||||
vscode.ConfigurationTarget.Global
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notifications should be shown for a specific error category and severity
|
||||
*/
|
||||
shouldShowNotification(
|
||||
category: ErrorCategory,
|
||||
severity: ErrorSeverity
|
||||
): boolean {
|
||||
const preferences = this.getPreferences();
|
||||
|
||||
// Check global toggles first
|
||||
if (
|
||||
!preferences.enableToastNotifications &&
|
||||
!preferences.enableVSCodeNotifications
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check category preferences
|
||||
const categoryPref = preferences.categoryPreferences[category];
|
||||
if (categoryPref && !categoryPref.showToUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check severity preferences
|
||||
const severityPref = preferences.severityPreferences[severity];
|
||||
if (severityPref && !severityPref.showToUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate notification type for an error
|
||||
*/
|
||||
getNotificationType(
|
||||
category: ErrorCategory,
|
||||
severity: ErrorSeverity
|
||||
): NotificationType {
|
||||
const preferences = this.getPreferences();
|
||||
|
||||
// Check category preference first
|
||||
const categoryPref = preferences.categoryPreferences[category];
|
||||
if (categoryPref) {
|
||||
return categoryPref.notificationType;
|
||||
}
|
||||
|
||||
// Fall back to severity preference
|
||||
const severityPref = preferences.severityPreferences[severity];
|
||||
if (severityPref) {
|
||||
return severityPref.notificationType;
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return this.getDefaultNotificationType(severity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get toast duration for a specific severity
|
||||
*/
|
||||
getToastDuration(severity: ErrorSeverity): number {
|
||||
const preferences = this.getPreferences();
|
||||
|
||||
switch (severity) {
|
||||
case ErrorSeverity.LOW:
|
||||
return preferences.toastDuration.info;
|
||||
case ErrorSeverity.MEDIUM:
|
||||
return preferences.toastDuration.warning;
|
||||
case ErrorSeverity.HIGH:
|
||||
case ErrorSeverity.CRITICAL:
|
||||
return preferences.toastDuration.error;
|
||||
default:
|
||||
return preferences.toastDuration.warning;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset preferences to defaults
|
||||
*/
|
||||
async resetToDefaults(): Promise<void> {
|
||||
const config = vscode.workspace.getConfiguration(this.configSection);
|
||||
|
||||
// Reset all notification settings
|
||||
await config.update(
|
||||
'notifications',
|
||||
undefined,
|
||||
vscode.ConfigurationTarget.Global
|
||||
);
|
||||
|
||||
logger.log('Task Master Kanban notification preferences reset to defaults');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category-based preferences with defaults
|
||||
*/
|
||||
private getCategoryPreferences(config: vscode.WorkspaceConfiguration): Record<
|
||||
ErrorCategory,
|
||||
{
|
||||
showToUser: boolean;
|
||||
notificationType: NotificationType;
|
||||
logToConsole: boolean;
|
||||
}
|
||||
> {
|
||||
const defaults = {
|
||||
[ErrorCategory.MCP_CONNECTION]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_ERROR,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.CONFIGURATION]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_WARNING,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.TASK_LOADING]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.TOAST_WARNING,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.UI_RENDERING]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.TOAST_INFO,
|
||||
logToConsole: false
|
||||
},
|
||||
[ErrorCategory.VALIDATION]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.TOAST_WARNING,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.NETWORK]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.TOAST_WARNING,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.INTERNAL]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_ERROR,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.TASK_MASTER_API]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.TOAST_ERROR,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.DATA_VALIDATION]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.TOAST_WARNING,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.DATA_PARSING]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_ERROR,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.TASK_DATA_CORRUPTION]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_ERROR,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.VSCODE_API]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_ERROR,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.WEBVIEW]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.TOAST_WARNING,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.EXTENSION_HOST]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_ERROR,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.USER_INTERACTION]: {
|
||||
showToUser: false,
|
||||
notificationType: NotificationType.CONSOLE_ONLY,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.DRAG_DROP]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.TOAST_INFO,
|
||||
logToConsole: false
|
||||
},
|
||||
[ErrorCategory.COMPONENT_RENDER]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.TOAST_WARNING,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.PERMISSION]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_ERROR,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.FILE_SYSTEM]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_ERROR,
|
||||
logToConsole: true
|
||||
},
|
||||
[ErrorCategory.UNKNOWN]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_WARNING,
|
||||
logToConsole: true
|
||||
}
|
||||
};
|
||||
|
||||
// Allow user overrides from settings
|
||||
const userPreferences = config.get('notifications.categoryPreferences', {});
|
||||
return { ...defaults, ...userPreferences };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get severity-based preferences with defaults
|
||||
*/
|
||||
private getSeverityPreferences(config: vscode.WorkspaceConfiguration): Record<
|
||||
ErrorSeverity,
|
||||
{
|
||||
showToUser: boolean;
|
||||
notificationType: NotificationType;
|
||||
minToastDuration: number;
|
||||
}
|
||||
> {
|
||||
const defaults = {
|
||||
[ErrorSeverity.LOW]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.TOAST_INFO,
|
||||
minToastDuration: 3000
|
||||
},
|
||||
[ErrorSeverity.MEDIUM]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.TOAST_WARNING,
|
||||
minToastDuration: 5000
|
||||
},
|
||||
[ErrorSeverity.HIGH]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_WARNING,
|
||||
minToastDuration: 7000
|
||||
},
|
||||
[ErrorSeverity.CRITICAL]: {
|
||||
showToUser: true,
|
||||
notificationType: NotificationType.VSCODE_ERROR,
|
||||
minToastDuration: 10000
|
||||
}
|
||||
};
|
||||
|
||||
// Allow user overrides from settings
|
||||
const userPreferences = config.get('notifications.severityPreferences', {});
|
||||
return { ...defaults, ...userPreferences };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default notification type for severity
|
||||
*/
|
||||
private getDefaultNotificationType(
|
||||
severity: ErrorSeverity
|
||||
): NotificationType {
|
||||
switch (severity) {
|
||||
case ErrorSeverity.LOW:
|
||||
return NotificationType.TOAST_INFO;
|
||||
case ErrorSeverity.MEDIUM:
|
||||
return NotificationType.TOAST_WARNING;
|
||||
case ErrorSeverity.HIGH:
|
||||
return NotificationType.VSCODE_WARNING;
|
||||
case ErrorSeverity.CRITICAL:
|
||||
return NotificationType.VSCODE_ERROR;
|
||||
default:
|
||||
return NotificationType.CONSOLE_ONLY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export convenience functions
|
||||
export function getNotificationPreferences(): NotificationPreferences {
|
||||
return NotificationPreferencesManager.getInstance().getPreferences();
|
||||
}
|
||||
|
||||
export function updateNotificationPreferences(
|
||||
preferences: Partial<NotificationPreferences>
|
||||
): Promise<void> {
|
||||
return NotificationPreferencesManager.getInstance().updatePreferences(
|
||||
preferences
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldShowNotification(
|
||||
category: ErrorCategory,
|
||||
severity: ErrorSeverity
|
||||
): boolean {
|
||||
return NotificationPreferencesManager.getInstance().shouldShowNotification(
|
||||
category,
|
||||
severity
|
||||
);
|
||||
}
|
||||
|
||||
export function getNotificationType(
|
||||
category: ErrorCategory,
|
||||
severity: ErrorSeverity
|
||||
): NotificationType {
|
||||
return NotificationPreferencesManager.getInstance().getNotificationType(
|
||||
category,
|
||||
severity
|
||||
);
|
||||
}
|
||||
|
||||
export function getToastDuration(severity: ErrorSeverity): number {
|
||||
return NotificationPreferencesManager.getInstance().getToastDuration(
|
||||
severity
|
||||
);
|
||||
}
|
||||
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
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* TaskMaster 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 TaskMaster
|
||||
*/
|
||||
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: String(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: String(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: String(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: String(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
@@ -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,482 @@
|
||||
/**
|
||||
* 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}`
|
||||
),
|
||||
testStrategy: this.validateAndNormalizeString(
|
||||
subtask.testStrategy,
|
||||
undefined,
|
||||
`subtask testStrategy 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;
|
||||
}
|
||||
}
|
||||
157
apps/extension/src/utils/task-master-api/types/index.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* TaskMaster API Types
|
||||
* All type definitions for the TaskMaster 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;
|
||||
testStrategy?: 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;
|
||||
}
|
||||
106
apps/extension/src/webview/App.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Main App Component
|
||||
*/
|
||||
|
||||
import React, { useReducer, useState, useEffect, useRef } from 'react';
|
||||
import { VSCodeContext } from './contexts/VSCodeContext';
|
||||
import { QueryProvider } from './providers/QueryProvider';
|
||||
import { AppContent } from './components/AppContent';
|
||||
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' });
|
||||
|
||||
// React Query will handle task fetching, so we only need to load 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 (
|
||||
<QueryProvider>
|
||||
<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
|
||||
)
|
||||
});
|
||||
}}
|
||||
>
|
||||
<AppContent />
|
||||
<ToastContainer
|
||||
notifications={state.toastNotifications}
|
||||
onDismiss={(id) => dispatch({ type: 'REMOVE_TOAST', payload: id })}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</VSCodeContext.Provider>
|
||||
</QueryProvider>
|
||||
);
|
||||
};
|
||||
33
apps/extension/src/webview/components/AppContent.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { TaskMasterKanban } from './TaskMasterKanban';
|
||||
import TaskDetailsView from '@/components/TaskDetailsView';
|
||||
import { ConfigView } from '@/components/ConfigView';
|
||||
import { useVSCodeContext } from '../contexts/VSCodeContext';
|
||||
|
||||
export const AppContent: React.FC = () => {
|
||||
const { state, dispatch, sendMessage } = useVSCodeContext();
|
||||
|
||||
if (state.currentView === 'config') {
|
||||
return (
|
||||
<ConfigView
|
||||
sendMessage={sendMessage}
|
||||
onNavigateBack={() => dispatch({ type: 'NAVIGATE_TO_KANBAN' })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.currentView === 'task-details' && state.selectedTaskId) {
|
||||
return (
|
||||
<TaskDetailsView
|
||||
taskId={state.selectedTaskId}
|
||||
onNavigateBack={() => dispatch({ type: 'NAVIGATE_TO_KANBAN' })}
|
||||
onNavigateToTask={(taskId: string) =>
|
||||
dispatch({ type: 'NAVIGATE_TO_TASK', payload: taskId })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to Kanban view
|
||||
return <TaskMasterKanban />;
|
||||
};
|
||||
138
apps/extension/src/webview/components/EmptyState.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { ExternalLink, Terminal, MessageSquare, Plus } from 'lucide-react';
|
||||
import { TaskMasterLogo } from '../../components/TaskMasterLogo';
|
||||
|
||||
interface EmptyStateProps {
|
||||
currentTag: string;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({ currentTag }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full overflow-auto">
|
||||
<div className="max-w-2xl mx-auto text-center p-8">
|
||||
{/* Empty state illustration */}
|
||||
<div className="mb-8 max-w-96 mx-auto">
|
||||
<TaskMasterLogo className="w-32 h-32 mx-auto text-vscode-foreground/20" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold mb-2 text-vscode-foreground">
|
||||
No tasks in "{currentTag}" tag
|
||||
</h2>
|
||||
<p className="text-vscode-foreground/70 mb-8">
|
||||
Get started by adding tasks to this tag using the commands below
|
||||
</p>
|
||||
|
||||
{/* Command suggestions */}
|
||||
<div className="space-y-4 text-left">
|
||||
<div className="bg-vscode-editor-background/50 border border-vscode-panel-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Terminal className="w-4 h-4 text-vscode-terminal-ansiGreen" />
|
||||
<h3 className="font-medium">CLI Commands</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="bg-vscode-editor-background rounded p-2 font-mono text-sm">
|
||||
<span className="text-vscode-terminal-ansiYellow">
|
||||
task-master
|
||||
</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiCyan">parse-prd</span>{' '}
|
||||
<span className="text-vscode-foreground/70">
|
||||
<path-to-prd>
|
||||
</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiMagenta">
|
||||
--append
|
||||
</span>
|
||||
<div className="text-xs text-vscode-foreground/50 mt-1">
|
||||
Parse a PRD and append tasks to current tag
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-vscode-editor-background rounded p-2 font-mono text-sm">
|
||||
<span className="text-vscode-terminal-ansiYellow">
|
||||
task-master
|
||||
</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiCyan">add-task</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiMagenta">
|
||||
--prompt
|
||||
</span>{' '}
|
||||
<span className="text-vscode-foreground/70">
|
||||
"Your task description"
|
||||
</span>
|
||||
<div className="text-xs text-vscode-foreground/50 mt-1">
|
||||
Add a single task with AI assistance
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-vscode-editor-background rounded p-2 font-mono text-sm">
|
||||
<span className="text-vscode-terminal-ansiYellow">
|
||||
task-master
|
||||
</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiCyan">add-task</span>{' '}
|
||||
<span className="text-vscode-terminal-ansiMagenta">--help</span>
|
||||
<div className="text-xs text-vscode-foreground/50 mt-1">
|
||||
View all options for adding tasks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-vscode-editor-background/50 border border-vscode-panel-border rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<MessageSquare className="w-4 h-4 text-vscode-textLink-foreground" />
|
||||
<h3 className="font-medium">MCP Examples</h3>
|
||||
</div>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<Plus className="w-4 h-4 mt-0.5 text-vscode-foreground/50" />
|
||||
<div>
|
||||
<div className="text-vscode-foreground">
|
||||
"Add a task to tag {currentTag}: Implement user
|
||||
authentication"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Plus className="w-4 h-4 mt-0.5 text-vscode-foreground/50" />
|
||||
<div>
|
||||
<div className="text-vscode-foreground">
|
||||
"Parse this PRD and add tasks to {currentTag}: [paste PRD
|
||||
content]"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Plus className="w-4 h-4 mt-0.5 text-vscode-foreground/50" />
|
||||
<div>
|
||||
<div className="text-vscode-foreground">
|
||||
"Create 5 tasks for building a REST API in tag {currentTag}"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documentation link */}
|
||||
<div className="flex justify-center pt-4">
|
||||
<a
|
||||
href="https://docs.task-master.dev"
|
||||
className="inline-flex items-center gap-2 text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground transition-colors"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
// Use VS Code API to open external link
|
||||
if (window.acquireVsCodeApi) {
|
||||
const vscode = window.acquireVsCodeApi();
|
||||
vscode.postMessage({
|
||||
type: 'openExternal',
|
||||
url: 'https://docs.task-master.dev'
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">
|
||||
View TaskMaster Documentation
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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
@@ -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
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Priority Badge Component
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { TaskMasterTask } from '../types';
|
||||
|
||||
interface PriorityBadgeProps {
|
||||
priority: TaskMasterTask['priority'];
|
||||
}
|
||||
|
||||
export const PriorityBadge: React.FC<PriorityBadgeProps> = ({ priority }) => {
|
||||
if (!priority) return null;
|
||||
|
||||
const variants = {
|
||||
high: 'destructive' as const,
|
||||
medium: 'default' as const,
|
||||
low: 'secondary' as const
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant={variants[priority] || 'secondary'}
|
||||
className="text-xs font-normal px-2 py-0.5"
|
||||
>
|
||||
{priority}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
52
apps/extension/src/webview/components/SidebarView.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TaskMasterLogo } from '../../components/TaskMasterLogo';
|
||||
|
||||
interface SidebarViewProps {
|
||||
initialConnectionStatus?: boolean;
|
||||
}
|
||||
|
||||
// Acquire VS Code API only once globally to avoid "already acquired" error
|
||||
const vscode = window.acquireVsCodeApi ? window.acquireVsCodeApi() : null;
|
||||
|
||||
export const SidebarView: React.FC<SidebarViewProps> = ({
|
||||
initialConnectionStatus = false
|
||||
}) => {
|
||||
const [isConnected, setIsConnected] = useState(initialConnectionStatus);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const message = event.data;
|
||||
if (message.type === 'connectionStatus') {
|
||||
setIsConnected(message.data.isConnected);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleOpenBoard = () => {
|
||||
vscode?.postMessage({ command: 'openBoard' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center p-6">
|
||||
<div className="text-center">
|
||||
<TaskMasterLogo className="w-20 h-20 mx-auto mb-5 opacity-80 text-vscode-foreground" />
|
||||
|
||||
<h2 className="text-xl font-semibold mb-6 text-vscode-foreground">
|
||||
TaskMaster
|
||||
</h2>
|
||||
|
||||
<button
|
||||
onClick={handleOpenBoard}
|
||||
className="w-full px-4 py-2 bg-vscode-button-background text-vscode-button-foreground rounded hover:bg-vscode-button-hoverBackground transition-colors text-sm font-medium"
|
||||
>
|
||||
Open Kanban Board
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
80
apps/extension/src/webview/components/TaskCard.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Task Card Component for Kanban Board
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { KanbanCard } from '@/components/ui/shadcn-io/kanban';
|
||||
import { PriorityBadge } from './PriorityBadge';
|
||||
import type { TaskMasterTask } from '../types';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: TaskMasterTask;
|
||||
dragging?: boolean;
|
||||
onViewDetails?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
task,
|
||||
dragging,
|
||||
onViewDetails
|
||||
}) => {
|
||||
const handleCardClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
onViewDetails?.(task.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<KanbanCard
|
||||
id={task.id}
|
||||
name={task.title}
|
||||
index={0} // Index is not used in our implementation
|
||||
parent={task.status}
|
||||
className="cursor-pointer p-3 transition-shadow hover:shadow-md bg-vscode-editor-background border-vscode-border group"
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
<div className="space-y-3 h-full flex flex-col">
|
||||
<div className="flex items-start justify-between gap-2 flex-shrink-0">
|
||||
<h3 className="font-medium text-sm leading-tight flex-1 min-w-0 text-vscode-foreground">
|
||||
{task.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<PriorityBadge priority={task.priority} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{task.description && (
|
||||
<p className="text-xs text-vscode-foreground/70 line-clamp-3 leading-relaxed flex-1 min-h-0">
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs mt-auto pt-2 flex-shrink-0 border-t border-vscode-border/20">
|
||||
<span className="font-mono text-vscode-foreground/50 flex-shrink-0">
|
||||
#{task.id}
|
||||
</span>
|
||||
{task.dependencies && task.dependencies.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-vscode-foreground/50 flex-shrink-0 ml-2">
|
||||
<span>Deps:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{task.dependencies.map((depId, index) => (
|
||||
<React.Fragment key={depId}>
|
||||
<button
|
||||
className="font-mono hover:text-vscode-link-activeForeground hover:underline transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewDetails?.(depId);
|
||||
}}
|
||||
>
|
||||
#{depId}
|
||||
</button>
|
||||
{index < task.dependencies!.length - 1 && <span>,</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</KanbanCard>
|
||||
);
|
||||
};
|
||||
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>
|
||||
);
|
||||
};
|
||||
412
apps/extension/src/webview/components/TaskMasterKanban.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Main Kanban Board Component
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
type DragEndEvent,
|
||||
KanbanBoard,
|
||||
KanbanCards,
|
||||
KanbanHeader,
|
||||
KanbanProvider
|
||||
} from '@/components/ui/shadcn-io/kanban';
|
||||
import { TaskCard } from './TaskCard';
|
||||
import { TaskEditModal } from './TaskEditModal';
|
||||
import { PollingStatus } from './PollingStatus';
|
||||
import { TagDropdown } from './TagDropdown';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { useVSCodeContext } from '../contexts/VSCodeContext';
|
||||
import {
|
||||
useTasks,
|
||||
useUpdateTaskStatus,
|
||||
useUpdateTask,
|
||||
taskKeys
|
||||
} from '../hooks/useTaskQueries';
|
||||
import { kanbanStatuses, HEADER_HEIGHT } from '../constants';
|
||||
import type { TaskMasterTask, TaskUpdates } from '../types';
|
||||
|
||||
export const TaskMasterKanban: React.FC = () => {
|
||||
const { state, dispatch, sendMessage, availableHeight } = useVSCodeContext();
|
||||
const queryClient = useQueryClient();
|
||||
const {
|
||||
error: legacyError,
|
||||
editingTask,
|
||||
polling,
|
||||
currentTag,
|
||||
availableTags
|
||||
} = state;
|
||||
const [activeTask, setActiveTask] = useState<TaskMasterTask | null>(null);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
// Use React Query to fetch tasks
|
||||
const {
|
||||
data: serverTasks = [],
|
||||
isLoading,
|
||||
error,
|
||||
isFetching,
|
||||
isSuccess
|
||||
} = useTasks({ tag: currentTag });
|
||||
const updateTaskStatus = useUpdateTaskStatus();
|
||||
const updateTask = useUpdateTask();
|
||||
|
||||
// Debug logging
|
||||
console.log('🔍 TaskMasterKanban Query State:', {
|
||||
isLoading,
|
||||
isFetching,
|
||||
isSuccess,
|
||||
tasksCount: serverTasks?.length,
|
||||
error
|
||||
});
|
||||
|
||||
// Temporary state only for active drag operations
|
||||
const [tempReorderedTasks, setTempReorderedTasks] = useState<
|
||||
TaskMasterTask[] | null
|
||||
>(null);
|
||||
|
||||
// Use temp tasks only if actively set, otherwise use server tasks
|
||||
const tasks = tempReorderedTasks ?? serverTasks;
|
||||
|
||||
// Calculate header height for proper kanban board sizing
|
||||
const kanbanHeight = availableHeight - HEADER_HEIGHT;
|
||||
|
||||
// Group tasks by status
|
||||
const tasksByStatus = kanbanStatuses.reduce(
|
||||
(acc, status) => {
|
||||
acc[status.id] = tasks.filter((task) => task.status === status.id);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, TaskMasterTask[]>
|
||||
);
|
||||
|
||||
// Debug logging
|
||||
console.log('TaskMasterKanban render:', {
|
||||
tasksCount: tasks.length,
|
||||
currentTag,
|
||||
tasksByStatus: Object.entries(tasksByStatus).map(([status, tasks]) => ({
|
||||
status,
|
||||
count: tasks.length,
|
||||
taskIds: tasks.map((t) => t.id)
|
||||
})),
|
||||
allTaskIds: tasks.map((t) => ({ id: t.id, title: t.title }))
|
||||
});
|
||||
|
||||
// Handle task update
|
||||
const handleUpdateTask = async (taskId: string, updates: TaskUpdates) => {
|
||||
console.log(`🔄 Updating task ${taskId} content:`, updates);
|
||||
|
||||
try {
|
||||
await updateTask.mutateAsync({
|
||||
taskId,
|
||||
updates,
|
||||
options: { append: false, research: false }
|
||||
});
|
||||
|
||||
console.log(`✅ Task ${taskId} content updated successfully`);
|
||||
|
||||
// Close the edit modal
|
||||
dispatch({
|
||||
type: 'SET_EDITING_TASK',
|
||||
payload: { taskId: null }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to update task ${taskId}:`, error);
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: `Failed to update task: ${error}`
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drag start
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
const taskId = event.active.id as string;
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (task) {
|
||||
setActiveTask(task);
|
||||
}
|
||||
},
|
||||
[tasks]
|
||||
);
|
||||
|
||||
// Handle drag cancel
|
||||
const handleDragCancel = useCallback(() => {
|
||||
setActiveTask(null);
|
||||
// Clear any temporary state
|
||||
setTempReorderedTasks(null);
|
||||
}, []);
|
||||
|
||||
// Handle drag end
|
||||
const handleDragEnd = useCallback(
|
||||
async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
// Reset active task
|
||||
setActiveTask(null);
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
// Clear any temp state if drag was cancelled
|
||||
setTempReorderedTasks(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = active.id as string;
|
||||
const newStatus = over.id as TaskMasterTask['status'];
|
||||
|
||||
// Find the task
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
if (!task || task.status === newStatus) {
|
||||
// Clear temp state if no change needed
|
||||
setTempReorderedTasks(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the optimistically reordered tasks
|
||||
const reorderedTasks = tasks.map((t) =>
|
||||
t.id === taskId ? { ...t, status: newStatus } : t
|
||||
);
|
||||
|
||||
// Set temporary state to show immediate visual feedback
|
||||
setTempReorderedTasks(reorderedTasks);
|
||||
|
||||
try {
|
||||
// Update on server - React Query will handle optimistic updates
|
||||
await updateTaskStatus.mutateAsync({ taskId, newStatus });
|
||||
// Clear temp state after mutation starts successfully
|
||||
setTempReorderedTasks(null);
|
||||
} catch (error) {
|
||||
// On error, clear temp state - React Query will revert optimistic update
|
||||
setTempReorderedTasks(null);
|
||||
dispatch({
|
||||
type: 'SET_ERROR',
|
||||
payload: `Failed to update task status: ${error}`
|
||||
});
|
||||
}
|
||||
},
|
||||
[tasks, updateTaskStatus, dispatch]
|
||||
);
|
||||
|
||||
// Handle retry connection
|
||||
const handleRetry = useCallback(() => {
|
||||
sendMessage({ type: 'retryConnection' });
|
||||
}, [sendMessage]);
|
||||
|
||||
// Handle refresh
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
// Invalidate all task queries
|
||||
await queryClient.invalidateQueries({ queryKey: taskKeys.all });
|
||||
} finally {
|
||||
// Reset after a short delay to show the animation
|
||||
setTimeout(() => setIsRefreshing(false), 500);
|
||||
}
|
||||
}, [queryClient]);
|
||||
|
||||
// Handle tag switching
|
||||
const handleTagSwitch = useCallback(
|
||||
async (tagName: string) => {
|
||||
console.log('Switching to tag:', tagName);
|
||||
await sendMessage({ type: 'switchTag', data: { tagName } });
|
||||
dispatch({
|
||||
type: 'SET_TAG_DATA',
|
||||
payload: { currentTag: tagName, availableTags }
|
||||
});
|
||||
},
|
||||
[sendMessage, dispatch, availableTags]
|
||||
);
|
||||
|
||||
// Use React Query loading state
|
||||
const displayError = error
|
||||
? error instanceof Error
|
||||
? error.message
|
||||
: String(error)
|
||||
: legacyError;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
style={{ height: `${kanbanHeight}px` }}
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-vscode-foreground mx-auto mb-4" />
|
||||
<p className="text-sm text-vscode-foreground/70">Loading tasks...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayError) {
|
||||
return (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 m-4">
|
||||
<p className="text-red-400 text-sm">Error: {displayError}</p>
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'CLEAR_ERROR' })}
|
||||
className="mt-2 text-sm text-red-400 hover:text-red-300 underline"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col" style={{ height: `${availableHeight}px` }}>
|
||||
<div className="flex-shrink-0 p-4 bg-vscode-sidebar-background border-b border-vscode-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold text-vscode-foreground">
|
||||
TaskMaster Kanban
|
||||
</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<TagDropdown
|
||||
currentTag={currentTag}
|
||||
availableTags={availableTags}
|
||||
onTagSwitch={handleTagSwitch}
|
||||
sendMessage={sendMessage}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="p-1.5 rounded hover:bg-vscode-button-hoverBackground transition-colors"
|
||||
title="Refresh tasks"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 text-vscode-foreground/70 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
<PollingStatus polling={polling} onRetry={handleRetry} />
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${state.isConnected ? 'bg-green-400' : 'bg-red-400'}`}
|
||||
/>
|
||||
<span className="text-xs text-vscode-foreground/70">
|
||||
{state.connectionStatus}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dispatch({ type: 'NAVIGATE_TO_CONFIG' })}
|
||||
className="p-1.5 rounded hover:bg-vscode-button-hoverBackground transition-colors"
|
||||
title="TaskMaster Configuration"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 text-vscode-foreground/70"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 px-4 py-4 overflow-hidden"
|
||||
style={{ height: `${kanbanHeight}px` }}
|
||||
>
|
||||
{tasks.length === 0 ? (
|
||||
<EmptyState currentTag={currentTag} />
|
||||
) : (
|
||||
<KanbanProvider
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
className="kanban-container w-full h-full overflow-x-auto overflow-y-hidden"
|
||||
dragOverlay={
|
||||
activeTask ? <TaskCard task={activeTask} dragging /> : null
|
||||
}
|
||||
>
|
||||
<div className="flex gap-4 h-full min-w-fit">
|
||||
{kanbanStatuses.map((status) => {
|
||||
const statusTasks = tasksByStatus[status.id] || [];
|
||||
const hasScrollbar = statusTasks.length > 4;
|
||||
|
||||
return (
|
||||
<KanbanBoard
|
||||
key={status.id}
|
||||
id={status.id}
|
||||
className={`
|
||||
w-80 flex flex-col
|
||||
border border-vscode-border/30
|
||||
rounded-lg
|
||||
bg-vscode-sidebar-background/50
|
||||
`}
|
||||
>
|
||||
<KanbanHeader
|
||||
name={`${status.title} (${statusTasks.length})`}
|
||||
color={status.color}
|
||||
className="px-3 py-3 text-sm font-medium flex-shrink-0 border-b border-vscode-border/30"
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
flex flex-col gap-2
|
||||
overflow-y-auto overflow-x-hidden
|
||||
p-2
|
||||
scrollbar-thin scrollbar-track-transparent
|
||||
${hasScrollbar ? 'pr-1' : ''}
|
||||
`}
|
||||
style={{
|
||||
maxHeight: `${kanbanHeight - 80}px`
|
||||
}}
|
||||
>
|
||||
<KanbanCards>
|
||||
{statusTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onViewDetails={(taskId) => {
|
||||
console.log(
|
||||
'🔍 Navigating to task details:',
|
||||
taskId
|
||||
);
|
||||
dispatch({
|
||||
type: 'NAVIGATE_TO_TASK',
|
||||
payload: taskId
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</KanbanCards>
|
||||
</div>
|
||||
</KanbanBoard>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</KanbanProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task Edit Modal */}
|
||||
{editingTask?.taskId && editingTask.editData && (
|
||||
<TaskEditModal
|
||||
task={editingTask.editData}
|
||||
onSave={handleUpdateTask}
|
||||
onCancel={() => {
|
||||
dispatch({
|
||||
type: 'SET_EDITING_TASK',
|
||||
payload: { taskId: null }
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
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
@@ -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>
|
||||
);
|
||||
};
|
||||
42
apps/extension/src/webview/constants/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Application constants
|
||||
*/
|
||||
|
||||
import type { Status } from '@/components/ui/shadcn-io/kanban';
|
||||
|
||||
export const kanbanStatuses = [
|
||||
{
|
||||
id: 'pending',
|
||||
title: 'Pending',
|
||||
color: 'yellow',
|
||||
className: 'text-yellow-600 border-yellow-600/20'
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
title: 'In Progress',
|
||||
color: 'blue',
|
||||
className: 'text-blue-600 border-blue-600/20'
|
||||
},
|
||||
{
|
||||
id: 'review',
|
||||
title: 'Review',
|
||||
color: 'purple',
|
||||
className: 'text-purple-600 border-purple-600/20'
|
||||
},
|
||||
{
|
||||
id: 'done',
|
||||
title: 'Done',
|
||||
color: 'green',
|
||||
className: 'text-green-600 border-green-600/20'
|
||||
},
|
||||
{
|
||||
id: 'deferred',
|
||||
title: 'Deferred',
|
||||
color: 'gray',
|
||||
className: 'text-gray-600 border-gray-600/20'
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const CACHE_DURATION = 30000; // 30 seconds
|
||||
export const 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
@@ -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;
|
||||
};
|
||||
229
apps/extension/src/webview/hooks/useTaskQueries.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useVSCodeContext } from '../contexts/VSCodeContext';
|
||||
import type { TaskMasterTask, TaskUpdates } from '../types';
|
||||
|
||||
// Query keys factory
|
||||
export const taskKeys = {
|
||||
all: ['tasks'] as const,
|
||||
lists: () => [...taskKeys.all, 'list'] as const,
|
||||
list: (filters: { tag?: string; status?: string }) =>
|
||||
[...taskKeys.lists(), filters] as const,
|
||||
details: () => [...taskKeys.all, 'detail'] as const,
|
||||
detail: (id: string) => [...taskKeys.details(), id] as const
|
||||
};
|
||||
|
||||
// Hook to fetch all tasks
|
||||
export function useTasks(options?: { tag?: string; status?: string }) {
|
||||
const { sendMessage } = useVSCodeContext();
|
||||
|
||||
return useQuery({
|
||||
queryKey: taskKeys.list(options || {}),
|
||||
queryFn: async () => {
|
||||
console.log('🔍 Fetching tasks with options:', options);
|
||||
const response = await sendMessage({
|
||||
type: 'getTasks',
|
||||
data: {
|
||||
tag: options?.tag,
|
||||
withSubtasks: true
|
||||
}
|
||||
});
|
||||
console.log('📋 Tasks fetched:', response);
|
||||
return response as TaskMasterTask[];
|
||||
},
|
||||
staleTime: 0 // Consider data stale immediately
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to fetch a single task with full details
|
||||
export function useTaskDetails(taskId: string) {
|
||||
const { sendMessage } = useVSCodeContext();
|
||||
|
||||
return useQuery({
|
||||
queryKey: taskKeys.detail(taskId),
|
||||
queryFn: async () => {
|
||||
const response = await sendMessage({
|
||||
type: 'mcpRequest',
|
||||
tool: 'get_task',
|
||||
params: {
|
||||
id: taskId
|
||||
}
|
||||
});
|
||||
|
||||
// Parse the MCP response
|
||||
let fullTaskData = null;
|
||||
if (response?.data?.content?.[0]?.text) {
|
||||
try {
|
||||
const parsed = JSON.parse(response.data.content[0].text);
|
||||
fullTaskData = parsed.data;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse MCP response:', e);
|
||||
}
|
||||
} else if (response?.data?.data) {
|
||||
fullTaskData = response.data.data;
|
||||
}
|
||||
|
||||
return fullTaskData as TaskMasterTask;
|
||||
},
|
||||
enabled: !!taskId
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to update task status
|
||||
export function useUpdateTaskStatus() {
|
||||
const { sendMessage } = useVSCodeContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
taskId,
|
||||
newStatus
|
||||
}: {
|
||||
taskId: string;
|
||||
newStatus: TaskMasterTask['status'];
|
||||
}) => {
|
||||
const response = await sendMessage({
|
||||
type: 'updateTaskStatus',
|
||||
data: { taskId, newStatus }
|
||||
});
|
||||
return { taskId, newStatus, response };
|
||||
},
|
||||
// Optimistic update to prevent snap-back
|
||||
onMutate: async ({ taskId, newStatus }) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: taskKeys.all });
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousTasks = queryClient.getQueriesData({
|
||||
queryKey: taskKeys.all
|
||||
});
|
||||
|
||||
// Optimistically update all task queries
|
||||
queryClient.setQueriesData({ queryKey: taskKeys.all }, (old: any) => {
|
||||
if (!old) return old;
|
||||
|
||||
// Handle both array and object responses
|
||||
if (Array.isArray(old)) {
|
||||
return old.map((task: TaskMasterTask) =>
|
||||
task.id === taskId ? { ...task, status: newStatus } : task
|
||||
);
|
||||
}
|
||||
|
||||
return old;
|
||||
});
|
||||
|
||||
// Return a context object with the snapshot
|
||||
return { previousTasks };
|
||||
},
|
||||
// If the mutation fails, roll back to the previous value
|
||||
onError: (err, variables, context) => {
|
||||
if (context?.previousTasks) {
|
||||
context.previousTasks.forEach(([queryKey, data]) => {
|
||||
queryClient.setQueryData(queryKey, data);
|
||||
});
|
||||
}
|
||||
},
|
||||
// Always refetch after error or success to ensure consistency
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: taskKeys.all });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to update task content
|
||||
export function useUpdateTask() {
|
||||
const { sendMessage } = useVSCodeContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
taskId,
|
||||
updates,
|
||||
options = {}
|
||||
}: {
|
||||
taskId: string;
|
||||
updates: TaskUpdates | { description: string };
|
||||
options?: { append?: boolean; research?: boolean };
|
||||
}) => {
|
||||
console.log('🔄 Updating task:', taskId, updates, options);
|
||||
|
||||
const response = await sendMessage({
|
||||
type: 'updateTask',
|
||||
data: { taskId, updates, options }
|
||||
});
|
||||
|
||||
console.log('📥 Update task response:', response);
|
||||
|
||||
// Check for error in response
|
||||
if (response && typeof response === 'object' && 'error' in response) {
|
||||
throw new Error(response.error || 'Failed to update task');
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
onSuccess: async (data, variables) => {
|
||||
console.log('✅ Task update successful, invalidating all task queries');
|
||||
console.log('Response data:', data);
|
||||
console.log('Task ID:', variables.taskId);
|
||||
|
||||
// Invalidate ALL task-related queries (same as handleRefresh)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: taskKeys.all
|
||||
});
|
||||
|
||||
console.log(
|
||||
'🔄 All task queries invalidated for task:',
|
||||
variables.taskId
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hook to update subtask
|
||||
export function useUpdateSubtask() {
|
||||
const { sendMessage } = useVSCodeContext();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
taskId,
|
||||
prompt,
|
||||
options = {}
|
||||
}: {
|
||||
taskId: string;
|
||||
prompt: string;
|
||||
options?: { research?: boolean };
|
||||
}) => {
|
||||
console.log('🔄 Updating subtask:', taskId, prompt, options);
|
||||
|
||||
const response = await sendMessage({
|
||||
type: 'updateSubtask',
|
||||
data: { taskId, prompt, options }
|
||||
});
|
||||
|
||||
console.log('📥 Update subtask response:', response);
|
||||
|
||||
// Check for error in response
|
||||
if (response && typeof response === 'object' && 'error' in response) {
|
||||
throw new Error(response.error || 'Failed to update subtask');
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
onSuccess: async (data, variables) => {
|
||||
console.log(
|
||||
'✅ Subtask update successful, invalidating all task queries'
|
||||
);
|
||||
console.log('Subtask ID:', variables.taskId);
|
||||
|
||||
// Invalidate ALL task-related queries (same as handleRefresh)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: taskKeys.all
|
||||
});
|
||||
|
||||
console.log(
|
||||
'🔄 All task queries invalidated for subtask:',
|
||||
variables.taskId
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
242
apps/extension/src/webview/hooks/useVSCodeMessages.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 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 '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
@@ -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;
|
||||
};
|
||||
242
apps/extension/src/webview/index.css
Normal file
@@ -0,0 +1,242 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* shadcn/ui CSS variables */
|
||||
@theme {
|
||||
/* VS Code CSS variables will be injected here */
|
||||
/* color-scheme: var(--vscode-theme-kind, light); */
|
||||
|
||||
/* shadcn/ui variables - adapted for VS Code */
|
||||
--color-background: var(--vscode-editor-background);
|
||||
--color-sidebar-background: var(--vscode-sideBar-background);
|
||||
--color-foreground: var(--vscode-foreground);
|
||||
--color-card: var(--vscode-editor-background);
|
||||
--color-card-foreground: var(--vscode-foreground);
|
||||
--color-popover: var(--vscode-editor-background);
|
||||
--color-popover-foreground: var(--vscode-foreground);
|
||||
--color-primary: var(--vscode-button-background);
|
||||
--color-primary-foreground: var(--vscode-button-foreground);
|
||||
--color-secondary: var(--vscode-button-secondaryBackground);
|
||||
--color-secondary-foreground: var(--vscode-button-secondaryForeground);
|
||||
--color-widget-background: var(--vscode-editorWidget-background);
|
||||
--color-widget-border: var(--vscode-editorWidget-border);
|
||||
--color-code-snippet-background: var(--vscode-textPreformat-background);
|
||||
--color-code-snippet-text: var(--vscode-textPreformat-foreground);
|
||||
--font-editor-font: var(--vscode-editor-font-family);
|
||||
--font-editor-size: var(--vscode-editor-font-size);
|
||||
--color-input-background: var(--vscode-input-background);
|
||||
--color-input-foreground: var(--vscode-input-foreground);
|
||||
--color-accent: var(--vscode-focusBorder);
|
||||
--color-accent-foreground: var(--vscode-foreground);
|
||||
--color-destructive: var(--vscode-errorForeground);
|
||||
--color-destructive-foreground: var(--vscode-foreground);
|
||||
--color-border: var(--vscode-panel-border);
|
||||
--color-ring: var(--vscode-focusBorder);
|
||||
--color-link: var(--vscode-editorLink-foreground);
|
||||
--color-link-hover: var(--vscode-editorLink-activeForeground);
|
||||
--color-textSeparator-foreground: var(--vscode-textSeparator-foreground);
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* VS Code specific color mappings for Tailwind utilities */
|
||||
--color-vscode-foreground: var(--vscode-foreground);
|
||||
--color-vscode-button-background: var(--vscode-button-background);
|
||||
--color-vscode-button-foreground: var(--vscode-button-foreground);
|
||||
--color-vscode-button-hoverBackground: var(--vscode-button-hoverBackground);
|
||||
--color-vscode-editor-background: var(--vscode-editor-background);
|
||||
--color-vscode-input-background: var(--vscode-input-background);
|
||||
--color-vscode-input-foreground: var(--vscode-input-foreground);
|
||||
--color-vscode-dropdown-background: var(--vscode-dropdown-background);
|
||||
--color-vscode-dropdown-foreground: var(--vscode-dropdown-foreground);
|
||||
--color-vscode-dropdown-border: var(--vscode-dropdown-border);
|
||||
--color-vscode-focusBorder: var(--vscode-focusBorder);
|
||||
--color-vscode-panel-border: var(--vscode-panel-border);
|
||||
--color-vscode-sideBar-background: var(--vscode-sideBar-background);
|
||||
--color-vscode-sideBar-foreground: var(--vscode-sideBar-foreground);
|
||||
--color-vscode-sideBarTitle-foreground: var(--vscode-sideBarTitle-foreground);
|
||||
--color-vscode-testing-iconPassed: var(--vscode-testing-iconPassed);
|
||||
--color-vscode-testing-iconFailed: var(--vscode-testing-iconFailed);
|
||||
--color-vscode-errorForeground: var(--vscode-errorForeground);
|
||||
--color-vscode-editorWidget-background: var(--vscode-editorWidget-background);
|
||||
--color-vscode-editorWidget-border: var(--vscode-editorWidget-border);
|
||||
--color-vscode-list-hoverBackground: var(--vscode-list-hoverBackground);
|
||||
--color-vscode-list-activeSelectionBackground: var(
|
||||
--vscode-list-activeSelectionBackground
|
||||
);
|
||||
--color-vscode-list-activeSelectionForeground: var(
|
||||
--vscode-list-activeSelectionForeground
|
||||
);
|
||||
--color-vscode-badge-background: var(--vscode-badge-background);
|
||||
--color-vscode-badge-foreground: var(--vscode-badge-foreground);
|
||||
--color-vscode-textLink-foreground: var(--vscode-textLink-foreground);
|
||||
--color-vscode-textLink-activeForeground: var(
|
||||
--vscode-textLink-activeForeground
|
||||
);
|
||||
--color-vscode-icon-foreground: var(--vscode-icon-foreground);
|
||||
--color-vscode-descriptionForeground: var(--vscode-descriptionForeground);
|
||||
--color-vscode-disabledForeground: var(--vscode-disabledForeground);
|
||||
}
|
||||
|
||||
/* Reset body to match VS Code styles instead of Tailwind defaults */
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--vscode-editor-background) !important;
|
||||
color: var(--vscode-foreground) !important;
|
||||
font-family: var(--vscode-font-family) !important;
|
||||
font-size: var(--vscode-font-size) !important;
|
||||
font-weight: var(--vscode-font-weight) !important;
|
||||
line-height: 1.4 !important;
|
||||
}
|
||||
|
||||
/* Ensure root container takes full space */
|
||||
#root {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Override any conflicting Tailwind defaults for VS Code integration */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Ensure buttons and inputs use VS Code styling */
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced scrollbar styling for Kanban board */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--vscode-scrollbarSlider-background, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(
|
||||
--vscode-scrollbarSlider-hoverBackground,
|
||||
rgba(255, 255, 255, 0.2)
|
||||
);
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(
|
||||
--vscode-scrollbarSlider-activeBackground,
|
||||
rgba(255, 255, 255, 0.3)
|
||||
);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: var(--vscode-scrollbarSlider-background, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
/* Kanban specific styles */
|
||||
@layer components {
|
||||
.kanban-container {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Smooth scrolling for better UX */
|
||||
.kanban-container {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Ensure proper touch scrolling on mobile */
|
||||
.kanban-container {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Add subtle shadow for depth */
|
||||
.kanban-column {
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Enhanced scrolling for column content areas */
|
||||
.kanban-column > div[style*="overflow-y"] {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(
|
||||
--vscode-scrollbarSlider-hoverBackground,
|
||||
rgba(255, 255, 255, 0.2)
|
||||
)
|
||||
var(--vscode-scrollbarSlider-background, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
/* Card hover effects */
|
||||
.kanban-card {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.kanban-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Focus indicators for accessibility */
|
||||
.kanban-card:focus-visible {
|
||||
outline: 2px solid var(--vscode-focusBorder);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Line clamp utility for text truncation */
|
||||
@layer utilities {
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
|
||||
/* Custom scrollbar utilities */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.scrollbar-track-transparent {
|
||||
scrollbar-color: var(
|
||||
--vscode-scrollbarSlider-hoverBackground,
|
||||
rgba(255, 255, 255, 0.2)
|
||||
)
|
||||
transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
28
apps/extension/src/webview/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Webview Entry Point
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
// CSS is built separately by Tailwind
|
||||
|
||||
// VS Code API declaration
|
||||
declare global {
|
||||
interface Window {
|
||||
acquireVsCodeApi?: () => {
|
||||
postMessage: (message: any) => void;
|
||||
setState: (state: any) => void;
|
||||
getState: () => any;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize React app
|
||||
const container = document.getElementById('root');
|
||||
if (container) {
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
} else {
|
||||
console.error('❌ Root container not found');
|
||||
}
|
||||
34
apps/extension/src/webview/providers/QueryProvider.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
// Create a stable query client
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
// Don't refetch on window focus by default
|
||||
refetchOnWindowFocus: false,
|
||||
// Keep data fresh for 30 seconds
|
||||
staleTime: 30 * 1000,
|
||||
// Cache data for 5 minutes
|
||||
gcTime: 5 * 60 * 1000,
|
||||
// Retry failed requests 3 times
|
||||
retry: 3,
|
||||
// Retry delay exponentially backs off
|
||||
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000)
|
||||
},
|
||||
mutations: {
|
||||
// Don't retry mutations by default
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface QueryProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const QueryProvider: React.FC<QueryProviderProps> = ({ children }) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
192
apps/extension/src/webview/reducers/appReducer.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Main application state reducer
|
||||
*/
|
||||
|
||||
import type { AppState, AppAction } from '../types';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export const appReducer = (state: AppState, action: AppAction): AppState => {
|
||||
logger.debug(
|
||||
'Reducer action:',
|
||||
action.type,
|
||||
'payload' in action ? action.payload : 'no payload'
|
||||
);
|
||||
switch (action.type) {
|
||||
case 'SET_TASKS':
|
||||
const newTasks = Array.isArray(action.payload) ? action.payload : [];
|
||||
logger.debug('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':
|
||||
logger.debug('📍 Reducer: Navigating to task:', action.payload);
|
||||
return {
|
||||
...state,
|
||||
currentView: 'task-details',
|
||||
selectedTaskId: action.payload
|
||||
};
|
||||
case 'NAVIGATE_TO_KANBAN':
|
||||
logger.debug('📍 Reducer: Navigating to kanban');
|
||||
return { ...state, currentView: 'kanban', selectedTaskId: undefined };
|
||||
case 'NAVIGATE_TO_CONFIG':
|
||||
logger.debug('📍 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']
|
||||
};
|
||||
16
apps/extension/src/webview/sidebar.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { SidebarView } from './components/SidebarView';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
if (!rootElement) {
|
||||
console.error('Sidebar: Root element not found');
|
||||
} else {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<SidebarView />
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
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[] };
|
||||
};
|
||||
155
apps/extension/src/webview/utils/logger.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Webview Logger Utility
|
||||
* Provides conditional logging based on environment
|
||||
*/
|
||||
|
||||
type LogLevel = 'log' | 'warn' | 'error' | 'debug' | 'info';
|
||||
|
||||
interface LogEntry {
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
data?: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
class WebviewLogger {
|
||||
private static instance: WebviewLogger;
|
||||
private enabled: boolean;
|
||||
private logHistory: LogEntry[] = [];
|
||||
private maxHistorySize = 100;
|
||||
|
||||
private constructor() {
|
||||
// Enable logging in development, disable in production
|
||||
// Check for development mode via various indicators
|
||||
this.enabled = this.isDevelopment();
|
||||
}
|
||||
|
||||
static getInstance(): WebviewLogger {
|
||||
if (!WebviewLogger.instance) {
|
||||
WebviewLogger.instance = new WebviewLogger();
|
||||
}
|
||||
return WebviewLogger.instance;
|
||||
}
|
||||
|
||||
private isDevelopment(): boolean {
|
||||
// Check various indicators for development mode
|
||||
// VS Code webviews don't have process.env, so we check other indicators
|
||||
return (
|
||||
// Check if running in localhost (development server)
|
||||
window.location.hostname === 'localhost' ||
|
||||
// Check for development query parameter
|
||||
window.location.search.includes('debug=true') ||
|
||||
// Check for VS Code development mode indicator
|
||||
(window as any).__VSCODE_DEV_MODE__ === true ||
|
||||
// Default to false in production
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
private addToHistory(entry: LogEntry): void {
|
||||
this.logHistory.push(entry);
|
||||
if (this.logHistory.length > this.maxHistorySize) {
|
||||
this.logHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private logMessage(level: LogLevel, message: string, ...args: any[]): void {
|
||||
const entry: LogEntry = {
|
||||
level,
|
||||
message,
|
||||
data: args.length > 0 ? args : undefined,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
this.addToHistory(entry);
|
||||
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Format the message with timestamp
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
||||
|
||||
// Use appropriate console method
|
||||
switch (level) {
|
||||
case 'error':
|
||||
console.error(prefix, message, ...args);
|
||||
break;
|
||||
case 'warn':
|
||||
console.warn(prefix, message, ...args);
|
||||
break;
|
||||
case 'debug':
|
||||
console.debug(prefix, message, ...args);
|
||||
break;
|
||||
case 'info':
|
||||
console.info(prefix, message, ...args);
|
||||
break;
|
||||
default:
|
||||
console.log(prefix, message, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
log(message: string, ...args: any[]): void {
|
||||
this.logMessage('log', message, ...args);
|
||||
}
|
||||
|
||||
error(message: string, ...args: any[]): void {
|
||||
// Always log errors, even in production
|
||||
const entry: LogEntry = {
|
||||
level: 'error',
|
||||
message,
|
||||
data: args.length > 0 ? args : undefined,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
this.addToHistory(entry);
|
||||
console.error(`[${new Date().toISOString()}] [ERROR]`, message, ...args);
|
||||
}
|
||||
|
||||
warn(message: string, ...args: any[]): void {
|
||||
this.logMessage('warn', message, ...args);
|
||||
}
|
||||
|
||||
debug(message: string, ...args: any[]): void {
|
||||
this.logMessage('debug', message, ...args);
|
||||
}
|
||||
|
||||
info(message: string, ...args: any[]): void {
|
||||
this.logMessage('info', message, ...args);
|
||||
}
|
||||
|
||||
// Enable/disable logging dynamically
|
||||
setEnabled(enabled: boolean): void {
|
||||
this.enabled = enabled;
|
||||
if (enabled) {
|
||||
console.log('[WebviewLogger] Logging enabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Get log history (useful for debugging)
|
||||
getHistory(): LogEntry[] {
|
||||
return [...this.logHistory];
|
||||
}
|
||||
|
||||
// Clear log history
|
||||
clearHistory(): void {
|
||||
this.logHistory = [];
|
||||
}
|
||||
|
||||
// Export logs as string (useful for bug reports)
|
||||
exportLogs(): string {
|
||||
return this.logHistory
|
||||
.map((entry) => {
|
||||
const timestamp = new Date(entry.timestamp).toISOString();
|
||||
const data = entry.data ? JSON.stringify(entry.data) : '';
|
||||
return `[${timestamp}] [${entry.level.toUpperCase()}] ${entry.message} ${data}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const logger = WebviewLogger.getInstance();
|
||||
|
||||
// Export type for use in other files
|
||||
export type { WebviewLogger };
|
||||
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)
|
||||
});
|
||||
};
|
||||
@@ -1,113 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "libReplacement": true, /* Enable lib replacement. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs" /* Specify what module code is generated. */,
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
"module": "ESNext",
|
||||
"target": "ES2022",
|
||||
"outDir": "out",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"sourceMap": true,
|
||||
"rootDir": "src",
|
||||
"strict": true /* enable all strict type-checking options */,
|
||||
"moduleResolution": "Node",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"jsx": "react-jsx",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/lib/*": ["./src/lib/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", ".vscode-test", "out", "dist"]
|
||||
}
|
||||
|
||||
@@ -24,7 +24,11 @@
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"include": ["apps/extension/**/*.ts", "apps/extension/**/*.tsx"],
|
||||
"ignore": ["**/*", "!apps/extension/**/*"],
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"useOptionalChain": "off",
|
||||
@@ -44,7 +48,8 @@
|
||||
"useNumberNamespace": "off",
|
||||
"noParameterAssign": "off",
|
||||
"useTemplate": "off",
|
||||
"noUnusedTemplateLiteral": "off"
|
||||
"noUnusedTemplateLiteral": "off",
|
||||
"noNonNullAssertion": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
7021
package-lock.json
generated
@@ -9,10 +9,7 @@
|
||||
"task-master-mcp": "mcp-server/server.js",
|
||||
"task-master-ai": "mcp-server/server.js"
|
||||
},
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"."
|
||||
],
|
||||
"workspaces": ["apps/*", "."],
|
||||
"scripts": {
|
||||
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
||||
"test:fails": "node --experimental-vm-modules node_modules/.bin/jest --onlyFailures",
|
||||
@@ -126,7 +123,6 @@
|
||||
"jest-environment-node": "^29.7.0",
|
||||
"mock-fs": "^5.5.0",
|
||||
"prettier": "^3.5.3",
|
||||
"react": "^18.3.1",
|
||||
"supertest": "^7.1.0",
|
||||
"tsx": "^4.16.2"
|
||||
}
|
||||
|
||||