mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-16 05:37:31 +00:00
refactor(main-content): migrate MainContent to TypeScript and modularize UI/state boundaries
Replace the previous monolithic MainContent.jsx with a typed one and extract focused subcomponents/hooks to improve readability, local state ownership, and maintainability while keeping runtime behavior unchanged. Key changes: - Replace `src/components/MainContent.jsx` with `src/components/MainContent.tsx`. - Add typed contracts for main-content domain in `src/components/main-content/types.ts`. - Extract header composition into: - `MainContentHeader.tsx` - `MainContentTitle.tsx` - `MainContentTabSwitcher.tsx` - `MobileMenuButton.tsx` - Extract loading/empty project views into `MainContentStateView.tsx`. - Extract editor presentation into `EditorSidebar.tsx`. - Move editor file-open + resize behavior into `useEditorSidebar.ts`. - Move mobile menu touch/click suppression logic into `useMobileMenuHandlers.ts`. - Extract TaskMaster-specific concerns into `TaskMasterPanel.tsx`: - task detail modal state - PRD editor modal state - PRD list loading/refresh - PRD save notification lifecycle Behavior/compatibility notes: - Preserve existing tab behavior, session passthrough props, and Chat/Git/File flows. - Keep interop with existing JS components via boundary `as any` casts where needed. - No intentional functional changes; this commit is structural/type-oriented refactor. Validation: - `npm run typecheck` passes. - `npm run build` passes (existing unrelated CSS minify warnings remain).
This commit is contained in:
60
src/components/main-content/EditorSidebar.tsx
Normal file
60
src/components/main-content/EditorSidebar.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import CodeEditor from '../CodeEditor';
|
||||
import type { EditorSidebarProps } from './types';
|
||||
|
||||
const AnyCodeEditor = CodeEditor as any;
|
||||
|
||||
export default function EditorSidebar({
|
||||
editingFile,
|
||||
isMobile,
|
||||
editorExpanded,
|
||||
editorWidth,
|
||||
resizeHandleRef,
|
||||
onResizeStart,
|
||||
onCloseEditor,
|
||||
onToggleEditorExpand,
|
||||
projectPath,
|
||||
}: EditorSidebarProps) {
|
||||
if (!editingFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<AnyCodeEditor
|
||||
file={editingFile}
|
||||
onClose={onCloseEditor}
|
||||
projectPath={projectPath}
|
||||
isSidebar={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!editorExpanded && (
|
||||
<div
|
||||
ref={resizeHandleRef}
|
||||
onMouseDown={onResizeStart}
|
||||
className="flex-shrink-0 w-1 bg-gray-200 dark:bg-gray-700 hover:bg-blue-500 dark:hover:bg-blue-600 cursor-col-resize transition-colors relative group"
|
||||
title="Drag to resize"
|
||||
>
|
||||
<div className="absolute inset-y-0 left-1/2 -translate-x-1/2 w-1 bg-blue-500 dark:bg-blue-600 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${editorExpanded ? 'flex-1' : ''}`}
|
||||
style={editorExpanded ? undefined : { width: `${editorWidth}px` }}
|
||||
>
|
||||
<AnyCodeEditor
|
||||
file={editingFile}
|
||||
onClose={onCloseEditor}
|
||||
projectPath={projectPath}
|
||||
isSidebar
|
||||
isExpanded={editorExpanded}
|
||||
onToggleExpand={onToggleEditorExpand}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/components/main-content/MainContentHeader.tsx
Normal file
38
src/components/main-content/MainContentHeader.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import MobileMenuButton from './MobileMenuButton';
|
||||
import MainContentTabSwitcher from './MainContentTabSwitcher';
|
||||
import MainContentTitle from './MainContentTitle';
|
||||
import type { MainContentHeaderProps } from './types';
|
||||
|
||||
export default function MainContentHeader({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
shouldShowTasksTab,
|
||||
isMobile,
|
||||
onMenuClick,
|
||||
}: MainContentHeaderProps) {
|
||||
return (
|
||||
<div className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0">
|
||||
<div className="flex items-center justify-between relative">
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
{isMobile && <MobileMenuButton onMenuClick={onMenuClick} />}
|
||||
<MainContentTitle
|
||||
activeTab={activeTab}
|
||||
selectedProject={selectedProject}
|
||||
selectedSession={selectedSession}
|
||||
shouldShowTasksTab={shouldShowTasksTab}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 hidden sm:block">
|
||||
<MainContentTabSwitcher
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
shouldShowTasksTab={shouldShowTasksTab}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
src/components/main-content/MainContentStateView.tsx
Normal file
55
src/components/main-content/MainContentStateView.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MobileMenuButton from './MobileMenuButton';
|
||||
import type { MainContentStateViewProps } from './types';
|
||||
|
||||
export default function MainContentStateView({ mode, isMobile, onMenuClick }: MainContentStateViewProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isLoading = mode === 'loading';
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{isMobile && (
|
||||
<div className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0">
|
||||
<MobileMenuButton onMenuClick={onMenuClick} compact />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400">
|
||||
<div className="w-12 h-12 mx-auto mb-4">
|
||||
<div
|
||||
className="w-full h-full rounded-full border-4 border-gray-200 border-t-blue-500"
|
||||
style={{
|
||||
animation: 'spin 1s linear infinite',
|
||||
WebkitAnimation: 'spin 1s linear infinite',
|
||||
MozAnimation: 'spin 1s linear infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">{t('mainContent.loading')}</h2>
|
||||
<p>{t('mainContent.settingUpWorkspace')}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 max-w-md mx-auto px-6">
|
||||
<div className="w-16 h-16 mx-auto mb-6 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="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-white">{t('mainContent.chooseProject')}</h2>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-6 leading-relaxed">{t('mainContent.selectProjectDescription')}</p>
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 border border-blue-200 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
<strong>{t('mainContent.tip')}:</strong> {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/main-content/MainContentTabSwitcher.tsx
Normal file
84
src/components/main-content/MainContentTabSwitcher.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import Tooltip from '../Tooltip';
|
||||
import type { AppTab } from '../../types/app';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type MainContentTabSwitcherProps = {
|
||||
activeTab: AppTab;
|
||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||
shouldShowTasksTab: boolean;
|
||||
};
|
||||
|
||||
type TabDefinition = {
|
||||
id: AppTab;
|
||||
labelKey: string;
|
||||
iconPath: string;
|
||||
};
|
||||
|
||||
const BASE_TABS: TabDefinition[] = [
|
||||
{
|
||||
id: 'chat',
|
||||
labelKey: 'tabs.chat',
|
||||
iconPath:
|
||||
'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',
|
||||
},
|
||||
{
|
||||
id: 'shell',
|
||||
labelKey: 'tabs.shell',
|
||||
iconPath: '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',
|
||||
},
|
||||
{
|
||||
id: 'files',
|
||||
labelKey: 'tabs.files',
|
||||
iconPath: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z',
|
||||
},
|
||||
{
|
||||
id: 'git',
|
||||
labelKey: 'tabs.git',
|
||||
iconPath: 'M13 10V3L4 14h7v7l9-11h-7z',
|
||||
},
|
||||
];
|
||||
|
||||
const TASKS_TAB: TabDefinition = {
|
||||
id: 'tasks',
|
||||
labelKey: 'tabs.tasks',
|
||||
iconPath:
|
||||
'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',
|
||||
};
|
||||
|
||||
function getButtonClasses(tabId: AppTab, activeTab: AppTab) {
|
||||
const base = 'relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200';
|
||||
|
||||
if (tabId === activeTab) {
|
||||
return `${base} bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm`;
|
||||
}
|
||||
|
||||
return `${base} text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700`;
|
||||
}
|
||||
|
||||
export default function MainContentTabSwitcher({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
shouldShowTasksTab,
|
||||
}: MainContentTabSwitcherProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const tabs = shouldShowTasksTab ? [...BASE_TABS, TASKS_TAB] : BASE_TABS;
|
||||
|
||||
return (
|
||||
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
||||
{tabs.map((tab) => (
|
||||
<Tooltip key={tab.id} content={t(tab.labelKey)} position="bottom">
|
||||
<button onClick={() => setActiveTab(tab.id)} className={getButtonClasses(tab.id, activeTab)}>
|
||||
<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={tab.iconPath} />
|
||||
</svg>
|
||||
<span className="hidden md:hidden lg:inline">{t(tab.labelKey)}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
84
src/components/main-content/MainContentTitle.tsx
Normal file
84
src/components/main-content/MainContentTitle.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import ClaudeLogo from '../ClaudeLogo';
|
||||
import CursorLogo from '../CursorLogo';
|
||||
import type { AppTab, Project, ProjectSession } from '../../types/app';
|
||||
|
||||
type MainContentTitleProps = {
|
||||
activeTab: AppTab;
|
||||
selectedProject: Project;
|
||||
selectedSession: ProjectSession | null;
|
||||
shouldShowTasksTab: boolean;
|
||||
};
|
||||
|
||||
function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string) {
|
||||
if (activeTab === 'files') {
|
||||
return t('mainContent.projectFiles');
|
||||
}
|
||||
|
||||
if (activeTab === 'git') {
|
||||
return t('tabs.git');
|
||||
}
|
||||
|
||||
if (activeTab === 'tasks' && shouldShowTasksTab) {
|
||||
return 'TaskMaster';
|
||||
}
|
||||
|
||||
return 'Project';
|
||||
}
|
||||
|
||||
function getSessionTitle(session: ProjectSession): string {
|
||||
if (session.__provider === 'cursor') {
|
||||
return (session.name as string) || 'Untitled Session';
|
||||
}
|
||||
|
||||
return (session.summary as string) || 'New Session';
|
||||
}
|
||||
|
||||
export default function MainContentTitle({
|
||||
activeTab,
|
||||
selectedProject,
|
||||
selectedSession,
|
||||
shouldShowTasksTab,
|
||||
}: MainContentTitleProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession);
|
||||
const showChatNewSession = activeTab === 'chat' && !selectedSession;
|
||||
|
||||
return (
|
||||
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-x-auto scrollbar-hide">
|
||||
{showSessionIcon && (
|
||||
<div className="w-5 h-5 flex-shrink-0 flex items-center justify-center">
|
||||
{selectedSession?.__provider === 'cursor' ? (
|
||||
<CursorLogo className="w-4 h-4" />
|
||||
) : (
|
||||
<ClaudeLogo className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{activeTab === 'chat' && selectedSession ? (
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white whitespace-nowrap overflow-x-auto scrollbar-hide">
|
||||
{getSessionTitle(selectedSession)}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{selectedProject.displayName}</div>
|
||||
</div>
|
||||
) : showChatNewSession ? (
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">{t('mainContent.newSession')}</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{selectedProject.displayName}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-gray-900 dark:text-white">
|
||||
{getTabTitle(activeTab, shouldShowTasksTab, t)}
|
||||
</h2>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">{selectedProject.displayName}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/components/main-content/MobileMenuButton.tsx
Normal file
23
src/components/main-content/MobileMenuButton.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { MobileMenuButtonProps } from './types';
|
||||
import { useMobileMenuHandlers } from '../../hooks/main-content/useMobileMenuHandlers';
|
||||
|
||||
export default function MobileMenuButton({ onMenuClick, compact = false }: MobileMenuButtonProps) {
|
||||
const { handleMobileMenuClick, handleMobileMenuTouchEnd } = useMobileMenuHandlers(onMenuClick);
|
||||
|
||||
const buttonClasses = compact
|
||||
? '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-700 pwa-menu-button'
|
||||
: '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-700 touch-manipulation active:scale-95 pwa-menu-button flex-shrink-0';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleMobileMenuClick}
|
||||
onTouchEnd={handleMobileMenuTouchEnd}
|
||||
className={buttonClasses}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
206
src/components/main-content/TaskMasterPanel.tsx
Normal file
206
src/components/main-content/TaskMasterPanel.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
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';
|
||||
|
||||
const AnyTaskList = TaskList as any;
|
||||
const AnyTaskDetail = TaskDetail as any;
|
||||
const AnyPRDEditor = PRDEditor 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 && (
|
||||
<AnyPRDEditor
|
||||
project={currentProject}
|
||||
projectPath={currentProject?.fullPath || currentProject?.path}
|
||||
onClose={handleClosePrdEditor}
|
||||
isNewFile={!selectedPRD?.isExisting}
|
||||
file={{
|
||||
name: selectedPRD?.name || 'prd.txt',
|
||||
content: selectedPRD?.content || '',
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
107
src/components/main-content/types.ts
Normal file
107
src/components/main-content/types.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { Dispatch, MouseEvent, RefObject, SetStateAction } from 'react';
|
||||
import type { AppTab, Project, ProjectSession } from '../../types/app';
|
||||
|
||||
export type SessionLifecycleHandler = (sessionId?: string | null) => void;
|
||||
|
||||
export interface DiffInfo {
|
||||
old_string?: string;
|
||||
new_string?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface EditingFile {
|
||||
name: string;
|
||||
path: string;
|
||||
projectName?: string;
|
||||
diffInfo?: DiffInfo | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TaskMasterTask {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
details?: string;
|
||||
testStrategy?: string;
|
||||
parentId?: string | number;
|
||||
dependencies?: Array<string | number>;
|
||||
subtasks?: TaskMasterTask[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TaskReference {
|
||||
id: string | number;
|
||||
title?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type TaskSelection = TaskMasterTask | TaskReference;
|
||||
|
||||
export interface PrdFile {
|
||||
name: string;
|
||||
content?: string;
|
||||
isExisting?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface MainContentProps {
|
||||
selectedProject: Project | null;
|
||||
selectedSession: ProjectSession | null;
|
||||
activeTab: AppTab;
|
||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||
ws: WebSocket | null;
|
||||
sendMessage: (message: unknown) => void;
|
||||
latestMessage: unknown;
|
||||
isMobile: boolean;
|
||||
onMenuClick: () => void;
|
||||
isLoading: boolean;
|
||||
onInputFocusChange: (focused: boolean) => void;
|
||||
onSessionActive: SessionLifecycleHandler;
|
||||
onSessionInactive: SessionLifecycleHandler;
|
||||
onSessionProcessing: SessionLifecycleHandler;
|
||||
onSessionNotProcessing: SessionLifecycleHandler;
|
||||
processingSessions: Set<string>;
|
||||
onReplaceTemporarySession: SessionLifecycleHandler;
|
||||
onNavigateToSession: (targetSessionId: string) => void;
|
||||
onShowSettings: () => void;
|
||||
externalMessageUpdate: number;
|
||||
}
|
||||
|
||||
export interface MainContentHeaderProps {
|
||||
activeTab: AppTab;
|
||||
setActiveTab: Dispatch<SetStateAction<AppTab>>;
|
||||
selectedProject: Project;
|
||||
selectedSession: ProjectSession | null;
|
||||
shouldShowTasksTab: boolean;
|
||||
isMobile: boolean;
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export interface MainContentStateViewProps {
|
||||
mode: 'loading' | 'empty';
|
||||
isMobile: boolean;
|
||||
onMenuClick: () => void;
|
||||
}
|
||||
|
||||
export interface MobileMenuButtonProps {
|
||||
onMenuClick: () => void;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export interface EditorSidebarProps {
|
||||
editingFile: EditingFile | null;
|
||||
isMobile: boolean;
|
||||
editorExpanded: boolean;
|
||||
editorWidth: number;
|
||||
resizeHandleRef: RefObject<HTMLDivElement>;
|
||||
onResizeStart: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
onCloseEditor: () => void;
|
||||
onToggleEditorExpand: () => void;
|
||||
projectPath?: string;
|
||||
}
|
||||
|
||||
export interface TaskMasterPanelProps {
|
||||
isVisible: boolean;
|
||||
}
|
||||
Reference in New Issue
Block a user