Files
claudecodeui/src/components/main-content/view/subcomponents/TaskMasterPanel.tsx
Haileyesus 92032a19ee 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
2026-03-02 17:04:02 +03:00

207 lines
6.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import TaskList from '../../../TaskList';
import TaskDetail from '../../../TaskDetail';
import PRDEditor from '../../../PRDEditor';
import { useTaskMaster } from '../../../../contexts/TaskMasterContext';
import { api } from '../../../../utils/api';
import type { Project } from '../../../../types/app';
import type { PrdFile, TaskMasterPanelProps, TaskMasterTask, TaskSelection } from '../../types/types';
const AnyTaskList = TaskList as any;
const AnyTaskDetail = TaskDetail as any;
type TaskMasterContextValue = {
tasks?: TaskMasterTask[];
currentProject?: Project | null;
refreshTasks?: (() => void) | null;
};
type PrdListResponse = {
prdFiles?: PrdFile[];
prds?: PrdFile[];
};
const PRD_SAVED_MESSAGE = 'PRD saved successfully!';
function getPrdFiles(data: PrdListResponse): PrdFile[] {
return data.prdFiles || data.prds || [];
}
export default function TaskMasterPanel({ isVisible }: TaskMasterPanelProps) {
const { tasks = [], currentProject, refreshTasks } = useTaskMaster() as TaskMasterContextValue;
const [selectedTask, setSelectedTask] = useState<TaskMasterTask | null>(null);
const [showTaskDetail, setShowTaskDetail] = useState(false);
const [showPRDEditor, setShowPRDEditor] = useState(false);
const [selectedPRD, setSelectedPRD] = useState<PrdFile | null>(null);
const [existingPRDs, setExistingPRDs] = useState<PrdFile[]>([]);
const [prdNotification, setPRDNotification] = useState<string | null>(null);
const prdNotificationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showPrdNotification = useCallback((message: string) => {
if (prdNotificationTimeoutRef.current) {
clearTimeout(prdNotificationTimeoutRef.current);
}
setPRDNotification(message);
prdNotificationTimeoutRef.current = setTimeout(() => {
setPRDNotification(null);
prdNotificationTimeoutRef.current = null;
}, 3000);
}, []);
const loadExistingPrds = useCallback(async () => {
if (!currentProject?.name) {
setExistingPRDs([]);
return;
}
try {
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
if (!response.ok) {
setExistingPRDs([]);
return;
}
const data = (await response.json()) as PrdListResponse;
setExistingPRDs(getPrdFiles(data));
} catch (error) {
console.error('Failed to load existing PRDs:', error);
setExistingPRDs([]);
}
}, [currentProject?.name]);
const refreshPrds = useCallback(
async (showNotification = false) => {
await loadExistingPrds();
if (showNotification) {
showPrdNotification(PRD_SAVED_MESSAGE);
}
},
[loadExistingPrds, showPrdNotification],
);
useEffect(() => {
void loadExistingPrds();
}, [loadExistingPrds]);
useEffect(() => {
return () => {
if (prdNotificationTimeoutRef.current) {
clearTimeout(prdNotificationTimeoutRef.current);
}
};
}, []);
const handleTaskClick = useCallback(
(task: TaskSelection) => {
if (!task || typeof task !== 'object' || !('id' in task)) {
return;
}
if (!('title' in task) || !task.title) {
const fullTask = tasks.find((candidate) => String(candidate.id) === String(task.id));
if (fullTask) {
setSelectedTask(fullTask);
setShowTaskDetail(true);
}
return;
}
setSelectedTask(task as TaskMasterTask);
setShowTaskDetail(true);
},
[tasks],
);
const handleTaskDetailClose = useCallback(() => {
setShowTaskDetail(false);
setSelectedTask(null);
}, []);
const handleTaskStatusChange = useCallback(
(taskId: string | number, newStatus: string) => {
console.log('Update task status:', taskId, newStatus);
refreshTasks?.();
},
[refreshTasks],
);
const handleOpenPrdEditor = useCallback((prd: PrdFile | null = null) => {
setSelectedPRD(prd);
setShowPRDEditor(true);
}, []);
const handleClosePrdEditor = useCallback(() => {
setShowPRDEditor(false);
setSelectedPRD(null);
}, []);
const handlePrdSave = useCallback(async () => {
handleClosePrdEditor();
await refreshPrds(true);
refreshTasks?.();
}, [handleClosePrdEditor, refreshPrds, refreshTasks]);
return (
<>
<div className={`h-full ${isVisible ? 'block' : 'hidden'}`}>
<div className="h-full flex flex-col overflow-hidden">
<AnyTaskList
tasks={tasks}
onTaskClick={handleTaskClick}
showParentTasks
className="flex-1 overflow-y-auto p-4"
currentProject={currentProject}
onTaskCreated={refreshTasks || undefined}
onShowPRDEditor={handleOpenPrdEditor}
existingPRDs={existingPRDs}
onRefreshPRDs={(showNotification = false) => {
void refreshPrds(showNotification);
}}
/>
</div>
</div>
{showTaskDetail && selectedTask && (
<AnyTaskDetail
task={selectedTask}
isOpen={showTaskDetail}
onClose={handleTaskDetailClose}
onStatusChange={handleTaskStatusChange}
onTaskClick={handleTaskClick}
/>
)}
{showPRDEditor && (
<PRDEditor
project={currentProject}
projectPath={currentProject?.fullPath || currentProject?.path}
onClose={handleClosePrdEditor}
isNewFile={!selectedPRD?.isExisting}
file={{
name: selectedPRD?.name || 'prd.txt',
content: selectedPRD?.content || '',
isExisting: selectedPRD?.isExisting,
}}
onSave={handlePrdSave}
/>
)}
{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>
)}
</>
);
}