refactor(prd-editor): modularize PRD editor with typed feature modules

Break the legacy PRDEditor.jsx monolith into a feature-based TypeScript architecture under src/components/prd-editor while keeping behavior parity and readability.

Key changes:

- Replace PRDEditor.jsx with a typed orchestrator component and a compatibility export bridge at src/components/PRDEditor.tsx.

- Split responsibilities into dedicated hooks: document loading/init, existing PRD registry fetching, save workflow with overwrite detection, and keyboard shortcuts.

- Split UI into focused view components: header, editor/preview body, footer stats, loading state, generate-tasks modal, and overwrite-confirm modal.

- Move filename concerns into utility helpers (sanitize, extension handling, default naming) and centralize template/constants.

- Keep component-local state close to the UI that owns it (workspace controls/modal toggles), while shared workflow state remains in the feature container.

- Reuse the existing MarkdownPreview component for safer markdown rendering instead of ad-hoc HTML conversion.

- Update TaskMasterPanel integration to consume typed PRDEditor directly (remove any-cast) and pass isExisting metadata for correct overwrite behavior.

- Keep all new/changed files below 300 lines and add targeted comments where behavior needs clarification.

Validation:

- npm run typecheck

- npm run build
This commit is contained in:
Haileyesus
2026-03-02 17:04:02 +03:00
parent 6f686b9da8
commit f68600bb76
19 changed files with 1172 additions and 873 deletions

View File

@@ -1,871 +0,0 @@
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;

View File

@@ -0,0 +1 @@
export { default } from './prd-editor';

View File

@@ -9,7 +9,6 @@ import type { PrdFile, TaskMasterPanelProps, TaskMasterTask, TaskSelection } fro
const AnyTaskList = TaskList as any; const AnyTaskList = TaskList as any;
const AnyTaskDetail = TaskDetail as any; const AnyTaskDetail = TaskDetail as any;
const AnyPRDEditor = PRDEditor as any;
type TaskMasterContextValue = { type TaskMasterContextValue = {
tasks?: TaskMasterTask[]; tasks?: TaskMasterTask[];
@@ -178,7 +177,7 @@ export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
)} )}
{showPRDEditor && ( {showPRDEditor && (
<AnyPRDEditor <PRDEditor
project={currentProject} project={currentProject}
projectPath={currentProject?.fullPath || currentProject?.path} projectPath={currentProject?.fullPath || currentProject?.path}
onClose={handleClosePrdEditor} onClose={handleClosePrdEditor}
@@ -186,6 +185,7 @@ export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
file={{ file={{
name: selectedPRD?.name || 'prd.txt', name: selectedPRD?.name || 'prd.txt',
content: selectedPRD?.content || '', content: selectedPRD?.content || '',
isExisting: selectedPRD?.isExisting,
}} }}
onSave={handlePrdSave} onSave={handlePrdSave}
/> />

View File

@@ -0,0 +1,138 @@
import { useCallback, useMemo, useState } from 'react';
import type { Project } from '../../types/app';
import { usePrdDocument } from './hooks/usePrdDocument';
import { usePrdKeyboardShortcuts } from './hooks/usePrdKeyboardShortcuts';
import { usePrdRegistry } from './hooks/usePrdRegistry';
import { usePrdSave } from './hooks/usePrdSave';
import type { PrdFile } from './types';
import { ensurePrdExtension } from './utils/fileName';
import OverwriteConfirmModal from './view/OverwriteConfirmModal';
import PrdEditorLoadingState from './view/PrdEditorLoadingState';
import PrdEditorWorkspace from './view/PrdEditorWorkspace';
type PRDEditorProps = {
file?: PrdFile | null;
onClose: () => void;
projectPath?: string;
project?: Project | null;
initialContent?: string;
isNewFile?: boolean;
onSave?: () => Promise<void> | void;
};
export default function PRDEditor({
file,
onClose,
projectPath,
project,
initialContent = '',
isNewFile = false,
onSave,
}: PRDEditorProps) {
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState<boolean>(false);
const [overwriteFileName, setOverwriteFileName] = useState<string>('');
const { content, setContent, fileName, setFileName, loading, loadError } = usePrdDocument({
file,
isNewFile,
initialContent,
projectPath,
});
const { existingPrds, refreshExistingPrds } = usePrdRegistry({
projectName: project?.name,
});
const isExistingFile = useMemo(() => !isNewFile || Boolean(file?.isExisting), [file?.isExisting, isNewFile]);
const { savePrd, saving, saveSuccess } = usePrdSave({
projectName: project?.name,
existingPrds,
isExistingFile,
onAfterSave: async () => {
await refreshExistingPrds();
await onSave?.();
},
});
const handleDownload = useCallback(() => {
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
const downloadedFileName = ensurePrdExtension(fileName || 'prd');
anchor.href = url;
anchor.download = downloadedFileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
}, [content, fileName]);
const handleSave = useCallback(
async (allowOverwrite = false) => {
const result = await savePrd({
content,
fileName,
allowOverwrite,
});
if (result.status === 'needs-overwrite') {
setOverwriteFileName(result.fileName);
setShowOverwriteConfirm(true);
return;
}
if (result.status === 'failed') {
alert(result.message);
}
},
[content, fileName, savePrd],
);
const confirmOverwrite = useCallback(async () => {
setShowOverwriteConfirm(false);
await handleSave(true);
}, [handleSave]);
usePrdKeyboardShortcuts({
onSave: () => {
void handleSave();
},
onClose,
});
if (loading) {
return <PrdEditorLoadingState />;
}
return (
<>
<PrdEditorWorkspace
content={content}
onContentChange={setContent}
fileName={fileName}
onFileNameChange={setFileName}
isNewFile={isNewFile}
saving={saving}
saveSuccess={saveSuccess}
onSave={() => {
void handleSave();
}}
onDownload={handleDownload}
onClose={onClose}
loadError={loadError}
/>
<OverwriteConfirmModal
isOpen={showOverwriteConfirm}
fileName={overwriteFileName || ensurePrdExtension(fileName || 'prd')}
saving={saving}
onCancel={() => setShowOverwriteConfirm(false)}
onConfirm={() => {
void confirmOverwrite();
}}
/>
</>
);
}

View File

@@ -0,0 +1,67 @@
export const PRD_TEMPLATE = `# Product Requirements Document
## 1. Overview
- Product Name:
- Owner:
- Last Updated:
- Version:
Describe what problem this product solves and who it serves.
## 2. Objectives
- Primary objective:
- Secondary objective:
- Out-of-scope:
## 3. User Stories
- As a <role>, I want <capability>, so that <benefit>.
- As a <role>, I want <capability>, so that <benefit>.
## 4. Functional Requirements
### Core Requirements
- Requirement 1
- Requirement 2
### Edge Cases
- Edge case 1
- Edge case 2
## 5. Non-Functional Requirements
- Performance:
- Security:
- Reliability:
- Accessibility:
## 6. Success Metrics
- Metric 1:
- Metric 2:
## 7. Technical Notes
- Architecture constraints:
- Dependencies:
- Integration points:
## 8. Release Plan
### Milestone 1
- Scope:
- Exit criteria:
### Milestone 2
- Scope:
- Exit criteria:
## 9. Risks and Mitigations
- Risk:
- Impact:
- Mitigation:
## 10. Open Questions
- Question 1
- Question 2
`;
export const PRD_DOCS_URL =
'https://github.com/eyaltoledano/claude-task-master/blob/main/docs/examples.md';
export const INVALID_FILE_NAME_CHARACTERS = /[<>:"/\\|?*]/g;
export const PRD_EXTENSION_PATTERN = /\.(txt|md)$/i;

View File

@@ -0,0 +1,132 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import { PRD_TEMPLATE } from '../constants';
import type { PrdFile } from '../types';
import { createDefaultPrdName, sanitizeFileName, stripPrdExtension } from '../utils/fileName';
type UsePrdDocumentArgs = {
file?: PrdFile | null;
isNewFile: boolean;
initialContent: string;
projectPath?: string;
};
type UsePrdDocumentResult = {
content: string;
setContent: (nextContent: string) => void;
fileName: string;
setFileName: (nextFileName: string) => void;
loading: boolean;
loadError: string | null;
};
export function usePrdDocument({
file,
isNewFile,
initialContent,
projectPath,
}: UsePrdDocumentArgs): UsePrdDocumentResult {
const [content, setContent] = useState<string>(initialContent || '');
const [fileName, setFileNameState] = useState<string>('');
const [loading, setLoading] = useState<boolean>(!isNewFile);
const [loadError, setLoadError] = useState<string | null>(null);
const setFileName = useCallback((nextFileName: string) => {
setFileNameState(sanitizeFileName(nextFileName));
}, []);
useEffect(() => {
let isMounted = true;
const initialize = async () => {
const defaultName = file?.name
? stripPrdExtension(file.name)
: createDefaultPrdName(new Date());
if (isMounted) {
setFileNameState(defaultName);
}
// Loading precedence:
// 1) new file -> initial content or template
// 2) provided content -> use it directly
// 3) legacy file path -> fetch from API
if (isNewFile) {
if (!isMounted) {
return;
}
setContent(initialContent || PRD_TEMPLATE);
setLoadError(null);
setLoading(false);
return;
}
if (file?.content) {
if (!isMounted) {
return;
}
setContent(file.content);
setLoadError(null);
setLoading(false);
return;
}
if (!file?.projectName || !file?.path) {
if (!isMounted) {
return;
}
setContent(initialContent || PRD_TEMPLATE);
setLoadError(null);
setLoading(false);
return;
}
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()) as { content?: string };
if (!isMounted) {
return;
}
setContent(data.content || PRD_TEMPLATE);
setLoadError(null);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
if (!isMounted) {
return;
}
setContent(initialContent || PRD_TEMPLATE);
setLoadError(`Unable to load file content: ${message}`);
} finally {
if (isMounted) {
setLoading(false);
}
}
};
void initialize();
return () => {
isMounted = false;
};
}, [file, initialContent, isNewFile, projectPath]);
return {
content,
setContent,
fileName,
setFileName,
loading,
loadError,
};
}

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react';
type UsePrdKeyboardShortcutsArgs = {
onSave: () => void;
onClose: () => void;
};
export function usePrdKeyboardShortcuts({
onSave,
onClose,
}: UsePrdKeyboardShortcutsArgs): void {
useEffect(() => {
// Keep shortcuts global so the editor behaves consistently in fullscreen and modal mode.
const handleKeyDown = (event: KeyboardEvent) => {
const loweredKey = event.key.toLowerCase();
if ((event.ctrlKey || event.metaKey) && loweredKey === 's') {
event.preventDefault();
onSave();
return;
}
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose, onSave]);
}

View File

@@ -0,0 +1,50 @@
import { useCallback, useEffect, useState } from 'react';
import { api } from '../../../utils/api';
import type { ExistingPrdFile, PrdListResponse } from '../types';
type UsePrdRegistryArgs = {
projectName?: string;
};
type UsePrdRegistryResult = {
existingPrds: ExistingPrdFile[];
refreshExistingPrds: () => Promise<void>;
};
function getPrdFiles(data: PrdListResponse): ExistingPrdFile[] {
return data.prdFiles || data.prds || [];
}
export function usePrdRegistry({ projectName }: UsePrdRegistryArgs): UsePrdRegistryResult {
const [existingPrds, setExistingPrds] = useState<ExistingPrdFile[]>([]);
const refreshExistingPrds = useCallback(async () => {
if (!projectName) {
setExistingPrds([]);
return;
}
try {
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(projectName)}`);
if (!response.ok) {
setExistingPrds([]);
return;
}
const data = (await response.json()) as PrdListResponse;
setExistingPrds(getPrdFiles(data));
} catch (error) {
console.error('Failed to fetch existing PRDs:', error);
setExistingPrds([]);
}
}, [projectName]);
useEffect(() => {
void refreshExistingPrds();
}, [refreshExistingPrds]);
return {
existingPrds,
refreshExistingPrds,
};
}

View File

@@ -0,0 +1,111 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { authenticatedFetch } from '../../../utils/api';
import type { ExistingPrdFile, SavePrdInput, SavePrdResult } from '../types';
import { ensurePrdExtension } from '../utils/fileName';
type UsePrdSaveArgs = {
projectName?: string;
existingPrds: ExistingPrdFile[];
isExistingFile: boolean;
onAfterSave?: () => Promise<void>;
};
type UsePrdSaveResult = {
savePrd: (input: SavePrdInput) => Promise<SavePrdResult>;
saving: boolean;
saveSuccess: boolean;
};
export function usePrdSave({
projectName,
existingPrds,
isExistingFile,
onAfterSave,
}: UsePrdSaveArgs): UsePrdSaveResult {
const [saving, setSaving] = useState<boolean>(false);
const [saveSuccess, setSaveSuccess] = useState<boolean>(false);
const saveSuccessTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (saveSuccessTimeoutRef.current) {
clearTimeout(saveSuccessTimeoutRef.current);
}
};
}, []);
const savePrd = useCallback(
async ({ content, fileName, allowOverwrite = false }: SavePrdInput): Promise<SavePrdResult> => {
if (!content.trim()) {
return { status: 'failed', message: 'Please add content before saving.' };
}
if (!fileName.trim()) {
return { status: 'failed', message: 'Please provide a filename for the PRD.' };
}
if (!projectName) {
return { status: 'failed', message: 'No project selected. Please reopen the editor.' };
}
const finalFileName = ensurePrdExtension(fileName.trim());
const hasConflict = existingPrds.some((prd) => prd.name === finalFileName);
// Overwrite confirmation is only required when creating a brand-new PRD.
if (hasConflict && !allowOverwrite && !isExistingFile) {
return { status: 'needs-overwrite', fileName: finalFileName };
}
setSaving(true);
try {
const response = await authenticatedFetch(`/api/taskmaster/prd/${encodeURIComponent(projectName)}`, {
method: 'POST',
body: JSON.stringify({
fileName: finalFileName,
content,
}),
});
if (!response.ok) {
const fallbackMessage = `Save failed: ${response.status}`;
try {
const errorData = (await response.json()) as { message?: string };
return { status: 'failed', message: errorData.message || fallbackMessage };
} catch {
return { status: 'failed', message: fallbackMessage };
}
}
if (saveSuccessTimeoutRef.current) {
clearTimeout(saveSuccessTimeoutRef.current);
}
setSaveSuccess(true);
saveSuccessTimeoutRef.current = setTimeout(() => {
setSaveSuccess(false);
saveSuccessTimeoutRef.current = null;
}, 2000);
if (onAfterSave) {
await onAfterSave();
}
return { status: 'saved', fileName: finalFileName };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return { status: 'failed', message: `Error saving PRD: ${message}` };
} finally {
setSaving(false);
}
},
[existingPrds, isExistingFile, onAfterSave, projectName],
);
return {
savePrd,
saving,
saveSuccess,
};
}

View File

@@ -0,0 +1 @@
export { default } from './PRDEditor';

View File

@@ -0,0 +1,30 @@
export type PrdFile = {
name?: string;
path?: string;
projectName?: string;
content?: string;
isExisting?: boolean;
};
export type ExistingPrdFile = {
name: string;
content?: string;
isExisting?: boolean;
[key: string]: unknown;
};
export type PrdListResponse = {
prdFiles?: ExistingPrdFile[];
prds?: ExistingPrdFile[];
};
export type SavePrdInput = {
content: string;
fileName: string;
allowOverwrite?: boolean;
};
export type SavePrdResult =
| { status: 'saved'; fileName: string }
| { status: 'needs-overwrite'; fileName: string }
| { status: 'failed'; message: string };

View File

@@ -0,0 +1,18 @@
import { INVALID_FILE_NAME_CHARACTERS, PRD_EXTENSION_PATTERN } from '../constants';
export function sanitizeFileName(value: string): string {
return value.replace(INVALID_FILE_NAME_CHARACTERS, '');
}
export function stripPrdExtension(value: string): string {
return value.replace(PRD_EXTENSION_PATTERN, '');
}
export function ensurePrdExtension(value: string): string {
return PRD_EXTENSION_PATTERN.test(value) ? value : `${value}.txt`;
}
export function createDefaultPrdName(date: Date): string {
const isoDate = date.toISOString().split('T')[0];
return `prd-${isoDate}`;
}

View File

@@ -0,0 +1,77 @@
import { Sparkles, X } from 'lucide-react';
import { PRD_DOCS_URL } from '../constants';
type GenerateTasksModalProps = {
isOpen: boolean;
fileName: string;
onClose: () => void;
};
export default function GenerateTasksModal({
isOpen,
fileName,
onClose,
}: GenerateTasksModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[300] 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">
<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={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>
<div className="p-6 space-y-4">
<div className="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 border border-purple-200 dark:border-purple-800">
<h4 className="font-semibold text-purple-900 dark:text-purple-100 mb-2">
Ask Claude Code directly
</h4>
<p className="text-sm text-purple-800 dark:text-purple-200 mb-3">
Save this PRD, then ask Claude Code in chat to parse the file and create your initial tasks.
</p>
<div className="bg-white dark:bg-gray-800 rounded border border-purple-200 dark:border-purple-700 p-3">
<p className="text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Example prompt</p>
<p className="text-xs text-gray-900 dark:text-white font-mono">
I have a PRD at .taskmaster/docs/{fileName}. Parse it and create the initial tasks.
</p>
</div>
</div>
<div className="text-center pt-4 border-t border-gray-200 dark:border-gray-700">
<a
href={PRD_DOCS_URL}
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>
<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
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { AlertTriangle, Save } from 'lucide-react';
type OverwriteConfirmModalProps = {
isOpen: boolean;
fileName: string;
saving: boolean;
onCancel: () => void;
onConfirm: () => void;
};
export default function OverwriteConfirmModal({
isOpen,
fileName,
saving,
onCancel,
onConfirm,
}: OverwriteConfirmModalProps) {
if (!isOpen) {
return null;
}
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/50" onClick={onCancel} />
<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 named "{fileName}" already exists. Do you want to overwrite it?
</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
disabled={saving}
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={onConfirm}
disabled={saving}
className="px-4 py-2 text-sm text-white bg-yellow-600 hover:bg-yellow-700 rounded-md flex items-center gap-2 transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
<span>{saving ? 'Saving...' : 'Overwrite'}</span>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useMemo } from 'react';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { EditorView } from '@codemirror/view';
import CodeMirror from '@uiw/react-codemirror';
import MarkdownPreview from '../../code-editor/view/subcomponents/markdown/MarkdownPreview';
type PrdEditorBodyProps = {
content: string;
onContentChange: (nextContent: string) => void;
previewMode: boolean;
isDarkMode: boolean;
wordWrap: boolean;
};
export default function PrdEditorBody({
content,
onContentChange,
previewMode,
isDarkMode,
wordWrap,
}: PrdEditorBodyProps) {
const extensions = useMemo(
() => [markdown(), ...(wordWrap ? [EditorView.lineWrapping] : [])],
[wordWrap],
);
if (previewMode) {
return (
<div className="h-full overflow-y-auto p-6 prose prose-gray dark:prose-invert max-w-none">
<MarkdownPreview content={content} />
</div>
);
}
return (
<CodeMirror
value={content}
onChange={onContentChange}
extensions={extensions}
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,
}}
/>
);
}

View File

@@ -0,0 +1,36 @@
import { useMemo } from 'react';
type PrdEditorFooterProps = {
content: string;
};
type ContentStats = {
lines: number;
characters: number;
words: number;
};
function getContentStats(content: string): ContentStats {
return {
lines: content.split('\n').length,
characters: content.length,
words: content.split(/\s+/).filter(Boolean).length,
};
}
export default function PrdEditorFooter({ content }: PrdEditorFooterProps) {
const stats = useMemo(() => getContentStats(content), [content]);
return (
<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: {stats.lines}</span>
<span>Characters: {stats.characters}</span>
<span>Words: {stats.words}</span>
<span>Format: Markdown</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">Press Ctrl+S to save and Esc to close</div>
</div>
);
}

View File

@@ -0,0 +1,228 @@
import { useRef } from 'react';
import type { ReactNode } from 'react';
import {
Download,
Eye,
FileText,
Maximize2,
Minimize2,
Moon,
Save,
Sparkles,
Sun,
X,
} from 'lucide-react';
import { cn } from '../../../lib/utils';
type PrdEditorHeaderProps = {
fileName: string;
onFileNameChange: (nextFileName: string) => void;
isNewFile: boolean;
previewMode: boolean;
onTogglePreview: () => void;
wordWrap: boolean;
onToggleWordWrap: () => void;
isDarkMode: boolean;
onToggleTheme: () => void;
onDownload: () => void;
onOpenGenerateTasks: () => void;
canGenerateTasks: boolean;
onSave: () => void;
saving: boolean;
saveSuccess: boolean;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onClose: () => void;
};
type HeaderIconButtonProps = {
title: string;
onClick: () => void;
icon: ReactNode;
active?: boolean;
};
function HeaderIconButton({ title, onClick, icon, active = false }: HeaderIconButtonProps) {
return (
<button
onClick={onClick}
title={title}
className={cn(
'p-2 rounded-md min-w-[44px] min-h-[44px] md:min-w-0 md:min-h-0 flex items-center justify-center transition-colors',
active
? 'text-purple-600 dark:text-purple-400 bg-purple-50 dark:bg-purple-900/50'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800',
)}
>
{icon}
</button>
);
}
export default function PrdEditorHeader({
fileName,
onFileNameChange,
isNewFile,
previewMode,
onTogglePreview,
wordWrap,
onToggleWordWrap,
isDarkMode,
onToggleTheme,
onDownload,
onOpenGenerateTasks,
canGenerateTasks,
onSave,
saving,
saveSuccess,
isFullscreen,
onToggleFullscreen,
onClose,
}: PrdEditorHeaderProps) {
const fileNameInputRef = useRef<HTMLInputElement | null>(null);
return (
<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">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 min-w-0">
<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
ref={fileNameInputRef}
type="text"
value={fileName}
onChange={(event) => onFileNameChange(event.target.value)}
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={() => fileNameInputRef.current?.focus()}
className="p-1 text-gray-400 hover:text-purple-600 dark:hover:text-purple-400 transition-colors"
title="Focus filename input"
>
<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>
<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>
<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">
<HeaderIconButton
title={previewMode ? 'Switch to edit mode' : 'Preview markdown'}
onClick={onTogglePreview}
icon={<Eye className="w-5 h-5 md:w-4 md:h-4" />}
active={previewMode}
/>
<HeaderIconButton
title={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
onClick={onToggleWordWrap}
icon={<span className="text-sm md:text-xs font-mono font-bold">WRAP</span>}
active={wordWrap}
/>
<HeaderIconButton
title="Toggle theme"
onClick={onToggleTheme}
icon={
isDarkMode ? (
<Sun className="w-5 h-5 md:w-4 md:h-4" />
) : (
<Moon className="w-5 h-5 md:w-4 md:h-4" />
)
}
/>
<HeaderIconButton
title="Download PRD"
onClick={onDownload}
icon={<Download className="w-5 h-5 md:w-4 md:h-4" />}
/>
<button
onClick={onOpenGenerateTasks}
disabled={!canGenerateTasks}
className={cn(
'px-3 py-2 rounded-md disabled:opacity-50 flex items-center gap-2 transition-colors text-sm font-medium text-white min-h-[44px] md:min-h-0',
'bg-purple-600 hover:bg-purple-700',
)}
title="Generate tasks from PRD content"
>
<Sparkles className="w-4 h-4" />
<span className="hidden md:inline">Generate Tasks</span>
</button>
<button
onClick={onSave}
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={onToggleFullscreen}
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>
<HeaderIconButton
title="Close"
onClick={onClose}
icon={<X className="w-6 h-6 md:w-4 md:h-4" />}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
export default function PrdEditorLoadingState() {
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" />
<span className="text-gray-900 dark:text-white">Loading PRD...</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { useState } from 'react';
import { cn } from '../../../lib/utils';
import { ensurePrdExtension } from '../utils/fileName';
import GenerateTasksModal from './GenerateTasksModal';
import PrdEditorBody from './PrdEditorBody';
import PrdEditorFooter from './PrdEditorFooter';
import PrdEditorHeader from './PrdEditorHeader';
type PrdEditorWorkspaceProps = {
content: string;
onContentChange: (nextContent: string) => void;
fileName: string;
onFileNameChange: (nextFileName: string) => void;
isNewFile: boolean;
saving: boolean;
saveSuccess: boolean;
onSave: () => void;
onDownload: () => void;
onClose: () => void;
loadError: string | null;
};
export default function PrdEditorWorkspace({
content,
onContentChange,
fileName,
onFileNameChange,
isNewFile,
saving,
saveSuccess,
onSave,
onDownload,
onClose,
loadError,
}: PrdEditorWorkspaceProps) {
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
const [isDarkMode, setIsDarkMode] = useState<boolean>(true);
const [previewMode, setPreviewMode] = useState<boolean>(false);
const [wordWrap, setWordWrap] = useState<boolean>(true);
const [showGenerateModal, setShowGenerateModal] = useState<boolean>(false);
const handleOpenGenerateTasks = () => {
if (!content.trim()) {
alert('Please add content to the PRD before generating tasks.');
return;
}
setShowGenerateModal(true);
};
return (
<div
className={cn(
'fixed inset-0 z-[200] md:bg-black/50 md:flex md:items-center md:justify-center',
isFullscreen ? 'md:p-0' : 'md:p-4',
)}
>
<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]',
)}
>
{loadError && (
<div className="px-4 py-3 border-b border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200 text-sm">
{loadError}
</div>
)}
<PrdEditorHeader
fileName={fileName}
onFileNameChange={onFileNameChange}
isNewFile={isNewFile}
previewMode={previewMode}
onTogglePreview={() => setPreviewMode((current) => !current)}
wordWrap={wordWrap}
onToggleWordWrap={() => setWordWrap((current) => !current)}
isDarkMode={isDarkMode}
onToggleTheme={() => setIsDarkMode((current) => !current)}
onDownload={onDownload}
onOpenGenerateTasks={handleOpenGenerateTasks}
canGenerateTasks={Boolean(content.trim())}
onSave={onSave}
saving={saving}
saveSuccess={saveSuccess}
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen((current) => !current)}
onClose={onClose}
/>
<div className="flex-1 overflow-hidden">
<PrdEditorBody
content={content}
onContentChange={onContentChange}
previewMode={previewMode}
isDarkMode={isDarkMode}
wordWrap={wordWrap}
/>
</div>
<PrdEditorFooter content={content} />
</div>
<GenerateTasksModal
isOpen={showGenerateModal}
fileName={ensurePrdExtension(fileName || 'prd')}
onClose={() => setShowGenerateModal(false)}
/>
</div>
);
}