From cdc03e754fc0807989734572d842402c4a92493e Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Sat, 7 Feb 2026 17:23:56 +0300 Subject: [PATCH] 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). --- src/components/MainContent.jsx | 713 ------------------ src/components/MainContent.tsx | 182 +++++ src/components/main-content/EditorSidebar.tsx | 60 ++ .../main-content/MainContentHeader.tsx | 38 + .../main-content/MainContentStateView.tsx | 55 ++ .../main-content/MainContentTabSwitcher.tsx | 84 +++ .../main-content/MainContentTitle.tsx | 84 +++ .../main-content/MobileMenuButton.tsx | 23 + .../main-content/TaskMasterPanel.tsx | 206 +++++ src/components/main-content/types.ts | 107 +++ src/hooks/main-content/useEditorSidebar.ts | 110 +++ .../main-content/useMobileMenuHandlers.ts | 50 ++ 12 files changed, 999 insertions(+), 713 deletions(-) delete mode 100644 src/components/MainContent.jsx create mode 100644 src/components/MainContent.tsx create mode 100644 src/components/main-content/EditorSidebar.tsx create mode 100644 src/components/main-content/MainContentHeader.tsx create mode 100644 src/components/main-content/MainContentStateView.tsx create mode 100644 src/components/main-content/MainContentTabSwitcher.tsx create mode 100644 src/components/main-content/MainContentTitle.tsx create mode 100644 src/components/main-content/MobileMenuButton.tsx create mode 100644 src/components/main-content/TaskMasterPanel.tsx create mode 100644 src/components/main-content/types.ts create mode 100644 src/hooks/main-content/useEditorSidebar.ts create mode 100644 src/hooks/main-content/useMobileMenuHandlers.ts diff --git a/src/components/MainContent.jsx b/src/components/MainContent.jsx deleted file mode 100644 index fb7002e..0000000 --- a/src/components/MainContent.jsx +++ /dev/null @@ -1,713 +0,0 @@ -/* - * MainContent.jsx - Main Content Area with Session Protection Props Passthrough - * - * SESSION PROTECTION PASSTHROUGH: - * =============================== - * - * This component serves as a passthrough layer for Session Protection functions: - * - Receives session management functions from App.jsx - * - Passes them down to ChatInterface.jsx - * - * No session protection logic is implemented here - it's purely a props bridge. - */ - -import React, { useState, useEffect, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import ChatInterface from './ChatInterface'; -import FileTree from './FileTree'; -import CodeEditor from './CodeEditor'; -import StandaloneShell from './StandaloneShell'; -import GitPanel from './GitPanel'; -import ErrorBoundary from './ErrorBoundary'; -import ClaudeLogo from './ClaudeLogo'; -import CursorLogo from './CursorLogo'; -import TaskList from './TaskList'; -import TaskDetail from './TaskDetail'; -import PRDEditor from './PRDEditor'; -import Tooltip from './Tooltip'; -import { useTaskMaster } from '../contexts/TaskMasterContext'; -import { useTasksSettings } from '../contexts/TasksSettingsContext'; -import { api } from '../utils/api'; - -import { useUiPreferences } from '../hooks/useUiPreferences'; - -function MainContent({ - selectedProject, - selectedSession, - activeTab, - setActiveTab, - ws, - sendMessage, - latestMessage, - isMobile, - onMenuClick, - isLoading, - onInputFocusChange, - // Session Protection Props: Functions passed down from App.jsx to manage active session state - // These functions control when project updates are paused during active conversations - onSessionActive, // Mark session as active when user sends message - onSessionInactive, // Mark session as inactive when conversation completes/aborts - onSessionProcessing, // Mark session as processing (thinking/working) - onSessionNotProcessing, // Mark session as not processing (finished thinking) - processingSessions, // Set of session IDs currently processing - onReplaceTemporarySession, // Replace temporary session ID with real session ID from WebSocket - onNavigateToSession, // Navigate to a specific session (for Claude CLI session duplication workaround) - onShowSettings, // Show tools settings panel - externalMessageUpdate // Trigger for external CLI updates to current session -}) { - const { t } = useTranslation(); - const [editingFile, setEditingFile] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); - const [showTaskDetail, setShowTaskDetail] = useState(false); - const [editorWidth, setEditorWidth] = useState(600); - const [isResizing, setIsResizing] = useState(false); - const [editorExpanded, setEditorExpanded] = useState(false); - const resizeRef = useRef(null); - const suppressNextMenuClickRef = useRef(false); - - const { preferences } = useUiPreferences(); - const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences; - - // PRD Editor state - const [showPRDEditor, setShowPRDEditor] = useState(false); - const [selectedPRD, setSelectedPRD] = useState(null); - const [existingPRDs, setExistingPRDs] = useState([]); - const [prdNotification, setPRDNotification] = useState(null); - - // TaskMaster context - const { tasks, currentProject, refreshTasks, setCurrentProject } = useTaskMaster(); - const { tasksEnabled, isTaskMasterInstalled, isTaskMasterReady } = useTasksSettings(); - - // Only show tasks tab if TaskMaster is installed and enabled - const shouldShowTasksTab = tasksEnabled && isTaskMasterInstalled; - - // Sync selectedProject with TaskMaster context - useEffect(() => { - if (selectedProject && selectedProject !== currentProject) { - setCurrentProject(selectedProject); - } - }, [selectedProject, currentProject, setCurrentProject]); - - // Switch away from tasks tab when tasks are disabled or TaskMaster is not installed - useEffect(() => { - if (!shouldShowTasksTab && activeTab === 'tasks') { - setActiveTab('chat'); - } - }, [shouldShowTasksTab, activeTab, setActiveTab]); - - // Load existing PRDs when current project changes - useEffect(() => { - const loadExistingPRDs = async () => { - if (!currentProject?.name) { - setExistingPRDs([]); - return; - } - - try { - const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`); - if (response.ok) { - const data = await response.json(); - setExistingPRDs(data.prdFiles || []); - } else { - setExistingPRDs([]); - } - } catch (error) { - console.error('Failed to load existing PRDs:', error); - setExistingPRDs([]); - } - }; - - loadExistingPRDs(); - }, [currentProject?.name]); - - const handleFileOpen = (filePath, diffInfo = null) => { - // Create a file object that CodeEditor expects - const file = { - name: filePath.split('/').pop(), - path: filePath, - projectName: selectedProject?.name, - diffInfo: diffInfo // Pass along diff information if available - }; - setEditingFile(file); - }; - - const handleCloseEditor = () => { - setEditingFile(null); - setEditorExpanded(false); - }; - - const handleToggleEditorExpand = () => { - setEditorExpanded(!editorExpanded); - }; - - const handleTaskClick = (task) => { - // If task is just an ID (from dependency click), find the full task object - if (typeof task === 'object' && task.id && !task.title) { - const fullTask = tasks?.find(t => t.id === task.id); - if (fullTask) { - setSelectedTask(fullTask); - setShowTaskDetail(true); - } - } else { - setSelectedTask(task); - setShowTaskDetail(true); - } - }; - - const handleTaskDetailClose = () => { - setShowTaskDetail(false); - setSelectedTask(null); - }; - - const handleTaskStatusChange = (taskId, newStatus) => { - // This would integrate with TaskMaster API to update task status - console.log('Update task status:', taskId, newStatus); - refreshTasks?.(); - }; - - const openMobileMenu = (event) => { - if (event) { - event.preventDefault(); - event.stopPropagation(); - } - - onMenuClick(); - }; - - const handleMobileMenuTouchEnd = (event) => { - suppressNextMenuClickRef.current = true; - openMobileMenu(event); - - window.setTimeout(() => { - suppressNextMenuClickRef.current = false; - }, 350); - }; - - const handleMobileMenuClick = (event) => { - if (suppressNextMenuClickRef.current) { - event.preventDefault(); - event.stopPropagation(); - return; - } - - openMobileMenu(event); - }; - - // Handle resize functionality - const handleMouseDown = (e) => { - if (isMobile) return; // Disable resize on mobile - setIsResizing(true); - e.preventDefault(); - }; - - useEffect(() => { - const handleMouseMove = (e) => { - if (!isResizing) return; - - const container = resizeRef.current?.parentElement; - if (!container) return; - - const containerRect = container.getBoundingClientRect(); - const newWidth = containerRect.right - e.clientX; - - // Min width: 300px, Max width: 80% of container - const minWidth = 300; - const maxWidth = containerRect.width * 0.8; - - if (newWidth >= minWidth && newWidth <= maxWidth) { - setEditorWidth(newWidth); - } - }; - - const handleMouseUp = () => { - setIsResizing(false); - }; - - if (isResizing) { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - } - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - }; - }, [isResizing]); - - if (isLoading) { - return ( -
- {/* Header with menu button for mobile */} - {isMobile && ( -
- -
- )} -
-
-
-
-
-

{t('mainContent.loading')}

-

{t('mainContent.settingUpWorkspace')}

-
-
-
- ); - } - - if (!selectedProject) { - return ( -
- {/* Header with menu button for mobile */} - {isMobile && ( -
- -
- )} -
-
-
- - - -
-

{t('mainContent.chooseProject')}

-

- {t('mainContent.selectProjectDescription')} -

-
-

- 💡 {t('mainContent.tip')}: {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')} -

-
-
-
-
- ); - } - - return ( -
- {/* Header with tabs */} -
-
-
- {isMobile && ( - - )} -
- {activeTab === 'chat' && selectedSession && ( -
- {selectedSession.__provider === 'cursor' ? ( - - ) : ( - - )} -
- )} -
- {activeTab === 'chat' && selectedSession ? ( -
-

- {selectedSession.__provider === 'cursor' ? (selectedSession.name || 'Untitled Session') : (selectedSession.summary || 'New Session')} -

-
- {selectedProject.displayName} -
-
- ) : activeTab === 'chat' && !selectedSession ? ( -
-

- {t('mainContent.newSession')} -

-
- {selectedProject.displayName} -
-
- ) : ( -
-

- {activeTab === 'files' ? t('mainContent.projectFiles') : - activeTab === 'git' ? t('tabs.git') : - (activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' : - 'Project'} -

-
- {selectedProject.displayName} -
-
- )} -
-
-
- - {/* Modern Tab Navigation - Right Side */} -
-
- - - - - - - - - - - - - {shouldShowTasksTab && ( - - - - )} - {/* */} -
-
-
-
- - {/* Content Area with Right Sidebar */} -
- {/* Main Content */} -
-
- - setActiveTab('tasks') : null} - /> - -
- {activeTab === 'files' && ( -
- -
- )} - {activeTab === 'shell' && ( -
- -
- )} - {activeTab === 'git' && ( -
- -
- )} - {shouldShowTasksTab && ( -
-
- { - setSelectedPRD(prd); - setShowPRDEditor(true); - }} - existingPRDs={existingPRDs} - onRefreshPRDs={(showNotification = false) => { - // Reload existing PRDs - if (currentProject?.name) { - api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`) - .then(response => response.ok ? response.json() : Promise.reject()) - .then(data => { - setExistingPRDs(data.prdFiles || []); - if (showNotification) { - setPRDNotification('PRD saved successfully!'); - setTimeout(() => setPRDNotification(null), 3000); - } - }) - .catch(error => console.error('Failed to refresh PRDs:', error)); - } - }} - /> -
-
- )} -
- {/* { - sendMessage({ - type: 'server:start', - projectPath: selectedProject?.fullPath, - script: script - }); - }} - onStopServer={() => { - sendMessage({ - type: 'server:stop', - projectPath: selectedProject?.fullPath - }); - }} - onScriptSelect={setCurrentScript} - currentScript={currentScript} - isMobile={isMobile} - serverLogs={serverLogs} - onClearLogs={() => setServerLogs([])} - /> */} -
-
- - {/* Code Editor Right Sidebar - Desktop only, Mobile uses modal */} - {editingFile && !isMobile && ( - <> - {/* Resize Handle - Hidden when expanded */} - {!editorExpanded && ( -
- {/* Visual indicator on hover */} -
-
- )} - - {/* Editor Sidebar */} -
- -
- - )} -
- - {/* Code Editor Modal for Mobile */} - {editingFile && isMobile && ( - - )} - - {/* Task Detail Modal */} - {shouldShowTasksTab && showTaskDetail && selectedTask && ( - - )} - {/* PRD Editor Modal */} - {showPRDEditor && ( - { - setShowPRDEditor(false); - setSelectedPRD(null); - }} - isNewFile={!selectedPRD?.isExisting} - file={{ - name: selectedPRD?.name || 'prd.txt', - content: selectedPRD?.content || '' - }} - onSave={async () => { - setShowPRDEditor(false); - setSelectedPRD(null); - - // Reload existing PRDs with notification - try { - const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`); - if (response.ok) { - const data = await response.json(); - setExistingPRDs(data.prdFiles || []); - setPRDNotification('PRD saved successfully!'); - setTimeout(() => setPRDNotification(null), 3000); - } - } catch (error) { - console.error('Failed to refresh PRDs:', error); - } - - refreshTasks?.(); - }} - /> - )} - {/* PRD Notification */} - {prdNotification && ( -
-
- - - - {prdNotification} -
-
- )} -
- ); -} - -export default React.memo(MainContent); diff --git a/src/components/MainContent.tsx b/src/components/MainContent.tsx new file mode 100644 index 0000000..4ea9b18 --- /dev/null +++ b/src/components/MainContent.tsx @@ -0,0 +1,182 @@ +import React, { useEffect } from 'react'; + +import ChatInterface from './ChatInterface'; +import FileTree from './FileTree'; +import StandaloneShell from './StandaloneShell'; +import GitPanel from './GitPanel'; +import ErrorBoundary from './ErrorBoundary'; + +import MainContentHeader from './main-content/MainContentHeader'; +import MainContentStateView from './main-content/MainContentStateView'; +import EditorSidebar from './main-content/EditorSidebar'; +import TaskMasterPanel from './main-content/TaskMasterPanel'; +import type { MainContentProps } from './main-content/types'; + +import { useTaskMaster } from '../contexts/TaskMasterContext'; +import { useTasksSettings } from '../contexts/TasksSettingsContext'; +import { useUiPreferences } from '../hooks/useUiPreferences'; +import { useEditorSidebar } from '../hooks/main-content/useEditorSidebar'; +import type { Project } from '../types/app'; + +const AnyChatInterface = ChatInterface as any; +const AnyStandaloneShell = StandaloneShell as any; +const AnyGitPanel = GitPanel as any; + +type TaskMasterContextValue = { + currentProject?: Project | null; + setCurrentProject?: ((project: Project) => void) | null; +}; + +type TasksSettingsContextValue = { + tasksEnabled: boolean; + isTaskMasterInstalled: boolean | null; + isTaskMasterReady: boolean | null; +}; + +function MainContent({ + selectedProject, + selectedSession, + activeTab, + setActiveTab, + ws, + sendMessage, + latestMessage, + isMobile, + onMenuClick, + isLoading, + onInputFocusChange, + onSessionActive, + onSessionInactive, + onSessionProcessing, + onSessionNotProcessing, + processingSessions, + onReplaceTemporarySession, + onNavigateToSession, + onShowSettings, + externalMessageUpdate, +}: MainContentProps) { + const { preferences } = useUiPreferences(); + const { autoExpandTools, showRawParameters, showThinking, autoScrollToBottom, sendByCtrlEnter } = preferences; + + const { currentProject, setCurrentProject } = useTaskMaster() as TaskMasterContextValue; + const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings() as TasksSettingsContextValue; + + const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled); + + const { + editingFile, + editorWidth, + editorExpanded, + resizeHandleRef, + handleFileOpen, + handleCloseEditor, + handleToggleEditorExpand, + handleResizeStart, + } = useEditorSidebar({ + selectedProject, + isMobile, + }); + + useEffect(() => { + if (selectedProject && selectedProject !== currentProject) { + setCurrentProject?.(selectedProject); + } + }, [selectedProject, currentProject, setCurrentProject]); + + useEffect(() => { + if (!shouldShowTasksTab && activeTab === 'tasks') { + setActiveTab('chat'); + } + }, [shouldShowTasksTab, activeTab, setActiveTab]); + + if (isLoading) { + return ; + } + + if (!selectedProject) { + return ; + } + + return ( +
+ + +
+
+
+ + setActiveTab('tasks') : null} + /> + +
+ + {activeTab === 'files' && ( +
+ +
+ )} + + {activeTab === 'shell' && ( +
+ +
+ )} + + {activeTab === 'git' && ( +
+ +
+ )} + + {shouldShowTasksTab && } + +
+
+ + +
+
+ ); +} + +export default React.memo(MainContent); diff --git a/src/components/main-content/EditorSidebar.tsx b/src/components/main-content/EditorSidebar.tsx new file mode 100644 index 0000000..78fe2c4 --- /dev/null +++ b/src/components/main-content/EditorSidebar.tsx @@ -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 ( + + ); + } + + return ( + <> + {!editorExpanded && ( +
+
+
+ )} + +
+ +
+ + ); +} diff --git a/src/components/main-content/MainContentHeader.tsx b/src/components/main-content/MainContentHeader.tsx new file mode 100644 index 0000000..81e3739 --- /dev/null +++ b/src/components/main-content/MainContentHeader.tsx @@ -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 ( +
+
+
+ {isMobile && } + +
+ +
+ +
+
+
+ ); +} diff --git a/src/components/main-content/MainContentStateView.tsx b/src/components/main-content/MainContentStateView.tsx new file mode 100644 index 0000000..f9b4e14 --- /dev/null +++ b/src/components/main-content/MainContentStateView.tsx @@ -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 ( +
+ {isMobile && ( +
+ +
+ )} + + {isLoading ? ( +
+
+
+
+
+

{t('mainContent.loading')}

+

{t('mainContent.settingUpWorkspace')}

+
+
+ ) : ( +
+
+
+ + + +
+

{t('mainContent.chooseProject')}

+

{t('mainContent.selectProjectDescription')}

+
+

+ {t('mainContent.tip')}: {isMobile ? t('mainContent.createProjectMobile') : t('mainContent.createProjectDesktop')} +

+
+
+
+ )} +
+ ); +} diff --git a/src/components/main-content/MainContentTabSwitcher.tsx b/src/components/main-content/MainContentTabSwitcher.tsx new file mode 100644 index 0000000..5625165 --- /dev/null +++ b/src/components/main-content/MainContentTabSwitcher.tsx @@ -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>; + 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 ( +
+ {tabs.map((tab) => ( + + + + ))} +
+ ); +} diff --git a/src/components/main-content/MainContentTitle.tsx b/src/components/main-content/MainContentTitle.tsx new file mode 100644 index 0000000..7d2773b --- /dev/null +++ b/src/components/main-content/MainContentTitle.tsx @@ -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 ( +
+ {showSessionIcon && ( +
+ {selectedSession?.__provider === 'cursor' ? ( + + ) : ( + + )} +
+ )} + +
+ {activeTab === 'chat' && selectedSession ? ( +
+

+ {getSessionTitle(selectedSession)} +

+
{selectedProject.displayName}
+
+ ) : showChatNewSession ? ( +
+

{t('mainContent.newSession')}

+
{selectedProject.displayName}
+
+ ) : ( +
+

+ {getTabTitle(activeTab, shouldShowTasksTab, t)} +

+
{selectedProject.displayName}
+
+ )} +
+
+ ); +} diff --git a/src/components/main-content/MobileMenuButton.tsx b/src/components/main-content/MobileMenuButton.tsx new file mode 100644 index 0000000..405558e --- /dev/null +++ b/src/components/main-content/MobileMenuButton.tsx @@ -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 ( + + ); +} diff --git a/src/components/main-content/TaskMasterPanel.tsx b/src/components/main-content/TaskMasterPanel.tsx new file mode 100644 index 0000000..cc7df38 --- /dev/null +++ b/src/components/main-content/TaskMasterPanel.tsx @@ -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(null); + const [showTaskDetail, setShowTaskDetail] = useState(false); + + const [showPRDEditor, setShowPRDEditor] = useState(false); + const [selectedPRD, setSelectedPRD] = useState(null); + const [existingPRDs, setExistingPRDs] = useState([]); + const [prdNotification, setPRDNotification] = useState(null); + + const prdNotificationTimeoutRef = useRef | 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 ( + <> +
+
+ { + void refreshPrds(showNotification); + }} + /> +
+
+ + {showTaskDetail && selectedTask && ( + + )} + + {showPRDEditor && ( + + )} + + {prdNotification && ( +
+
+ + + + {prdNotification} +
+
+ )} + + ); +} diff --git a/src/components/main-content/types.ts b/src/components/main-content/types.ts new file mode 100644 index 0000000..2ebcfc3 --- /dev/null +++ b/src/components/main-content/types.ts @@ -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; + 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>; + 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; + onReplaceTemporarySession: SessionLifecycleHandler; + onNavigateToSession: (targetSessionId: string) => void; + onShowSettings: () => void; + externalMessageUpdate: number; +} + +export interface MainContentHeaderProps { + activeTab: AppTab; + setActiveTab: Dispatch>; + 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; + onResizeStart: (event: MouseEvent) => void; + onCloseEditor: () => void; + onToggleEditorExpand: () => void; + projectPath?: string; +} + +export interface TaskMasterPanelProps { + isVisible: boolean; +} diff --git a/src/hooks/main-content/useEditorSidebar.ts b/src/hooks/main-content/useEditorSidebar.ts new file mode 100644 index 0000000..9ca57fe --- /dev/null +++ b/src/hooks/main-content/useEditorSidebar.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { MouseEvent as ReactMouseEvent } from 'react'; +import type { Project } from '../../types/app'; +import type { DiffInfo, EditingFile } from '../../components/main-content/types'; + +type UseEditorSidebarOptions = { + selectedProject: Project | null; + isMobile: boolean; + initialWidth?: number; +}; + +export function useEditorSidebar({ + selectedProject, + isMobile, + initialWidth = 600, +}: UseEditorSidebarOptions) { + const [editingFile, setEditingFile] = useState(null); + const [editorWidth, setEditorWidth] = useState(initialWidth); + const [editorExpanded, setEditorExpanded] = useState(false); + const [isResizing, setIsResizing] = useState(false); + const resizeHandleRef = useRef(null); + + const handleFileOpen = useCallback( + (filePath: string, diffInfo: DiffInfo | null = null) => { + const normalizedPath = filePath.replace(/\\/g, '/'); + const fileName = normalizedPath.split('/').pop() || filePath; + + setEditingFile({ + name: fileName, + path: filePath, + projectName: selectedProject?.name, + diffInfo, + }); + }, + [selectedProject?.name], + ); + + const handleCloseEditor = useCallback(() => { + setEditingFile(null); + setEditorExpanded(false); + }, []); + + const handleToggleEditorExpand = useCallback(() => { + setEditorExpanded((prev) => !prev); + }, []); + + const handleResizeStart = useCallback( + (event: ReactMouseEvent) => { + if (isMobile) { + return; + } + + setIsResizing(true); + event.preventDefault(); + }, + [isMobile], + ); + + useEffect(() => { + const handleMouseMove = (event: globalThis.MouseEvent) => { + if (!isResizing) { + return; + } + + const container = resizeHandleRef.current?.parentElement; + if (!container) { + return; + } + + const containerRect = container.getBoundingClientRect(); + const newWidth = containerRect.right - event.clientX; + + const minWidth = 300; + const maxWidth = containerRect.width * 0.8; + + if (newWidth >= minWidth && newWidth <= maxWidth) { + setEditorWidth(newWidth); + } + }; + + const handleMouseUp = () => { + setIsResizing(false); + }; + + if (isResizing) { + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + } + + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + }; + }, [isResizing]); + + return { + editingFile, + editorWidth, + editorExpanded, + resizeHandleRef, + handleFileOpen, + handleCloseEditor, + handleToggleEditorExpand, + handleResizeStart, + }; +} diff --git a/src/hooks/main-content/useMobileMenuHandlers.ts b/src/hooks/main-content/useMobileMenuHandlers.ts new file mode 100644 index 0000000..d9472b5 --- /dev/null +++ b/src/hooks/main-content/useMobileMenuHandlers.ts @@ -0,0 +1,50 @@ +import { useCallback, useRef } from 'react'; +import type { MouseEvent, TouchEvent } from 'react'; + +type MenuEvent = MouseEvent | TouchEvent; + +export function useMobileMenuHandlers(onMenuClick: () => void) { + const suppressNextMenuClickRef = useRef(false); + + const openMobileMenu = useCallback( + (event?: MenuEvent) => { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + + onMenuClick(); + }, + [onMenuClick], + ); + + const handleMobileMenuTouchEnd = useCallback( + (event: TouchEvent) => { + suppressNextMenuClickRef.current = true; + openMobileMenu(event); + + window.setTimeout(() => { + suppressNextMenuClickRef.current = false; + }, 350); + }, + [openMobileMenu], + ); + + const handleMobileMenuClick = useCallback( + (event: MouseEvent) => { + if (suppressNextMenuClickRef.current) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + openMobileMenu(event); + }, + [openMobileMenu], + ); + + return { + handleMobileMenuClick, + handleMobileMenuTouchEnd, + }; +}