mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-04 20:05:38 +08:00
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:
@@ -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>(
|
||||
() => ({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user