mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-02-14 12:47:33 +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:
@@ -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 (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
{/* Header with menu button for mobile */}
|
|
||||||
{isMobile && (
|
|
||||||
<div
|
|
||||||
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={handleMobileMenuClick}
|
|
||||||
onTouchEnd={handleMobileMenuTouchEnd}
|
|
||||||
className="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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedProject) {
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
{/* Header with menu button for mobile */}
|
|
||||||
{isMobile && (
|
|
||||||
<div
|
|
||||||
className="bg-background border-b border-border p-2 sm:p-3 pwa-header-safe flex-shrink-0"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={handleMobileMenuClick}
|
|
||||||
onTouchEnd={handleMobileMenuTouchEnd}
|
|
||||||
className="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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col">
|
|
||||||
{/* Header with tabs */}
|
|
||||||
<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 && (
|
|
||||||
<button
|
|
||||||
onClick={handleMobileMenuClick}
|
|
||||||
onTouchEnd={handleMobileMenuTouchEnd}
|
|
||||||
className="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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-x-auto scrollbar-hide">
|
|
||||||
{activeTab === 'chat' && selectedSession && (
|
|
||||||
<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">
|
|
||||||
{selectedSession.__provider === 'cursor' ? (selectedSession.name || 'Untitled Session') : (selectedSession.summary || 'New Session')}
|
|
||||||
</h2>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
||||||
{selectedProject.displayName}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : activeTab === 'chat' && !selectedSession ? (
|
|
||||||
<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">
|
|
||||||
{activeTab === 'files' ? t('mainContent.projectFiles') :
|
|
||||||
activeTab === 'git' ? t('tabs.git') :
|
|
||||||
(activeTab === 'tasks' && shouldShowTasksTab) ? 'TaskMaster' :
|
|
||||||
'Project'}
|
|
||||||
</h2>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
||||||
{selectedProject.displayName}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Modern Tab Navigation - Right Side */}
|
|
||||||
<div className="flex-shrink-0 hidden sm:block">
|
|
||||||
<div className="relative flex bg-gray-100 dark:bg-gray-800 rounded-lg p-1">
|
|
||||||
<Tooltip content={t('tabs.chat')} position="bottom">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('chat')}
|
|
||||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md ${
|
|
||||||
activeTab === 'chat'
|
|
||||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
|
||||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden md:hidden lg:inline">{t('tabs.chat')}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip content={t('tabs.shell')} position="bottom">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('shell')}
|
|
||||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
|
||||||
activeTab === 'shell'
|
|
||||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
|
||||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden md:hidden lg:inline">{t('tabs.shell')}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip content={t('tabs.files')} position="bottom">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('files')}
|
|
||||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
|
||||||
activeTab === 'files'
|
|
||||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
|
||||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-5l-2-2H5a2 2 0 00-2 2z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden md:hidden lg:inline">{t('tabs.files')}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip content={t('tabs.git')} position="bottom">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('git')}
|
|
||||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
|
||||||
activeTab === 'git'
|
|
||||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
|
||||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden md:hidden lg:inline">{t('tabs.git')}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
{shouldShowTasksTab && (
|
|
||||||
<Tooltip content={t('tabs.tasks')} position="bottom">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('tasks')}
|
|
||||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
|
||||||
activeTab === 'tasks'
|
|
||||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
|
||||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden md:hidden lg:inline">{t('tabs.tasks')}</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
{/* <button
|
|
||||||
onClick={() => setActiveTab('preview')}
|
|
||||||
className={`relative px-2 sm:px-3 py-1.5 text-xs sm:text-sm font-medium rounded-md transition-all duration-200 ${
|
|
||||||
activeTab === 'preview'
|
|
||||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow-sm'
|
|
||||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1 sm:gap-1.5">
|
|
||||||
<svg className="w-3 sm:w-3.5 h-3 sm:h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
|
||||||
</svg>
|
|
||||||
<span className="hidden sm:inline">Preview</span>
|
|
||||||
</span>
|
|
||||||
</button> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Area with Right Sidebar */}
|
|
||||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className={`flex-1 flex flex-col min-h-0 overflow-hidden ${editingFile ? 'mr-0' : ''} ${editorExpanded ? 'hidden' : ''}`}>
|
|
||||||
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
|
|
||||||
<ErrorBoundary showDetails={true}>
|
|
||||||
<ChatInterface
|
|
||||||
selectedProject={selectedProject}
|
|
||||||
selectedSession={selectedSession}
|
|
||||||
ws={ws}
|
|
||||||
sendMessage={sendMessage}
|
|
||||||
latestMessage={latestMessage}
|
|
||||||
onFileOpen={handleFileOpen}
|
|
||||||
onInputFocusChange={onInputFocusChange}
|
|
||||||
onSessionActive={onSessionActive}
|
|
||||||
onSessionInactive={onSessionInactive}
|
|
||||||
onSessionProcessing={onSessionProcessing}
|
|
||||||
onSessionNotProcessing={onSessionNotProcessing}
|
|
||||||
processingSessions={processingSessions}
|
|
||||||
onReplaceTemporarySession={onReplaceTemporarySession}
|
|
||||||
onNavigateToSession={onNavigateToSession}
|
|
||||||
onShowSettings={onShowSettings}
|
|
||||||
autoExpandTools={autoExpandTools}
|
|
||||||
showRawParameters={showRawParameters}
|
|
||||||
showThinking={showThinking}
|
|
||||||
autoScrollToBottom={autoScrollToBottom}
|
|
||||||
sendByCtrlEnter={sendByCtrlEnter}
|
|
||||||
externalMessageUpdate={externalMessageUpdate}
|
|
||||||
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}
|
|
||||||
/>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</div>
|
|
||||||
{activeTab === 'files' && (
|
|
||||||
<div className="h-full overflow-hidden">
|
|
||||||
<FileTree selectedProject={selectedProject} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeTab === 'shell' && (
|
|
||||||
<div className="h-full w-full overflow-hidden">
|
|
||||||
<StandaloneShell
|
|
||||||
project={selectedProject}
|
|
||||||
session={selectedSession}
|
|
||||||
showHeader={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{activeTab === 'git' && (
|
|
||||||
<div className="h-full overflow-hidden">
|
|
||||||
<GitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{shouldShowTasksTab && (
|
|
||||||
<div className={`h-full ${activeTab === 'tasks' ? 'block' : 'hidden'}`}>
|
|
||||||
<div className="h-full flex flex-col overflow-hidden">
|
|
||||||
<TaskList
|
|
||||||
tasks={tasks || []}
|
|
||||||
onTaskClick={handleTaskClick}
|
|
||||||
showParentTasks={true}
|
|
||||||
className="flex-1 overflow-y-auto p-4"
|
|
||||||
currentProject={currentProject}
|
|
||||||
onTaskCreated={refreshTasks}
|
|
||||||
onShowPRDEditor={(prd = null) => {
|
|
||||||
setSelectedPRD(prd);
|
|
||||||
setShowPRDEditor(true);
|
|
||||||
}}
|
|
||||||
existingPRDs={existingPRDs}
|
|
||||||
onRefreshPRDs={(showNotification = false) => {
|
|
||||||
// Reload existing PRDs
|
|
||||||
if (currentProject?.name) {
|
|
||||||
api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`)
|
|
||||||
.then(response => response.ok ? response.json() : Promise.reject())
|
|
||||||
.then(data => {
|
|
||||||
setExistingPRDs(data.prdFiles || []);
|
|
||||||
if (showNotification) {
|
|
||||||
setPRDNotification('PRD saved successfully!');
|
|
||||||
setTimeout(() => setPRDNotification(null), 3000);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => console.error('Failed to refresh PRDs:', error));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`}>
|
|
||||||
{/* <LivePreviewPanel
|
|
||||||
selectedProject={selectedProject}
|
|
||||||
serverStatus={serverStatus}
|
|
||||||
serverUrl={serverUrl}
|
|
||||||
availableScripts={availableScripts}
|
|
||||||
onStartServer={(script) => {
|
|
||||||
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([])}
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Code Editor Right Sidebar - Desktop only, Mobile uses modal */}
|
|
||||||
{editingFile && !isMobile && (
|
|
||||||
<>
|
|
||||||
{/* Resize Handle - Hidden when expanded */}
|
|
||||||
{!editorExpanded && (
|
|
||||||
<div
|
|
||||||
ref={resizeRef}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
{/* Visual indicator on hover */}
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Editor Sidebar */}
|
|
||||||
<div
|
|
||||||
className={`flex-shrink-0 border-l border-gray-200 dark:border-gray-700 h-full overflow-hidden ${editorExpanded ? 'flex-1' : ''}`}
|
|
||||||
style={editorExpanded ? {} : { width: `${editorWidth}px` }}
|
|
||||||
>
|
|
||||||
<CodeEditor
|
|
||||||
file={editingFile}
|
|
||||||
onClose={handleCloseEditor}
|
|
||||||
projectPath={selectedProject?.path}
|
|
||||||
isSidebar={true}
|
|
||||||
isExpanded={editorExpanded}
|
|
||||||
onToggleExpand={handleToggleEditorExpand}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Code Editor Modal for Mobile */}
|
|
||||||
{editingFile && isMobile && (
|
|
||||||
<CodeEditor
|
|
||||||
file={editingFile}
|
|
||||||
onClose={handleCloseEditor}
|
|
||||||
projectPath={selectedProject?.path}
|
|
||||||
isSidebar={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Task Detail Modal */}
|
|
||||||
{shouldShowTasksTab && showTaskDetail && selectedTask && (
|
|
||||||
<TaskDetail
|
|
||||||
task={selectedTask}
|
|
||||||
isOpen={showTaskDetail}
|
|
||||||
onClose={handleTaskDetailClose}
|
|
||||||
onStatusChange={handleTaskStatusChange}
|
|
||||||
onTaskClick={handleTaskClick}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* PRD Editor Modal */}
|
|
||||||
{showPRDEditor && (
|
|
||||||
<PRDEditor
|
|
||||||
project={currentProject}
|
|
||||||
projectPath={currentProject?.fullPath || currentProject?.path}
|
|
||||||
onClose={() => {
|
|
||||||
setShowPRDEditor(false);
|
|
||||||
setSelectedPRD(null);
|
|
||||||
}}
|
|
||||||
isNewFile={!selectedPRD?.isExisting}
|
|
||||||
file={{
|
|
||||||
name: selectedPRD?.name || 'prd.txt',
|
|
||||||
content: selectedPRD?.content || ''
|
|
||||||
}}
|
|
||||||
onSave={async () => {
|
|
||||||
setShowPRDEditor(false);
|
|
||||||
setSelectedPRD(null);
|
|
||||||
|
|
||||||
// Reload existing PRDs with notification
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/taskmaster/prd/${encodeURIComponent(currentProject.name)}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
setExistingPRDs(data.prdFiles || []);
|
|
||||||
setPRDNotification('PRD saved successfully!');
|
|
||||||
setTimeout(() => setPRDNotification(null), 3000);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh PRDs:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshTasks?.();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* PRD Notification */}
|
|
||||||
{prdNotification && (
|
|
||||||
<div className="fixed bottom-4 right-4 z-50 animate-in slide-in-from-bottom-2 duration-300">
|
|
||||||
<div className="bg-green-600 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3">
|
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
<span className="font-medium">{prdNotification}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default React.memo(MainContent);
|
|
||||||
182
src/components/MainContent.tsx
Normal file
182
src/components/MainContent.tsx
Normal file
@@ -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 <MainContentStateView mode="loading" isMobile={isMobile} onMenuClick={onMenuClick} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedProject) {
|
||||||
|
return <MainContentStateView mode="empty" isMobile={isMobile} onMenuClick={onMenuClick} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<MainContentHeader
|
||||||
|
activeTab={activeTab}
|
||||||
|
setActiveTab={setActiveTab}
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
selectedSession={selectedSession}
|
||||||
|
shouldShowTasksTab={shouldShowTasksTab}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onMenuClick={onMenuClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||||
|
<div className={`flex-1 flex flex-col min-h-0 overflow-hidden ${editorExpanded ? 'hidden' : ''}`}>
|
||||||
|
<div className={`h-full ${activeTab === 'chat' ? 'block' : 'hidden'}`}>
|
||||||
|
<ErrorBoundary showDetails>
|
||||||
|
<AnyChatInterface
|
||||||
|
selectedProject={selectedProject}
|
||||||
|
selectedSession={selectedSession}
|
||||||
|
ws={ws}
|
||||||
|
sendMessage={sendMessage}
|
||||||
|
latestMessage={latestMessage}
|
||||||
|
onFileOpen={handleFileOpen}
|
||||||
|
onInputFocusChange={onInputFocusChange}
|
||||||
|
onSessionActive={onSessionActive}
|
||||||
|
onSessionInactive={onSessionInactive}
|
||||||
|
onSessionProcessing={onSessionProcessing}
|
||||||
|
onSessionNotProcessing={onSessionNotProcessing}
|
||||||
|
processingSessions={processingSessions}
|
||||||
|
onReplaceTemporarySession={onReplaceTemporarySession}
|
||||||
|
onNavigateToSession={onNavigateToSession}
|
||||||
|
onShowSettings={onShowSettings}
|
||||||
|
autoExpandTools={autoExpandTools}
|
||||||
|
showRawParameters={showRawParameters}
|
||||||
|
showThinking={showThinking}
|
||||||
|
autoScrollToBottom={autoScrollToBottom}
|
||||||
|
sendByCtrlEnter={sendByCtrlEnter}
|
||||||
|
externalMessageUpdate={externalMessageUpdate}
|
||||||
|
onShowAllTasks={tasksEnabled ? () => setActiveTab('tasks') : null}
|
||||||
|
/>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'files' && (
|
||||||
|
<div className="h-full overflow-hidden">
|
||||||
|
<FileTree selectedProject={selectedProject} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'shell' && (
|
||||||
|
<div className="h-full w-full overflow-hidden">
|
||||||
|
<AnyStandaloneShell project={selectedProject} session={selectedSession} showHeader={false} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'git' && (
|
||||||
|
<div className="h-full overflow-hidden">
|
||||||
|
<AnyGitPanel selectedProject={selectedProject} isMobile={isMobile} onFileOpen={handleFileOpen} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
|
||||||
|
|
||||||
|
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditorSidebar
|
||||||
|
editingFile={editingFile}
|
||||||
|
isMobile={isMobile}
|
||||||
|
editorExpanded={editorExpanded}
|
||||||
|
editorWidth={editorWidth}
|
||||||
|
resizeHandleRef={resizeHandleRef}
|
||||||
|
onResizeStart={handleResizeStart}
|
||||||
|
onCloseEditor={handleCloseEditor}
|
||||||
|
onToggleEditorExpand={handleToggleEditorExpand}
|
||||||
|
projectPath={selectedProject.path}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(MainContent);
|
||||||
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;
|
||||||
|
}
|
||||||
110
src/hooks/main-content/useEditorSidebar.ts
Normal file
110
src/hooks/main-content/useEditorSidebar.ts
Normal file
@@ -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<EditingFile | null>(null);
|
||||||
|
const [editorWidth, setEditorWidth] = useState(initialWidth);
|
||||||
|
const [editorExpanded, setEditorExpanded] = useState(false);
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const resizeHandleRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>) => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
50
src/hooks/main-content/useMobileMenuHandlers.ts
Normal file
50
src/hooks/main-content/useMobileMenuHandlers.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import type { MouseEvent, TouchEvent } from 'react';
|
||||||
|
|
||||||
|
type MenuEvent = MouseEvent<HTMLButtonElement> | TouchEvent<HTMLButtonElement>;
|
||||||
|
|
||||||
|
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<HTMLButtonElement>) => {
|
||||||
|
suppressNextMenuClickRef.current = true;
|
||||||
|
openMobileMenu(event);
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
suppressNextMenuClickRef.current = false;
|
||||||
|
}, 350);
|
||||||
|
},
|
||||||
|
[openMobileMenu],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMobileMenuClick = useCallback(
|
||||||
|
(event: MouseEvent<HTMLButtonElement>) => {
|
||||||
|
if (suppressNextMenuClickRef.current) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openMobileMenu(event);
|
||||||
|
},
|
||||||
|
[openMobileMenu],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleMobileMenuClick,
|
||||||
|
handleMobileMenuTouchEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user