perf(projects): lazy-load TaskMaster metadata per selected project

Why:

- /api/projects is a hot path (initial load, sidebar refresh, websocket sync).

- Scanning .taskmaster for every project on each call added avoidable fs I/O and payload size.

- TaskMaster metadata is only needed after selecting a specific project.

- Moving it to a project-scoped endpoint makes loading cost match user intent.

- The UI now hydrates TaskMaster state on selection and keeps it across refresh events.

- This prevents status flicker/regression while still removing global scan overhead.

- Selection fetches are sequence-guarded to block stale async responses on fast switching.

- isManuallyAdded was removed from responses to keep the public project contract minimal.

- Project dumps now use incrementing snapshot files to preserve history for debugging.

What changed:

- Added GET /api/projects/:projectName/taskmaster and getProjectTaskMaster().

- Removed TaskMaster detection from bulk getProjects().

- Added api.projectTaskmaster(...) plus selection-time hydration in frontend contexts.

- Merged cached taskmaster values into refreshed project lists for continuity.

- Removed isManuallyAdded from manual project payloads.
This commit is contained in:
Haileyesus
2026-04-24 13:30:04 +03:00
parent f99af1ff67
commit 15171e1428
5 changed files with 300 additions and 75 deletions

View File

@@ -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<TaskMasterContextError | null>(null);
const currentProjectNameRef = useRef<string | null>(null);
const projectTaskMasterRef = useRef<TaskMasterProjectInfo | null>(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<TaskMasterContextValue>(
() => ({

View File

@@ -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) => {

View File

@@ -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