diff --git a/server/index.js b/server/index.js index 13741d13..bf7b3da0 100755 --- a/server/index.js +++ b/server/index.js @@ -28,7 +28,7 @@ import { spawn } from 'child_process'; import pty from 'node-pty'; import mime from 'mime-types'; -import { getProjects, getSessions, renameProject, deleteSession, deleteProject, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js'; +import { getProjects, getSessions, renameProject, deleteSession, deleteProject, getProjectTaskMaster, extractProjectDirectory, clearProjectDirectoryCache, searchConversations } from './projects.js'; import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval, getPendingApprovalsForSession, reconnectSessionWriter } from './claude-sdk.js'; import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js'; import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js'; @@ -428,6 +428,16 @@ app.get('/api/projects', authenticateToken, async (req, res) => { } }); +app.get('/api/projects/:projectName/taskmaster', authenticateToken, async (req, res) => { + try { + const { projectName } = req.params; + const taskMasterDetails = await getProjectTaskMaster(projectName); + res.json(taskMasterDetails); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + app.get('/api/projects/:projectName/sessions', authenticateToken, async (req, res) => { try { const { limit = 5, offset = 0 } = req.query; diff --git a/server/projects.js b/server/projects.js index 2f2e02b0..8d8cf5c8 100755 --- a/server/projects.js +++ b/server/projects.js @@ -68,10 +68,31 @@ import sessionManager from './sessionManager.js'; import { applyCustomSessionNames } from './database/db.js'; import { getModuleDir, findAppRoot } from './utils/runtime-paths.js'; -// TODO: Remove the file writer after we have confidence in the stability of the project discovery system. This is just to help us debug and understand the projects being loaded in the wild, and will be removed in a future update. +// Snapshot files are kept as incrementing artifacts under .tmp/project-dumps for later review. const __dirname = getModuleDir(import.meta.url); const APP_ROOT = findAppRoot(__dirname); -const PROJECTS_DUMP_FILE = path.join(APP_ROOT, '.tmp', 'project-dumps', 'projects-latest.json'); +const PROJECTS_DUMP_DIR = path.join(APP_ROOT, '.tmp', 'project-dumps'); +let projectsSnapshotCounter = null; + +async function getNextProjectsSnapshotPath() { + await fs.mkdir(PROJECTS_DUMP_DIR, { recursive: true }); + + if (projectsSnapshotCounter === null) { + const entries = await fs.readdir(PROJECTS_DUMP_DIR).catch(() => []); + projectsSnapshotCounter = entries.reduce((max, entry) => { + const match = entry.match(/^projects-(\d+)\.json$/); + if (!match) { + return max; + } + + return Math.max(max, Number(match[1])); + }, 0); + } + + projectsSnapshotCounter += 1; + const suffix = String(projectsSnapshotCounter).padStart(4, '0'); + return path.join(PROJECTS_DUMP_DIR, `projects-${suffix}.json`); +} async function writeProjectsSnapshot(projects) { try { @@ -81,12 +102,24 @@ async function writeProjectsSnapshot(projects) { projects }; - await fs.mkdir(path.dirname(PROJECTS_DUMP_FILE), { recursive: true }); - await fs.writeFile( - PROJECTS_DUMP_FILE, - JSON.stringify(snapshot, (_, value) => (typeof value === 'bigint' ? value.toString() : value), 2), - 'utf8' + const snapshotJson = JSON.stringify( + snapshot, + (_, value) => (typeof value === 'bigint' ? value.toString() : value), + 2 ); + + while (true) { + const snapshotPath = await getNextProjectsSnapshotPath(); + try { + await fs.writeFile(snapshotPath, snapshotJson, { encoding: 'utf8', flag: 'wx' }); + break; + } catch (error) { + if (error.code === 'EEXIST') { + continue; + } + throw error; + } + } } catch (error) { console.warn('Could not write projects snapshot:', error.message); } @@ -220,6 +253,29 @@ async function detectTaskMasterFolder(projectPath) { } } +function normalizeTaskMasterInfo(taskMasterResult = null) { + const hasTaskmaster = Boolean(taskMasterResult?.hasTaskmaster); + const hasEssentialFiles = Boolean(taskMasterResult?.hasEssentialFiles); + + return { + hasTaskmaster, + hasEssentialFiles, + metadata: taskMasterResult?.metadata ?? null, + status: hasTaskmaster && hasEssentialFiles ? 'configured' : 'not-configured' + }; +} + +async function getProjectTaskMaster(projectName) { + const projectPath = await extractProjectDirectory(projectName); + const taskMasterResult = await detectTaskMasterFolder(projectPath); + + return { + projectName, + projectPath, + taskmaster: normalizeTaskMasterInfo(taskMasterResult) + }; +} + // Cache for extracted project directories const projectDirectoryCache = new Map(); @@ -287,6 +343,7 @@ async function generateDisplayName(projectName, actualProjectDir = null) { } // Extract the actual project directory from JSONL sessions (with caching) +// TODO: Get the project id as parameter and return the actual project directory from the database async function extractProjectDirectory(projectName) { // Check cache first if (projectDirectoryCache.has(projectName)) { @@ -295,6 +352,7 @@ async function extractProjectDirectory(projectName) { // Check project config for originalPath (manually added projects via UI or platform) // This handles projects with dashes in their directory names correctly + const config = await loadProjectConfig(); if (config[projectName]?.originalPath) { const originalPath = config[projectName].originalPath; @@ -517,27 +575,8 @@ async function getProjects(progressCallback = null) { } applyCustomSessionNames(project.geminiSessions, 'gemini'); - // Add TaskMaster detection - try { - const taskMasterResult = await detectTaskMasterFolder(actualProjectDir); - project.taskmaster = { - hasTaskmaster: taskMasterResult.hasTaskmaster, - hasEssentialFiles: taskMasterResult.hasEssentialFiles, - metadata: taskMasterResult.metadata, - status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured' - }; - } catch (e) { - console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message); - project.taskmaster = { - hasTaskmaster: false, - hasEssentialFiles: false, - metadata: null, - status: 'error' - }; - } - projects.push(project); - console.log(`Loaded project: ${project.displayName} (${project.name}) with ${project.sessions.length} sessions, ${project.cursorSessions.length} Cursor sessions, ${project.codexSessions.length} Codex sessions, and ${project.geminiSessions.length} Gemini sessions.`); + // console.log(`Loaded project: ${project.displayName} (${project.name}) with ${project.sessions.length} sessions, ${project.cursorSessions.length} Cursor sessions, ${project.codexSessions.length} Codex sessions, and ${project.geminiSessions.length} Gemini sessions.`); // console.log("Full project data:", project); } } catch (error) { @@ -583,7 +622,6 @@ async function getProjects(progressCallback = null) { path: actualProjectDir, displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir), fullPath: actualProjectDir, - isManuallyAdded: true, sessions: [], geminiSessions: [], sessionMeta: { @@ -623,32 +661,6 @@ async function getProjects(progressCallback = null) { } applyCustomSessionNames(project.geminiSessions, 'gemini'); - // Add TaskMaster detection for manual projects - try { - const taskMasterResult = await detectTaskMasterFolder(actualProjectDir); - - // Determine TaskMaster status - let taskMasterStatus = 'not-configured'; - if (taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles) { - taskMasterStatus = 'taskmaster-only'; // We don't check MCP for manual projects in bulk - } - - project.taskmaster = { - status: taskMasterStatus, - hasTaskmaster: taskMasterResult.hasTaskmaster, - hasEssentialFiles: taskMasterResult.hasEssentialFiles, - metadata: taskMasterResult.metadata - }; - } catch (error) { - console.warn(`TaskMaster detection failed for manual project ${projectName}:`, error.message); - project.taskmaster = { - status: 'error', - hasTaskmaster: false, - hasEssentialFiles: false, - error: error.message - }; - } - projects.push(project); } } @@ -1292,7 +1304,6 @@ async function addProjectManually(projectPath, displayName = null) { path: absolutePath, fullPath: absolutePath, displayName: displayName || await generateDisplayName(projectName, absolutePath), - isManuallyAdded: true, sessions: [], cursorSessions: [] }; @@ -2565,6 +2576,7 @@ export { deleteSession, deleteProject, addProjectManually, + getProjectTaskMaster, extractProjectDirectory, clearProjectDirectoryCache, getCodexSessionMessages, diff --git a/src/components/task-master/context/TaskMasterContext.tsx b/src/components/task-master/context/TaskMasterContext.tsx index 37953ad8..aa42ae7b 100644 --- a/src/components/task-master/context/TaskMasterContext.tsx +++ b/src/components/task-master/context/TaskMasterContext.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; + import { api } from '../../../utils/api'; import { useAuth } from '../../auth/context/AuthContext'; import { useWebSocket } from '../../../contexts/WebSocketContext'; @@ -74,11 +75,17 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode }) const [error, setError] = useState(null); const currentProjectNameRef = useRef(null); + const projectTaskMasterRef = useRef(null); + const taskMasterRequestSeqRef = useRef(0); useEffect(() => { currentProjectNameRef.current = currentProject?.name ?? null; }, [currentProject?.name]); + useEffect(() => { + projectTaskMasterRef.current = projectTaskMaster; + }, [projectTaskMaster]); + const clearError = useCallback(() => { setError(null); }, []); @@ -88,16 +95,93 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode }) setError(createTaskMasterError(context, caughtError)); }, []); - const setCurrentProject = useCallback((project: TaskMasterProjectInput) => { - const normalizedProject = project ? enrichProject(project as TaskMasterProject) : null; - setCurrentProjectState(normalizedProject); - setProjectTaskMaster(normalizedProject?.taskmaster ?? null); + const applyTaskMasterInfo = useCallback((projectName: string, taskMasterInfo: TaskMasterProjectInfo | null) => { + setProjectTaskMaster(taskMasterInfo); - // Project-scoped task data is reset immediately to avoid stale task rendering. - setTasks([]); - setNextTask(null); + setProjects((previousProjects) => + previousProjects.map((project) => { + if (project.name !== projectName) { + return project; + } + + return enrichProject({ + ...project, + taskmaster: taskMasterInfo ?? undefined, + }); + }), + ); + + setCurrentProjectState((previousProject) => { + if (!previousProject || previousProject.name !== projectName) { + return previousProject; + } + + return enrichProject({ + ...previousProject, + taskmaster: taskMasterInfo ?? undefined, + }); + }); }, []); + const refreshCurrentProjectTaskMaster = useCallback( + async (projectName: string) => { + if (!projectName || !user || !token) { + return; + } + + const requestSequence = ++taskMasterRequestSeqRef.current; + + try { + const response = await api.projectTaskmaster(projectName); + if (!response.ok) { + throw new Error(`Failed to fetch TaskMaster details: ${response.status}`); + } + + const data = (await response.json()) as { taskmaster?: TaskMasterProjectInfo }; + const resolvedTaskMasterInfo = data.taskmaster ?? null; + + if ( + requestSequence !== taskMasterRequestSeqRef.current + || currentProjectNameRef.current !== projectName + ) { + return; + } + + applyTaskMasterInfo(projectName, resolvedTaskMasterInfo); + } catch (caughtError) { + if ( + requestSequence !== taskMasterRequestSeqRef.current + || currentProjectNameRef.current !== projectName + ) { + return; + } + + handleError('load selected project TaskMaster info', caughtError); + } + }, + [applyTaskMasterInfo, handleError, token, user], + ); + + const setCurrentProject = useCallback( + (project: TaskMasterProjectInput) => { + const normalizedProject = project ? enrichProject(project as TaskMasterProject) : null; + setCurrentProjectState(normalizedProject); + setProjectTaskMaster(normalizedProject?.taskmaster ?? null); + + // Project-scoped task data is reset immediately to avoid stale task rendering. + setTasks([]); + setNextTask(null); + + if (!normalizedProject?.name) { + taskMasterRequestSeqRef.current += 1; + return; + } + + void refreshCurrentProjectTaskMaster(normalizedProject.name); + }, + [refreshCurrentProjectTaskMaster], + ); + const refreshProjects = useCallback(async () => { if (!user || !token) { setProjects([]); @@ -121,7 +205,25 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode }) const loadedProjects = Array.isArray(data) ? (data as TaskMasterProject[]) : []; const enrichedProjects = loadedProjects.map((project) => enrichProject(project)); - setProjects(enrichedProjects); + setProjects((previousProjects) => { + const taskMasterByProjectName = new Map( + previousProjects + .filter((project) => Boolean(project.taskmaster)) + .map((project) => [project.name, project.taskmaster]), + ); + + return enrichedProjects.map((project) => { + const cachedTaskMasterInfo = taskMasterByProjectName.get(project.name); + if (!cachedTaskMasterInfo) { + return project; + } + + return enrichProject({ + ...project, + taskmaster: cachedTaskMasterInfo, + }); + }); + }); const currentProjectName = currentProjectNameRef.current; if (!currentProjectName) { @@ -129,14 +231,34 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode }) } const matchingProject = enrichedProjects.find((project) => project.name === currentProjectName) ?? null; - setCurrentProjectState(matchingProject); - setProjectTaskMaster(matchingProject?.taskmaster ?? null); + + if (!matchingProject) { + taskMasterRequestSeqRef.current += 1; + setCurrentProjectState(null); + setProjectTaskMaster(null); + setTasks([]); + setNextTask(null); + return; + } + + const cachedTaskMasterInfo = matchingProject.taskmaster ?? projectTaskMasterRef.current ?? null; + setCurrentProjectState( + cachedTaskMasterInfo + ? enrichProject({ + ...matchingProject, + taskmaster: cachedTaskMasterInfo, + }) + : matchingProject, + ); + setProjectTaskMaster(cachedTaskMasterInfo); + + void refreshCurrentProjectTaskMaster(currentProjectName); } catch (caughtError) { handleError('load projects', caughtError); } finally { setIsLoading(false); } - }, [clearError, handleError, token, user]); + }, [clearError, handleError, refreshCurrentProjectTaskMaster, token, user]); const refreshTasks = useCallback(async () => { const projectName = currentProject?.name; @@ -216,6 +338,9 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode }) } if (message.type === 'taskmaster-project-updated' && message.projectName) { + if (message.projectName === currentProjectNameRef.current) { + void refreshCurrentProjectTaskMaster(message.projectName); + } void refreshProjects(); return; } @@ -228,7 +353,7 @@ export function TaskMasterProvider({ children }: { children: React.ReactNode }) if (message.type === 'taskmaster-mcp-status-changed') { void refreshMCPStatus(); } - }, [currentProject?.name, latestMessage, refreshMCPStatus, refreshProjects, refreshTasks]); + }, [currentProject?.name, latestMessage, refreshCurrentProjectTaskMaster, refreshMCPStatus, refreshProjects, refreshTasks]); const contextValue = useMemo( () => ({ diff --git a/src/hooks/useProjectsState.ts b/src/hooks/useProjectsState.ts index 28cf682e..cd1fa8ed 100644 --- a/src/hooks/useProjectsState.ts +++ b/src/hooks/useProjectsState.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { NavigateFunction } from 'react-router-dom'; + import { api } from '../utils/api'; import type { AppSocketMessage, @@ -63,6 +64,30 @@ const projectsHaveChanges = ( }); }; +const mergeTaskMasterCache = (nextProjects: Project[], previousProjects: Project[]): Project[] => { + if (previousProjects.length === 0) { + return nextProjects; + } + + const previousTaskMasterByProject = new Map( + previousProjects + .filter((project) => Boolean(project.taskmaster)) + .map((project) => [project.name, project.taskmaster]), + ); + + return nextProjects.map((project) => { + const cachedTaskMasterInfo = previousTaskMasterByProject.get(project.name); + if (!cachedTaskMasterInfo) { + return project; + } + + return { + ...project, + taskmaster: cachedTaskMasterInfo, + }; + }); +}; + const getProjectSessions = (project: Project): ProjectSession[] => { return [ ...(project.sessions ?? []), @@ -165,12 +190,14 @@ export function useProjectsState({ const projectData = (await response.json()) as Project[]; setProjects((prevProjects) => { + const mergedProjects = mergeTaskMasterCache(projectData, prevProjects); + if (prevProjects.length === 0) { - return projectData; + return mergedProjects; } - return projectsHaveChanges(prevProjects, projectData, true) - ? projectData + return projectsHaveChanges(prevProjects, mergedProjects, true) + ? mergedProjects : prevProjects; }); } catch (error) { @@ -187,6 +214,46 @@ export function useProjectsState({ await fetchProjects({ showLoadingState: false }); }, [fetchProjects]); + const hydrateProjectTaskMaster = useCallback(async (projectName: string) => { + if (!projectName) { + return; + } + + try { + const response = await api.projectTaskmaster(projectName); + if (!response.ok) { + return; + } + + const data = (await response.json()) as { taskmaster?: Project['taskmaster'] }; + const taskMasterInfo = data.taskmaster; + if (!taskMasterInfo) { + return; + } + + setProjects((previousProjects) => + previousProjects.map((project) => + project.name === projectName + ? { ...project, taskmaster: taskMasterInfo } + : project, + ), + ); + + setSelectedProject((previousProject) => { + if (!previousProject || previousProject.name !== projectName) { + return previousProject; + } + + return { + ...previousProject, + taskmaster: taskMasterInfo, + }; + }); + } catch (error) { + console.error(`Error fetching TaskMaster info for project ${projectName}:`, error); + } + }, []); + const openSettings = useCallback((tab = 'tools') => { setSettingsInitialTab(tab); setShowSettings(true); @@ -196,6 +263,14 @@ export function useProjectsState({ void fetchProjects(); }, [fetchProjects]); + useEffect(() => { + if (!selectedProject?.name) { + return; + } + + void hydrateProjectTaskMaster(selectedProject.name); + }, [hydrateProjectTaskMaster, selectedProject?.name]); + // Auto-select the project when there is only one, so the user lands on the new session page useEffect(() => { if (!isLoadingProjects && projects.length === 1 && !selectedProject && !sessionId) { @@ -254,7 +329,7 @@ export function useProjectsState({ (selectedSession && activeSessions.has(selectedSession.id)) || (activeSessions.size > 0 && Array.from(activeSessions).some((id) => id.startsWith('new-session-'))); - const updatedProjects = projectsMessage.projects; + const updatedProjects = mergeTaskMasterCache(projectsMessage.projects, projects); if ( hasActiveSession && @@ -450,16 +525,17 @@ export function useProjectsState({ try { const response = await api.projects(); const freshProjects = (await response.json()) as Project[]; + const mergedProjects = mergeTaskMasterCache(freshProjects, projects); setProjects((prevProjects) => - projectsHaveChanges(prevProjects, freshProjects, true) ? freshProjects : prevProjects, + projectsHaveChanges(prevProjects, mergedProjects, true) ? mergedProjects : prevProjects, ); if (!selectedProject) { return; } - const refreshedProject = freshProjects.find((project) => project.name === selectedProject.name); + const refreshedProject = mergedProjects.find((project) => project.name === selectedProject.name); if (!refreshedProject) { return; } @@ -490,7 +566,7 @@ export function useProjectsState({ } catch (error) { console.error('Error refreshing sidebar:', error); } - }, [selectedProject, selectedSession]); + }, [projects, selectedProject, selectedSession]); const handleProjectDelete = useCallback( (projectName: string) => { diff --git a/src/utils/api.js b/src/utils/api.js index 3968daf9..1465610f 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -52,6 +52,8 @@ export const api = { // Protected endpoints // config endpoint removed - no longer needed (frontend uses window.location) projects: () => authenticatedFetch('/api/projects'), + projectTaskmaster: (projectName) => + authenticatedFetch(`/api/projects/${encodeURIComponent(projectName)}/taskmaster`), sessions: (projectName, limit = 5, offset = 0) => authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`), // Unified endpoint — all providers through one URL