mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-24 19:35:44 +08:00
Merge branch 'main' into fix-injection
This commit is contained in:
@@ -9,4 +9,7 @@
|
||||
#API server
|
||||
PORT=3001
|
||||
#Frontend port
|
||||
VITE_PORT=5173
|
||||
VITE_PORT=5173
|
||||
|
||||
# Uncomment the following line if you have a custom claude cli path other than the default "claude"
|
||||
# CLAUDE_CLI_PATH=claude
|
||||
|
||||
21
.release-it.json
Normal file
21
.release-it.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"git": {
|
||||
"commitMessage": "Release ${version}",
|
||||
"tagName": "v${version}",
|
||||
"changelog": "git log --pretty=format:\"* %s (%h)\" ${from}...${to}"
|
||||
},
|
||||
"npm": {
|
||||
"publish": true
|
||||
},
|
||||
"github": {
|
||||
"release": true,
|
||||
"releaseName": "Claude Code UI v${version}",
|
||||
"releaseNotes": {
|
||||
"commit": "* ${commit.subject} (${sha}){ - thanks @${author.login}!}",
|
||||
"excludeMatches": ["viper151"]
|
||||
}
|
||||
},
|
||||
"hooks": {
|
||||
"before:init": ["npm run build"]
|
||||
}
|
||||
}
|
||||
33
README.md
33
README.md
@@ -47,6 +47,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
|
||||
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
|
||||
- **Session Management** - Resume conversations, manage multiple sessions, and track history
|
||||
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
|
||||
- **Model Compatibility** - Works with Claude Sonnet 4, Opus 4.1, and GPT-5
|
||||
|
||||
|
||||
@@ -58,7 +59,17 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
|
||||
- [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and configured, and/or
|
||||
- [Cursor CLI](https://docs.cursor.com/en/cli/overview) installed and configured
|
||||
|
||||
### Installation
|
||||
### One-click Operation (Recommended)
|
||||
|
||||
No installation required, direct operation:
|
||||
|
||||
```bash
|
||||
npx @siteboon/claude-code-ui
|
||||
```
|
||||
|
||||
Your default browser will automatically open the Claude Code UI interface.
|
||||
|
||||
### Local Development Installation
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
@@ -109,6 +120,19 @@ To use Claude Code's full functionality, you'll need to manually enable tools:
|
||||
|
||||
**Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.
|
||||
|
||||
## TaskMaster AI Integration *(Optional)*
|
||||
|
||||
Claude Code UI supports **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** (aka claude-task-master) integration for advanced project management and AI-powered task planning.
|
||||
|
||||
It provides
|
||||
- AI-powered task generation from PRDs (Product Requirements Documents)
|
||||
- Smart task breakdown and dependency management
|
||||
- Visual task boards and progress tracking
|
||||
|
||||
**Setup & Documentation**: Visit the [TaskMaster AI GitHub repository](https://github.com/eyaltoledano/claude-task-master) for installation instructions, configuration guides, and usage examples.
|
||||
After installing it you should be able to enable it from the Settings
|
||||
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### Core Features
|
||||
@@ -136,6 +160,11 @@ The UI automatically discovers Claude Code projects from `~/.claude/projects/` a
|
||||
#### Git Explorer
|
||||
|
||||
|
||||
#### TaskMaster AI Integration *(Optional)*
|
||||
- **Visual Task Board** - Kanban-style interface for managing development tasks
|
||||
- **PRD Parser** - Create Product Requirements Documents and parse them into structured tasks
|
||||
- **Progress Tracking** - Real-time status updates and completion tracking
|
||||
|
||||
#### Session Management
|
||||
- **Session Persistence** - All conversations automatically saved
|
||||
- **Session Organization** - Group sessions by project and timestamp
|
||||
@@ -238,7 +267,7 @@ This project is open source and free to use, modify, and distribute under the GP
|
||||
- **[Vite](https://vitejs.dev/)** - Fast build tool and dev server
|
||||
- **[Tailwind CSS](https://tailwindcss.com/)** - Utility-first CSS framework
|
||||
- **[CodeMirror](https://codemirror.net/)** - Advanced code editor
|
||||
|
||||
- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(Optional)* - AI-powered project management and task planning
|
||||
|
||||
## Support & Community
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Claude Code UI</title>
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
<!-- iOS Safari PWA Meta Tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Claude UI" />
|
||||
|
||||
<!-- iOS Safari Icons -->
|
||||
|
||||
2285
package-lock.json
generated
2285
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -1,23 +1,40 @@
|
||||
{
|
||||
"name": "claude-code-ui",
|
||||
"version": "1.7.0",
|
||||
"name": "@siteboon/claude-code-ui",
|
||||
"version": "1.8.10",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "server/index.js",
|
||||
"bin": {
|
||||
"claude-code-ui": "server/index.js"
|
||||
},
|
||||
"files": [
|
||||
"server/",
|
||||
"dist/",
|
||||
"README.md"
|
||||
],
|
||||
"homepage": "https://claudecodeui.siteboon.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/siteboon/claudecodeui.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/siteboon/claudecodeui/issues"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "concurrently --kill-others \"npm run server\" \"npm run client\"",
|
||||
"server": "node server/index.js",
|
||||
"client": "vite --host",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "npm run build && npm run server"
|
||||
"start": "npm run build && npm run server",
|
||||
"release": "release-it"
|
||||
},
|
||||
"keywords": [
|
||||
"claude",
|
||||
"claude coode",
|
||||
"ai",
|
||||
"code",
|
||||
"anthropic",
|
||||
"ui",
|
||||
"assistant"
|
||||
"mobile"
|
||||
],
|
||||
"author": "Claude Code UI Contributors",
|
||||
"license": "MIT",
|
||||
@@ -63,10 +80,12 @@
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"auto-changelog": "^2.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"concurrently": "^8.2.2",
|
||||
"node-gyp": "^10.0.0",
|
||||
"postcss": "^8.4.32",
|
||||
"release-it": "^19.0.5",
|
||||
"sharp": "^0.34.2",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^7.0.4"
|
||||
|
||||
@@ -237,7 +237,11 @@ async function spawnClaude(command, options = {}, ws) {
|
||||
console.log('🔍 Full command args:', JSON.stringify(args, null, 2));
|
||||
console.log('🔍 Final Claude command will be: claude ' + args.join(' '));
|
||||
|
||||
const claudeProcess = spawnFunction('claude', args, {
|
||||
// Use Claude CLI from environment variable or default to 'claude'
|
||||
const claudePath = process.env.CLAUDE_CLI_PATH || 'claude';
|
||||
console.log('🔍 Using Claude CLI path:', claudePath);
|
||||
|
||||
const claudeProcess = spawnFunction(claudePath, args, {
|
||||
cwd: workingDir,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env } // Inherit all environment variables
|
||||
|
||||
112
server/index.js
112
server/index.js
@@ -1,3 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
// Load environment variables from .env file
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -43,6 +44,8 @@ import gitRoutes from './routes/git.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import mcpRoutes from './routes/mcp.js';
|
||||
import cursorRoutes from './routes/cursor.js';
|
||||
import taskmasterRoutes from './routes/taskmaster.js';
|
||||
import mcpUtilsRoutes from './routes/mcp-utils.js';
|
||||
import { initializeDatabase } from './database/db.js';
|
||||
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
|
||||
|
||||
@@ -162,6 +165,9 @@ const wss = new WebSocketServer({
|
||||
}
|
||||
});
|
||||
|
||||
// Make WebSocket server available to routes
|
||||
app.locals.wss = wss;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
@@ -180,6 +186,12 @@ app.use('/api/mcp', authenticateToken, mcpRoutes);
|
||||
// Cursor API Routes (protected)
|
||||
app.use('/api/cursor', authenticateToken, cursorRoutes);
|
||||
|
||||
// TaskMaster API Routes (protected)
|
||||
app.use('/api/taskmaster', authenticateToken, taskmasterRoutes);
|
||||
|
||||
// MCP utilities
|
||||
app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
|
||||
|
||||
// Static files served after API routes
|
||||
app.use(express.static(path.join(__dirname, '../dist')));
|
||||
|
||||
@@ -290,6 +302,66 @@ app.post('/api/projects/create', authenticateToken, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Browse filesystem endpoint for project suggestions - uses existing getFileTree
|
||||
app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { path: dirPath } = req.query;
|
||||
|
||||
// Default to home directory if no path provided
|
||||
const homeDir = os.homedir();
|
||||
let targetPath = dirPath ? dirPath.replace('~', homeDir) : homeDir;
|
||||
|
||||
// Resolve and normalize the path
|
||||
targetPath = path.resolve(targetPath);
|
||||
|
||||
// Security check - ensure path is accessible
|
||||
try {
|
||||
await fs.promises.access(targetPath);
|
||||
const stats = await fs.promises.stat(targetPath);
|
||||
|
||||
if (!stats.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path is not a directory' });
|
||||
}
|
||||
} catch (err) {
|
||||
return res.status(404).json({ error: 'Directory not accessible' });
|
||||
}
|
||||
|
||||
// Use existing getFileTree function with shallow depth (only direct children)
|
||||
const fileTree = await getFileTree(targetPath, 1, 0, false); // maxDepth=1, showHidden=false
|
||||
|
||||
// Filter only directories and format for suggestions
|
||||
const directories = fileTree
|
||||
.filter(item => item.type === 'directory')
|
||||
.map(item => ({
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
type: 'directory'
|
||||
}))
|
||||
.slice(0, 20); // Limit results
|
||||
|
||||
// Add common directories if browsing home directory
|
||||
const suggestions = [];
|
||||
if (targetPath === homeDir) {
|
||||
const commonDirs = ['Desktop', 'Documents', 'Projects', 'Development', 'Dev', 'Code', 'workspace'];
|
||||
const existingCommon = directories.filter(dir => commonDirs.includes(dir.name));
|
||||
const otherDirs = directories.filter(dir => !commonDirs.includes(dir.name));
|
||||
|
||||
suggestions.push(...existingCommon, ...otherDirs);
|
||||
} else {
|
||||
suggestions.push(...directories);
|
||||
}
|
||||
|
||||
res.json({
|
||||
path: targetPath,
|
||||
suggestions: suggestions
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error browsing filesystem:', error);
|
||||
res.status(500).json({ error: 'Failed to browse filesystem' });
|
||||
}
|
||||
});
|
||||
|
||||
// Read file content endpoint
|
||||
app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
@@ -435,7 +507,7 @@ app.get('/api/projects/:projectName/files', authenticateToken, async (req, res)
|
||||
return res.status(404).json({ error: `Project path not found: ${actualPath}` });
|
||||
}
|
||||
|
||||
const files = await getFileTree(actualPath, 3, 0, true);
|
||||
const files = await getFileTree(actualPath, 10, 0, true);
|
||||
const hiddenFiles = files.filter(f => f.name.startsWith('.'));
|
||||
res.json(files);
|
||||
} catch (error) {
|
||||
@@ -547,16 +619,26 @@ function handleShellConnection(ws) {
|
||||
const sessionId = data.sessionId;
|
||||
const hasSession = data.hasSession;
|
||||
const provider = data.provider || 'claude';
|
||||
const initialCommand = data.initialCommand;
|
||||
const isPlainShell = data.isPlainShell || (!!initialCommand && !hasSession) || provider === 'plain-shell';
|
||||
|
||||
console.log('🚀 Starting shell in:', projectPath);
|
||||
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : 'New session');
|
||||
console.log('🤖 Provider:', provider);
|
||||
console.log('📋 Session info:', hasSession ? `Resume session ${sessionId}` : (isPlainShell ? 'Plain shell mode' : 'New session'));
|
||||
console.log('🤖 Provider:', isPlainShell ? 'plain-shell' : provider);
|
||||
if (initialCommand) {
|
||||
console.log('⚡ Initial command:', initialCommand);
|
||||
}
|
||||
|
||||
// First send a welcome message
|
||||
const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
|
||||
const welcomeMsg = hasSession ?
|
||||
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
||||
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
||||
let welcomeMsg;
|
||||
if (isPlainShell) {
|
||||
welcomeMsg = `\x1b[36mStarting terminal in: ${projectPath}\x1b[0m\r\n`;
|
||||
} else {
|
||||
const providerName = provider === 'cursor' ? 'Cursor' : 'Claude';
|
||||
welcomeMsg = hasSession ?
|
||||
`\x1b[36mResuming ${providerName} session ${sessionId} in: ${projectPath}\x1b[0m\r\n` :
|
||||
`\x1b[36mStarting new ${providerName} session in: ${projectPath}\x1b[0m\r\n`;
|
||||
}
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'output',
|
||||
@@ -566,7 +648,14 @@ function handleShellConnection(ws) {
|
||||
try {
|
||||
// Prepare the shell command adapted to the platform and provider
|
||||
let shellCommand;
|
||||
if (provider === 'cursor') {
|
||||
if (isPlainShell) {
|
||||
// Plain shell mode - just run the initial command in the project directory
|
||||
if (os.platform() === 'win32') {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
|
||||
} else {
|
||||
shellCommand = `cd "${projectPath}" && ${initialCommand}`;
|
||||
}
|
||||
} else if (provider === 'cursor') {
|
||||
// Use cursor-agent command
|
||||
if (os.platform() === 'win32') {
|
||||
if (hasSession && sessionId) {
|
||||
@@ -582,19 +671,20 @@ function handleShellConnection(ws) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Use claude command (default)
|
||||
// Use claude command (default) or initialCommand if provided
|
||||
const command = initialCommand || 'claude';
|
||||
if (os.platform() === 'win32') {
|
||||
if (hasSession && sessionId) {
|
||||
// Try to resume session, but with fallback to new session if it fails
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
|
||||
} else {
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; claude`;
|
||||
shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
|
||||
}
|
||||
} else {
|
||||
if (hasSession && sessionId) {
|
||||
shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
|
||||
} else {
|
||||
shellCommand = `cd "${projectPath}" && claude`;
|
||||
shellCommand = `cd "${projectPath}" && ${command}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,134 @@ import sqlite3 from 'sqlite3';
|
||||
import { open } from 'sqlite';
|
||||
import os from 'os';
|
||||
|
||||
// Import TaskMaster detection functions
|
||||
async function detectTaskMasterFolder(projectPath) {
|
||||
try {
|
||||
const taskMasterPath = path.join(projectPath, '.taskmaster');
|
||||
|
||||
// Check if .taskmaster directory exists
|
||||
try {
|
||||
const stats = await fs.stat(taskMasterPath);
|
||||
if (!stats.isDirectory()) {
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: '.taskmaster exists but is not a directory'
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: '.taskmaster directory not found'
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check for key TaskMaster files
|
||||
const keyFiles = [
|
||||
'tasks/tasks.json',
|
||||
'config.json'
|
||||
];
|
||||
|
||||
const fileStatus = {};
|
||||
let hasEssentialFiles = true;
|
||||
|
||||
for (const file of keyFiles) {
|
||||
const filePath = path.join(taskMasterPath, file);
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
fileStatus[file] = true;
|
||||
} catch (error) {
|
||||
fileStatus[file] = false;
|
||||
if (file === 'tasks/tasks.json') {
|
||||
hasEssentialFiles = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse tasks.json if it exists for metadata
|
||||
let taskMetadata = null;
|
||||
if (fileStatus['tasks/tasks.json']) {
|
||||
try {
|
||||
const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
|
||||
const tasksContent = await fs.readFile(tasksPath, 'utf8');
|
||||
const tasksData = JSON.parse(tasksContent);
|
||||
|
||||
// Handle both tagged and legacy formats
|
||||
let tasks = [];
|
||||
if (tasksData.tasks) {
|
||||
// Legacy format
|
||||
tasks = tasksData.tasks;
|
||||
} else {
|
||||
// Tagged format - get tasks from all tags
|
||||
Object.values(tasksData).forEach(tagData => {
|
||||
if (tagData.tasks) {
|
||||
tasks = tasks.concat(tagData.tasks);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate task statistics
|
||||
const stats = tasks.reduce((acc, task) => {
|
||||
acc.total++;
|
||||
acc[task.status] = (acc[task.status] || 0) + 1;
|
||||
|
||||
// Count subtasks
|
||||
if (task.subtasks) {
|
||||
task.subtasks.forEach(subtask => {
|
||||
acc.subtotalTasks++;
|
||||
acc.subtasks = acc.subtasks || {};
|
||||
acc.subtasks[subtask.status] = (acc.subtasks[subtask.status] || 0) + 1;
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {
|
||||
total: 0,
|
||||
subtotalTasks: 0,
|
||||
pending: 0,
|
||||
'in-progress': 0,
|
||||
done: 0,
|
||||
review: 0,
|
||||
deferred: 0,
|
||||
cancelled: 0,
|
||||
subtasks: {}
|
||||
});
|
||||
|
||||
taskMetadata = {
|
||||
taskCount: stats.total,
|
||||
subtaskCount: stats.subtotalTasks,
|
||||
completed: stats.done || 0,
|
||||
pending: stats.pending || 0,
|
||||
inProgress: stats['in-progress'] || 0,
|
||||
review: stats.review || 0,
|
||||
completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
|
||||
lastModified: (await fs.stat(tasksPath)).mtime.toISOString()
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse tasks.json:', parseError.message);
|
||||
taskMetadata = { error: 'Failed to parse tasks.json' };
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasTaskmaster: true,
|
||||
hasEssentialFiles,
|
||||
files: fileStatus,
|
||||
metadata: taskMetadata,
|
||||
path: taskMasterPath
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error detecting TaskMaster folder:', error);
|
||||
return {
|
||||
hasTaskmaster: false,
|
||||
reason: `Error checking directory: ${error.message}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Cache for extracted project directories
|
||||
const projectDirectoryCache = new Map();
|
||||
|
||||
@@ -298,6 +426,25 @@ async function getProjects() {
|
||||
project.cursorSessions = [];
|
||||
}
|
||||
|
||||
// Add TaskMaster detection
|
||||
try {
|
||||
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
|
||||
project.taskmaster = {
|
||||
hasTaskmaster: taskMasterResult.hasTaskmaster,
|
||||
hasEssentialFiles: taskMasterResult.hasEssentialFiles,
|
||||
metadata: taskMasterResult.metadata,
|
||||
status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured'
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message);
|
||||
project.taskmaster = {
|
||||
hasTaskmaster: false,
|
||||
hasEssentialFiles: false,
|
||||
metadata: null,
|
||||
status: 'error'
|
||||
};
|
||||
}
|
||||
|
||||
projects.push(project);
|
||||
}
|
||||
}
|
||||
@@ -341,6 +488,32 @@ async function getProjects() {
|
||||
console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message);
|
||||
}
|
||||
|
||||
// Add TaskMaster detection for manual projects
|
||||
try {
|
||||
const taskMasterResult = await detectTaskMasterFolder(actualProjectDir);
|
||||
|
||||
// Determine TaskMaster status
|
||||
let taskMasterStatus = 'not-configured';
|
||||
if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) {
|
||||
taskMasterStatus = 'taskmaster-only'; // We don't check MCP for manual projects in bulk
|
||||
}
|
||||
|
||||
project.taskmaster = {
|
||||
status: taskMasterStatus,
|
||||
hasTaskmaster: taskMasterResult.hasTaskmaster,
|
||||
hasEssentialFiles: taskMasterResult.hasEssentialFiles,
|
||||
metadata: taskMasterResult.metadata
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`TaskMaster detection failed for manual project ${projectName}:`, error.message);
|
||||
project.taskmaster = {
|
||||
status: 'error',
|
||||
hasTaskmaster: false,
|
||||
hasEssentialFiles: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
projects.push(project);
|
||||
}
|
||||
}
|
||||
@@ -359,7 +532,7 @@ async function getSessions(projectName, limit = 5, offset = 0) {
|
||||
return { sessions: [], hasMore: false, total: 0 };
|
||||
}
|
||||
|
||||
// For performance, get file stats to sort by modification time
|
||||
// Sort files by modification time (newest first)
|
||||
const filesWithStats = await Promise.all(
|
||||
jsonlFiles.map(async (file) => {
|
||||
const filePath = path.join(projectDir, file);
|
||||
@@ -367,40 +540,97 @@ async function getSessions(projectName, limit = 5, offset = 0) {
|
||||
return { file, mtime: stats.mtime };
|
||||
})
|
||||
);
|
||||
|
||||
// Sort files by modification time (newest first) for better performance
|
||||
filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
const allSessions = new Map();
|
||||
let processedCount = 0;
|
||||
const allEntries = [];
|
||||
const uuidToSessionMap = new Map();
|
||||
|
||||
// Process files in order of modification time
|
||||
// Collect all sessions and entries from all files
|
||||
for (const { file } of filesWithStats) {
|
||||
const jsonlFile = path.join(projectDir, file);
|
||||
const sessions = await parseJsonlSessions(jsonlFile);
|
||||
const result = await parseJsonlSessions(jsonlFile);
|
||||
|
||||
// Merge sessions, avoiding duplicates by session ID
|
||||
sessions.forEach(session => {
|
||||
result.sessions.forEach(session => {
|
||||
if (!allSessions.has(session.id)) {
|
||||
allSessions.set(session.id, session);
|
||||
}
|
||||
});
|
||||
|
||||
processedCount++;
|
||||
allEntries.push(...result.entries);
|
||||
|
||||
// Early exit optimization: if we have enough sessions and processed recent files
|
||||
if (allSessions.size >= (limit + offset) * 2 && processedCount >= Math.min(3, filesWithStats.length)) {
|
||||
// Early exit optimization for large projects
|
||||
if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort by last activity
|
||||
const sortedSessions = Array.from(allSessions.values()).sort((a, b) =>
|
||||
new Date(b.lastActivity) - new Date(a.lastActivity)
|
||||
);
|
||||
// Build UUID-to-session mapping for timeline detection
|
||||
allEntries.forEach(entry => {
|
||||
if (entry.uuid && entry.sessionId) {
|
||||
uuidToSessionMap.set(entry.uuid, entry.sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
const total = sortedSessions.length;
|
||||
const paginatedSessions = sortedSessions.slice(offset, offset + limit);
|
||||
// Group sessions by first user message ID
|
||||
const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
|
||||
const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
|
||||
|
||||
// Find the first user message for each session
|
||||
allEntries.forEach(entry => {
|
||||
if (entry.sessionId && entry.type === 'user' && entry.parentUuid === null && entry.uuid) {
|
||||
// This is a first user message in a session (parentUuid is null)
|
||||
const firstUserMsgId = entry.uuid;
|
||||
|
||||
if (!sessionToFirstUserMsgId.has(entry.sessionId)) {
|
||||
sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId);
|
||||
|
||||
const session = allSessions.get(entry.sessionId);
|
||||
if (session) {
|
||||
if (!sessionGroups.has(firstUserMsgId)) {
|
||||
sessionGroups.set(firstUserMsgId, {
|
||||
latestSession: session,
|
||||
allSessions: [session]
|
||||
});
|
||||
} else {
|
||||
const group = sessionGroups.get(firstUserMsgId);
|
||||
group.allSessions.push(session);
|
||||
|
||||
// Update latest session if this one is more recent
|
||||
if (new Date(session.lastActivity) > new Date(group.latestSession.lastActivity)) {
|
||||
group.latestSession = session;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Collect all sessions that don't belong to any group (standalone sessions)
|
||||
const groupedSessionIds = new Set();
|
||||
sessionGroups.forEach(group => {
|
||||
group.allSessions.forEach(session => groupedSessionIds.add(session.id));
|
||||
});
|
||||
|
||||
const standaloneSessionsArray = Array.from(allSessions.values())
|
||||
.filter(session => !groupedSessionIds.has(session.id));
|
||||
|
||||
// Combine grouped sessions (only show latest from each group) + standalone sessions
|
||||
const latestFromGroups = Array.from(sessionGroups.values()).map(group => {
|
||||
const session = { ...group.latestSession };
|
||||
// Add metadata about grouping
|
||||
if (group.allSessions.length > 1) {
|
||||
session.isGrouped = true;
|
||||
session.groupSize = group.allSessions.length;
|
||||
session.groupSessions = group.allSessions.map(s => s.id);
|
||||
}
|
||||
return session;
|
||||
});
|
||||
const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray]
|
||||
.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
||||
|
||||
const total = visibleSessions.length;
|
||||
const paginatedSessions = visibleSessions.slice(offset, offset + limit);
|
||||
const hasMore = offset + limit < total;
|
||||
|
||||
return {
|
||||
@@ -418,6 +648,7 @@ async function getSessions(projectName, limit = 5, offset = 0) {
|
||||
|
||||
async function parseJsonlSessions(filePath) {
|
||||
const sessions = new Map();
|
||||
const entries = [];
|
||||
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(filePath);
|
||||
@@ -426,14 +657,11 @@ async function parseJsonlSessions(filePath) {
|
||||
crlfDelay: Infinity
|
||||
});
|
||||
|
||||
// console.log(`[JSONL Parser] Reading file: ${filePath}`);
|
||||
let lineCount = 0;
|
||||
|
||||
for await (const line of rl) {
|
||||
if (line.trim()) {
|
||||
lineCount++;
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
entries.push(entry);
|
||||
|
||||
if (entry.sessionId) {
|
||||
if (!sessions.has(entry.sessionId)) {
|
||||
@@ -448,43 +676,37 @@ async function parseJsonlSessions(filePath) {
|
||||
|
||||
const session = sessions.get(entry.sessionId);
|
||||
|
||||
// Update summary if this is a summary entry
|
||||
// Update summary from summary entries or first user message
|
||||
if (entry.type === 'summary' && entry.summary) {
|
||||
session.summary = entry.summary;
|
||||
} else if (entry.message?.role === 'user' && entry.message?.content && session.summary === 'New Session') {
|
||||
// Use first user message as summary if no summary entry exists
|
||||
const content = entry.message.content;
|
||||
if (typeof content === 'string' && content.length > 0) {
|
||||
// Skip command messages that start with <command-name>
|
||||
if (!content.startsWith('<command-name>')) {
|
||||
session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content;
|
||||
}
|
||||
if (typeof content === 'string' && content.length > 0 && !content.startsWith('<command-name>')) {
|
||||
session.summary = content.length > 50 ? content.substring(0, 50) + '...' : content;
|
||||
}
|
||||
}
|
||||
|
||||
// Count messages instead of storing them all
|
||||
session.messageCount = (session.messageCount || 0) + 1;
|
||||
session.messageCount++;
|
||||
|
||||
// Update last activity
|
||||
if (entry.timestamp) {
|
||||
session.lastActivity = new Date(entry.timestamp);
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn(`[JSONL Parser] Error parsing line ${lineCount}:`, parseError.message);
|
||||
// Skip malformed lines silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// console.log(`[JSONL Parser] Processed ${lineCount} lines, found ${sessions.size} sessions`);
|
||||
return {
|
||||
sessions: Array.from(sessions.values()),
|
||||
entries: entries
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error reading JSONL file:', error);
|
||||
return { sessions: [], entries: [] };
|
||||
}
|
||||
|
||||
// Convert Map to Array and sort by last activity
|
||||
return Array.from(sessions.values()).sort((a, b) =>
|
||||
new Date(b.lastActivity) - new Date(a.lastActivity)
|
||||
);
|
||||
}
|
||||
|
||||
// Get messages for a specific session with pagination support
|
||||
@@ -677,22 +899,16 @@ async function addProjectManually(projectPath, displayName = null) {
|
||||
// Generate project name (encode path for use as directory name)
|
||||
const projectName = absolutePath.replace(/\//g, '-');
|
||||
|
||||
// Check if project already exists in config or as a folder
|
||||
// Check if project already exists in config
|
||||
const config = await loadProjectConfig();
|
||||
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
|
||||
|
||||
try {
|
||||
await fs.access(projectDir);
|
||||
throw new Error(`Project already exists for path: ${absolutePath}`);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (config[projectName]) {
|
||||
throw new Error(`Project already configured for path: ${absolutePath}`);
|
||||
}
|
||||
|
||||
// Allow adding projects even if the directory exists - this enables tracking
|
||||
// existing Claude Code or Cursor projects in the UI
|
||||
|
||||
// Add to config as manually added project
|
||||
config[projectName] = {
|
||||
|
||||
48
server/routes/mcp-utils.js
Normal file
48
server/routes/mcp-utils.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* MCP UTILITIES API ROUTES
|
||||
* ========================
|
||||
*
|
||||
* API endpoints for MCP server detection and configuration utilities.
|
||||
* These endpoints expose centralized MCP detection functionality.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { detectTaskMasterMCPServer, getAllMCPServers } from '../utils/mcp-detector.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /api/mcp-utils/taskmaster-server
|
||||
* Check if TaskMaster MCP server is configured
|
||||
*/
|
||||
router.get('/taskmaster-server', async (req, res) => {
|
||||
try {
|
||||
const result = await detectTaskMasterMCPServer();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('TaskMaster MCP detection error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to detect TaskMaster MCP server',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/mcp-utils/all-servers
|
||||
* Get all configured MCP servers
|
||||
*/
|
||||
router.get('/all-servers', async (req, res) => {
|
||||
try {
|
||||
const result = await getAllMCPServers();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('MCP servers detection error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get MCP servers',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
1971
server/routes/taskmaster.js
Normal file
1971
server/routes/taskmaster.js
Normal file
File diff suppressed because it is too large
Load Diff
198
server/utils/mcp-detector.js
Normal file
198
server/utils/mcp-detector.js
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* MCP SERVER DETECTION UTILITY
|
||||
* ============================
|
||||
*
|
||||
* Centralized utility for detecting MCP server configurations.
|
||||
* Used across TaskMaster integration and other MCP-dependent features.
|
||||
*/
|
||||
|
||||
import { promises as fsPromises } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
/**
|
||||
* Check if task-master-ai MCP server is configured
|
||||
* Reads directly from Claude configuration files like claude-cli.js does
|
||||
* @returns {Promise<Object>} MCP detection result
|
||||
*/
|
||||
export async function detectTaskMasterMCPServer() {
|
||||
try {
|
||||
// Read Claude configuration files directly (same logic as mcp.js)
|
||||
const homeDir = os.homedir();
|
||||
const configPaths = [
|
||||
path.join(homeDir, '.claude.json'),
|
||||
path.join(homeDir, '.claude', 'settings.json')
|
||||
];
|
||||
|
||||
let configData = null;
|
||||
let configPath = null;
|
||||
|
||||
// Try to read from either config file
|
||||
for (const filepath of configPaths) {
|
||||
try {
|
||||
const fileContent = await fsPromises.readFile(filepath, 'utf8');
|
||||
configData = JSON.parse(fileContent);
|
||||
configPath = filepath;
|
||||
break;
|
||||
} catch (error) {
|
||||
// File doesn't exist or is not valid JSON, try next
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return {
|
||||
hasMCPServer: false,
|
||||
reason: 'No Claude configuration file found',
|
||||
hasConfig: false
|
||||
};
|
||||
}
|
||||
|
||||
// Look for task-master-ai in user-scoped MCP servers
|
||||
let taskMasterServer = null;
|
||||
if (configData.mcpServers && typeof configData.mcpServers === 'object') {
|
||||
const serverEntry = Object.entries(configData.mcpServers).find(([name, config]) =>
|
||||
name === 'task-master-ai' ||
|
||||
name.includes('task-master') ||
|
||||
(config && config.command && config.command.includes('task-master'))
|
||||
);
|
||||
|
||||
if (serverEntry) {
|
||||
const [name, config] = serverEntry;
|
||||
taskMasterServer = {
|
||||
name,
|
||||
scope: 'user',
|
||||
config,
|
||||
type: config.command ? 'stdio' : (config.url ? 'http' : 'unknown')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Also check project-specific MCP servers if not found globally
|
||||
if (!taskMasterServer && configData.projects) {
|
||||
for (const [projectPath, projectConfig] of Object.entries(configData.projects)) {
|
||||
if (projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
|
||||
const serverEntry = Object.entries(projectConfig.mcpServers).find(([name, config]) =>
|
||||
name === 'task-master-ai' ||
|
||||
name.includes('task-master') ||
|
||||
(config && config.command && config.command.includes('task-master'))
|
||||
);
|
||||
|
||||
if (serverEntry) {
|
||||
const [name, config] = serverEntry;
|
||||
taskMasterServer = {
|
||||
name,
|
||||
scope: 'local',
|
||||
projectPath,
|
||||
config,
|
||||
type: config.command ? 'stdio' : (config.url ? 'http' : 'unknown')
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (taskMasterServer) {
|
||||
const isValid = !!(taskMasterServer.config &&
|
||||
(taskMasterServer.config.command || taskMasterServer.config.url));
|
||||
const hasEnvVars = !!(taskMasterServer.config &&
|
||||
taskMasterServer.config.env &&
|
||||
Object.keys(taskMasterServer.config.env).length > 0);
|
||||
|
||||
return {
|
||||
hasMCPServer: true,
|
||||
isConfigured: isValid,
|
||||
hasApiKeys: hasEnvVars,
|
||||
scope: taskMasterServer.scope,
|
||||
config: {
|
||||
command: taskMasterServer.config?.command,
|
||||
args: taskMasterServer.config?.args || [],
|
||||
url: taskMasterServer.config?.url,
|
||||
envVars: hasEnvVars ? Object.keys(taskMasterServer.config.env) : [],
|
||||
type: taskMasterServer.type
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// Get list of available servers for debugging
|
||||
const availableServers = [];
|
||||
if (configData.mcpServers) {
|
||||
availableServers.push(...Object.keys(configData.mcpServers));
|
||||
}
|
||||
if (configData.projects) {
|
||||
for (const projectConfig of Object.values(configData.projects)) {
|
||||
if (projectConfig.mcpServers) {
|
||||
availableServers.push(...Object.keys(projectConfig.mcpServers).map(name => `local:${name}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasMCPServer: false,
|
||||
reason: 'task-master-ai not found in configured MCP servers',
|
||||
hasConfig: true,
|
||||
configPath,
|
||||
availableServers
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error detecting MCP server config:', error);
|
||||
return {
|
||||
hasMCPServer: false,
|
||||
reason: `Error checking MCP config: ${error.message}`,
|
||||
hasConfig: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all configured MCP servers (not just TaskMaster)
|
||||
* @returns {Promise<Object>} All MCP servers configuration
|
||||
*/
|
||||
export async function getAllMCPServers() {
|
||||
try {
|
||||
const homeDir = os.homedir();
|
||||
const configPaths = [
|
||||
path.join(homeDir, '.claude.json'),
|
||||
path.join(homeDir, '.claude', 'settings.json')
|
||||
];
|
||||
|
||||
let configData = null;
|
||||
let configPath = null;
|
||||
|
||||
// Try to read from either config file
|
||||
for (const filepath of configPaths) {
|
||||
try {
|
||||
const fileContent = await fsPromises.readFile(filepath, 'utf8');
|
||||
configData = JSON.parse(fileContent);
|
||||
configPath = filepath;
|
||||
break;
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return {
|
||||
hasConfig: false,
|
||||
servers: {},
|
||||
projectServers: {}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasConfig: true,
|
||||
configPath,
|
||||
servers: configData.mcpServers || {},
|
||||
projectServers: configData.projects || {}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error getting all MCP servers:', error);
|
||||
return {
|
||||
hasConfig: false,
|
||||
error: error.message,
|
||||
servers: {},
|
||||
projectServers: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
129
server/utils/taskmaster-websocket.js
Normal file
129
server/utils/taskmaster-websocket.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* TASKMASTER WEBSOCKET UTILITIES
|
||||
* ==============================
|
||||
*
|
||||
* Utilities for broadcasting TaskMaster state changes via WebSocket.
|
||||
* Integrates with the existing WebSocket system to provide real-time updates.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Broadcast TaskMaster project update to all connected clients
|
||||
* @param {WebSocket.Server} wss - WebSocket server instance
|
||||
* @param {string} projectName - Name of the updated project
|
||||
* @param {Object} taskMasterData - Updated TaskMaster data
|
||||
*/
|
||||
export function broadcastTaskMasterProjectUpdate(wss, projectName, taskMasterData) {
|
||||
if (!wss || !projectName) {
|
||||
console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'taskmaster-project-updated',
|
||||
projectName,
|
||||
taskMasterData,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
try {
|
||||
client.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Error sending TaskMaster project update:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast TaskMaster tasks update for a specific project
|
||||
* @param {WebSocket.Server} wss - WebSocket server instance
|
||||
* @param {string} projectName - Name of the project with updated tasks
|
||||
* @param {Object} tasksData - Updated tasks data
|
||||
*/
|
||||
export function broadcastTaskMasterTasksUpdate(wss, projectName, tasksData) {
|
||||
if (!wss || !projectName) {
|
||||
console.warn('TaskMaster WebSocket broadcast: Missing wss or projectName');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'taskmaster-tasks-updated',
|
||||
projectName,
|
||||
tasksData,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
try {
|
||||
client.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Error sending TaskMaster tasks update:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast MCP server status change
|
||||
* @param {WebSocket.Server} wss - WebSocket server instance
|
||||
* @param {Object} mcpStatus - Updated MCP server status
|
||||
*/
|
||||
export function broadcastMCPStatusChange(wss, mcpStatus) {
|
||||
if (!wss) {
|
||||
console.warn('TaskMaster WebSocket broadcast: Missing wss');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'taskmaster-mcp-status-changed',
|
||||
mcpStatus,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
try {
|
||||
client.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Error sending TaskMaster MCP status update:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast general TaskMaster update notification
|
||||
* @param {WebSocket.Server} wss - WebSocket server instance
|
||||
* @param {string} updateType - Type of update (e.g., 'initialization', 'configuration')
|
||||
* @param {Object} data - Additional data about the update
|
||||
*/
|
||||
export function broadcastTaskMasterUpdate(wss, updateType, data = {}) {
|
||||
if (!wss || !updateType) {
|
||||
console.warn('TaskMaster WebSocket broadcast: Missing wss or updateType');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: 'taskmaster-update',
|
||||
updateType,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
|
||||
wss.clients.forEach((client) => {
|
||||
if (client.readyState === 1) { // WebSocket.OPEN
|
||||
try {
|
||||
client.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Error sending TaskMaster update:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
87
src/App.jsx
87
src/App.jsx
@@ -23,12 +23,14 @@ import { BrowserRouter as Router, Routes, Route, useNavigate, useParams } from '
|
||||
import Sidebar from './components/Sidebar';
|
||||
import MainContent from './components/MainContent';
|
||||
import MobileNav from './components/MobileNav';
|
||||
import ToolsSettings from './components/ToolsSettings';
|
||||
import Settings from './components/Settings';
|
||||
import QuickSettingsPanel from './components/QuickSettingsPanel';
|
||||
|
||||
import { useWebSocket } from './utils/websocket';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { TaskMasterProvider } from './contexts/TaskMasterContext';
|
||||
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
|
||||
import { WebSocketProvider, useWebSocketContext } from './contexts/WebSocketContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import { useVersionCheck } from './hooks/useVersionCheck';
|
||||
import { api, authenticatedFetch } from './utils/api';
|
||||
@@ -50,7 +52,7 @@ function AppContent() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
const [showToolsSettings, setShowToolsSettings] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showQuickSettings, setShowQuickSettings] = useState(false);
|
||||
const [autoExpandTools, setAutoExpandTools] = useState(() => {
|
||||
const saved = localStorage.getItem('autoExpandTools');
|
||||
@@ -74,7 +76,38 @@ function AppContent() {
|
||||
// until the conversation completes or is aborted.
|
||||
const [activeSessions, setActiveSessions] = useState(new Set()); // Track sessions with active conversations
|
||||
|
||||
const { ws, sendMessage, messages } = useWebSocket();
|
||||
const { ws, sendMessage, messages } = useWebSocketContext();
|
||||
|
||||
// Detect if running as PWA
|
||||
const [isPWA, setIsPWA] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if running in standalone mode (PWA)
|
||||
const checkPWA = () => {
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone ||
|
||||
document.referrer.includes('android-app://');
|
||||
setIsPWA(isStandalone);
|
||||
|
||||
// Add class to html and body for CSS targeting
|
||||
if (isStandalone) {
|
||||
document.documentElement.classList.add('pwa-mode');
|
||||
document.body.classList.add('pwa-mode');
|
||||
} else {
|
||||
document.documentElement.classList.remove('pwa-mode');
|
||||
document.body.classList.remove('pwa-mode');
|
||||
}
|
||||
};
|
||||
|
||||
checkPWA();
|
||||
|
||||
// Listen for changes
|
||||
window.matchMedia('(display-mode: standalone)').addEventListener('change', checkPWA);
|
||||
|
||||
return () => {
|
||||
window.matchMedia('(display-mode: standalone)').removeEventListener('change', checkPWA);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
@@ -554,11 +587,13 @@ function AppContent() {
|
||||
onProjectDelete={handleProjectDelete}
|
||||
isLoading={isLoadingProjects}
|
||||
onRefresh={handleSidebarRefresh}
|
||||
onShowSettings={() => setShowToolsSettings(true)}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
isPWA={isPWA}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -582,9 +617,10 @@ function AppContent() {
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={`relative w-[85vw] max-w-sm sm:w-80 bg-card border-r border-border h-full transform transition-transform duration-150 ease-out ${
|
||||
className={`relative w-[85vw] max-w-sm sm:w-80 bg-card border-r border-border transform transition-transform duration-150 ease-out ${
|
||||
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
style={{ height: 'calc(100vh - 80px)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
>
|
||||
@@ -599,18 +635,20 @@ function AppContent() {
|
||||
onProjectDelete={handleProjectDelete}
|
||||
isLoading={isLoadingProjects}
|
||||
onRefresh={handleSidebarRefresh}
|
||||
onShowSettings={() => setShowToolsSettings(true)}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
updateAvailable={updateAvailable}
|
||||
latestVersion={latestVersion}
|
||||
currentVersion={currentVersion}
|
||||
onShowVersionModal={() => setShowVersionModal(true)}
|
||||
isPWA={isPWA}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area - Flexible */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className={`flex-1 flex flex-col min-w-0 ${isMobile && !isInputFocused ? 'pb-16' : ''}`}>
|
||||
<MainContent
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
@@ -620,6 +658,7 @@ function AppContent() {
|
||||
sendMessage={sendMessage}
|
||||
messages={messages}
|
||||
isMobile={isMobile}
|
||||
isPWA={isPWA}
|
||||
onMenuClick={() => setSidebarOpen(true)}
|
||||
isLoading={isLoadingProjects}
|
||||
onInputFocusChange={setIsInputFocused}
|
||||
@@ -627,7 +666,7 @@ function AppContent() {
|
||||
onSessionInactive={markSessionAsInactive}
|
||||
onReplaceTemporarySession={replaceTemporarySession}
|
||||
onNavigateToSession={(sessionId) => navigate(`/session/${sessionId}`)}
|
||||
onShowSettings={() => setShowToolsSettings(true)}
|
||||
onShowSettings={() => setShowSettings(true)}
|
||||
autoExpandTools={autoExpandTools}
|
||||
showRawParameters={showRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
@@ -672,10 +711,10 @@ function AppContent() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tools Settings Modal */}
|
||||
<ToolsSettings
|
||||
isOpen={showToolsSettings}
|
||||
onClose={() => setShowToolsSettings(false)}
|
||||
{/* Settings Modal */}
|
||||
<Settings
|
||||
isOpen={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
projects={projects}
|
||||
/>
|
||||
|
||||
@@ -690,14 +729,20 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<ProtectedRoute>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ProtectedRoute>
|
||||
<WebSocketProvider>
|
||||
<TasksSettingsProvider>
|
||||
<TaskMasterProvider>
|
||||
<ProtectedRoute>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" element={<AppContent />} />
|
||||
<Route path="/session/:sessionId" element={<AppContent />} />
|
||||
</Routes>
|
||||
</Router>
|
||||
</ProtectedRoute>
|
||||
</TaskMasterProvider>
|
||||
</TasksSettingsProvider>
|
||||
</WebSocketProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ import { useDropzone } from 'react-dropzone';
|
||||
import TodoList from './TodoList';
|
||||
import ClaudeLogo from './ClaudeLogo.jsx';
|
||||
import CursorLogo from './CursorLogo.jsx';
|
||||
import NextTaskBanner from './NextTaskBanner.jsx';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
|
||||
import ClaudeStatus from './ClaudeStatus';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
@@ -155,9 +157,11 @@ const safeLocalStorage = {
|
||||
|
||||
// Memoized message component to prevent unnecessary re-renders
|
||||
const MessageComponent = memo(({ message, index, prevMessage, createDiff, onFileOpen, onShowSettings, autoExpandTools, showRawParameters }) => {
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
prevMessage.type === 'assistant' &&
|
||||
!prevMessage.isToolUse && !message.isToolUse;
|
||||
const isGrouped = prevMessage && prevMessage.type === message.type &&
|
||||
((prevMessage.type === 'assistant') ||
|
||||
(prevMessage.type === 'user') ||
|
||||
(prevMessage.type === 'tool') ||
|
||||
(prevMessage.type === 'error'));
|
||||
const messageRef = React.useRef(null);
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
React.useEffect(() => {
|
||||
@@ -1162,7 +1166,8 @@ const ImageAttachment = ({ file, onRemove, uploadProgress, error }) => {
|
||||
// - onReplaceTemporarySession: Called to replace temporary session ID with real WebSocket session ID
|
||||
//
|
||||
// This ensures uninterrupted chat experience by pausing sidebar refreshes during conversations.
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom, sendByCtrlEnter }) {
|
||||
function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, messages, onFileOpen, onInputFocusChange, onSessionActive, onSessionInactive, onReplaceTemporarySession, onNavigateToSession, onShowSettings, autoExpandTools, showRawParameters, autoScrollToBottom, sendByCtrlEnter, onTaskClick, onShowAllTasks }) {
|
||||
const { tasksEnabled } = useTasksSettings();
|
||||
const [input, setInput] = useState(() => {
|
||||
if (typeof window !== 'undefined' && selectedProject) {
|
||||
return safeLocalStorage.getItem(`draft_input_${selectedProject.name}`) || '';
|
||||
@@ -2014,6 +2019,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// When resuming a session, Claude CLI creates a new session instead of resuming.
|
||||
// We detect this by checking for system/init messages with session_id that differs
|
||||
// from our current session. When found, we need to switch the user to the new session.
|
||||
// This works exactly like new session detection - preserve messages during navigation.
|
||||
if (latestMessage.data.type === 'system' &&
|
||||
latestMessage.data.subtype === 'init' &&
|
||||
latestMessage.data.session_id &&
|
||||
@@ -2026,6 +2032,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
});
|
||||
|
||||
// Mark this as a system-initiated session change to preserve messages
|
||||
// This works exactly like new session init - messages stay visible during navigation
|
||||
setIsSystemSessionChange(true);
|
||||
|
||||
// Switch to the new session using React Router navigation
|
||||
@@ -2740,7 +2747,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
// Get tools settings from localStorage based on provider
|
||||
const getToolsSettings = () => {
|
||||
try {
|
||||
const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-tools-settings';
|
||||
const settingsKey = provider === 'cursor' ? 'cursor-tools-settings' : 'claude-settings';
|
||||
const savedSettings = safeLocalStorage.getItem(settingsKey);
|
||||
if (savedSettings) {
|
||||
return JSON.parse(savedSettings);
|
||||
@@ -3092,6 +3099,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
: 'Select a provider above to begin'
|
||||
}
|
||||
</p>
|
||||
|
||||
{/* Show NextTaskBanner when provider is selected and ready */}
|
||||
{provider && tasksEnabled && (
|
||||
<div className="mt-4 px-4 sm:px-0">
|
||||
<NextTaskBanner
|
||||
onStartTask={() => setInput('Start the next task')}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedSession && (
|
||||
@@ -3100,6 +3117,16 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
<p className="text-sm sm:text-base leading-relaxed">
|
||||
Ask questions about your code, request changes, or get help with development tasks
|
||||
</p>
|
||||
|
||||
{/* Show NextTaskBanner for existing sessions too */}
|
||||
{tasksEnabled && (
|
||||
<div className="mt-4 px-4 sm:px-0">
|
||||
<NextTaskBanner
|
||||
onStartTask={() => setInput('Start the next task')}
|
||||
onShowAllTasks={onShowAllTasks}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3192,7 +3219,7 @@ function ChatInterface({ selectedProject, selectedSession, ws, sendMessage, mess
|
||||
|
||||
{/* Input Area - Fixed Bottom */}
|
||||
<div className={`p-2 sm:p-4 md:p-4 flex-shrink-0 ${
|
||||
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-16 sm:pb-4 md:pb-6'
|
||||
isInputFocused ? 'pb-2 sm:pb-4 md:pb-6' : 'pb-2 sm:pb-4 md:pb-6'
|
||||
}`}>
|
||||
|
||||
<div className="flex-1">
|
||||
|
||||
88
src/components/CreateTaskModal.jsx
Normal file
88
src/components/CreateTaskModal.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { X, Sparkles } from 'lucide-react';
|
||||
|
||||
const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-200 dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create AI-Generated Task</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* AI-First Approach */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-blue-900 dark:text-blue-100 mb-2">
|
||||
💡 Pro Tip: Ask Claude Code Directly!
|
||||
</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
|
||||
You can simply ask Claude Code in the chat to create tasks for you.
|
||||
The AI assistant will automatically generate detailed tasks with research-backed insights.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-blue-200 dark:border-blue-700 p-3 mb-3">
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Example:</p>
|
||||
<p className="text-sm text-gray-900 dark:text-white font-mono">
|
||||
"Please add a new task to implement user profile image uploads using Cloudinary, research the best approach."
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<strong>This runs:</strong> <code className="bg-blue-100 dark:bg-blue-900/50 px-1 rounded text-xs">
|
||||
task-master add-task --prompt="Implement user profile image uploads using Cloudinary" --research
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learn More Link */}
|
||||
<div className="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
For more examples and advanced usage patterns:
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline font-medium"
|
||||
>
|
||||
View TaskMaster Documentation →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Got it, I'll ask Claude Code directly
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateTaskModal;
|
||||
41
src/components/DiffViewer.jsx
Normal file
41
src/components/DiffViewer.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
|
||||
function DiffViewer({ diff, fileName, isMobile, wrapText }) {
|
||||
if (!diff) {
|
||||
return (
|
||||
<div className="p-4 text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||
No diff available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderDiffLine = (line, index) => {
|
||||
const isAddition = line.startsWith('+') && !line.startsWith('+++');
|
||||
const isDeletion = line.startsWith('-') && !line.startsWith('---');
|
||||
const isHeader = line.startsWith('@@');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`font-mono text-xs p-2 ${
|
||||
isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto'
|
||||
} ${
|
||||
isAddition ? 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300' :
|
||||
isDeletion ? 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300' :
|
||||
isHeader ? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300' :
|
||||
'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="diff-viewer">
|
||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DiffViewer;
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye } from 'lucide-react';
|
||||
import { Input } from './ui/input';
|
||||
import { Folder, FolderOpen, File, FileText, FileCode, List, TableProperties, Eye, Search, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import ImageViewer from './ImageViewer';
|
||||
@@ -14,6 +15,8 @@ function FileTree({ selectedProject }) {
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [selectedImage, setSelectedImage] = useState(null);
|
||||
const [viewMode, setViewMode] = useState('detailed'); // 'simple', 'detailed', 'compact'
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filteredFiles, setFilteredFiles] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProject) {
|
||||
@@ -29,6 +32,51 @@ function FileTree({ selectedProject }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Filter files based on search query
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredFiles(files);
|
||||
} else {
|
||||
const filtered = filterFiles(files, searchQuery.toLowerCase());
|
||||
setFilteredFiles(filtered);
|
||||
|
||||
// Auto-expand directories that contain matches
|
||||
const expandMatches = (items) => {
|
||||
items.forEach(item => {
|
||||
if (item.type === 'directory' && item.children && item.children.length > 0) {
|
||||
setExpandedDirs(prev => new Set(prev.add(item.path)));
|
||||
expandMatches(item.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
expandMatches(filtered);
|
||||
}
|
||||
}, [files, searchQuery]);
|
||||
|
||||
// Recursively filter files and directories based on search query
|
||||
const filterFiles = (items, query) => {
|
||||
return items.reduce((filtered, item) => {
|
||||
const matchesName = item.name.toLowerCase().includes(query);
|
||||
let filteredChildren = [];
|
||||
|
||||
if (item.type === 'directory' && item.children) {
|
||||
filteredChildren = filterFiles(item.children, query);
|
||||
}
|
||||
|
||||
// Include item if:
|
||||
// 1. It matches the search query, or
|
||||
// 2. It's a directory with matching children
|
||||
if (matchesName || filteredChildren.length > 0) {
|
||||
filtered.push({
|
||||
...item,
|
||||
children: filteredChildren
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, []);
|
||||
};
|
||||
|
||||
const fetchFiles = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -308,42 +356,67 @@ function FileTree({ selectedProject }) {
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="p-4 border-b border-border flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">Files</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('simple')}
|
||||
title="Simple view"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('compact')}
|
||||
title="Compact view"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('detailed')}
|
||||
title="Detailed view"
|
||||
>
|
||||
<TableProperties className="w-4 h-4" />
|
||||
</Button>
|
||||
{/* Header with Search and View Mode Toggle */}
|
||||
<div className="p-4 border-b border-border space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-foreground">Files</h3>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={viewMode === 'simple' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('simple')}
|
||||
title="Simple view"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'compact' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('compact')}
|
||||
title="Compact view"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'detailed' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => changeViewMode('detailed')}
|
||||
title="Detailed view"
|
||||
>
|
||||
<TableProperties className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search files and folders..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-8 pr-8 h-8 text-sm"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-6 w-6 p-0 hover:bg-accent"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Headers for Detailed View */}
|
||||
{viewMode === 'detailed' && files.length > 0 && (
|
||||
{viewMode === 'detailed' && filteredFiles.length > 0 && (
|
||||
<div className="px-4 pt-2 pb-1 border-b border-border">
|
||||
<div className="grid grid-cols-12 gap-2 px-2 text-xs font-medium text-muted-foreground">
|
||||
<div className="col-span-5">Name</div>
|
||||
@@ -365,11 +438,21 @@ function FileTree({ selectedProject }) {
|
||||
Check if the project path is accessible
|
||||
</p>
|
||||
</div>
|
||||
) : filteredFiles.length === 0 && searchQuery ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-muted rounded-lg flex items-center justify-center mx-auto mb-3">
|
||||
<Search className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h4 className="font-medium text-foreground mb-1">No matches found</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Try a different search term or clear the search
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={viewMode === 'detailed' ? '' : 'space-y-1'}>
|
||||
{viewMode === 'simple' && renderFileTree(files)}
|
||||
{viewMode === 'compact' && renderCompactView(files)}
|
||||
{viewMode === 'detailed' && renderDetailedView(files)}
|
||||
{viewMode === 'simple' && renderFileTree(filteredFiles)}
|
||||
{viewMode === 'compact' && renderCompactView(filteredFiles)}
|
||||
{viewMode === 'detailed' && renderDetailedView(filteredFiles)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
|
||||
import { GitBranch, GitCommit, Plus, Minus, RefreshCw, Check, X, ChevronDown, ChevronRight, Info, History, FileText, Mic, MicOff, Sparkles, Download, RotateCcw, Trash2, AlertTriangle, Upload } from 'lucide-react';
|
||||
import { MicButton } from './MicButton.jsx';
|
||||
import { authenticatedFetch } from '../utils/api';
|
||||
import DiffViewer from './DiffViewer.jsx';
|
||||
|
||||
function GitPanel({ selectedProject, isMobile }) {
|
||||
const [gitStatus, setGitStatus] = useState(null);
|
||||
@@ -523,27 +524,6 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
}
|
||||
};
|
||||
|
||||
const renderDiffLine = (line, index) => {
|
||||
const isAddition = line.startsWith('+') && !line.startsWith('+++');
|
||||
const isDeletion = line.startsWith('-') && !line.startsWith('---');
|
||||
const isHeader = line.startsWith('@@');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`font-mono text-xs ${
|
||||
isMobile && wrapText ? 'whitespace-pre-wrap break-all' : 'whitespace-pre overflow-x-auto'
|
||||
} ${
|
||||
isAddition ? 'bg-green-50 dark:bg-green-950 text-green-700 dark:text-green-300' :
|
||||
isDeletion ? 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300' :
|
||||
isHeader ? 'bg-blue-50 dark:bg-blue-950 text-blue-700 dark:text-blue-300' :
|
||||
'text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusLabel = (status) => {
|
||||
switch (status) {
|
||||
@@ -590,7 +570,7 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
<div className="text-xs font-mono text-gray-600 dark:text-gray-400 mb-2">
|
||||
{commit.stats}
|
||||
</div>
|
||||
{diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
<DiffViewer diff={diff} fileName="commit" isMobile={isMobile} wrapText={wrapText} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -705,8 +685,8 @@ function GitPanel({ selectedProject, isMobile }) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto p-2">
|
||||
{diff && diff.split('\n').map((line, index) => renderDiffLine(line, index))}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{diff && <DiffViewer diff={diff} fileName={filePath} isMobile={isMobile} wrapText={wrapText} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,11 +15,18 @@ import React, { useState, useEffect } from 'react';
|
||||
import ChatInterface from './ChatInterface';
|
||||
import FileTree from './FileTree';
|
||||
import CodeEditor from './CodeEditor';
|
||||
import Shell from './Shell';
|
||||
import StandaloneShell from './StandaloneShell';
|
||||
import GitPanel from './GitPanel';
|
||||
import ErrorBoundary from './ErrorBoundary';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo';
|
||||
import TaskList from './TaskList';
|
||||
import TaskDetail from './TaskDetail';
|
||||
import PRDEditor from './PRDEditor';
|
||||
import Tooltip from './Tooltip';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
function MainContent({
|
||||
selectedProject,
|
||||
@@ -30,6 +37,7 @@ function MainContent({
|
||||
sendMessage,
|
||||
messages,
|
||||
isMobile,
|
||||
isPWA,
|
||||
onMenuClick,
|
||||
isLoading,
|
||||
onInputFocusChange,
|
||||
@@ -46,6 +54,60 @@ function MainContent({
|
||||
sendByCtrlEnter // Send by Ctrl+Enter mode for East Asian language input
|
||||
}) {
|
||||
const [editingFile, setEditingFile] = useState(null);
|
||||
const [selectedTask, setSelectedTask] = useState(null);
|
||||
const [showTaskDetail, setShowTaskDetail] = useState(false);
|
||||
|
||||
// PRD Editor state
|
||||
const [showPRDEditor, setShowPRDEditor] = useState(false);
|
||||
const [selectedPRD, setSelectedPRD] = useState(null);
|
||||
const [existingPRDs, setExistingPRDs] = useState([]);
|
||||
const [prdNotification, setPRDNotification] = useState(null);
|
||||
|
||||
// TaskMaster context
|
||||
const { tasks, currentProject, refreshTasks, setCurrentProject } = useTaskMaster();
|
||||
const { tasksEnabled, isTaskMasterInstalled, isTaskMasterReady } = useTasksSettings();
|
||||
|
||||
// Only show tasks tab if TaskMaster is installed and enabled
|
||||
const shouldShowTasksTab = tasksEnabled && isTaskMasterInstalled;
|
||||
|
||||
// Sync selectedProject with TaskMaster context
|
||||
useEffect(() => {
|
||||
if (selectedProject && selectedProject !== currentProject) {
|
||||
setCurrentProject(selectedProject);
|
||||
}
|
||||
}, [selectedProject, currentProject, setCurrentProject]);
|
||||
|
||||
// Switch away from tasks tab when tasks are disabled or TaskMaster is not installed
|
||||
useEffect(() => {
|
||||
if (!shouldShowTasksTab && activeTab === 'tasks') {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
}, [shouldShowTasksTab, activeTab, setActiveTab]);
|
||||
|
||||
// Load existing PRDs when current project changes
|
||||
useEffect(() => {
|
||||
const loadExistingPRDs = async () => {
|
||||
if (!currentProject?.name) {
|
||||
setExistingPRDs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setExistingPRDs(data.prdFiles || []);
|
||||
} else {
|
||||
setExistingPRDs([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load existing PRDs:', error);
|
||||
setExistingPRDs([]);
|
||||
}
|
||||
};
|
||||
|
||||
loadExistingPRDs();
|
||||
}, [currentProject?.name]);
|
||||
|
||||
const handleFileOpen = (filePath, diffInfo = null) => {
|
||||
// Create a file object that CodeEditor expects
|
||||
@@ -61,15 +123,42 @@ function MainContent({
|
||||
const handleCloseEditor = () => {
|
||||
setEditingFile(null);
|
||||
};
|
||||
|
||||
const handleTaskClick = (task) => {
|
||||
// If task is just an ID (from dependency click), find the full task object
|
||||
if (typeof task === 'object' && task.id && !task.title) {
|
||||
const fullTask = tasks?.find(t => t.id === task.id);
|
||||
if (fullTask) {
|
||||
setSelectedTask(fullTask);
|
||||
setShowTaskDetail(true);
|
||||
}
|
||||
} else {
|
||||
setSelectedTask(task);
|
||||
setShowTaskDetail(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskDetailClose = () => {
|
||||
setShowTaskDetail(false);
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const handleTaskStatusChange = (taskId, newStatus) => {
|
||||
// This would integrate with TaskMaster API to update task status
|
||||
console.log('Update task status:', taskId, newStatus);
|
||||
refreshTasks?.();
|
||||
};
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with menu button for mobile */}
|
||||
{isMobile && (
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -102,10 +191,12 @@ function MainContent({
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with menu button for mobile */}
|
||||
{isMobile && (
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="p-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -138,7 +229,9 @@ function MainContent({
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Header with tabs */}
|
||||
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 flex-shrink-0">
|
||||
<div
|
||||
className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 p-3 sm:p-4 pwa-header-safe flex-shrink-0"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 sm:space-x-3">
|
||||
{isMobile && (
|
||||
@@ -148,7 +241,7 @@ function MainContent({
|
||||
e.preventDefault();
|
||||
onMenuClick();
|
||||
}}
|
||||
className="p-2.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95"
|
||||
className="p-2.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700 touch-manipulation active:scale-95 pwa-menu-button"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
@@ -187,7 +280,10 @@ function MainContent({
|
||||
) : (
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{activeTab === 'files' ? 'Project Files' : activeTab === 'git' ? 'Source Control' : 'Project'}
|
||||
{activeTab === 'files' ? 'Project Files' :
|
||||
activeTab === 'git' ? 'Source Control' :
|
||||
(activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' :
|
||||
'Project'}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{selectedProject.displayName}
|
||||
@@ -201,66 +297,93 @@ function MainContent({
|
||||
{/* Modern Tab Navigation - Right Side */}
|
||||
<div className="flex-shrink-0 hidden sm:block">
|
||||
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Chat</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('shell')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'shell'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Shell</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('files')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'files'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Files</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('git')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'git'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Source Control</span>
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip content="Chat" position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md ${
|
||||
activeTab === 'chat'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Chat</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Shell" position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('shell')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'shell'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Shell</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Files" position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('files')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'files'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Files</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Source Control" position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('git')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'git'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Source Control</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{shouldShowTasksTab && (
|
||||
<Tooltip content="Tasks" position="bottom">
|
||||
<button
|
||||
onClick={() => setActiveTab('tasks')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
activeTab === 'tasks'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">Tasks</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* <button
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
||||
@@ -302,6 +425,7 @@ function MainContent({
|
||||
showRawParameters={showRawParameters}
|
||||
autoScrollToBottom={autoScrollToBottom}
|
||||
sendByCtrlEnter={sendByCtrlEnter}
|
||||
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
@@ -309,15 +433,50 @@ function MainContent({
|
||||
<FileTree selectedProject={selectedProject} />
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'shell' ? 'block' : 'hidden'}`}>
|
||||
<Shell
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
<StandaloneShell
|
||||
project={selectedProject}
|
||||
session={selectedSession}
|
||||
isActive={activeTab === 'shell'}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'git' ? 'block' : 'hidden'}`}>
|
||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} />
|
||||
</div>
|
||||
{shouldShowTasksTab && (
|
||||
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<TaskList
|
||||
tasks={tasks || []}
|
||||
onTaskClick={handleTaskClick}
|
||||
showParentTasks={true}
|
||||
className="flex-1 overflow-y-auto p-4"
|
||||
currentProject={currentProject}
|
||||
onTaskCreated={refreshTasks}
|
||||
onShowPRDEditor={(prd = null) => {
|
||||
setSelectedPRD(prd);
|
||||
setShowPRDEditor(true);
|
||||
}}
|
||||
existingPRDs={existingPRDs}
|
||||
onRefreshPRDs={(showNotification = false) => {
|
||||
// Reload existing PRDs
|
||||
if (currentProject?.name) {
|
||||
api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`)
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
setExistingPRDs(data.prdFiles || []);
|
||||
if (showNotification) {
|
||||
setPRDNotification('PRD saved successfully!');
|
||||
setTimeout(() => setPRDNotification(null), 3000);
|
||||
}
|
||||
})
|
||||
.catch(error => console.error('Failed to refresh PRDs:', error));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`}>
|
||||
{/* <LivePreviewPanel
|
||||
selectedProject={selectedProject}
|
||||
@@ -354,6 +513,63 @@ function MainContent({
|
||||
projectPath={selectedProject?.path}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
{shouldShowTasksTab && showTaskDetail && selectedTask && (
|
||||
<TaskDetail
|
||||
task={selectedTask}
|
||||
isOpen={showTaskDetail}
|
||||
onClose={handleTaskDetailClose}
|
||||
onStatusChange={handleTaskStatusChange}
|
||||
onTaskClick={handleTaskClick}
|
||||
/>
|
||||
)}
|
||||
{/* PRD Editor Modal */}
|
||||
{showPRDEditor && (
|
||||
<PRDEditor
|
||||
project={currentProject}
|
||||
projectPath={currentProject?.fullPath || currentProject?.path}
|
||||
onClose={() => {
|
||||
setShowPRDEditor(false);
|
||||
setSelectedPRD(null);
|
||||
}}
|
||||
isNewFile={!selectedPRD?.isExisting}
|
||||
file={{
|
||||
name: selectedPRD?.name || 'prd.txt',
|
||||
content: selectedPRD?.content || ''
|
||||
}}
|
||||
onSave={async () => {
|
||||
setShowPRDEditor(false);
|
||||
setSelectedPRD(null);
|
||||
|
||||
// Reload existing PRDs with notification
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setExistingPRDs(data.prdFiles || []);
|
||||
setPRDNotification('PRD saved successfully!');
|
||||
setTimeout(() => setPRDNotification(null), 3000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh PRDs:', error);
|
||||
}
|
||||
|
||||
refreshTasks?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* PRD Notification */}
|
||||
{prdNotification && (
|
||||
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 duration-300">
|
||||
<div className="bg-green-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="font-medium">{prdNotification}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, Globe } from 'lucide-react';
|
||||
import { MessageSquare, Folder, Terminal, GitBranch, Globe, CheckSquare } from 'lucide-react';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
|
||||
function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
const { tasksEnabled } = useTasksSettings();
|
||||
// Detect dark mode
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
const navItems = [
|
||||
@@ -24,7 +26,13 @@ function MobileNav({ activeTab, setActiveTab, isInputFocused }) {
|
||||
id: 'git',
|
||||
icon: GitBranch,
|
||||
onClick: () => setActiveTab('git')
|
||||
}
|
||||
},
|
||||
// Conditionally add tasks tab if enabled
|
||||
...(tasksEnabled ? [{
|
||||
id: 'tasks',
|
||||
icon: CheckSquare,
|
||||
onClick: () => setActiveTab('tasks')
|
||||
}] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
695
src/components/NextTaskBanner.jsx
Normal file
695
src/components/NextTaskBanner.jsx
Normal file
@@ -0,0 +1,695 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ArrowRight, List, Clock, Flag, CheckCircle, Circle, AlertCircle, Pause, ChevronDown, ChevronUp, Plus, FileText, Settings, X, Terminal, Eye, Play, Zap, Target } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { api } from '../utils/api';
|
||||
import Shell from './Shell';
|
||||
import TaskDetail from './TaskDetail';
|
||||
|
||||
const NextTaskBanner = ({ onShowAllTasks, onStartTask, className = '' }) => {
|
||||
const { nextTask, tasks, currentProject, isLoadingTasks, projectTaskMaster, refreshTasks, refreshProjects } = useTaskMaster();
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showTaskOptions, setShowTaskOptions] = useState(false);
|
||||
const [showCreateTaskModal, setShowCreateTaskModal] = useState(false);
|
||||
const [showTemplateSelector, setShowTemplateSelector] = useState(false);
|
||||
const [showCLI, setShowCLI] = useState(false);
|
||||
const [showTaskDetail, setShowTaskDetail] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Handler functions
|
||||
const handleInitializeTaskMaster = async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await api.taskmaster.init(currentProject.name);
|
||||
if (response.ok) {
|
||||
await refreshProjects();
|
||||
setShowTaskOptions(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to initialize TaskMaster:', error);
|
||||
alert(`Failed to initialize TaskMaster: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing TaskMaster:', error);
|
||||
alert('Error initializing TaskMaster. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateManualTask = () => {
|
||||
setShowCreateTaskModal(true);
|
||||
setShowTaskOptions(false);
|
||||
};
|
||||
|
||||
const handleParsePRD = () => {
|
||||
setShowTemplateSelector(true);
|
||||
setShowTaskOptions(false);
|
||||
};
|
||||
|
||||
// Don't show if no project or still loading
|
||||
if (!currentProject || isLoadingTasks) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let bannerContent;
|
||||
|
||||
// Show setup message only if no tasks exist AND TaskMaster is not configured
|
||||
if ((!tasks || tasks.length === 0) && !projectTaskMaster?.hasTaskmaster) {
|
||||
bannerContent = (
|
||||
<div className={cn(
|
||||
'bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mb-4',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<List className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
TaskMaster AI is not configured
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setShowTaskOptions(!showTaskOptions)}
|
||||
className="text-xs px-2 py-1 bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors flex items-center gap-1"
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
Initialize TaskMaster AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showTaskOptions && (
|
||||
<div className="mt-3 pt-3 border-t border-blue-200 dark:border-blue-800">
|
||||
{!projectTaskMaster?.hasTaskmaster && (
|
||||
<div className="mb-3 p-3 bg-blue-50 dark:bg-blue-900/50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
🎯 What is TaskMaster?
|
||||
</h4>
|
||||
<div className="text-xs text-blue-800 dark:text-blue-200 space-y-1">
|
||||
<p>• <strong>AI-Powered Task Management:</strong> Break complex projects into manageable subtasks</p>
|
||||
<p>• <strong>PRD Templates:</strong> Generate tasks from Product Requirements Documents</p>
|
||||
<p>• <strong>Dependency Tracking:</strong> Understand task relationships and execution order</p>
|
||||
<p>• <strong>Progress Visualization:</strong> Kanban boards and detailed task analytics</p>
|
||||
<p>• <strong>CLI Integration:</strong> Use taskmaster commands for advanced workflows</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{!projectTaskMaster?.hasTaskmaster ? (
|
||||
<button
|
||||
className="text-xs px-3 py-2 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-800 dark:text-slate-200 rounded transition-colors text-left flex items-center gap-2"
|
||||
onClick={() => setShowCLI(true)}
|
||||
>
|
||||
<Terminal className="w-3 h-3" />
|
||||
Initialize TaskMaster
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-2 p-2 bg-green-50 dark:bg-green-900/30 rounded text-xs text-green-800 dark:text-green-200">
|
||||
<strong>Add more tasks:</strong> Create additional tasks manually or generate them from a PRD template
|
||||
</div>
|
||||
<button
|
||||
className="text-xs px-3 py-2 bg-green-100 dark:bg-green-900 hover:bg-green-200 dark:hover:bg-green-800 text-green-800 dark:text-green-200 rounded transition-colors text-left flex items-center gap-2 disabled:opacity-50"
|
||||
onClick={handleCreateManualTask}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Create a new task manually
|
||||
</button>
|
||||
<button
|
||||
className="text-xs px-3 py-2 bg-purple-100 dark:bg-purple-900 hover:bg-purple-200 dark:hover:bg-purple-800 text-purple-800 dark:text-purple-200 rounded transition-colors text-left flex items-center gap-2 disabled:opacity-50"
|
||||
onClick={handleParsePRD}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{isLoading ? 'Parsing...' : 'Generate tasks from PRD template'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (nextTask) {
|
||||
// Show next task if available
|
||||
bannerContent = (
|
||||
<div className={cn(
|
||||
'bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-3 mb-4',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-5 h-5 bg-blue-100 dark:bg-blue-900/50 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<Target className="w-3 h-3 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400 font-medium">Task {nextTask.id}</span>
|
||||
{nextTask.priority === 'high' && (
|
||||
<div className="w-4 h-4 rounded bg-red-100 dark:bg-red-900/50 flex items-center justify-center" title="High Priority">
|
||||
<Zap className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
)}
|
||||
{nextTask.priority === 'medium' && (
|
||||
<div className="w-4 h-4 rounded bg-amber-100 dark:bg-amber-900/50 flex items-center justify-center" title="Medium Priority">
|
||||
<Flag className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
)}
|
||||
{nextTask.priority === 'low' && (
|
||||
<div className="w-4 h-4 rounded bg-gray-100 dark:bg-gray-800 flex items-center justify-center" title="Low Priority">
|
||||
<Circle className="w-2.5 h-2.5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100 line-clamp-1">
|
||||
{nextTask.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => onStartTask?.()}
|
||||
className="text-xs px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium transition-colors shadow-sm flex items-center gap-1"
|
||||
>
|
||||
<Play className="w-3 h-3" />
|
||||
Start Task
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTaskDetail(true)}
|
||||
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md transition-colors flex items-center gap-1"
|
||||
title="View task details"
|
||||
>
|
||||
<Eye className="w-3 h-3" />
|
||||
</button>
|
||||
{onShowAllTasks && (
|
||||
<button
|
||||
onClick={onShowAllTasks}
|
||||
className="text-xs px-2 py-1.5 border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-300 rounded-md transition-colors flex items-center gap-1"
|
||||
title="View all tasks"
|
||||
>
|
||||
<List className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
} else if (tasks && tasks.length > 0) {
|
||||
// Show completion message only if there are tasks and all are done
|
||||
const completedTasks = tasks.filter(task => task.status === 'done').length;
|
||||
const totalTasks = tasks.length;
|
||||
|
||||
bannerContent = (
|
||||
<div className={cn(
|
||||
'bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800 rounded-lg p-3 mb-4',
|
||||
className
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{completedTasks === totalTasks ? "All done! 🎉" : "No pending tasks"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{completedTasks}/{totalTasks}
|
||||
</span>
|
||||
<button
|
||||
onClick={onShowAllTasks}
|
||||
className="text-xs px-2 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded transition-colors"
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// TaskMaster is configured but no tasks exist - don't show anything in chat
|
||||
bannerContent = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{bannerContent}
|
||||
|
||||
{/* Create Task Modal */}
|
||||
{showCreateTaskModal && (
|
||||
<CreateTaskModal
|
||||
currentProject={currentProject}
|
||||
onClose={() => setShowCreateTaskModal(false)}
|
||||
onTaskCreated={() => {
|
||||
refreshTasks();
|
||||
setShowCreateTaskModal(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Template Selector Modal */}
|
||||
{showTemplateSelector && (
|
||||
<TemplateSelector
|
||||
currentProject={currentProject}
|
||||
onClose={() => setShowTemplateSelector(false)}
|
||||
onTemplateApplied={() => {
|
||||
refreshTasks();
|
||||
setShowTemplateSelector(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TaskMaster CLI Setup Modal */}
|
||||
{showCLI && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-4xl h-[600px] flex flex-col">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900/50 rounded-lg flex items-center justify-center">
|
||||
<Terminal className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">TaskMaster Setup</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Interactive CLI for {currentProject?.displayName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCLI(false)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Terminal Container */}
|
||||
<div className="flex-1 p-4">
|
||||
<div className="h-full bg-black rounded-lg overflow-hidden">
|
||||
<Shell
|
||||
selectedProject={currentProject}
|
||||
selectedSession={null}
|
||||
isActive={true}
|
||||
initialCommand="npx task-master init"
|
||||
isPlainShell={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
TaskMaster initialization will start automatically
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCLI(false)}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
{showTaskDetail && nextTask && (
|
||||
<TaskDetail
|
||||
task={nextTask}
|
||||
isOpen={showTaskDetail}
|
||||
onClose={() => setShowTaskDetail(false)}
|
||||
onStatusChange={() => refreshTasks?.()}
|
||||
onTaskClick={null} // Disable dependency navigation in NextTaskBanner for now
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Simple Create Task Modal Component
|
||||
const CreateTaskModal = ({ currentProject, onClose, onTaskCreated }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
useAI: false,
|
||||
prompt: ''
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const taskData = formData.useAI
|
||||
? { prompt: formData.prompt, priority: formData.priority }
|
||||
: { title: formData.title, description: formData.description, priority: formData.priority };
|
||||
|
||||
const response = await api.taskmaster.addTask(currentProject.name, taskData);
|
||||
|
||||
if (response.ok) {
|
||||
onTaskCreated();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to create task:', error);
|
||||
alert(`Failed to create task: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
alert('Error creating task. Please try again.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Create New Task</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.useAI}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, useAI: e.target.checked }))}
|
||||
/>
|
||||
Use AI to generate task details
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.useAI ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Task Description (AI will generate details)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.prompt}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, prompt: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
rows="3"
|
||||
placeholder="Describe what you want to accomplish..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Task Title
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="Enter task title..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
rows="3"
|
||||
placeholder="Describe the task..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, priority: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded disabled:opacity-50"
|
||||
disabled={isSubmitting || (formData.useAI && !formData.prompt.trim()) || (!formData.useAI && (!formData.title.trim() || !formData.description.trim()))}
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Task'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Template Selector Modal Component
|
||||
const TemplateSelector = ({ currentProject, onClose, onTemplateApplied }) => {
|
||||
const [templates, setTemplates] = useState([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||
const [customizations, setCustomizations] = useState({});
|
||||
const [fileName, setFileName] = useState('prd.txt');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isApplying, setIsApplying] = useState(false);
|
||||
const [step, setStep] = useState('select'); // 'select', 'customize', 'generate'
|
||||
|
||||
useEffect(() => {
|
||||
const loadTemplates = async () => {
|
||||
try {
|
||||
const response = await api.taskmaster.getTemplates();
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setTemplates(data.templates);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading templates:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTemplates();
|
||||
}, []);
|
||||
|
||||
const handleSelectTemplate = (template) => {
|
||||
setSelectedTemplate(template);
|
||||
// Find placeholders in template content
|
||||
const placeholders = template.content.match(/\[([^\]]+)\]/g) || [];
|
||||
const uniquePlaceholders = [...new Set(placeholders.map(p => p.slice(1, -1)))];
|
||||
|
||||
const initialCustomizations = {};
|
||||
uniquePlaceholders.forEach(placeholder => {
|
||||
initialCustomizations[placeholder] = '';
|
||||
});
|
||||
|
||||
setCustomizations(initialCustomizations);
|
||||
setStep('customize');
|
||||
};
|
||||
|
||||
const handleApplyTemplate = async () => {
|
||||
if (!selectedTemplate || !currentProject) return;
|
||||
|
||||
setIsApplying(true);
|
||||
try {
|
||||
// Apply template
|
||||
const applyResponse = await api.taskmaster.applyTemplate(currentProject.name, {
|
||||
templateId: selectedTemplate.id,
|
||||
fileName,
|
||||
customizations
|
||||
});
|
||||
|
||||
if (!applyResponse.ok) {
|
||||
const error = await applyResponse.json();
|
||||
throw new Error(error.message || 'Failed to apply template');
|
||||
}
|
||||
|
||||
// Parse PRD to generate tasks
|
||||
const parseResponse = await api.taskmaster.parsePRD(currentProject.name, {
|
||||
fileName,
|
||||
numTasks: 10
|
||||
});
|
||||
|
||||
if (!parseResponse.ok) {
|
||||
const error = await parseResponse.json();
|
||||
throw new Error(error.message || 'Failed to generate tasks');
|
||||
}
|
||||
|
||||
setStep('generate');
|
||||
setTimeout(() => {
|
||||
onTemplateApplied();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error applying template:', error);
|
||||
alert(`Error: ${error.message}`);
|
||||
setIsApplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-900 dark:text-white">Loading templates...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{step === 'select' ? 'Select PRD Template' :
|
||||
step === 'customize' ? 'Customize Template' :
|
||||
'Generating Tasks'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{step === 'select' && (
|
||||
<div className="space-y-3">
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className="p-4 border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
|
||||
onClick={() => handleSelectTemplate(template)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">{template.name}</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">{template.description}</p>
|
||||
<span className="inline-block text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded mt-2">
|
||||
{template.category}
|
||||
</span>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-gray-400 mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'customize' && selectedTemplate && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
File Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="prd.txt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Object.keys(customizations).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Customize Template
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(customizations).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
{key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase())}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setCustomizations(prev => ({ ...prev, [key]: e.target.value }))}
|
||||
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
placeholder={`Enter ${key.toLowerCase()}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
onClick={() => setStep('select')}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApplyTemplate}
|
||||
className="flex-1 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded disabled:opacity-50"
|
||||
disabled={isApplying}
|
||||
>
|
||||
{isApplying ? 'Applying...' : 'Apply & Generate Tasks'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'generate' && (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-16 h-16 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
Template Applied Successfully!
|
||||
</h4>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Your PRD has been created and tasks are being generated...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextTaskBanner;
|
||||
871
src/components/PRDEditor.jsx
Normal file
871
src/components/PRDEditor.jsx
Normal file
@@ -0,0 +1,871 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { X, Save, Download, Maximize2, Minimize2, Eye, FileText, Sparkles, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { api, authenticatedFetch } from '../utils/api';
|
||||
|
||||
const PRDEditor = ({
|
||||
file,
|
||||
onClose,
|
||||
projectPath,
|
||||
project, // Add project object
|
||||
initialContent = '',
|
||||
isNewFile = false,
|
||||
onSave
|
||||
}) => {
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [loading, setLoading] = useState(!isNewFile);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [isDarkMode, setIsDarkMode] = useState(true);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [previewMode, setPreviewMode] = useState(false);
|
||||
const [wordWrap, setWordWrap] = useState(true); // Default to true for markdown
|
||||
const [fileName, setFileName] = useState('');
|
||||
const [showGenerateModal, setShowGenerateModal] = useState(false);
|
||||
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
|
||||
const [existingPRDs, setExistingPRDs] = useState([]);
|
||||
|
||||
const editorRef = useRef(null);
|
||||
|
||||
const PRD_TEMPLATE = `# Product Requirements Document - Example Project
|
||||
|
||||
## 1. Overview
|
||||
**Product Name:** AI-Powered Task Manager
|
||||
**Version:** 1.0
|
||||
**Date:** 2024-12-27
|
||||
**Author:** Development Team
|
||||
|
||||
This document outlines the requirements for building an AI-powered task management application that integrates with development workflows and provides intelligent task breakdown and prioritization.
|
||||
|
||||
## 2. Objectives
|
||||
- Create an intuitive task management system that works seamlessly with developer tools
|
||||
- Provide AI-powered task generation from high-level requirements
|
||||
- Enable real-time collaboration and progress tracking
|
||||
- Integrate with popular development environments (VS Code, Cursor, etc.)
|
||||
|
||||
### Success Metrics
|
||||
- User adoption rate > 80% within development teams
|
||||
- Task completion rate improvement of 25%
|
||||
- Time-to-delivery reduction of 15%
|
||||
|
||||
## 3. User Stories
|
||||
|
||||
### Core Functionality
|
||||
- As a project manager, I want to create PRDs that automatically generate detailed tasks so I can save time on project planning
|
||||
- As a developer, I want to see my next task clearly highlighted so I can maintain focus
|
||||
- As a team lead, I want to track progress across multiple projects so I can provide accurate status updates
|
||||
- As a developer, I want tasks to be broken down into implementable subtasks so I can work more efficiently
|
||||
|
||||
### AI Integration
|
||||
- As a user, I want to describe a feature in natural language and get detailed implementation tasks so I can start working immediately
|
||||
- As a project manager, I want the AI to analyze task complexity and suggest appropriate time estimates
|
||||
- As a developer, I want intelligent task prioritization based on dependencies and deadlines
|
||||
|
||||
### Collaboration
|
||||
- As a team member, I want to see real-time updates when tasks are completed so I can coordinate my work
|
||||
- As a stakeholder, I want to view project progress through intuitive dashboards
|
||||
- As a developer, I want to add implementation notes to tasks for future reference
|
||||
|
||||
## 4. Functional Requirements
|
||||
|
||||
### Task Management
|
||||
- Create, edit, and delete tasks with rich metadata (priority, status, dependencies, estimates)
|
||||
- Hierarchical task structure with subtasks and sub-subtasks
|
||||
- Real-time status updates and progress tracking
|
||||
- Dependency management with circular dependency detection
|
||||
- Bulk operations (move, update status, assign)
|
||||
|
||||
### AI Features
|
||||
- Natural language PRD parsing to generate structured tasks
|
||||
- Intelligent task breakdown with complexity analysis
|
||||
- Automated subtask generation with implementation details
|
||||
- Smart dependency suggestion
|
||||
- Progress prediction based on historical data
|
||||
|
||||
### Integration Features
|
||||
- VS Code/Cursor extension for in-editor task management
|
||||
- Git integration for linking commits to tasks
|
||||
- API for third-party tool integration
|
||||
- Webhook support for external notifications
|
||||
- CLI tool for command-line task management
|
||||
|
||||
### User Interface
|
||||
- Responsive web application (desktop and mobile)
|
||||
- Multiple view modes (Kanban, list, calendar)
|
||||
- Dark/light theme support
|
||||
- Drag-and-drop task organization
|
||||
- Advanced filtering and search capabilities
|
||||
- Keyboard shortcuts for power users
|
||||
|
||||
## 5. Technical Requirements
|
||||
|
||||
### Frontend
|
||||
- React.js with TypeScript for type safety
|
||||
- Modern UI framework (Tailwind CSS)
|
||||
- State management (Context API or Redux)
|
||||
- Real-time updates via WebSockets
|
||||
- Progressive Web App (PWA) support
|
||||
- Accessibility compliance (WCAG 2.1 AA)
|
||||
|
||||
### Backend
|
||||
- Node.js with Express.js framework
|
||||
- RESTful API design with OpenAPI documentation
|
||||
- Real-time communication via Socket.io
|
||||
- Background job processing
|
||||
- Rate limiting and security middleware
|
||||
|
||||
### AI Integration
|
||||
- Integration with multiple AI providers (OpenAI, Anthropic, etc.)
|
||||
- Fallback model support
|
||||
- Context-aware prompt engineering
|
||||
- Token usage optimization
|
||||
- Model response caching
|
||||
|
||||
### Database
|
||||
- Primary: PostgreSQL for relational data
|
||||
- Cache: Redis for session management and real-time features
|
||||
- Full-text search capabilities
|
||||
- Database migrations and seeding
|
||||
- Backup and recovery procedures
|
||||
|
||||
### Infrastructure
|
||||
- Docker containerization
|
||||
- Cloud deployment (AWS/GCP/Azure)
|
||||
- Auto-scaling capabilities
|
||||
- Monitoring and logging (structured logging)
|
||||
- CI/CD pipeline with automated testing
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
### Performance
|
||||
- Page load time < 2 seconds
|
||||
- API response time < 500ms for 95% of requests
|
||||
- Support for 1000+ concurrent users
|
||||
- Efficient handling of large task lists (10,000+ tasks)
|
||||
|
||||
### Security
|
||||
- JWT-based authentication with refresh tokens
|
||||
- Role-based access control (RBAC)
|
||||
- Data encryption at rest and in transit
|
||||
- Regular security audits and penetration testing
|
||||
- GDPR and privacy compliance
|
||||
|
||||
### Reliability
|
||||
- 99.9% uptime SLA
|
||||
- Graceful error handling and recovery
|
||||
- Data backup every 6 hours with point-in-time recovery
|
||||
- Disaster recovery plan with RTO < 4 hours
|
||||
|
||||
### Scalability
|
||||
- Horizontal scaling for both frontend and backend
|
||||
- Database read replicas for query optimization
|
||||
- CDN for static asset delivery
|
||||
- Microservices architecture for future expansion
|
||||
|
||||
## 7. User Experience Design
|
||||
|
||||
### Information Architecture
|
||||
- Intuitive navigation with breadcrumbs
|
||||
- Context-aware menus and actions
|
||||
- Progressive disclosure of complex features
|
||||
- Consistent design patterns throughout
|
||||
|
||||
### Interaction Design
|
||||
- Smooth animations and transitions
|
||||
- Immediate feedback for user actions
|
||||
- Undo/redo functionality for critical operations
|
||||
- Smart defaults and auto-save features
|
||||
|
||||
### Visual Design
|
||||
- Modern, clean interface with plenty of whitespace
|
||||
- Consistent color scheme and typography
|
||||
- Clear visual hierarchy with proper contrast ratios
|
||||
- Iconography that supports comprehension
|
||||
|
||||
## 8. Integration Requirements
|
||||
|
||||
### Development Tools
|
||||
- VS Code extension with task panel and quick actions
|
||||
- Cursor IDE integration with AI task suggestions
|
||||
- Terminal CLI for command-line workflow
|
||||
- Browser extension for web-based tools
|
||||
|
||||
### Third-Party Services
|
||||
- GitHub/GitLab integration for issue sync
|
||||
- Slack/Discord notifications
|
||||
- Calendar integration (Google Calendar, Outlook)
|
||||
- Time tracking tools (Toggl, Harvest)
|
||||
|
||||
### APIs and Webhooks
|
||||
- RESTful API with comprehensive documentation
|
||||
- GraphQL endpoint for complex queries
|
||||
- Webhook system for external integrations
|
||||
- SDK development for major programming languages
|
||||
|
||||
## 9. Implementation Phases
|
||||
|
||||
### Phase 1: Core MVP (8-10 weeks)
|
||||
- Basic task management (CRUD operations)
|
||||
- Simple AI task generation
|
||||
- Web interface with essential features
|
||||
- User authentication and basic permissions
|
||||
|
||||
### Phase 2: Enhanced Features (6-8 weeks)
|
||||
- Advanced AI features (complexity analysis, subtask generation)
|
||||
- Real-time collaboration
|
||||
- Mobile-responsive design
|
||||
- Integration with one development tool (VS Code)
|
||||
|
||||
### Phase 3: Enterprise Features (4-6 weeks)
|
||||
- Advanced user management and permissions
|
||||
- API and webhook system
|
||||
- Performance optimization
|
||||
- Comprehensive testing and security audit
|
||||
|
||||
### Phase 4: Ecosystem Expansion (4-6 weeks)
|
||||
- Additional tool integrations
|
||||
- Mobile app development
|
||||
- Advanced analytics and reporting
|
||||
- Third-party marketplace preparation
|
||||
|
||||
## 10. Risk Assessment
|
||||
|
||||
### Technical Risks
|
||||
- AI model reliability and cost management
|
||||
- Real-time synchronization complexity
|
||||
- Database performance with large datasets
|
||||
- Integration complexity with multiple tools
|
||||
|
||||
### Business Risks
|
||||
- User adoption in competitive market
|
||||
- AI provider dependency
|
||||
- Data privacy and security concerns
|
||||
- Feature scope creep and timeline delays
|
||||
|
||||
### Mitigation Strategies
|
||||
- Implement robust error handling and fallback systems
|
||||
- Develop comprehensive testing strategy
|
||||
- Create detailed documentation and user guides
|
||||
- Establish clear project scope and change management process
|
||||
|
||||
## 11. Success Criteria
|
||||
|
||||
### Development Milestones
|
||||
- Alpha version with core features completed
|
||||
- Beta version with selected user group feedback
|
||||
- Production-ready version with full feature set
|
||||
- Post-launch iterations based on user feedback
|
||||
|
||||
### Business Metrics
|
||||
- User engagement and retention rates
|
||||
- Task completion and productivity metrics
|
||||
- Customer satisfaction scores (NPS > 50)
|
||||
- Revenue targets and subscription growth
|
||||
|
||||
## 12. Appendices
|
||||
|
||||
### Glossary
|
||||
- **PRD**: Product Requirements Document
|
||||
- **AI**: Artificial Intelligence
|
||||
- **CRUD**: Create, Read, Update, Delete
|
||||
- **API**: Application Programming Interface
|
||||
- **CI/CD**: Continuous Integration/Continuous Deployment
|
||||
|
||||
### References
|
||||
- Industry best practices for task management
|
||||
- AI integration patterns and examples
|
||||
- Security and compliance requirements
|
||||
- Performance benchmarking data
|
||||
|
||||
---
|
||||
|
||||
**Document Control:**
|
||||
- Version: 1.0
|
||||
- Last Updated: December 27, 2024
|
||||
- Next Review: January 15, 2025
|
||||
- Approved By: Product Owner, Technical Lead`;
|
||||
|
||||
// Initialize filename and load content
|
||||
useEffect(() => {
|
||||
const initializeEditor = async () => {
|
||||
// Set initial filename
|
||||
if (file?.name) {
|
||||
setFileName(file.name.replace(/\.(txt|md)$/, '')); // Remove extension for editing
|
||||
} else if (isNewFile) {
|
||||
// Generate default filename based on current date
|
||||
const now = new Date();
|
||||
const dateStr = now.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
setFileName(`prd-${dateStr}`);
|
||||
}
|
||||
|
||||
// Load content
|
||||
if (isNewFile) {
|
||||
setContent(PRD_TEMPLATE);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// If content is directly provided (for existing PRDs loaded from API)
|
||||
if (file.content) {
|
||||
setContent(file.content);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to loading from file path (legacy support)
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await api.readFile(file.projectName, file.path);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setContent(data.content || PRD_TEMPLATE);
|
||||
} catch (error) {
|
||||
console.error('Error loading PRD file:', error);
|
||||
setContent(`# Error Loading PRD\n\nError: ${error.message}\n\nFile: ${file?.name || 'New PRD'}\nPath: ${file?.path || 'Not saved yet'}\n\n${PRD_TEMPLATE}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeEditor();
|
||||
}, [file, projectPath, isNewFile]);
|
||||
|
||||
// Fetch existing PRDs to check for conflicts
|
||||
useEffect(() => {
|
||||
const fetchExistingPRDs = async () => {
|
||||
if (!project?.name) {
|
||||
console.log('No project name available:', project);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('Fetching PRDs for project:', project.name);
|
||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(project.name)}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log('Fetched existing PRDs:', data.prds);
|
||||
setExistingPRDs(data.prds || []);
|
||||
} else {
|
||||
console.log('Failed to fetch PRDs:', response.status, response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching existing PRDs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchExistingPRDs();
|
||||
}, [project?.name]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!content.trim()) {
|
||||
alert('Please add content before saving.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileName.trim()) {
|
||||
alert('Please provide a filename for the PRD.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if file already exists
|
||||
const fullFileName = fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`;
|
||||
const existingFile = existingPRDs.find(prd => prd.name === fullFileName);
|
||||
|
||||
console.log('Save check:', {
|
||||
fullFileName,
|
||||
existingPRDs,
|
||||
existingFile,
|
||||
isExisting: file?.isExisting,
|
||||
fileObject: file,
|
||||
shouldShowModal: existingFile && !file?.isExisting
|
||||
});
|
||||
|
||||
if (existingFile && !file?.isExisting) {
|
||||
console.log('Showing overwrite confirmation modal');
|
||||
// Show confirmation modal for overwrite
|
||||
setShowOverwriteConfirm(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await performSave();
|
||||
};
|
||||
|
||||
const performSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
// Ensure filename has .txt extension
|
||||
const fullFileName = fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`;
|
||||
|
||||
const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(project?.name)}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
fileName: fullFileName,
|
||||
content
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || `Save failed: ${response.status}`);
|
||||
}
|
||||
|
||||
// Show success feedback
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
|
||||
// Update existing PRDs list
|
||||
const response2 = await api.get(`/taskmaster/prd/${encodeURIComponent(project.name)}`);
|
||||
if (response2.ok) {
|
||||
const data = await response2.json();
|
||||
setExistingPRDs(data.prds || []);
|
||||
}
|
||||
|
||||
// Call the onSave callback if provided (for UI updates)
|
||||
if (onSave) {
|
||||
await onSave();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving PRD:', error);
|
||||
alert(`Error saving PRD: ${error.message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const blob = new Blob([content], { type: 'text/markdown' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const downloadFileName = fileName ? `${fileName}.txt` : 'prd.txt';
|
||||
a.download = downloadFileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const handleGenerateTasks = async () => {
|
||||
if (!content.trim()) {
|
||||
alert('Please add content to the PRD before generating tasks.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show AI-first modal instead of simple confirm
|
||||
setShowGenerateModal(true);
|
||||
};
|
||||
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
setIsFullscreen(!isFullscreen);
|
||||
};
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 's') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [content]);
|
||||
|
||||
// Simple markdown to HTML converter for preview
|
||||
const renderMarkdown = (markdown) => {
|
||||
return markdown
|
||||
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
||||
.replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
|
||||
.replace(/\*(.*)\*/gim, '<em>$1</em>')
|
||||
.replace(/^\- (.*$)/gim, '<li>$1</li>')
|
||||
.replace(/(<li>.*<\/li>)/gims, '<ul>$1</ul>')
|
||||
.replace(/\n\n/gim, '</p><p>')
|
||||
.replace(/^(?!<[h|u|l])(.*$)/gim, '<p>$1</p>')
|
||||
.replace(/<\/ul>\s*<ul>/gim, '');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[200] md:bg-black/50 md:flex md:items-center md:justify-center">
|
||||
<div className="w-full h-full md:rounded-lg md:w-auto md:h-auto p-8 flex items-center justify-center bg-white dark:bg-gray-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||
<span className="text-gray-900 dark:text-white">Loading PRD...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed inset-0 z-[200] ${
|
||||
'md:bg-black/50 md:flex md:items-center md:justify-center md:p-4'
|
||||
} ${isFullscreen ? 'md:p-0' : ''}`}>
|
||||
<div className={cn(
|
||||
'bg-white dark:bg-gray-900 shadow-2xl flex flex-col',
|
||||
'w-full h-full md:rounded-lg md:shadow-2xl',
|
||||
isFullscreen
|
||||
? 'md:w-full md:h-full md:rounded-none'
|
||||
: 'md:w-full md:max-w-6xl md:h-[85vh] md:max-h-[85vh]'
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 flex-shrink-0 min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="w-8 h-8 bg-purple-600 rounded flex items-center justify-center flex-shrink-0">
|
||||
<FileText className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Mobile: Stack filename and tags vertically for more space */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 min-w-0">
|
||||
{/* Filename input row - full width on mobile */}
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div className="flex items-center min-w-0 flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md px-3 py-2 focus-within:ring-2 focus-within:ring-purple-500 focus-within:border-purple-500 dark:focus-within:ring-purple-400 dark:focus-within:border-purple-400">
|
||||
<input
|
||||
type="text"
|
||||
value={fileName}
|
||||
onChange={(e) => {
|
||||
// Remove invalid filename characters
|
||||
const sanitizedValue = e.target.value.replace(/[<>:"/\\|?*]/g, '');
|
||||
setFileName(sanitizedValue);
|
||||
}}
|
||||
className="font-medium text-gray-900 dark:text-white bg-transparent border-none outline-none min-w-0 flex-1 text-base sm:text-sm placeholder-gray-400 dark:placeholder-gray-500"
|
||||
placeholder="Enter PRD filename"
|
||||
maxLength={100}
|
||||
/>
|
||||
<span className="text-sm sm:text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-1">.txt</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => document.querySelector('input[placeholder="Enter PRD filename"]')?.focus()}
|
||||
className="p-1 text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
|
||||
title="Click to edit filename"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tags row - moves to second line on mobile for more filename space */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<span className="text-xs bg-purple-100 dark:bg-purple-900 text-purple-600 dark:text-purple-300 px-2 py-1 rounded whitespace-nowrap">
|
||||
📋 PRD
|
||||
</span>
|
||||
{isNewFile && (
|
||||
<span className="text-xs bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-300 px-2 py-1 rounded whitespace-nowrap">
|
||||
✨ New
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description - smaller on mobile */}
|
||||
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400 truncate mt-1">
|
||||
Product Requirements Document
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setPreviewMode(!previewMode)}
|
||||
className={cn(
|
||||
'p-2 md:p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
'min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center',
|
||||
previewMode
|
||||
? 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
)}
|
||||
title={previewMode ? 'Switch to edit mode' : 'Preview markdown'}
|
||||
>
|
||||
<Eye className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setWordWrap(!wordWrap)}
|
||||
className={cn(
|
||||
'p-2 md:p-2 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800',
|
||||
'min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center',
|
||||
wordWrap
|
||||
? 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
)}
|
||||
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
|
||||
>
|
||||
<span className="text-sm md:text-xs font-mono font-bold">↵</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsDarkMode(!isDarkMode)}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title="Toggle theme"
|
||||
>
|
||||
<span className="text-lg md:text-base">{isDarkMode ? '☀️' : '🌙'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title="Download PRD"
|
||||
>
|
||||
<Download className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleGenerateTasks}
|
||||
disabled={!content.trim()}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors text-sm font-medium',
|
||||
'bg-purple-600 hover:bg-purple-700 text-white',
|
||||
'min-h-[44px] md:min-h-0'
|
||||
)}
|
||||
title="Generate tasks from PRD content"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span className="hidden md:inline">Generate Tasks</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className={cn(
|
||||
'px-3 py-2 text-white rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors',
|
||||
'min-h-[44px] md:min-h-0',
|
||||
saveSuccess
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-purple-600 hover:bg-purple-700'
|
||||
)}
|
||||
>
|
||||
{saveSuccess ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 md:w-4 md:h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Saved!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-5 h-5 md:w-4 md:h-4" />
|
||||
<span className="hidden sm:inline">{saving ? 'Saving...' : 'Save PRD'}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
className="hidden md:flex p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 items-center justify-center"
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 md:p-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-6 h-6 md:w-4 md:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor/Preview Content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{previewMode ? (
|
||||
<div className="h-full overflow-y-auto p-6 prose prose-gray dark:prose-invert max-w-none">
|
||||
<div
|
||||
className="markdown-preview"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(content) }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<CodeMirror
|
||||
ref={editorRef}
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
extensions={[
|
||||
markdown(),
|
||||
...(wordWrap ? [EditorView.lineWrapping] : [])
|
||||
]}
|
||||
theme={isDarkMode ? oneDark : undefined}
|
||||
height="100%"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
height: '100%',
|
||||
}}
|
||||
basicSetup={{
|
||||
lineNumbers: true,
|
||||
foldGutter: true,
|
||||
dropCursor: false,
|
||||
allowMultipleSelections: false,
|
||||
indentOnInput: true,
|
||||
bracketMatching: true,
|
||||
closeBrackets: true,
|
||||
autocompletion: true,
|
||||
highlightSelectionMatches: true,
|
||||
searchKeymap: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-3 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 flex-shrink-0">
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>Lines: {content.split('\n').length}</span>
|
||||
<span>Characters: {content.length}</span>
|
||||
<span>Words: {content.split(/\s+/).filter(word => word.length > 0).length}</span>
|
||||
<span>Format: Markdown</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Press Ctrl+S to save • Esc to close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Tasks Modal */}
|
||||
{showGenerateModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-md border border-gray-200 dark:border-gray-700">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center">
|
||||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Generate Tasks from PRD</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowGenerateModal(false)}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* AI-First Approach */}
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-purple-100 dark:bg-purple-900/50 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-purple-900 dark:text-purple-100 mb-2">
|
||||
💡 Pro Tip: Ask Claude Code Directly!
|
||||
</h4>
|
||||
<p className="text-sm text-purple-800 dark:text-purple-200 mb-3">
|
||||
You can simply ask Claude Code in the chat to parse your PRD and generate tasks.
|
||||
The AI assistant will automatically save your PRD and create detailed tasks with implementation details.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded border border-purple-200 dark:border-purple-700 p-3 mb-3">
|
||||
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">💬 Example:</p>
|
||||
<p className="text-xs text-gray-900 dark:text-white font-mono">
|
||||
"I've just initialized a new project with Claude Task Master. I have a PRD at .taskmaster/docs/{fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`}. Can you help me parse it and set up the initial tasks?"
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300">
|
||||
<strong>This will:</strong> Save your PRD, analyze its content, and generate structured tasks with subtasks, dependencies, and implementation details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learn More Link */}
|
||||
<div className="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
For more examples and advanced usage patterns:
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block text-sm text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 underline font-medium"
|
||||
>
|
||||
View TaskMaster Documentation →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={() => setShowGenerateModal(false)}
|
||||
className="w-full px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Got it, I'll ask Claude Code directly
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overwrite Confirmation Modal */}
|
||||
{showOverwriteConfirm && (
|
||||
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={() => setShowOverwriteConfirm(false)} />
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full border border-gray-200 dark:border-gray-700">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="p-2 rounded-full mr-3 bg-yellow-100 dark:bg-yellow-900">
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
File Already Exists
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
A PRD file named "{fileName.endsWith('.txt') || fileName.endsWith('.md') ? fileName : `${fileName}.txt`}" already exists.
|
||||
Do you want to overwrite it with the current content?
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => setShowOverwriteConfirm(false)}
|
||||
className="px-4 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setShowOverwriteConfirm(false);
|
||||
await performSave();
|
||||
}}
|
||||
className="px-4 py-2 text-sm text-white bg-yellow-600 hover:bg-yellow-700 rounded-md flex items-center space-x-2 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Overwrite</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PRDEditor;
|
||||
@@ -2,11 +2,23 @@ import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Badge } from './ui/badge';
|
||||
import { X, Plus, Settings, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen } from 'lucide-react';
|
||||
import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
import StandaloneShell from './StandaloneShell';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo';
|
||||
|
||||
function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
function Settings({ isOpen, onClose, projects = [] }) {
|
||||
const { isDarkMode, toggleDarkMode } = useTheme();
|
||||
const {
|
||||
tasksEnabled,
|
||||
setTasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
isTaskMasterReady,
|
||||
installationStatus,
|
||||
isCheckingInstallation
|
||||
} = useTasksSettings();
|
||||
const [allowedTools, setAllowedTools] = useState([]);
|
||||
const [disallowedTools, setDisallowedTools] = useState([]);
|
||||
const [newAllowedTool, setNewAllowedTool] = useState('');
|
||||
@@ -50,6 +62,11 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
const [newCursorCommand, setNewCursorCommand] = useState('');
|
||||
const [newCursorDisallowedCommand, setNewCursorDisallowedCommand] = useState('');
|
||||
const [cursorMcpServers, setCursorMcpServers] = useState([]);
|
||||
|
||||
// Login modal states
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
const [loginProvider, setLoginProvider] = useState(''); // 'claude' or 'cursor'
|
||||
const [selectedProject, setSelectedProject] = useState(null);
|
||||
// Common tool patterns for Claude
|
||||
const commonTools = [
|
||||
'Bash(git log:*)',
|
||||
@@ -316,7 +333,7 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
try {
|
||||
|
||||
// Load Claude settings from localStorage
|
||||
const savedSettings = localStorage.getItem('claude-tools-settings');
|
||||
const savedSettings = localStorage.getItem('claude-settings');
|
||||
|
||||
if (savedSettings) {
|
||||
const settings = JSON.parse(savedSettings);
|
||||
@@ -362,6 +379,26 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Login handlers
|
||||
const handleClaudeLogin = () => {
|
||||
setLoginProvider('claude');
|
||||
setSelectedProject(projects?.[0] || { name: 'default', fullPath: process.cwd() });
|
||||
setShowLoginModal(true);
|
||||
};
|
||||
|
||||
const handleCursorLogin = () => {
|
||||
setLoginProvider('cursor');
|
||||
setSelectedProject(projects?.[0] || { name: 'default', fullPath: process.cwd() });
|
||||
setShowLoginModal(true);
|
||||
};
|
||||
|
||||
const handleLoginComplete = (exitCode) => {
|
||||
if (exitCode === 0) {
|
||||
// Login successful - could show a success message here
|
||||
}
|
||||
setShowLoginModal(false);
|
||||
};
|
||||
|
||||
const saveSettings = () => {
|
||||
setIsSaving(true);
|
||||
setSaveStatus(null);
|
||||
@@ -385,7 +422,7 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
};
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('claude-tools-settings', JSON.stringify(claudeSettings));
|
||||
localStorage.setItem('claude-settings', JSON.stringify(claudeSettings));
|
||||
localStorage.setItem('cursor-tools-settings', JSON.stringify(cursorSettings));
|
||||
|
||||
setSaveStatus('success');
|
||||
@@ -591,7 +628,7 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
<div className="bg-background border border-border md:rounded-lg shadow-xl w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-border flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
|
||||
<SettingsIcon className="w-5 h-5 md:w-6 md:h-6 text-blue-600" />
|
||||
<h2 className="text-lg md:text-xl font-semibold text-foreground">
|
||||
Settings
|
||||
</h2>
|
||||
@@ -630,6 +667,16 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
>
|
||||
Appearance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('tasks')}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'tasks'
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Tasks
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -720,7 +767,10 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Claude Tools
|
||||
<div className="flex items-center gap-2">
|
||||
<ClaudeLogo className="w-4 h-4" />
|
||||
<span>Claude</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setToolsProvider('cursor')}
|
||||
@@ -730,12 +780,15 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
Cursor Tools
|
||||
<div className="flex items-center gap-2">
|
||||
<CursorLogo className="w-4 h-4" />
|
||||
<span>Cursor</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claude Tools Content */}
|
||||
{/* Claude Content */}
|
||||
{toolsProvider === 'claude' && (
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
|
||||
@@ -767,6 +820,36 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Claude Login */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<LogIn className="w-5 h-5 text-blue-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Authentication
|
||||
</h3>
|
||||
</div>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-blue-900 dark:text-blue-100">
|
||||
Claude CLI Login
|
||||
</div>
|
||||
<div className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Sign in to your Claude account to enable AI features
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleClaudeLogin}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed Tools */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1465,7 +1548,7 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cursor Tools Content */}
|
||||
{/* Cursor Content */}
|
||||
{toolsProvider === 'cursor' && (
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
|
||||
@@ -1497,6 +1580,36 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cursor Login */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<LogIn className="w-5 h-5 text-purple-500" />
|
||||
<h3 className="text-lg font-medium text-foreground">
|
||||
Authentication
|
||||
</h3>
|
||||
</div>
|
||||
<div className="bg-purple-50 dark:bg-purple-900/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-purple-900 dark:text-purple-100">
|
||||
Cursor CLI Login
|
||||
</div>
|
||||
<div className="text-sm text-purple-700 dark:text-purple-300">
|
||||
Sign in to your Cursor account to enable AI features
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCursorLogin}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
<LogIn className="w-4 h-4 mr-2" />
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed Shell Commands */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1674,6 +1787,160 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tasks Tab */}
|
||||
{activeTab === 'tasks' && (
|
||||
<div className="space-y-6 md:space-y-8">
|
||||
{/* Installation Status Check */}
|
||||
{isCheckingInstallation ? (
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
<span className="text-sm text-muted-foreground">Checking TaskMaster installation...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* TaskMaster Not Installed Warning */}
|
||||
{!isTaskMasterInstalled && (
|
||||
<div className="bg-orange-50 dark:bg-orange-950/50 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-orange-100 dark:bg-orange-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-4 h-4 text-orange-600 dark:text-orange-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-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-orange-900 dark:text-orange-100 mb-2">
|
||||
TaskMaster AI CLI Not Installed
|
||||
</div>
|
||||
<div className="text-sm text-orange-800 dark:text-orange-200 space-y-3">
|
||||
<p>TaskMaster CLI is required to use task management features. Install it to get started:</p>
|
||||
|
||||
<div className="bg-orange-100 dark:bg-orange-900/50 rounded-lg p-3 font-mono text-sm">
|
||||
<code>npm install -g task-master-ai</code>
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
View on GitHub
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">After installation:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 text-xs">
|
||||
<li>Restart this application</li>
|
||||
<li>TaskMaster features will automatically become available</li>
|
||||
<li>Use <code className="bg-orange-100 dark:bg-orange-800 px-1 rounded">task-master init</code> in your project directory</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TaskMaster Settings */}
|
||||
<div className="space-y-4">
|
||||
<div className="bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-foreground">
|
||||
Enable TaskMaster Integration
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">
|
||||
Show TaskMaster tasks, banners, and sidebar indicators across the interface
|
||||
</div>
|
||||
{!isTaskMasterInstalled && (
|
||||
<div className="text-xs text-orange-600 dark:text-orange-400 mt-1">
|
||||
TaskMaster CLI must be installed first
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tasksEnabled}
|
||||
onChange={(e) => setTasksEnabled(e.target.checked)}
|
||||
disabled={!isTaskMasterInstalled}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className={`w-11 h-6 ${!isTaskMasterInstalled ? 'bg-gray-300 dark:bg-gray-600' : 'bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800'} rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600`}></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TaskMaster Information */}
|
||||
<div className="bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 bg-blue-100 dark:bg-blue-900 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Zap className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
🎯 About TaskMaster AI
|
||||
</div>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200 space-y-2">
|
||||
<p><strong>AI-Powered Task Management:</strong> Break complex projects into manageable subtasks with AI assistance</p>
|
||||
<p><strong>PRD:</strong> Generate structured tasks from Product Requirements Documents</p>
|
||||
<p><strong>Dependency Tracking:</strong> Understand task relationships and execution order</p>
|
||||
<p><strong>Progress Visualization:</strong> Kanban boards, and detailed task views</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GitHub Link and Resources */}
|
||||
<div className="mt-4 pt-4 border-t border-blue-200 dark:border-blue-700">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 bg-blue-100 dark:bg-blue-800 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<svg className="w-3 h-3 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
📚 Learn More & Tutorial
|
||||
</div>
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200 space-y-2">
|
||||
<p>TaskMaster AI (aka <strong>claude-task-master</strong> ) is an advanced AI-powered task management system built for developers.</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<a
|
||||
href="https://github.com/eyaltoledano/claude-task-master"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 font-medium"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
View on GitHub
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
Find documentation, setup guides, and examples for advanced TaskMaster workflows
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1722,8 +1989,35 @@ function ToolsSettings({ isOpen, onClose, projects = [] }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Modal */}
|
||||
{showLoginModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 max-md:items-stretch max-md:justify-stretch">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-4xl h-3/4 flex flex-col md:max-w-4xl md:h-3/4 md:rounded-lg md:m-4 max-md:max-w-none max-md:h-full max-md:rounded-none max-md:m-0">
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{loginProvider === 'claude' ? 'Claude CLI Login' : 'Cursor CLI Login'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowLoginModal(false)}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<StandaloneShell
|
||||
project={selectedProject}
|
||||
command={loginProvider === 'claude' ? 'claude /login' : 'cursor-agent login'}
|
||||
onComplete={handleLoginComplete}
|
||||
showHeader={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolsSettings;
|
||||
export default Settings;
|
||||
@@ -29,7 +29,7 @@ if (typeof document !== 'undefined') {
|
||||
// Global store for shell sessions to persist across tab switches
|
||||
const shellSessions = new Map();
|
||||
|
||||
function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
function Shell({ selectedProject, selectedSession, isActive, initialCommand, isPlainShell = false, onProcessComplete }) {
|
||||
const terminalRef = useRef(null);
|
||||
const terminal = useRef(null);
|
||||
const fitAddon = useRef(null);
|
||||
@@ -434,13 +434,17 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
const initPayload = {
|
||||
type: 'init',
|
||||
projectPath: selectedProject.fullPath || selectedProject.path,
|
||||
sessionId: selectedSession?.id,
|
||||
hasSession: !!selectedSession,
|
||||
provider: selectedSession?.__provider || 'claude',
|
||||
sessionId: isPlainShell ? null : selectedSession?.id,
|
||||
hasSession: isPlainShell ? false : !!selectedSession,
|
||||
provider: isPlainShell ? 'plain-shell' : (selectedSession?.__provider || 'claude'),
|
||||
cols: terminal.current.cols,
|
||||
rows: terminal.current.rows
|
||||
rows: terminal.current.rows,
|
||||
initialCommand: initialCommand,
|
||||
isPlainShell: isPlainShell
|
||||
};
|
||||
|
||||
console.log('Shell init payload:', initPayload);
|
||||
|
||||
ws.current.send(JSON.stringify(initPayload));
|
||||
|
||||
// Also send resize message immediately after init
|
||||
@@ -473,6 +477,18 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
urls.push(match[1]);
|
||||
}
|
||||
|
||||
if (isPlainShell && onProcessComplete) {
|
||||
const cleanOutput = output.replace(/\x1b\[[0-9;]*m/g, ''); // Remove ANSI codes
|
||||
if (cleanOutput.includes('Process exited with code 0')) {
|
||||
onProcessComplete(0); // Success
|
||||
} else if (cleanOutput.match(/Process exited with code (\d+)/)) {
|
||||
const exitCode = parseInt(cleanOutput.match(/Process exited with code (\d+)/)[1]);
|
||||
if (exitCode !== 0) {
|
||||
onProcessComplete(exitCode); // Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If URLs found, log them for potential opening
|
||||
|
||||
terminal.current.write(output);
|
||||
@@ -606,14 +622,16 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
<span>Continue in Shell</span>
|
||||
</button>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
{selectedSession ?
|
||||
(() => {
|
||||
const displaySessionName = selectedSession.__provider === 'cursor'
|
||||
? (selectedSession.name || 'Untitled Session')
|
||||
: (selectedSession.summary || 'New Session');
|
||||
return `Resume session: ${displaySessionName.slice(0, 50)}...`;
|
||||
})() :
|
||||
'Start a new Claude session'
|
||||
{isPlainShell ?
|
||||
`Run ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
selectedSession ?
|
||||
(() => {
|
||||
const displaySessionName = selectedSession.__provider === 'cursor'
|
||||
? (selectedSession.name || 'Untitled Session')
|
||||
: (selectedSession.summary || 'New Session');
|
||||
return `Resume session: ${displaySessionName.slice(0, 50)}...`;
|
||||
})() :
|
||||
'Start a new Claude session'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -629,7 +647,10 @@ function Shell({ selectedProject, selectedSession, isActive }) {
|
||||
<span className="text-base font-medium">Connecting to shell...</span>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-3 px-2">
|
||||
Starting Claude CLI in {selectedProject.displayName}
|
||||
{isPlainShell ?
|
||||
`Running ${initialCommand || 'command'} in ${selectedProject.displayName}` :
|
||||
`Starting Claude CLI in ${selectedProject.displayName}`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
@@ -8,7 +8,10 @@ import { FolderOpen, Folder, Plus, MessageSquare, Clock, ChevronDown, ChevronRig
|
||||
import { cn } from '../lib/utils';
|
||||
import ClaudeLogo from './ClaudeLogo';
|
||||
import CursorLogo from './CursorLogo.jsx';
|
||||
import TaskIndicator from './TaskIndicator';
|
||||
import { api } from '../utils/api';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import { useTasksSettings } from '../contexts/TasksSettingsContext';
|
||||
|
||||
// Move formatTimeAgo outside component to avoid recreation on every render
|
||||
const formatTimeAgo = (dateString, currentTime) => {
|
||||
@@ -51,7 +54,9 @@ function Sidebar({
|
||||
updateAvailable,
|
||||
latestVersion,
|
||||
currentVersion,
|
||||
onShowVersionModal
|
||||
onShowVersionModal,
|
||||
isPWA,
|
||||
isMobile
|
||||
}) {
|
||||
const [expandedProjects, setExpandedProjects] = useState(new Set());
|
||||
const [editingProject, setEditingProject] = useState(null);
|
||||
@@ -69,6 +74,14 @@ function Sidebar({
|
||||
const [editingSessionName, setEditingSessionName] = useState('');
|
||||
const [generatingSummary, setGeneratingSummary] = useState({});
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [showPathDropdown, setShowPathDropdown] = useState(false);
|
||||
const [pathList, setPathList] = useState([]);
|
||||
const [filteredPaths, setFilteredPaths] = useState([]);
|
||||
const [selectedPathIndex, setSelectedPathIndex] = useState(-1);
|
||||
|
||||
// TaskMaster context
|
||||
const { setCurrentProject, mcpServerStatus } = useTaskMaster();
|
||||
const { tasksEnabled } = useTasksSettings();
|
||||
|
||||
|
||||
// Starred projects state - persisted in localStorage
|
||||
@@ -134,7 +147,7 @@ function Sidebar({
|
||||
useEffect(() => {
|
||||
const loadSortOrder = () => {
|
||||
try {
|
||||
const savedSettings = localStorage.getItem('claude-tools-settings');
|
||||
const savedSettings = localStorage.getItem('claude-settings');
|
||||
if (savedSettings) {
|
||||
const settings = JSON.parse(savedSettings);
|
||||
setProjectSortOrder(settings.projectSortOrder || 'name');
|
||||
@@ -149,7 +162,7 @@ function Sidebar({
|
||||
|
||||
// Listen for storage changes
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === 'claude-tools-settings') {
|
||||
if (e.key === 'claude-settings') {
|
||||
loadSortOrder();
|
||||
}
|
||||
};
|
||||
@@ -169,6 +182,124 @@ function Sidebar({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load available paths for suggestions
|
||||
useEffect(() => {
|
||||
const loadPaths = async () => {
|
||||
try {
|
||||
// Get recent paths from localStorage
|
||||
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
|
||||
|
||||
// Load common/home directory paths
|
||||
const response = await api.browseFilesystem();
|
||||
const data = await response.json();
|
||||
|
||||
if (data.suggestions) {
|
||||
const homePaths = data.suggestions.map(s => ({ name: s.name, path: s.path }));
|
||||
const allPaths = [...recentPaths.map(path => ({ name: path.split('/').pop(), path })), ...homePaths];
|
||||
setPathList(allPaths);
|
||||
} else {
|
||||
setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading paths:', error);
|
||||
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
|
||||
setPathList(recentPaths.map(path => ({ name: path.split('/').pop(), path })));
|
||||
}
|
||||
};
|
||||
|
||||
loadPaths();
|
||||
}, []);
|
||||
|
||||
// Handle input change and path filtering with dynamic browsing (ChatInterface pattern + dynamic browsing)
|
||||
useEffect(() => {
|
||||
const inputValue = newProjectPath.trim();
|
||||
|
||||
if (inputValue.length === 0) {
|
||||
setShowPathDropdown(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show dropdown when user starts typing
|
||||
setShowPathDropdown(true);
|
||||
|
||||
const updateSuggestions = async () => {
|
||||
// First show filtered existing suggestions from pathList
|
||||
const staticFiltered = pathList.filter(pathItem =>
|
||||
pathItem.name.toLowerCase().includes(inputValue.toLowerCase()) ||
|
||||
pathItem.path.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
|
||||
// Check if input looks like a directory path for dynamic browsing
|
||||
const isDirPath = inputValue.includes('/') && inputValue.length > 1;
|
||||
|
||||
if (isDirPath) {
|
||||
try {
|
||||
let dirToSearch;
|
||||
|
||||
// Determine which directory to search
|
||||
if (inputValue.endsWith('/')) {
|
||||
// User typed "/home/simos/" - search inside /home/simos
|
||||
dirToSearch = inputValue.slice(0, -1);
|
||||
} else {
|
||||
// User typed "/home/simos/con" - search inside /home/simos for items starting with "con"
|
||||
const lastSlashIndex = inputValue.lastIndexOf('/');
|
||||
dirToSearch = inputValue.substring(0, lastSlashIndex);
|
||||
}
|
||||
|
||||
// Only search if we have a valid directory path (not root only)
|
||||
if (dirToSearch && dirToSearch !== '') {
|
||||
const response = await api.browseFilesystem(dirToSearch);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.suggestions) {
|
||||
// Filter directories that match the current input
|
||||
const partialName = inputValue.substring(inputValue.lastIndexOf('/') + 1);
|
||||
const dynamicPaths = data.suggestions
|
||||
.filter(suggestion => {
|
||||
const dirName = suggestion.name;
|
||||
return partialName ? dirName.toLowerCase().startsWith(partialName.toLowerCase()) : true;
|
||||
})
|
||||
.map(s => ({ name: s.name, path: s.path }))
|
||||
.slice(0, 8);
|
||||
|
||||
// Combine static and dynamic suggestions, prioritize dynamic
|
||||
const combined = [...dynamicPaths, ...staticFiltered].slice(0, 8);
|
||||
setFilteredPaths(combined);
|
||||
setSelectedPathIndex(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Dynamic browsing failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to just static filtered suggestions
|
||||
setFilteredPaths(staticFiltered.slice(0, 8));
|
||||
setSelectedPathIndex(-1);
|
||||
};
|
||||
|
||||
updateSuggestions();
|
||||
}, [newProjectPath, pathList]);
|
||||
|
||||
// Select path from dropdown (ChatInterface pattern)
|
||||
const selectPath = (pathItem) => {
|
||||
setNewProjectPath(pathItem.path);
|
||||
setShowPathDropdown(false);
|
||||
setSelectedPathIndex(-1);
|
||||
};
|
||||
|
||||
// Save path to recent paths
|
||||
const saveToRecentPaths = (path) => {
|
||||
try {
|
||||
const recentPaths = JSON.parse(localStorage.getItem('recentProjectPaths') || '[]');
|
||||
const updatedPaths = [path, ...recentPaths.filter(p => p !== path)].slice(0, 10);
|
||||
localStorage.setItem('recentProjectPaths', JSON.stringify(updatedPaths));
|
||||
} catch (error) {
|
||||
console.error('Error saving recent paths:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleProject = (projectName) => {
|
||||
const newExpanded = new Set(expandedProjects);
|
||||
if (newExpanded.has(projectName)) {
|
||||
@@ -340,8 +471,13 @@ function Sidebar({
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
|
||||
// Save the path to recent paths before clearing
|
||||
saveToRecentPaths(newProjectPath.trim());
|
||||
|
||||
setShowNewProject(false);
|
||||
setNewProjectPath('');
|
||||
setShowSuggestions(false);
|
||||
|
||||
// Refresh projects to show the new one
|
||||
if (window.refreshProjects) {
|
||||
@@ -364,6 +500,7 @@ function Sidebar({
|
||||
const cancelNewProject = () => {
|
||||
setShowNewProject(false);
|
||||
setNewProjectPath('');
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
const loadMoreSessions = async (project) => {
|
||||
@@ -417,8 +554,20 @@ function Sidebar({
|
||||
return displayName.includes(searchLower) || projectName.includes(searchLower);
|
||||
});
|
||||
|
||||
// Enhanced project selection that updates both the main UI and TaskMaster context
|
||||
const handleProjectSelect = (project) => {
|
||||
// Call the original project select handler
|
||||
onProjectSelect(project);
|
||||
|
||||
// Update TaskMaster context with the selected project
|
||||
setCurrentProject(project);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-card md:select-none">
|
||||
<div
|
||||
className="h-full flex flex-col bg-card md:select-none"
|
||||
style={isPWA && isMobile ? { paddingTop: '44px' } : {}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="md:p-4 md:border-b md:border-border">
|
||||
{/* Desktop Header */}
|
||||
@@ -463,7 +612,10 @@ function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Mobile Header */}
|
||||
<div className="md:hidden p-3 border-b border-border">
|
||||
<div
|
||||
className="md:hidden p-3 border-b border-border"
|
||||
style={isPWA && isMobile ? { paddingTop: '16px' } : {}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
||||
@@ -500,6 +652,7 @@ function Sidebar({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* New Project Form */}
|
||||
{showNewProject && (
|
||||
<div className="md:p-3 md:border-b md:border-border md:bg-muted/30">
|
||||
@@ -509,17 +662,96 @@ function Sidebar({
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
Create New Project
|
||||
</div>
|
||||
<Input
|
||||
value={newProjectPath}
|
||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||
placeholder="/path/to/project or relative/path"
|
||||
className="text-sm focus:ring-2 focus:ring-primary/20"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') createNewProject();
|
||||
if (e.key === 'Escape') cancelNewProject();
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={newProjectPath}
|
||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||
placeholder="/path/to/project or relative/path"
|
||||
className="text-sm focus:ring-2 focus:ring-primary/20"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
// Handle path dropdown navigation (ChatInterface pattern)
|
||||
if (showPathDropdown && filteredPaths.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedPathIndex(prev =>
|
||||
prev < filteredPaths.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedPathIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filteredPaths.length - 1
|
||||
);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedPathIndex >= 0) {
|
||||
selectPath(filteredPaths[selectedPathIndex]);
|
||||
} else if (filteredPaths.length > 0) {
|
||||
selectPath(filteredPaths[0]);
|
||||
} else {
|
||||
createNewProject();
|
||||
}
|
||||
return;
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowPathDropdown(false);
|
||||
return;
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (selectedPathIndex >= 0) {
|
||||
selectPath(filteredPaths[selectedPathIndex]);
|
||||
} else if (filteredPaths.length > 0) {
|
||||
selectPath(filteredPaths[0]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular input handling
|
||||
if (e.key === 'Enter') {
|
||||
createNewProject();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
cancelNewProject();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Path dropdown (ChatInterface pattern) */}
|
||||
{showPathDropdown && filteredPaths.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto z-50">
|
||||
{filteredPaths.map((pathItem, index) => (
|
||||
<div
|
||||
key={pathItem.path}
|
||||
className={`px-3 py-2 cursor-pointer border-b border-border last:border-b-0 ${
|
||||
index === selectedPathIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectPath(pathItem);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{pathItem.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{pathItem.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -542,8 +774,8 @@ function Sidebar({
|
||||
</div>
|
||||
|
||||
{/* Mobile Form - Simple Overlay */}
|
||||
<div className="md:hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm">
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="md:hidden fixed inset-0 z-[70] bg-black/50 backdrop-blur-sm flex items-end justify-center px-4 pb-24">
|
||||
<div className="w-full max-w-sm bg-card rounded-t-lg border-t border-border p-4 space-y-4 animate-in slide-in-from-bottom duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-primary/10 rounded-md flex items-center justify-center">
|
||||
@@ -563,17 +795,92 @@ function Sidebar({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
value={newProjectPath}
|
||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||
placeholder="/path/to/project or relative/path"
|
||||
className="text-sm h-10 rounded-md focus:border-primary transition-colors"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') createNewProject();
|
||||
if (e.key === 'Escape') cancelNewProject();
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={newProjectPath}
|
||||
onChange={(e) => setNewProjectPath(e.target.value)}
|
||||
placeholder="/path/to/project or relative/path"
|
||||
className="text-sm h-10 rounded-md focus:border-primary transition-colors"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
// Handle path dropdown navigation (same as desktop)
|
||||
if (showPathDropdown && filteredPaths.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedPathIndex(prev =>
|
||||
prev < filteredPaths.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedPathIndex(prev =>
|
||||
prev > 0 ? prev - 1 : filteredPaths.length - 1
|
||||
);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedPathIndex >= 0) {
|
||||
selectPath(filteredPaths[selectedPathIndex]);
|
||||
} else if (filteredPaths.length > 0) {
|
||||
selectPath(filteredPaths[0]);
|
||||
} else {
|
||||
createNewProject();
|
||||
}
|
||||
return;
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowPathDropdown(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular input handling
|
||||
if (e.key === 'Enter') {
|
||||
createNewProject();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
cancelNewProject();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
fontSize: '16px', // Prevents zoom on iOS
|
||||
WebkitAppearance: 'none'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mobile Path dropdown */}
|
||||
{showPathDropdown && filteredPaths.length > 0 && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-2 bg-popover border border-border rounded-md shadow-lg max-h-40 overflow-y-auto">
|
||||
{filteredPaths.map((pathItem, index) => (
|
||||
<div
|
||||
key={pathItem.path}
|
||||
className={`px-3 py-2.5 cursor-pointer border-b border-border last:border-b-0 active:scale-95 transition-all ${
|
||||
index === selectedPathIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectPath(pathItem);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{pathItem.name}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{pathItem.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -717,9 +1024,25 @@ function Sidebar({
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-foreground truncate">
|
||||
{project.displayName}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium text-foreground truncate">
|
||||
{project.displayName}
|
||||
</h3>
|
||||
{tasksEnabled && (
|
||||
<TaskIndicator
|
||||
status={(() => {
|
||||
const projectConfigured = project.taskmaster?.hasTaskmaster;
|
||||
const mcpConfigured = mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured;
|
||||
if (projectConfigured && mcpConfigured) return 'fully-configured';
|
||||
if (projectConfigured) return 'taskmaster-only';
|
||||
if (mcpConfigured) return 'mcp-only';
|
||||
return 'not-configured';
|
||||
})()}
|
||||
size="xs"
|
||||
className="flex-shrink-0 ml-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(() => {
|
||||
const sessionCount = getAllSessions(project).length;
|
||||
@@ -825,13 +1148,13 @@ function Sidebar({
|
||||
onClick={() => {
|
||||
// Desktop behavior: select project and toggle
|
||||
if (selectedProject?.name !== project.name) {
|
||||
onProjectSelect(project);
|
||||
handleProjectSelect(project);
|
||||
}
|
||||
toggleProject(project.name);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => {
|
||||
if (selectedProject?.name !== project.name) {
|
||||
onProjectSelect(project);
|
||||
handleProjectSelect(project);
|
||||
}
|
||||
toggleProject(project.name);
|
||||
})}
|
||||
@@ -1013,11 +1336,11 @@ function Sidebar({
|
||||
isActive ? "border-green-500/30 bg-green-50/5 dark:bg-green-900/5" : "border-border/30"
|
||||
)}
|
||||
onClick={() => {
|
||||
onProjectSelect(project);
|
||||
handleProjectSelect(project);
|
||||
onSessionSelect(session);
|
||||
}}
|
||||
onTouchEnd={handleTouchClick(() => {
|
||||
onProjectSelect(project);
|
||||
handleProjectSelect(project);
|
||||
onSessionSelect(session);
|
||||
})}
|
||||
>
|
||||
@@ -1239,7 +1562,7 @@ function Sidebar({
|
||||
<button
|
||||
className="w-full h-8 bg-primary hover:bg-primary/90 text-primary-foreground rounded-md flex items-center justify-center gap-2 font-medium text-xs active:scale-[0.98] transition-all duration-150"
|
||||
onClick={() => {
|
||||
onProjectSelect(project);
|
||||
handleProjectSelect(project);
|
||||
onNewSession(project);
|
||||
}}
|
||||
>
|
||||
@@ -1332,7 +1655,7 @@ function Sidebar({
|
||||
onClick={onShowSettings}
|
||||
>
|
||||
<Settings className="w-3 h-3" />
|
||||
<span className="text-xs">Tools Settings</span>
|
||||
<span className="text-xs">Settings</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
106
src/components/StandaloneShell.jsx
Normal file
106
src/components/StandaloneShell.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Shell from './Shell.jsx';
|
||||
|
||||
/**
|
||||
* Generic Shell wrapper that can be used in tabs, modals, and other contexts.
|
||||
* Provides a flexible API for both standalone and session-based usage.
|
||||
*
|
||||
* @param {Object} project - Project object with name, fullPath/path, displayName
|
||||
* @param {Object} session - Session object (optional, for tab usage)
|
||||
* @param {string} command - Initial command to run (optional)
|
||||
* @param {boolean} isActive - Whether the shell is active (for tab usage, default: true)
|
||||
* @param {boolean} isPlainShell - Use plain shell mode vs Claude CLI (default: auto-detect)
|
||||
* @param {boolean} autoConnect - Whether to auto-connect when mounted (default: true)
|
||||
* @param {function} onComplete - Callback when process completes (receives exitCode)
|
||||
* @param {function} onClose - Callback for close button (optional)
|
||||
* @param {string} title - Custom header title (optional)
|
||||
* @param {string} className - Additional CSS classes
|
||||
* @param {boolean} showHeader - Whether to show custom header (default: true)
|
||||
* @param {boolean} compact - Use compact layout (default: false)
|
||||
*/
|
||||
function StandaloneShell({
|
||||
project,
|
||||
session = null,
|
||||
command = null,
|
||||
isActive = true,
|
||||
isPlainShell = null, // Auto-detect: true if command provided, false if session provided
|
||||
autoConnect = true,
|
||||
onComplete = null,
|
||||
onClose = null,
|
||||
title = null,
|
||||
className = "",
|
||||
showHeader = true,
|
||||
compact = false
|
||||
}) {
|
||||
const [isCompleted, setIsCompleted] = useState(false);
|
||||
|
||||
// Auto-detect isPlainShell based on props
|
||||
const shouldUsePlainShell = isPlainShell !== null ? isPlainShell : (command !== null);
|
||||
|
||||
// Handle process completion
|
||||
const handleProcessComplete = (exitCode) => {
|
||||
setIsCompleted(true);
|
||||
if (onComplete) {
|
||||
onComplete(exitCode);
|
||||
}
|
||||
};
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className={`h-full flex items-center justify-center ${className}`}>
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="w-16 h-16 mx-auto mb-4 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Project Selected</h3>
|
||||
<p>A project is required to open a shell</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full flex flex-col ${className}`}>
|
||||
{/* Optional custom header */}
|
||||
{showHeader && title && (
|
||||
<div className="flex-shrink-0 bg-gray-800 border-b border-gray-700 px-4 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h3 className="text-sm font-medium text-gray-200">{title}</h3>
|
||||
{isCompleted && (
|
||||
<span className="text-xs text-green-400">(Completed)</span>
|
||||
)}
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-white"
|
||||
title="Close"
|
||||
>
|
||||
<svg className="w-4 h-4" 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shell component wrapper */}
|
||||
<div className="flex-1">
|
||||
<Shell
|
||||
selectedProject={project}
|
||||
selectedSession={session}
|
||||
isActive={isActive}
|
||||
initialCommand={command}
|
||||
isPlainShell={shouldUsePlainShell}
|
||||
onProcessComplete={handleProcessComplete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StandaloneShell;
|
||||
210
src/components/TaskCard.jsx
Normal file
210
src/components/TaskCard.jsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import React from 'react';
|
||||
import { Clock, CheckCircle, Circle, AlertCircle, Pause, X, ArrowRight, ChevronUp, Minus, Flag } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import Tooltip from './Tooltip';
|
||||
|
||||
const TaskCard = ({
|
||||
task,
|
||||
onClick,
|
||||
showParent = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const getStatusConfig = (status) => {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
bgColor: 'bg-green-50 dark:bg-green-950',
|
||||
borderColor: 'border-green-200 dark:border-green-800',
|
||||
iconColor: 'text-green-600 dark:text-green-400',
|
||||
textColor: 'text-green-900 dark:text-green-100',
|
||||
statusText: 'Done'
|
||||
};
|
||||
|
||||
case 'in-progress':
|
||||
return {
|
||||
icon: Clock,
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-950',
|
||||
borderColor: 'border-blue-200 dark:border-blue-800',
|
||||
iconColor: 'text-blue-600 dark:text-blue-400',
|
||||
textColor: 'text-blue-900 dark:text-blue-100',
|
||||
statusText: 'In Progress'
|
||||
};
|
||||
|
||||
case 'review':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-950',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800',
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
textColor: 'text-amber-900 dark:text-amber-100',
|
||||
statusText: 'Review'
|
||||
};
|
||||
|
||||
case 'deferred':
|
||||
return {
|
||||
icon: Pause,
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-800',
|
||||
borderColor: 'border-gray-200 dark:border-gray-700',
|
||||
iconColor: 'text-gray-500 dark:text-gray-400',
|
||||
textColor: 'text-gray-700 dark:text-gray-300',
|
||||
statusText: 'Deferred'
|
||||
};
|
||||
|
||||
case 'cancelled':
|
||||
return {
|
||||
icon: X,
|
||||
bgColor: 'bg-red-50 dark:bg-red-950',
|
||||
borderColor: 'border-red-200 dark:border-red-800',
|
||||
iconColor: 'text-red-600 dark:text-red-400',
|
||||
textColor: 'text-red-900 dark:text-red-100',
|
||||
statusText: 'Cancelled'
|
||||
};
|
||||
|
||||
case 'pending':
|
||||
default:
|
||||
return {
|
||||
icon: Circle,
|
||||
bgColor: 'bg-slate-50 dark:bg-slate-800',
|
||||
borderColor: 'border-slate-200 dark:border-slate-700',
|
||||
iconColor: 'text-slate-500 dark:text-slate-400',
|
||||
textColor: 'text-slate-900 dark:text-slate-100',
|
||||
statusText: 'Pending'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getStatusConfig(task.status);
|
||||
const Icon = config.icon;
|
||||
|
||||
const getPriorityIcon = (priority) => {
|
||||
switch (priority) {
|
||||
case 'high':
|
||||
return (
|
||||
<Tooltip content="High Priority">
|
||||
<div className="w-4 h-4 bg-red-100 dark:bg-red-900/30 rounded flex items-center justify-center">
|
||||
<ChevronUp className="w-2.5 h-2.5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'medium':
|
||||
return (
|
||||
<Tooltip content="Medium Priority">
|
||||
<div className="w-4 h-4 bg-amber-100 dark:bg-amber-900/30 rounded flex items-center justify-center">
|
||||
<Minus className="w-2.5 h-2.5 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'low':
|
||||
return (
|
||||
<Tooltip content="Low Priority">
|
||||
<div className="w-4 h-4 bg-blue-100 dark:bg-blue-900/30 rounded flex items-center justify-center">
|
||||
<Circle className="w-1.5 h-1.5 text-blue-600 dark:text-blue-400 fill-current" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Tooltip content="No Priority Set">
|
||||
<div className="w-4 h-4 bg-gray-100 dark:bg-gray-800 rounded flex items-center justify-center">
|
||||
<Circle className="w-1.5 h-1.5 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700',
|
||||
'hover:shadow-md hover:border-blue-300 dark:hover:border-blue-600 transition-all duration-200 cursor-pointer',
|
||||
'p-3 space-y-3',
|
||||
onClick && 'hover:-translate-y-0.5',
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Header with Task ID, Title, and Priority */}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
{/* Task ID and Title */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Tooltip content={`Task ID: ${task.id}`}>
|
||||
<span className="text-xs font-mono text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded">
|
||||
{task.id}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<h3 className="font-medium text-sm text-gray-900 dark:text-white line-clamp-2 leading-tight">
|
||||
{task.title}
|
||||
</h3>
|
||||
{showParent && task.parentId && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium">
|
||||
Task {task.parentId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Priority Icon */}
|
||||
<div className="flex-shrink-0">
|
||||
{getPriorityIcon(task.priority)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer with Dependencies and Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Dependencies */}
|
||||
<div className="flex items-center">
|
||||
{task.dependencies && Array.isArray(task.dependencies) && task.dependencies.length > 0 && (
|
||||
<Tooltip content={`Depends on: ${task.dependencies.map(dep => `Task ${dep}`).join(', ')}`}>
|
||||
<div className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
<ArrowRight className="w-3 h-3" />
|
||||
<span>Depends on: {task.dependencies.join(', ')}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<Tooltip content={`Status: ${config.statusText}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={cn('w-2 h-2 rounded-full', config.iconColor.replace('text-', 'bg-'))} />
|
||||
<span className={cn('text-xs font-medium', config.textColor)}>
|
||||
{config.statusText}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Subtask Progress (if applicable) */}
|
||||
{task.subtasks && task.subtasks.length > 0 && (
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Progress:</span>
|
||||
<Tooltip content={`${task.subtasks.filter(st => st.status === 'done').length} of ${task.subtasks.length} subtasks completed`}>
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
task.status === 'done' ? 'bg-green-500' : 'bg-blue-500'
|
||||
)}
|
||||
style={{
|
||||
width: `${Math.round((task.subtasks.filter(st => st.status === 'done').length / task.subtasks.length) * 100)}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Tooltip content={`${task.subtasks.filter(st => st.status === 'done').length} completed, ${task.subtasks.filter(st => st.status === 'pending').length} pending, ${task.subtasks.filter(st => st.status === 'in-progress').length} in progress`}>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{task.subtasks.filter(st => st.status === 'done').length}/{task.subtasks.length}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCard;
|
||||
406
src/components/TaskDetail.jsx
Normal file
406
src/components/TaskDetail.jsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Flag, User, ArrowRight, CheckCircle, Circle, AlertCircle, Pause, Edit, Save, Copy, ChevronDown, ChevronRight, Clock } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import TaskIndicator from './TaskIndicator';
|
||||
import { api } from '../utils/api';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
|
||||
const TaskDetail = ({
|
||||
task,
|
||||
onClose,
|
||||
onEdit,
|
||||
onStatusChange,
|
||||
onTaskClick,
|
||||
isOpen = true,
|
||||
className = ''
|
||||
}) => {
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editedTask, setEditedTask] = useState(task || {});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showDetails, setShowDetails] = useState(false);
|
||||
const [showTestStrategy, setShowTestStrategy] = useState(false);
|
||||
const { currentProject, refreshTasks } = useTaskMaster();
|
||||
|
||||
if (!isOpen || !task) return null;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!currentProject) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Only include changed fields
|
||||
const updates = {};
|
||||
if (editedTask.title !== task.title) updates.title = editedTask.title;
|
||||
if (editedTask.description !== task.description) updates.description = editedTask.description;
|
||||
if (editedTask.details !== task.details) updates.details = editedTask.details;
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
const response = await api.taskmaster.updateTask(currentProject.name, task.id, updates);
|
||||
|
||||
if (response.ok) {
|
||||
// Refresh tasks to get updated data
|
||||
refreshTasks?.();
|
||||
onEdit?.(editedTask);
|
||||
setEditMode(false);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to update task:', error);
|
||||
alert(`Failed to update task: ${error.message}`);
|
||||
}
|
||||
} else {
|
||||
setEditMode(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating task:', error);
|
||||
alert('Error updating task. Please try again.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (newStatus) => {
|
||||
if (!currentProject) return;
|
||||
|
||||
try {
|
||||
const response = await api.taskmaster.updateTask(currentProject.name, task.id, { status: newStatus });
|
||||
|
||||
if (response.ok) {
|
||||
refreshTasks?.();
|
||||
onStatusChange?.(task.id, newStatus);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('Failed to update task status:', error);
|
||||
alert(`Failed to update task status: ${error.message}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating task status:', error);
|
||||
alert('Error updating task status. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const copyTaskId = () => {
|
||||
navigator.clipboard.writeText(task.id.toString());
|
||||
};
|
||||
|
||||
const getStatusConfig = (status) => {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return { icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-950' };
|
||||
case 'in-progress':
|
||||
return { icon: Clock, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-950' };
|
||||
case 'review':
|
||||
return { icon: AlertCircle, color: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-950' };
|
||||
case 'deferred':
|
||||
return { icon: Pause, color: 'text-gray-500 dark:text-gray-400', bg: 'bg-gray-50 dark:bg-gray-800' };
|
||||
case 'cancelled':
|
||||
return { icon: X, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-950' };
|
||||
default:
|
||||
return { icon: Circle, color: 'text-slate-500 dark:text-slate-400', bg: 'bg-slate-50 dark:bg-slate-800' };
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig(task.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
|
||||
const getPriorityColor = (priority) => {
|
||||
switch (priority) {
|
||||
case 'high': return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950';
|
||||
case 'medium': return 'text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-950';
|
||||
case 'low': return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-950';
|
||||
default: return 'text-gray-600 dark:text-gray-400 bg-gray-50 dark:bg-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'in-progress', label: 'In Progress' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
{ value: 'deferred', label: 'Deferred' },
|
||||
{ value: 'cancelled', label: 'Cancelled' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-black/50">
|
||||
<div className={cn(
|
||||
'bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl',
|
||||
'w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col',
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<StatusIcon className={cn('w-6 h-6', statusConfig.color)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<button
|
||||
onClick={copyTaskId}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
title="Click to copy task ID"
|
||||
>
|
||||
<span>Task {task.id}</span>
|
||||
<Copy className="w-3 h-3" />
|
||||
</button>
|
||||
{task.parentId && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Subtask of Task {task.parentId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{editMode ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editedTask.title || ''}
|
||||
onChange={(e) => setEditedTask({ ...editedTask, title: e.target.value })}
|
||||
className="w-full text-lg font-semibold bg-transparent border-b-2 border-blue-500 focus:outline-none text-gray-900 dark:text-white"
|
||||
placeholder="Task title"
|
||||
/>
|
||||
) : (
|
||||
<h1 className="text-lg md:text-xl font-semibold text-gray-900 dark:text-white line-clamp-2">
|
||||
{task.title}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="p-2 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-950 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={isSaving ? "Saving..." : "Save changes"}
|
||||
>
|
||||
<Save className={cn("w-5 h-5", isSaving && "animate-spin")} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
setEditedTask(task);
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Cancel editing"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditMode(true)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
title="Edit task"
|
||||
>
|
||||
<Edit className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6 space-y-6 min-h-0">
|
||||
{/* Status and Metadata Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Status */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Status</label>
|
||||
<div className={cn(
|
||||
'w-full px-3 py-2 rounded-md border border-gray-300 dark:border-gray-600',
|
||||
statusConfig.bg,
|
||||
statusConfig.color
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon className="w-4 h-4" />
|
||||
<span className="font-medium capitalize">
|
||||
{statusOptions.find(option => option.value === task.status)?.label || task.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Priority</label>
|
||||
<div className={cn(
|
||||
'px-3 py-2 rounded-md text-sm font-medium capitalize',
|
||||
getPriorityColor(task.priority)
|
||||
)}>
|
||||
<Flag className="w-4 h-4 inline mr-2" />
|
||||
{task.priority || 'Not set'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dependencies */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Dependencies</label>
|
||||
{task.dependencies && task.dependencies.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{task.dependencies.map(depId => (
|
||||
<button
|
||||
key={depId}
|
||||
onClick={() => onTaskClick && onTaskClick({ id: depId })}
|
||||
className="px-2 py-1 bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded text-sm hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors cursor-pointer disabled:cursor-default disabled:opacity-50"
|
||||
disabled={!onTaskClick}
|
||||
title={onTaskClick ? `Click to view Task ${depId}` : `Task ${depId}`}
|
||||
>
|
||||
<ArrowRight className="w-3 h-3 inline mr-1" />
|
||||
{depId}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">No dependencies</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editedTask.description || ''}
|
||||
onChange={(e) => setEditedTask({ ...editedTask, description: e.target.value })}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="Task description"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{task.description || 'No description provided'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Implementation Details */}
|
||||
{task.details && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowDetails(!showDetails)}
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Implementation Details
|
||||
</span>
|
||||
{showDetails ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
{showDetails && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editedTask.details || ''}
|
||||
onChange={(e) => setEditedTask({ ...editedTask, details: e.target.value })}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="Implementation details"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-md p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{task.details}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Test Strategy */}
|
||||
{task.testStrategy && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg">
|
||||
<button
|
||||
onClick={() => setShowTestStrategy(!showTestStrategy)}
|
||||
className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Test Strategy
|
||||
</span>
|
||||
{showTestStrategy ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
{showTestStrategy && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-950 rounded-md p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
|
||||
{task.testStrategy}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtasks */}
|
||||
{task.subtasks && task.subtasks.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Subtasks ({task.subtasks.length})
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{task.subtasks.map(subtask => {
|
||||
const subtaskConfig = getStatusConfig(subtask.status);
|
||||
const SubtaskIcon = subtaskConfig.icon;
|
||||
return (
|
||||
<div key={subtask.id} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-md">
|
||||
<SubtaskIcon className={cn('w-4 h-4', subtaskConfig.color)} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{subtask.title}
|
||||
</h4>
|
||||
{subtask.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{subtask.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{subtask.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Task ID: {task.id}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDetail;
|
||||
108
src/components/TaskIndicator.jsx
Normal file
108
src/components/TaskIndicator.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, Settings, X, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
/**
|
||||
* TaskIndicator Component
|
||||
*
|
||||
* Displays TaskMaster status for projects in the sidebar with appropriate
|
||||
* icons and colors based on the project's TaskMaster configuration state.
|
||||
*/
|
||||
const TaskIndicator = ({
|
||||
status = 'not-configured',
|
||||
size = 'sm',
|
||||
className = '',
|
||||
showLabel = false
|
||||
}) => {
|
||||
const getIndicatorConfig = () => {
|
||||
switch (status) {
|
||||
case 'fully-configured':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-500 dark:text-green-400',
|
||||
bgColor: 'bg-green-50 dark:bg-green-950',
|
||||
label: 'TaskMaster Ready',
|
||||
title: 'TaskMaster fully configured with MCP server'
|
||||
};
|
||||
|
||||
case 'taskmaster-only':
|
||||
return {
|
||||
icon: Settings,
|
||||
color: 'text-blue-500 dark:text-blue-400',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-950',
|
||||
label: 'TaskMaster Init',
|
||||
title: 'TaskMaster initialized, MCP server needs setup'
|
||||
};
|
||||
|
||||
case 'mcp-only':
|
||||
return {
|
||||
icon: AlertCircle,
|
||||
color: 'text-amber-500 dark:text-amber-400',
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-950',
|
||||
label: 'MCP Ready',
|
||||
title: 'MCP server configured, TaskMaster needs initialization'
|
||||
};
|
||||
|
||||
case 'not-configured':
|
||||
case 'error':
|
||||
default:
|
||||
return {
|
||||
icon: X,
|
||||
color: 'text-gray-400 dark:text-gray-500',
|
||||
bgColor: 'bg-gray-50 dark:bg-gray-900',
|
||||
label: 'No TaskMaster',
|
||||
title: 'TaskMaster not configured'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getIndicatorConfig();
|
||||
const Icon = config.icon;
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-3 h-3',
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6'
|
||||
};
|
||||
|
||||
const paddingClasses = {
|
||||
xs: 'p-0.5',
|
||||
sm: 'p-1',
|
||||
md: 'p-1.5',
|
||||
lg: 'p-2'
|
||||
};
|
||||
|
||||
if (showLabel) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 text-xs rounded-md px-2 py-1 transition-colors',
|
||||
config.bgColor,
|
||||
config.color,
|
||||
className
|
||||
)}
|
||||
title={config.title}
|
||||
>
|
||||
<Icon className={sizeClasses[size]} />
|
||||
<span className="font-medium">{config.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-full transition-colors',
|
||||
config.bgColor,
|
||||
paddingClasses[size],
|
||||
className
|
||||
)}
|
||||
title={config.title}
|
||||
>
|
||||
<Icon className={cn(sizeClasses[size], config.color)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskIndicator;
|
||||
1054
src/components/TaskList.jsx
Normal file
1054
src/components/TaskList.jsx
Normal file
File diff suppressed because it is too large
Load Diff
603
src/components/TaskMasterSetupWizard.jsx
Normal file
603
src/components/TaskMasterSetupWizard.jsx
Normal file
@@ -0,0 +1,603 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, ChevronRight, ChevronLeft, CheckCircle, AlertCircle, Settings, Server, FileText, Sparkles, ExternalLink, Copy } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
const TaskMasterSetupWizard = ({
|
||||
isOpen = true,
|
||||
onClose,
|
||||
onComplete,
|
||||
currentProject,
|
||||
className = ''
|
||||
}) => {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [setupData, setSetupData] = useState({
|
||||
projectRoot: '',
|
||||
initGit: true,
|
||||
storeTasksInGit: true,
|
||||
addAliases: true,
|
||||
skipInstall: false,
|
||||
rules: ['claude'],
|
||||
mcpConfigured: false,
|
||||
prdContent: ''
|
||||
});
|
||||
|
||||
const totalSteps = 4;
|
||||
|
||||
useEffect(() => {
|
||||
if (currentProject) {
|
||||
setSetupData(prev => ({
|
||||
...prev,
|
||||
projectRoot: currentProject.path || ''
|
||||
}));
|
||||
}
|
||||
}, [currentProject]);
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Project Configuration',
|
||||
description: 'Configure basic TaskMaster settings for your project'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'MCP Server Setup',
|
||||
description: 'Ensure TaskMaster MCP server is properly configured'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'PRD Creation',
|
||||
description: 'Create or import a Product Requirements Document'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Complete Setup',
|
||||
description: 'Initialize TaskMaster and generate initial tasks'
|
||||
}
|
||||
];
|
||||
|
||||
const handleNext = async () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (currentStep === 1) {
|
||||
// Validate project configuration
|
||||
if (!setupData.projectRoot) {
|
||||
setError('Project root path is required');
|
||||
return;
|
||||
}
|
||||
setCurrentStep(2);
|
||||
} else if (currentStep === 2) {
|
||||
// Check MCP server status
|
||||
setLoading(true);
|
||||
try {
|
||||
const mcpStatus = await api.get('/mcp-utils/taskmaster-server');
|
||||
setSetupData(prev => ({
|
||||
...prev,
|
||||
mcpConfigured: mcpStatus.hasMCPServer && mcpStatus.isConfigured
|
||||
}));
|
||||
setCurrentStep(3);
|
||||
} catch (err) {
|
||||
setError('Failed to check MCP server status. You can continue but some features may not work.');
|
||||
setCurrentStep(3);
|
||||
}
|
||||
} else if (currentStep === 3) {
|
||||
// Validate PRD step
|
||||
if (!setupData.prdContent.trim()) {
|
||||
setError('Please create or import a PRD to continue');
|
||||
return;
|
||||
}
|
||||
setCurrentStep(4);
|
||||
} else if (currentStep === 4) {
|
||||
// Complete setup
|
||||
await completeSetup();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'An error occurred');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
setError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const completeSetup = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Initialize TaskMaster project
|
||||
const initResponse = await api.post('/taskmaster/initialize', {
|
||||
projectRoot: setupData.projectRoot,
|
||||
initGit: setupData.initGit,
|
||||
storeTasksInGit: setupData.storeTasksInGit,
|
||||
addAliases: setupData.addAliases,
|
||||
skipInstall: setupData.skipInstall,
|
||||
rules: setupData.rules,
|
||||
yes: true
|
||||
});
|
||||
|
||||
if (!initResponse.ok) {
|
||||
throw new Error('Failed to initialize TaskMaster project');
|
||||
}
|
||||
|
||||
// Save PRD content if provided
|
||||
if (setupData.prdContent.trim()) {
|
||||
const prdResponse = await api.post('/taskmaster/save-prd', {
|
||||
projectRoot: setupData.projectRoot,
|
||||
content: setupData.prdContent
|
||||
});
|
||||
|
||||
if (!prdResponse.ok) {
|
||||
console.warn('Failed to save PRD content');
|
||||
}
|
||||
}
|
||||
|
||||
// Parse PRD to generate initial tasks
|
||||
if (setupData.prdContent.trim()) {
|
||||
const parseResponse = await api.post('/taskmaster/parse-prd', {
|
||||
projectRoot: setupData.projectRoot,
|
||||
input: '.taskmaster/docs/prd.txt',
|
||||
numTasks: '10',
|
||||
research: false,
|
||||
force: false
|
||||
});
|
||||
|
||||
if (!parseResponse.ok) {
|
||||
console.warn('Failed to parse PRD and generate tasks');
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.();
|
||||
onClose?.();
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to complete TaskMaster setup');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyMCPConfig = () => {
|
||||
const mcpConfig = `{
|
||||
"mcpServers": {
|
||||
"": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "your_anthropic_key_here",
|
||||
"PERPLEXITY_API_KEY": "your_perplexity_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
navigator.clipboard.writeText(mcpConfig);
|
||||
};
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<Settings className="w-12 h-12 text-blue-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Project Configuration
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Configure TaskMaster settings for your project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Project Root Path
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={setupData.projectRoot}
|
||||
onChange={(e) => setSetupData(prev => ({ ...prev, projectRoot: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
placeholder="/path/to/your/project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white">Options</h4>
|
||||
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupData.initGit}
|
||||
onChange={(e) => setSetupData(prev => ({ ...prev, initGit: e.target.checked }))}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Initialize Git repository</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupData.storeTasksInGit}
|
||||
onChange={(e) => setSetupData(prev => ({ ...prev, storeTasksInGit: e.target.checked }))}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Store tasks in Git</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupData.addAliases}
|
||||
onChange={(e) => setSetupData(prev => ({ ...prev, addAliases: e.target.checked }))}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300">Add shell aliases (tm, taskmaster)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Rule Profiles
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['claude', 'cursor', 'vscode', 'roo', 'cline', 'windsurf'].map(rule => (
|
||||
<label key={rule} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={setupData.rules.includes(rule)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSetupData(prev => ({ ...prev, rules: [...prev.rules, rule] }));
|
||||
} else {
|
||||
setSetupData(prev => ({ ...prev, rules: prev.rules.filter(r => r !== rule) }));
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-blue-600 rounded focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700 dark:text-gray-300 capitalize">{rule}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<Server className="w-12 h-12 text-purple-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
MCP Server Setup
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
TaskMaster works best with the MCP server configured
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||
MCP Server Configuration
|
||||
</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200 mb-3">
|
||||
To enable full TaskMaster integration, add the MCP server configuration to your Claude settings.
|
||||
</p>
|
||||
|
||||
<div className="bg-white dark:bg-gray-800 rounded border p-3 mb-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-mono text-gray-600 dark:text-gray-400">.mcp.json</span>
|
||||
<button
|
||||
onClick={copyMCPConfig}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs text-gray-800 dark:text-gray-200 whitespace-pre-wrap">
|
||||
{`{
|
||||
"mcpServers": {
|
||||
"task-master-ai": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "--package=task-master-ai", "task-master-ai"],
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "your_anthropic_key_here",
|
||||
"PERPLEXITY_API_KEY": "your_perplexity_key_here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<a
|
||||
href="https://docs.anthropic.com/en/docs/build-with-claude/tool-use/mcp-servers"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex items-center gap-1"
|
||||
>
|
||||
Learn about MCP setup
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white mb-2">Current Status</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{setupData.mcpConfigured ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm text-green-700 dark:text-green-300">MCP server is configured</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="w-4 h-4 text-amber-500" />
|
||||
<span className="text-sm text-amber-700 dark:text-amber-300">MCP server not detected (optional)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<FileText className="w-12 h-12 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Product Requirements Document
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Create or import a PRD to generate initial tasks
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
PRD Content
|
||||
</label>
|
||||
<textarea
|
||||
value={setupData.prdContent}
|
||||
onChange={(e) => setSetupData(prev => ({ ...prev, prdContent: e.target.value }))}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="# Product Requirements Document
|
||||
|
||||
## 1. Overview
|
||||
Describe your project or feature...
|
||||
|
||||
## 2. Objectives
|
||||
- Primary goal
|
||||
- Success metrics
|
||||
|
||||
## 3. User Stories
|
||||
- As a user, I want...
|
||||
|
||||
## 4. Requirements
|
||||
- Feature requirements
|
||||
- Technical requirements
|
||||
|
||||
## 5. Implementation Plan
|
||||
- Phase 1: Core features
|
||||
- Phase 2: Enhancements"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Sparkles className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-1">
|
||||
AI Task Generation
|
||||
</h4>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
TaskMaster will analyze your PRD and automatically generate a structured task list with dependencies, priorities, and implementation details.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Complete Setup
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Ready to initialize TaskMaster for your project
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<h4 className="font-medium text-green-900 dark:text-green-100 mb-3">
|
||||
Setup Summary
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-green-800 dark:text-green-200">
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Project: {setupData.projectRoot}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Rules: {setupData.rules.join(', ')}
|
||||
</li>
|
||||
{setupData.mcpConfigured && (
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
MCP server configured
|
||||
</li>
|
||||
)}
|
||||
<li className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
PRD content ready ({setupData.prdContent.length} characters)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||
What happens next?
|
||||
</h4>
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm text-blue-800 dark:text-blue-200">
|
||||
<li>Initialize TaskMaster project structure</li>
|
||||
<li>Save your PRD to <code>.taskmaster/docs/prd.txt</code></li>
|
||||
<li>Generate initial tasks from your PRD</li>
|
||||
<li>Set up project configuration and rules</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop fixed inset-0 flex items-center justify-center z-[100] md:p-4 bg-black/50">
|
||||
<div className={cn(
|
||||
'bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 md:rounded-lg shadow-xl',
|
||||
'w-full md:max-w-4xl h-full md:h-[90vh] flex flex-col',
|
||||
className
|
||||
)}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sparkles className="w-6 h-6 text-blue-600" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
TaskMaster Setup Wizard
|
||||
</h1>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Step {currentStep} of {totalSteps}: {steps[currentStep - 1]?.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-md transition-colors"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="px-4 md:px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<div className={cn(
|
||||
'w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors',
|
||||
currentStep > step.id
|
||||
? 'bg-green-500 text-white'
|
||||
: currentStep === step.id
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
)}>
|
||||
{currentStep > step.id ? (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
) : (
|
||||
step.id
|
||||
)}
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={cn(
|
||||
'w-16 h-1 mx-2 rounded',
|
||||
currentStep > step.id
|
||||
? 'bg-green-500'
|
||||
: 'bg-gray-200 dark:bg-gray-700'
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-600 dark:text-gray-400">
|
||||
{steps.map(step => (
|
||||
<span key={step.id} className="text-center">
|
||||
{step.title}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
{renderStepContent()}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="w-5 h-5 text-red-600 dark:text-red-400 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-medium text-red-900 dark:text-red-100 mb-1">Error</h4>
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 md:p-6 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentStep === 1}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{currentStep} of {totalSteps}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{currentStep === totalSteps ? 'Setting up...' : 'Processing...'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{currentStep === totalSteps ? 'Complete Setup' : 'Next'}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskMasterSetupWizard;
|
||||
86
src/components/TaskMasterStatus.jsx
Normal file
86
src/components/TaskMasterStatus.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react';
|
||||
import { useTaskMaster } from '../contexts/TaskMasterContext';
|
||||
import TaskIndicator from './TaskIndicator';
|
||||
|
||||
const TaskMasterStatus = () => {
|
||||
const {
|
||||
currentProject,
|
||||
projectTaskMaster,
|
||||
mcpServerStatus,
|
||||
isLoading,
|
||||
isLoadingMCP,
|
||||
error
|
||||
} = useTaskMaster();
|
||||
|
||||
if (isLoading || isLoadingMCP) {
|
||||
return (
|
||||
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="animate-spin w-3 h-3 border border-gray-300 border-t-blue-500 rounded-full mr-2"></div>
|
||||
Loading TaskMaster status...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center text-sm text-red-500 dark:text-red-400">
|
||||
<span className="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
||||
TaskMaster Error
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show MCP server status
|
||||
const mcpConfigured = mcpServerStatus?.hasMCPServer && mcpServerStatus?.isConfigured;
|
||||
|
||||
// Show project TaskMaster status
|
||||
const projectConfigured = currentProject?.taskmaster?.hasTaskmaster;
|
||||
const taskCount = currentProject?.taskmaster?.metadata?.taskCount || 0;
|
||||
const completedCount = currentProject?.taskmaster?.metadata?.completed || 0;
|
||||
|
||||
if (!currentProject) {
|
||||
return (
|
||||
<div className="flex items-center text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="w-2 h-2 bg-gray-400 rounded-full mr-2"></span>
|
||||
No project selected
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine overall status for TaskIndicator
|
||||
let overallStatus = 'not-configured';
|
||||
if (projectConfigured && mcpConfigured) {
|
||||
overallStatus = 'fully-configured';
|
||||
} else if (projectConfigured) {
|
||||
overallStatus = 'taskmaster-only';
|
||||
} else if (mcpConfigured) {
|
||||
overallStatus = 'mcp-only';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* TaskMaster Status Indicator */}
|
||||
<TaskIndicator
|
||||
status={overallStatus}
|
||||
size="md"
|
||||
showLabel={true}
|
||||
/>
|
||||
|
||||
{/* Task Progress Info */}
|
||||
{projectConfigured && (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="font-medium">
|
||||
{completedCount}/{taskCount} tasks
|
||||
</span>
|
||||
{taskCount > 0 && (
|
||||
<span className="ml-2 opacity-75">
|
||||
({Math.round((completedCount / taskCount) * 100)}%)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskMasterStatus;
|
||||
@@ -51,9 +51,9 @@ const TodoList = ({ todos, isResult = false }) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{todos.map((todo) => (
|
||||
{todos.map((todo, index) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
key={todo.id || `todo-${index}`}
|
||||
className="flex items-start gap-3 p-3 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm hover:shadow-md dark:shadow-gray-900/50 transition-shadow"
|
||||
>
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
|
||||
91
src/components/Tooltip.jsx
Normal file
91
src/components/Tooltip.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
const Tooltip = ({
|
||||
children,
|
||||
content,
|
||||
position = 'top',
|
||||
className = '',
|
||||
delay = 500
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [timeoutId, setTimeoutId] = useState(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
const id = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, delay);
|
||||
setTimeoutId(id);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
setTimeoutId(null);
|
||||
}
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
const getPositionClasses = () => {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
|
||||
case 'bottom':
|
||||
return 'top-full left-1/2 transform -translate-x-1/2 mt-2';
|
||||
case 'left':
|
||||
return 'right-full top-1/2 transform -translate-y-1/2 mr-2';
|
||||
case 'right':
|
||||
return 'left-full top-1/2 transform -translate-y-1/2 ml-2';
|
||||
default:
|
||||
return 'bottom-full left-1/2 transform -translate-x-1/2 mb-2';
|
||||
}
|
||||
};
|
||||
|
||||
const getArrowClasses = () => {
|
||||
switch (position) {
|
||||
case 'top':
|
||||
return 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100';
|
||||
case 'bottom':
|
||||
return 'bottom-full left-1/2 transform -translate-x-1/2 border-b-gray-900 dark:border-b-gray-100';
|
||||
case 'left':
|
||||
return 'left-full top-1/2 transform -translate-y-1/2 border-l-gray-900 dark:border-l-gray-100';
|
||||
case 'right':
|
||||
return 'right-full top-1/2 transform -translate-y-1/2 border-r-gray-900 dark:border-r-gray-100';
|
||||
default:
|
||||
return 'top-full left-1/2 transform -translate-x-1/2 border-t-gray-900 dark:border-t-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
if (!content) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-block"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{children}
|
||||
|
||||
{isVisible && (
|
||||
<div className={cn(
|
||||
'absolute z-50 px-2 py-1 text-xs font-medium text-white bg-gray-900 dark:bg-gray-100 dark:text-gray-900 rounded shadow-lg whitespace-nowrap pointer-events-none',
|
||||
'animate-in fade-in-0 zoom-in-95 duration-200',
|
||||
getPositionClasses(),
|
||||
className
|
||||
)}>
|
||||
{content}
|
||||
|
||||
{/* Arrow */}
|
||||
<div className={cn(
|
||||
'absolute w-0 h-0 border-4 border-transparent',
|
||||
getArrowClasses()
|
||||
)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
324
src/contexts/TaskMasterContext.jsx
Normal file
324
src/contexts/TaskMasterContext.jsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
import { useAuth } from './AuthContext';
|
||||
import { useWebSocketContext } from './WebSocketContext';
|
||||
|
||||
const TaskMasterContext = createContext({
|
||||
// TaskMaster project state
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
projectTaskMaster: null,
|
||||
|
||||
// MCP server state
|
||||
mcpServerStatus: null,
|
||||
|
||||
// Tasks state
|
||||
tasks: [],
|
||||
nextTask: null,
|
||||
|
||||
// Loading states
|
||||
isLoading: false,
|
||||
isLoadingTasks: false,
|
||||
isLoadingMCP: false,
|
||||
|
||||
// Error state
|
||||
error: null,
|
||||
|
||||
// Actions
|
||||
refreshProjects: () => {},
|
||||
setCurrentProject: () => {},
|
||||
refreshTasks: () => {},
|
||||
refreshMCPStatus: () => {},
|
||||
clearError: () => {}
|
||||
});
|
||||
|
||||
export const useTaskMaster = () => {
|
||||
const context = useContext(TaskMasterContext);
|
||||
if (!context) {
|
||||
throw new Error('useTaskMaster must be used within a TaskMasterProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const TaskMasterProvider = ({ children }) => {
|
||||
// Get WebSocket messages from shared context to avoid duplicate connections
|
||||
const { messages } = useWebSocketContext();
|
||||
|
||||
// Authentication context
|
||||
const { user, token, isLoading: authLoading } = useAuth();
|
||||
|
||||
// State
|
||||
const [projects, setProjects] = useState([]);
|
||||
const [currentProject, setCurrentProjectState] = useState(null);
|
||||
const [projectTaskMaster, setProjectTaskMaster] = useState(null);
|
||||
const [mcpServerStatus, setMCPServerStatus] = useState(null);
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [nextTask, setNextTask] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingTasks, setIsLoadingTasks] = useState(false);
|
||||
const [isLoadingMCP, setIsLoadingMCP] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Helper to handle API errors
|
||||
const handleError = (error, context) => {
|
||||
console.error(`TaskMaster ${context} error:`, error);
|
||||
setError({
|
||||
message: error.message || `Failed to ${context}`,
|
||||
context,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
|
||||
// Clear error state
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// This will be defined after the functions are declared
|
||||
|
||||
// Refresh projects with TaskMaster metadata
|
||||
const refreshProjects = useCallback(async () => {
|
||||
// Only make API calls if user is authenticated
|
||||
if (!user || !token) {
|
||||
setProjects([]);
|
||||
setCurrentProjectState(null); // This might be the problem!
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
clearError();
|
||||
const response = await api.get('/projects');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch projects: ${response.status}`);
|
||||
}
|
||||
|
||||
const projectsData = await response.json();
|
||||
|
||||
// Check if projectsData is an array
|
||||
if (!Array.isArray(projectsData)) {
|
||||
console.error('Projects API returned non-array data:', projectsData);
|
||||
setProjects([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter and enrich projects with TaskMaster data
|
||||
const enrichedProjects = projectsData.map(project => ({
|
||||
...project,
|
||||
taskMasterConfigured: project.taskmaster?.hasTaskmaster || false,
|
||||
taskMasterStatus: project.taskmaster?.status || 'not-configured',
|
||||
taskCount: project.taskmaster?.metadata?.taskCount || 0,
|
||||
completedCount: project.taskmaster?.metadata?.completed || 0
|
||||
}));
|
||||
|
||||
setProjects(enrichedProjects);
|
||||
|
||||
// If current project is set, update its TaskMaster data
|
||||
if (currentProject) {
|
||||
const updatedCurrent = enrichedProjects.find(p => p.name === currentProject.name);
|
||||
if (updatedCurrent) {
|
||||
setCurrentProjectState(updatedCurrent);
|
||||
setProjectTaskMaster(updatedCurrent.taskmaster);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err, 'load projects');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [user, token]); // Remove currentProject dependency to avoid infinite loops
|
||||
|
||||
// Set current project and load its TaskMaster details
|
||||
const setCurrentProject = useCallback(async (project) => {
|
||||
try {
|
||||
setCurrentProjectState(project);
|
||||
|
||||
// Clear previous project's data immediately when switching projects
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
setProjectTaskMaster(null); // Clear previous TaskMaster data
|
||||
|
||||
// Try to fetch fresh TaskMaster detection data for the project
|
||||
if (project?.name) {
|
||||
try {
|
||||
const response = await api.get(`/taskmaster/detect/${encodeURIComponent(project.name)}`);
|
||||
if (response.ok) {
|
||||
const detectionData = await response.json();
|
||||
setProjectTaskMaster(detectionData.taskmaster);
|
||||
} else {
|
||||
// If individual detection fails, fall back to project data from /api/projects
|
||||
console.warn('Individual TaskMaster detection failed, using project data:', response.status);
|
||||
setProjectTaskMaster(project.taskmaster || null);
|
||||
}
|
||||
} catch (detectionError) {
|
||||
// If individual detection fails, fall back to project data from /api/projects
|
||||
console.warn('TaskMaster detection error, using project data:', detectionError.message);
|
||||
setProjectTaskMaster(project.taskmaster || null);
|
||||
}
|
||||
} else {
|
||||
setProjectTaskMaster(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error in setCurrentProject:', err);
|
||||
handleError(err, 'set current project');
|
||||
// Fall back to project data if available
|
||||
setProjectTaskMaster(project?.taskmaster || null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Refresh MCP server status
|
||||
const refreshMCPStatus = useCallback(async () => {
|
||||
// Only make API calls if user is authenticated
|
||||
if (!user || !token) {
|
||||
setMCPServerStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingMCP(true);
|
||||
clearError();
|
||||
const mcpStatus = await api.get('/mcp-utils/taskmaster-server');
|
||||
setMCPServerStatus(mcpStatus);
|
||||
} catch (err) {
|
||||
handleError(err, 'check MCP server status');
|
||||
} finally {
|
||||
setIsLoadingMCP(false);
|
||||
}
|
||||
}, [user, token]);
|
||||
|
||||
// Refresh tasks for current project - load real TaskMaster data
|
||||
const refreshTasks = useCallback(async () => {
|
||||
if (!currentProject) {
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only make API calls if user is authenticated
|
||||
if (!user || !token) {
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoadingTasks(true);
|
||||
clearError();
|
||||
|
||||
// Load tasks from the TaskMaster API endpoint
|
||||
const response = await api.get(`/taskmaster/tasks/${encodeURIComponent(currentProject.name)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to load tasks');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
setTasks(data.tasks || []);
|
||||
|
||||
// Find next task (pending or in-progress)
|
||||
const nextTask = data.tasks?.find(task =>
|
||||
task.status === 'pending' || task.status === 'in-progress'
|
||||
) || null;
|
||||
setNextTask(nextTask);
|
||||
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error loading tasks:', err);
|
||||
handleError(err, 'load tasks');
|
||||
// Set empty state on error
|
||||
setTasks([]);
|
||||
setNextTask(null);
|
||||
} finally {
|
||||
setIsLoadingTasks(false);
|
||||
}
|
||||
}, [currentProject, user, token]);
|
||||
|
||||
// Load initial data on mount or when auth changes
|
||||
useEffect(() => {
|
||||
if (!authLoading && user && token) {
|
||||
refreshProjects();
|
||||
refreshMCPStatus();
|
||||
} else {
|
||||
console.log('Auth not ready or no user, skipping project load:', { authLoading, user: !!user, token: !!token });
|
||||
}
|
||||
}, [refreshProjects, refreshMCPStatus, authLoading, user, token]);
|
||||
|
||||
// Clear errors when authentication changes
|
||||
useEffect(() => {
|
||||
if (user && token) {
|
||||
clearError();
|
||||
}
|
||||
}, [user, token, clearError]);
|
||||
|
||||
// Refresh tasks when current project changes
|
||||
useEffect(() => {
|
||||
if (currentProject?.name && user && token) {
|
||||
refreshTasks();
|
||||
}
|
||||
}, [currentProject?.name, user, token, refreshTasks]);
|
||||
|
||||
// Handle WebSocket messages for TaskMaster updates
|
||||
useEffect(() => {
|
||||
const latestMessage = messages[messages.length - 1];
|
||||
if (!latestMessage) return;
|
||||
|
||||
|
||||
switch (latestMessage.type) {
|
||||
case 'taskmaster-project-updated':
|
||||
// Refresh projects when TaskMaster state changes
|
||||
if (latestMessage.projectName) {
|
||||
refreshProjects();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'taskmaster-tasks-updated':
|
||||
// Refresh tasks for the current project
|
||||
if (latestMessage.projectName === currentProject?.name) {
|
||||
refreshTasks();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'taskmaster-mcp-status-changed':
|
||||
// Refresh MCP server status
|
||||
refreshMCPStatus();
|
||||
break;
|
||||
|
||||
default:
|
||||
// Ignore non-TaskMaster messages
|
||||
break;
|
||||
}
|
||||
}, [messages, refreshProjects, refreshTasks, refreshMCPStatus, currentProject]);
|
||||
|
||||
// Context value
|
||||
const contextValue = {
|
||||
// State
|
||||
projects,
|
||||
currentProject,
|
||||
projectTaskMaster,
|
||||
mcpServerStatus,
|
||||
tasks,
|
||||
nextTask,
|
||||
isLoading,
|
||||
isLoadingTasks,
|
||||
isLoadingMCP,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
refreshProjects,
|
||||
setCurrentProject,
|
||||
refreshTasks,
|
||||
refreshMCPStatus,
|
||||
clearError
|
||||
};
|
||||
|
||||
return (
|
||||
<TaskMasterContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</TaskMasterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskMasterContext;
|
||||
95
src/contexts/TasksSettingsContext.jsx
Normal file
95
src/contexts/TasksSettingsContext.jsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { api } from '../utils/api';
|
||||
|
||||
const TasksSettingsContext = createContext({
|
||||
tasksEnabled: true,
|
||||
setTasksEnabled: () => {},
|
||||
toggleTasksEnabled: () => {},
|
||||
isTaskMasterInstalled: null,
|
||||
isTaskMasterReady: null,
|
||||
installationStatus: null,
|
||||
isCheckingInstallation: true
|
||||
});
|
||||
|
||||
export const useTasksSettings = () => {
|
||||
const context = useContext(TasksSettingsContext);
|
||||
if (!context) {
|
||||
throw new Error('useTasksSettings must be used within a TasksSettingsProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const TasksSettingsProvider = ({ children }) => {
|
||||
const [tasksEnabled, setTasksEnabled] = useState(() => {
|
||||
// Load from localStorage on initialization
|
||||
const saved = localStorage.getItem('tasks-enabled');
|
||||
return saved !== null ? JSON.parse(saved) : true; // Default to true
|
||||
});
|
||||
|
||||
const [isTaskMasterInstalled, setIsTaskMasterInstalled] = useState(null);
|
||||
const [isTaskMasterReady, setIsTaskMasterReady] = useState(null);
|
||||
const [installationStatus, setInstallationStatus] = useState(null);
|
||||
const [isCheckingInstallation, setIsCheckingInstallation] = useState(true);
|
||||
|
||||
// Save to localStorage whenever tasksEnabled changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('tasks-enabled', JSON.stringify(tasksEnabled));
|
||||
}, [tasksEnabled]);
|
||||
|
||||
// Check TaskMaster installation status asynchronously on component mount
|
||||
useEffect(() => {
|
||||
const checkInstallation = async () => {
|
||||
try {
|
||||
const response = await api.get('/taskmaster/installation-status');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setInstallationStatus(data);
|
||||
setIsTaskMasterInstalled(data.installation?.isInstalled || false);
|
||||
setIsTaskMasterReady(data.isReady || false);
|
||||
|
||||
// If TaskMaster is not installed and user hasn't explicitly enabled tasks,
|
||||
// disable tasks automatically
|
||||
const userEnabledTasks = localStorage.getItem('tasks-enabled');
|
||||
if (!data.installation?.isInstalled && !userEnabledTasks) {
|
||||
setTasksEnabled(false);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to check TaskMaster installation status');
|
||||
setIsTaskMasterInstalled(false);
|
||||
setIsTaskMasterReady(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking TaskMaster installation:', error);
|
||||
setIsTaskMasterInstalled(false);
|
||||
setIsTaskMasterReady(false);
|
||||
} finally {
|
||||
setIsCheckingInstallation(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Run check asynchronously without blocking initial render
|
||||
setTimeout(checkInstallation, 0);
|
||||
}, []);
|
||||
|
||||
const toggleTasksEnabled = () => {
|
||||
setTasksEnabled(prev => !prev);
|
||||
};
|
||||
|
||||
const contextValue = {
|
||||
tasksEnabled,
|
||||
setTasksEnabled,
|
||||
toggleTasksEnabled,
|
||||
isTaskMasterInstalled,
|
||||
isTaskMasterReady,
|
||||
installationStatus,
|
||||
isCheckingInstallation
|
||||
};
|
||||
|
||||
return (
|
||||
<TasksSettingsContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</TasksSettingsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TasksSettingsContext;
|
||||
29
src/contexts/WebSocketContext.jsx
Normal file
29
src/contexts/WebSocketContext.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import { useWebSocket } from '../utils/websocket';
|
||||
|
||||
const WebSocketContext = createContext({
|
||||
ws: null,
|
||||
sendMessage: () => {},
|
||||
messages: [],
|
||||
isConnected: false
|
||||
});
|
||||
|
||||
export const useWebSocketContext = () => {
|
||||
const context = useContext(WebSocketContext);
|
||||
if (!context) {
|
||||
throw new Error('useWebSocketContext must be used within a WebSocketProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const WebSocketProvider = ({ children }) => {
|
||||
const webSocketData = useWebSocket();
|
||||
|
||||
return (
|
||||
<WebSocketContext.Provider value={webSocketData}>
|
||||
{children}
|
||||
</WebSocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default WebSocketContext;
|
||||
@@ -43,6 +43,22 @@
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Safe area CSS variables */
|
||||
--safe-area-inset-top: env(safe-area-inset-top);
|
||||
--safe-area-inset-right: env(safe-area-inset-right);
|
||||
--safe-area-inset-bottom: env(safe-area-inset-bottom);
|
||||
--safe-area-inset-left: env(safe-area-inset-left);
|
||||
}
|
||||
|
||||
/* Fallback for older iOS versions */
|
||||
@supports (padding-top: constant(safe-area-inset-top)) {
|
||||
:root {
|
||||
--safe-area-inset-top: constant(safe-area-inset-top);
|
||||
--safe-area-inset-right: constant(safe-area-inset-right);
|
||||
--safe-area-inset-bottom: constant(safe-area-inset-bottom);
|
||||
--safe-area-inset-left: constant(safe-area-inset-left);
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -82,12 +98,60 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Root element with safe area padding for PWA */
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Apply safe area padding in standalone mode */
|
||||
@supports (padding-top: env(safe-area-inset-top)) {
|
||||
@media (display-mode: standalone) {
|
||||
#root {
|
||||
padding-top: var(--safe-area-inset-top);
|
||||
padding-left: var(--safe-area-inset-left);
|
||||
padding-right: var(--safe-area-inset-right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* PWA mode detected by JavaScript - more reliable */
|
||||
html.pwa-mode,
|
||||
body.pwa-mode {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background-color: rgb(255 255 255); /* white - same as bg-white for safe area */
|
||||
}
|
||||
|
||||
body.pwa-mode #root {
|
||||
padding-top: calc(env(safe-area-inset-top, 0px) + 8px);
|
||||
padding-left: env(safe-area-inset-left);
|
||||
padding-right: env(safe-area-inset-right);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Adjust fixed inset positioning in PWA mode */
|
||||
body.pwa-mode .fixed.inset-0 {
|
||||
top: calc(env(safe-area-inset-top, 0px) + 8px);
|
||||
left: env(safe-area-inset-left);
|
||||
right: env(safe-area-inset-right);
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Dark mode safe area background */
|
||||
html.dark body.pwa-mode {
|
||||
background-color: rgb(31 41 55); /* gray-800 - matches header color */
|
||||
}
|
||||
|
||||
/* Global transition defaults */
|
||||
button,
|
||||
a,
|
||||
@@ -577,6 +641,25 @@
|
||||
padding-bottom: max(env(safe-area-inset-bottom), 12px);
|
||||
}
|
||||
|
||||
/* PWA specific header adjustments for iOS */
|
||||
.pwa-header-safe {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
/* When PWA mode is detected by JavaScript */
|
||||
body.pwa-mode .pwa-header-safe {
|
||||
/* Reset padding since #root already handles safe area */
|
||||
padding-top: 0px !important;
|
||||
}
|
||||
|
||||
/* For mobile PWA, ensure proper header spacing */
|
||||
@media screen and (max-width: 768px) {
|
||||
body.pwa-mode .pwa-header-safe {
|
||||
padding-top: 4px !important;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.chat-input-mobile {
|
||||
padding-bottom: calc(60px + max(env(safe-area-inset-bottom), 12px));
|
||||
|
||||
@@ -86,4 +86,56 @@ export const api = {
|
||||
body: formData,
|
||||
headers: {}, // Let browser set Content-Type for FormData
|
||||
}),
|
||||
|
||||
// TaskMaster endpoints
|
||||
taskmaster: {
|
||||
// Initialize TaskMaster in a project
|
||||
init: (projectName) =>
|
||||
authenticatedFetch(`/api/taskmaster/init/${projectName}`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Add a new task
|
||||
addTask: (projectName, { prompt, title, description, priority, dependencies }) =>
|
||||
authenticatedFetch(`/api/taskmaster/add-task/${projectName}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ prompt, title, description, priority, dependencies }),
|
||||
}),
|
||||
|
||||
// Parse PRD to generate tasks
|
||||
parsePRD: (projectName, { fileName, numTasks, append }) =>
|
||||
authenticatedFetch(`/api/taskmaster/parse-prd/${projectName}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ fileName, numTasks, append }),
|
||||
}),
|
||||
|
||||
// Get available PRD templates
|
||||
getTemplates: () =>
|
||||
authenticatedFetch('/api/taskmaster/prd-templates'),
|
||||
|
||||
// Apply a PRD template
|
||||
applyTemplate: (projectName, { templateId, fileName, customizations }) =>
|
||||
authenticatedFetch(`/api/taskmaster/apply-template/${projectName}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ templateId, fileName, customizations }),
|
||||
}),
|
||||
|
||||
// Update a task
|
||||
updateTask: (projectName, taskId, updates) =>
|
||||
authenticatedFetch(`/api/taskmaster/update-task/${projectName}/${taskId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
}),
|
||||
},
|
||||
|
||||
// Browse filesystem for project suggestions
|
||||
browseFilesystem: (dirPath = null) => {
|
||||
const params = new URLSearchParams();
|
||||
if (dirPath) params.append('path', dirPath);
|
||||
|
||||
return authenticatedFetch(`/api/browse-filesystem?${params}`);
|
||||
},
|
||||
|
||||
// Generic GET method for any endpoint
|
||||
get: (endpoint) => authenticatedFetch(`/api${endpoint}`),
|
||||
};
|
||||
@@ -17,7 +17,7 @@ export function useWebSocket() {
|
||||
ws.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}, []); // Keep dependency array but add proper cleanup
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
|
||||
BIN
store.db-shm
BIN
store.db-shm
Binary file not shown.
Reference in New Issue
Block a user