From 123ddc6d57d345ceed06bd6a451d7e59084e75c7 Mon Sep 17 00:00:00 2001 From: KyungTae Kim Date: Wed, 11 Feb 2026 13:51:44 +0900 Subject: [PATCH 1/4] perf: optimize project/session discovery without cache --- server/projects.js | 618 ++++++++++++++++++++++++++++++++------------- src/App.jsx | 21 -- 2 files changed, 449 insertions(+), 190 deletions(-) diff --git a/server/projects.js b/server/projects.js index 475b3232a..dd7a50bd9 100755 --- a/server/projects.js +++ b/server/projects.js @@ -197,11 +197,345 @@ async function detectTaskMasterFolder(projectPath) { // Cache for extracted project directories const projectDirectoryCache = new Map(); +const PROJECT_PREVIEW_TAIL_BYTES = 256 * 1024; +const PROJECT_PREVIEW_MAX_LINES_PER_FILE = 4000; +const PROJECT_PREVIEW_MAX_FILES = 12; + // Clear cache when needed (called when project files change) function clearProjectDirectoryCache() { projectDirectoryCache.clear(); } +function normalizeCodexPath(projectPath = '') { + if (!projectPath || typeof projectPath !== 'string') { + return ''; + } + + const cleanedPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath; + return path.normalize(cleanedPath); +} + +function getMessageText(content) { + if (Array.isArray(content)) { + const textParts = content + .filter((part) => part?.type === 'text' && typeof part.text === 'string') + .map((part) => part.text.trim()) + .filter(Boolean); + return textParts.join('\n'); + } + + if (typeof content === 'string') { + return content.trim(); + } + + return ''; +} + +function isSystemUserMessage(text) { + return ( + text.startsWith('') || + text.startsWith('') || + text.startsWith('') || + text.startsWith('') || + text.startsWith('') || + text.startsWith('Caveat:') || + text.startsWith('This session is being continued from a previous') || + text.startsWith('Invalid API key') || + text.includes('{"subtasks":') || + text.includes('CRITICAL: You MUST respond with ONLY a JSON') || + text === 'Warmup' + ); +} + +function isSystemAssistantMessage(text) { + return ( + text.startsWith('Invalid API key') || + text.includes('{"subtasks":') || + text.includes('CRITICAL: You MUST respond with ONLY a JSON') + ); +} + +function toSessionSummary(text) { + if (!text) return 'New Session'; + return text.length > 50 ? `${text.substring(0, 50)}...` : text; +} + +async function readJsonlTailLines(filePath, maxBytes = PROJECT_PREVIEW_TAIL_BYTES, maxLines = PROJECT_PREVIEW_MAX_LINES_PER_FILE) { + const stats = await fs.stat(filePath); + const readSize = Math.min(maxBytes, stats.size); + if (readSize === 0) { + return { lines: [], fullyRead: true }; + } + + const fh = await fs.open(filePath, 'r'); + try { + const buffer = Buffer.alloc(readSize); + await fh.read(buffer, 0, readSize, stats.size - readSize); + + let content = buffer.toString('utf8'); + const fullyRead = readSize === stats.size; + + if (!fullyRead) { + const firstNewlineIdx = content.indexOf('\n'); + content = firstNewlineIdx >= 0 ? content.slice(firstNewlineIdx + 1) : ''; + } + + const rawLines = content.split('\n').filter(line => line.trim()); + const lines = rawLines.length > maxLines ? rawLines.slice(rawLines.length - maxLines) : rawLines; + return { lines, fullyRead }; + } finally { + await fh.close(); + } +} + +async function getSessionsPreview(projectName, limit = 5, offset = 0) { + const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName); + + try { + const files = await fs.readdir(projectDir); + const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-')); + + if (jsonlFiles.length === 0) { + return { sessions: [], hasMore: false, total: 0, projectPathHint: null }; + } + + const filesWithStats = await Promise.all( + jsonlFiles.map(async (file) => { + const filePath = path.join(projectDir, file); + const stats = await fs.stat(filePath); + return { filePath, mtime: stats.mtimeMs }; + }) + ); + filesWithStats.sort((a, b) => b.mtime - a.mtime); + + const targetSessionCount = offset + limit + 1; + const sessions = new Map(); + const cwdCounts = new Map(); + + let scannedFiles = 0; + let truncatedReadDetected = false; + + for (const fileInfo of filesWithStats) { + if (scannedFiles >= PROJECT_PREVIEW_MAX_FILES) { + break; + } + + scannedFiles++; + const { lines, fullyRead } = await readJsonlTailLines(fileInfo.filePath); + if (!fullyRead) { + truncatedReadDetected = true; + } + + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + let entry; + try { + entry = JSON.parse(line); + } catch { + continue; + } + + if (!entry.sessionId) { + continue; + } + + if (!sessions.has(entry.sessionId)) { + sessions.set(entry.sessionId, { + id: entry.sessionId, + summary: 'New Session', + messageCount: 0, + lastActivity: null, + cwd: '', + lastUserMessage: null, + lastAssistantMessage: null + }); + } + + const session = sessions.get(entry.sessionId); + + if (entry.timestamp) { + const timestamp = new Date(entry.timestamp); + if (!Number.isNaN(timestamp.getTime())) { + const currentTime = timestamp.toISOString(); + if (!session.lastActivity || new Date(currentTime) > new Date(session.lastActivity)) { + session.lastActivity = currentTime; + } + } + } + + if (!session.cwd && typeof entry.cwd === 'string' && entry.cwd.trim()) { + session.cwd = entry.cwd; + cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1); + } + + if (session.summary === 'New Session' && entry.type === 'summary' && typeof entry.summary === 'string' && entry.summary.trim()) { + session.summary = entry.summary.trim(); + } + + const role = entry.message?.role; + if (role === 'user') { + session.messageCount += 1; + if (!session.lastUserMessage) { + const text = getMessageText(entry.message?.content); + if (text && !isSystemUserMessage(text)) { + session.lastUserMessage = text; + } + } + } else if (role === 'assistant') { + session.messageCount += 1; + if (!session.lastAssistantMessage && entry.isApiErrorMessage !== true) { + const text = getMessageText(entry.message?.content); + if (text && !isSystemAssistantMessage(text)) { + session.lastAssistantMessage = text; + } + } + } + } + + if (sessions.size >= targetSessionCount && !truncatedReadDetected) { + break; + } + } + + const nowIso = new Date().toISOString(); + const visibleSessions = Array.from(sessions.values()) + .map((session) => { + let summary = session.summary; + if (summary === 'New Session') { + summary = toSessionSummary(session.lastUserMessage || session.lastAssistantMessage || 'New Session'); + } + + return { + id: session.id, + summary, + messageCount: session.messageCount, + lastActivity: session.lastActivity || nowIso, + cwd: session.cwd || '' + }; + }) + .filter(session => !session.summary.startsWith('{ "')) + .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); + + const total = visibleSessions.length; + const paginatedSessions = visibleSessions.slice(offset, offset + limit); + const scannedAllFiles = scannedFiles >= filesWithStats.length && !truncatedReadDetected; + const hasMore = scannedAllFiles ? (offset + limit < total) : true; + + let projectPathHint = null; + if (visibleSessions.length > 0) { + projectPathHint = visibleSessions.find(session => session.cwd)?.cwd || null; + } + if (!projectPathHint && cwdCounts.size > 0) { + projectPathHint = Array.from(cwdCounts.entries()).sort((a, b) => b[1] - a[1])[0][0]; + } + + return { + sessions: paginatedSessions, + hasMore, + total, + projectPathHint + }; + } catch (error) { + if (error.code !== 'ENOENT') { + console.warn(`Could not build sessions preview for project ${projectName}:`, error.message); + } + return { sessions: [], hasMore: false, total: 0, projectPathHint: null }; + } +} + +async function findJsonlFilesRecursive(dir) { + const files = []; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await findJsonlFilesRecursive(fullPath)); + } else if (entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } + } catch (_) { + // Skip directories we cannot read + } + return files; +} + +async function buildCodexSessionsIndex(limitPerProject = 5) { + const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); + const sessionsByCwd = new Map(); + + try { + await fs.access(codexSessionsDir); + } catch { + return sessionsByCwd; + } + + const jsonlFiles = await findJsonlFilesRecursive(codexSessionsDir); + + for (const filePath of jsonlFiles) { + try { + const sessionData = await parseCodexSessionFile(filePath); + if (!sessionData?.cwd) { + continue; + } + + const normalizedCwd = normalizeCodexPath(sessionData.cwd); + if (!normalizedCwd) { + continue; + } + + const session = { + id: sessionData.id, + summary: sessionData.summary || 'Codex Session', + messageCount: sessionData.messageCount || 0, + lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), + cwd: sessionData.cwd, + model: sessionData.model, + filePath, + provider: 'codex' + }; + + if (!sessionsByCwd.has(normalizedCwd)) { + sessionsByCwd.set(normalizedCwd, []); + } + sessionsByCwd.get(normalizedCwd).push(session); + } catch (error) { + console.warn(`Could not parse Codex session file ${filePath}:`, error.message); + } + } + + for (const [cwd, sessions] of sessionsByCwd.entries()) { + const sortedSessions = sessions + .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)) + .slice(0, limitPerProject); + sessionsByCwd.set(cwd, sortedSessions); + } + + return sessionsByCwd; +} + +function getCodexSessionsForProjectFromIndex(codexSessionsIndex, projectPath, limit = 5) { + const normalizedProjectPath = normalizeCodexPath(projectPath); + if (!normalizedProjectPath || codexSessionsIndex.size === 0) { + return []; + } + + const matchedSessions = []; + for (const [sessionCwd, sessions] of codexSessionsIndex.entries()) { + try { + if (sessionCwd === normalizedProjectPath || path.relative(sessionCwd, normalizedProjectPath) === '') { + matchedSessions.push(...sessions); + } + } catch (_) { + // Ignore cross-device / invalid relative path comparisons + } + } + + matchedSessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); + return limit > 0 ? matchedSessions.slice(0, limit) : matchedSessions; +} + // Load project configuration file async function loadProjectConfig() { const configPath = path.join(os.homedir(), '.claude', 'project-config.json'); @@ -399,186 +733,132 @@ async function getProjects(progressCallback = null) { // Build set of existing project names for later directories.forEach(e => existingProjects.add(e.name)); - // Count manual projects not already in directories - const manualProjectsCount = Object.entries(config) - .filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name)) - .length; - - totalProjects = directories.length + manualProjectsCount; - - for (const entry of directories) { - processedProjects++; - - // Emit progress - if (progressCallback) { - progressCallback({ - phase: 'loading', - current: processedProjects, - total: totalProjects, - currentProject: entry.name - }); - } - - const projectPath = path.join(claudeDir, entry.name); - - // Extract actual project directory from JSONL sessions - const actualProjectDir = await extractProjectDirectory(entry.name); - - // Get display name from config or generate one - const customName = config[entry.name]?.displayName; - const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir); - const fullPath = actualProjectDir; - - const project = { - name: entry.name, - path: actualProjectDir, - displayName: customName || autoDisplayName, - fullPath: fullPath, - isCustomName: !!customName, - sessions: [] - }; - - // Try to get sessions for this project (just first 5 for performance) - try { - const sessionResult = await getSessions(entry.name, 5, 0); - project.sessions = sessionResult.sessions || []; - project.sessionMeta = { - hasMore: sessionResult.hasMore, - total: sessionResult.total - }; - } catch (e) { - console.warn(`Could not load sessions for project ${entry.name}:`, e.message); - } - - // Also fetch Cursor sessions for this project - try { - project.cursorSessions = await getCursorSessions(actualProjectDir); - } catch (e) { - console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message); - project.cursorSessions = []; - } - - // Also fetch Codex sessions for this project - try { - project.codexSessions = await getCodexSessions(actualProjectDir); - } catch (e) { - console.warn(`Could not load Codex sessions for project ${entry.name}:`, e.message); - project.codexSessions = []; - } - - // 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); - } } catch (error) { // If the directory doesn't exist (ENOENT), that's okay - just continue with empty projects if (error.code !== 'ENOENT') { console.error('Error reading projects directory:', error); } - // Calculate total for manual projects only (no directories exist) - totalProjects = Object.entries(config) - .filter(([name, cfg]) => cfg.manuallyAdded) - .length; } - - // Add manually configured projects that don't exist as folders yet - for (const [projectName, projectConfig] of Object.entries(config)) { - if (!existingProjects.has(projectName) && projectConfig.manuallyAdded) { - processedProjects++; - - // Emit progress for manual projects - if (progressCallback) { - progressCallback({ - phase: 'loading', - current: processedProjects, - total: totalProjects, - currentProject: projectName - }); - } - // Use the original path if available, otherwise extract from potential sessions - let actualProjectDir = projectConfig.originalPath; - - if (!actualProjectDir) { - try { - actualProjectDir = await extractProjectDirectory(projectName); - } catch (error) { - // Fall back to decoded project name - actualProjectDir = projectName.replace(/-/g, '/'); - } - } - - const project = { - name: projectName, - path: actualProjectDir, - displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir), - fullPath: actualProjectDir, - isCustomName: !!projectConfig.displayName, - isManuallyAdded: true, - sessions: [], - cursorSessions: [], - codexSessions: [] - }; + const manualProjects = Object.entries(config).filter(([name, cfg]) => cfg.manuallyAdded && !existingProjects.has(name)); + totalProjects = directories.length + manualProjects.length; + + const emitProjectProgress = (projectName) => { + processedProjects++; + if (progressCallback) { + progressCallback({ + phase: 'loading', + current: processedProjects, + total: totalProjects, + currentProject: projectName + }); + } + }; - // Try to fetch Cursor sessions for manual projects too - try { - project.cursorSessions = await getCursorSessions(actualProjectDir); - } catch (e) { - console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message); - } + const codexSessionsIndexPromise = buildCodexSessionsIndex(5); + + for (const entry of directories) { + emitProjectProgress(entry.name); + + const previewResult = await getSessionsPreview(entry.name, 5, 0); + const decodedProjectPath = entry.name.replace(/-/g, '/'); + const configuredProjectPath = config[entry.name]?.originalPath; + const actualProjectDir = configuredProjectPath || previewResult.projectPathHint || decodedProjectPath; + const customName = config[entry.name]?.displayName; + const autoDisplayName = await generateDisplayName(entry.name, actualProjectDir); + + const project = { + name: entry.name, + path: actualProjectDir, + displayName: customName || autoDisplayName, + fullPath: actualProjectDir, + isCustomName: !!customName, + sessions: previewResult.sessions || [], + sessionMeta: { + hasMore: previewResult.hasMore, + total: previewResult.total + }, + codexSessions: getCodexSessionsForProjectFromIndex(await codexSessionsIndexPromise, actualProjectDir, 5) + }; - // Try to fetch Codex sessions for manual projects too - try { - project.codexSessions = await getCodexSessions(actualProjectDir); - } catch (e) { - console.warn(`Could not load Codex sessions for manual project ${projectName}:`, e.message); - } + const [cursorSessionsResult, taskMasterResult] = await Promise.all([ + getCursorSessions(actualProjectDir).catch((e) => { + console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message); + return []; + }), + detectTaskMasterFolder(actualProjectDir).catch((e) => { + console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message); + return null; + }) + ]); + + project.cursorSessions = cursorSessionsResult; + if (taskMasterResult) { + project.taskmaster = { + hasTaskmaster: taskMasterResult.hasTaskmaster, + hasEssentialFiles: taskMasterResult.hasEssentialFiles, + metadata: taskMasterResult.metadata, + status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured' + }; + } else { + project.taskmaster = { + hasTaskmaster: false, + hasEssentialFiles: false, + metadata: null, + status: 'error' + }; + } - // 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); + projects.push(project); + } + + // Add manually configured projects that do not exist as folders + for (const [projectName, projectConfig] of manualProjects) { + emitProjectProgress(projectName); + + const actualProjectDir = projectConfig.originalPath || projectName.replace(/-/g, '/'); + const project = { + name: projectName, + path: actualProjectDir, + displayName: projectConfig.displayName || await generateDisplayName(projectName, actualProjectDir), + fullPath: actualProjectDir, + isCustomName: !!projectConfig.displayName, + isManuallyAdded: true, + sessions: [], + cursorSessions: [], + codexSessions: getCodexSessionsForProjectFromIndex(await codexSessionsIndexPromise, actualProjectDir, 5) + }; + + const [cursorSessionsResult, taskMasterResult] = await Promise.all([ + getCursorSessions(actualProjectDir).catch((e) => { + console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message); + return []; + }), + detectTaskMasterFolder(actualProjectDir).catch((e) => { + console.warn(`TaskMaster detection failed for manual project ${projectName}:`, e.message); + return null; + }) + ]); + + project.cursorSessions = cursorSessionsResult; + if (taskMasterResult) { + const taskMasterStatus = taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'taskmaster-only' : 'not-configured'; + project.taskmaster = { + status: taskMasterStatus, + hasTaskmaster: taskMasterResult.hasTaskmaster, + hasEssentialFiles: taskMasterResult.hasEssentialFiles, + metadata: taskMasterResult.metadata + }; + } else { + project.taskmaster = { + status: 'error', + hasTaskmaster: false, + hasEssentialFiles: false, + metadata: null + }; } + + projects.push(project); } // Emit completion after all projects (including manual) are processed diff --git a/src/App.jsx b/src/App.jsx index 3ded1e649..513f378ad 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -287,27 +287,6 @@ function AppContent() { const response = await api.projects(); const data = await response.json(); - // Always fetch Cursor sessions for each project so we can combine views - for (let project of data) { - try { - const url = `/api/cursor/sessions?projectPath=${encodeURIComponent(project.fullPath || project.path)}`; - const cursorResponse = await authenticatedFetch(url); - if (cursorResponse.ok) { - const cursorData = await cursorResponse.json(); - if (cursorData.success && cursorData.sessions) { - project.cursorSessions = cursorData.sessions; - } else { - project.cursorSessions = []; - } - } else { - project.cursorSessions = []; - } - } catch (error) { - console.error(`Error fetching Cursor sessions for project ${project.name}:`, error); - project.cursorSessions = []; - } - } - // Optimize to preserve object references when data hasn't changed setProjects(prevProjects => { // If no previous projects, just set the new data From 602df0b3631980637ee09712ebac9426d8b18dbc Mon Sep 17 00:00:00 2001 From: KyungTae Kim Date: Wed, 11 Feb 2026 14:42:48 +0900 Subject: [PATCH 2/4] perf: lazy hydrate sessions while preserving project list behavior --- server/projects.js | 107 ++++++++-------------- src/App.jsx | 175 +++++++++++++++++++++++++++++++----- src/components/Sidebar.jsx | 177 ++++++++++++++++++++++++++++++++----- src/utils/api.js | 6 +- 4 files changed, 351 insertions(+), 114 deletions(-) diff --git a/server/projects.js b/server/projects.js index dd7a50bd9..0b18dbd14 100755 --- a/server/projects.js +++ b/server/projects.js @@ -199,7 +199,6 @@ const projectDirectoryCache = new Map(); const PROJECT_PREVIEW_TAIL_BYTES = 256 * 1024; const PROJECT_PREVIEW_MAX_LINES_PER_FILE = 4000; -const PROJECT_PREVIEW_MAX_FILES = 12; // Clear cache when needed (called when project files change) function clearProjectDirectoryCache() { @@ -316,10 +315,6 @@ async function getSessionsPreview(projectName, limit = 5, offset = 0) { let truncatedReadDetected = false; for (const fileInfo of filesWithStats) { - if (scannedFiles >= PROJECT_PREVIEW_MAX_FILES) { - break; - } - scannedFiles++; const { lines, fullyRead } = await readJsonlTailLines(fileInfo.filePath); if (!fullyRead) { @@ -417,9 +412,14 @@ async function getSessionsPreview(projectName, limit = 5, offset = 0) { .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); const total = visibleSessions.length; + // Keep metadata behavior aligned with existing getSessions(limit=5) path used by project list. + const legacyTotalCap = Math.max(limit + offset, (limit + offset) * 2); + let effectiveTotal = Math.min(total, legacyTotalCap); + if (truncatedReadDetected && total > limit && effectiveTotal < legacyTotalCap) { + effectiveTotal = legacyTotalCap; + } const paginatedSessions = visibleSessions.slice(offset, offset + limit); - const scannedAllFiles = scannedFiles >= filesWithStats.length && !truncatedReadDetected; - const hasMore = scannedAllFiles ? (offset + limit < total) : true; + const hasMore = offset + limit < effectiveTotal; let projectPathHint = null; if (visibleSessions.length > 0) { @@ -432,7 +432,7 @@ async function getSessionsPreview(projectName, limit = 5, offset = 0) { return { sessions: paginatedSessions, hasMore, - total, + total: effectiveTotal, projectPathHint }; } catch (error) { @@ -755,12 +755,13 @@ async function getProjects(progressCallback = null) { } }; - const codexSessionsIndexPromise = buildCodexSessionsIndex(5); - for (const entry of directories) { emitProjectProgress(entry.name); - const previewResult = await getSessionsPreview(entry.name, 5, 0); + const [previewResult, sessionMetaResult] = await Promise.all([ + getSessionsPreview(entry.name, 5, 0), + getSessions(entry.name, 5, 0).catch(() => ({ hasMore: false, total: 0 })) + ]); const decodedProjectPath = entry.name.replace(/-/g, '/'); const configuredProjectPath = config[entry.name]?.originalPath; const actualProjectDir = configuredProjectPath || previewResult.projectPathHint || decodedProjectPath; @@ -775,39 +776,22 @@ async function getProjects(progressCallback = null) { isCustomName: !!customName, sessions: previewResult.sessions || [], sessionMeta: { - hasMore: previewResult.hasMore, - total: previewResult.total + hasMore: sessionMetaResult.hasMore, + total: sessionMetaResult.total, + lazy: true }, - codexSessions: getCodexSessionsForProjectFromIndex(await codexSessionsIndexPromise, actualProjectDir, 5) - }; - - const [cursorSessionsResult, taskMasterResult] = await Promise.all([ - getCursorSessions(actualProjectDir).catch((e) => { - console.warn(`Could not load Cursor sessions for project ${entry.name}:`, e.message); - return []; - }), - detectTaskMasterFolder(actualProjectDir).catch((e) => { - console.warn(`Could not detect TaskMaster for project ${entry.name}:`, e.message); - return null; - }) - ]); - - project.cursorSessions = cursorSessionsResult; - if (taskMasterResult) { - project.taskmaster = { - hasTaskmaster: taskMasterResult.hasTaskmaster, - hasEssentialFiles: taskMasterResult.hasEssentialFiles, - metadata: taskMasterResult.metadata, - status: taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'configured' : 'not-configured' - }; - } else { - project.taskmaster = { + sessionsHydrated: false, + cursorSessions: [], + cursorSessionsHydrated: false, + codexSessions: [], + codexSessionsHydrated: false, + taskmaster: { hasTaskmaster: false, hasEssentialFiles: false, metadata: null, - status: 'error' - }; - } + status: 'unknown' + } + }; projects.push(project); } @@ -826,37 +810,22 @@ async function getProjects(progressCallback = null) { isManuallyAdded: true, sessions: [], cursorSessions: [], - codexSessions: getCodexSessionsForProjectFromIndex(await codexSessionsIndexPromise, actualProjectDir, 5) - }; - - const [cursorSessionsResult, taskMasterResult] = await Promise.all([ - getCursorSessions(actualProjectDir).catch((e) => { - console.warn(`Could not load Cursor sessions for manual project ${projectName}:`, e.message); - return []; - }), - detectTaskMasterFolder(actualProjectDir).catch((e) => { - console.warn(`TaskMaster detection failed for manual project ${projectName}:`, e.message); - return null; - }) - ]); - - project.cursorSessions = cursorSessionsResult; - if (taskMasterResult) { - const taskMasterStatus = taskMasterResult.hasTaskmaster && taskMasterResult.hasEssentialFiles ? 'taskmaster-only' : 'not-configured'; - project.taskmaster = { - status: taskMasterStatus, - hasTaskmaster: taskMasterResult.hasTaskmaster, - hasEssentialFiles: taskMasterResult.hasEssentialFiles, - metadata: taskMasterResult.metadata - }; - } else { - project.taskmaster = { - status: 'error', + codexSessions: [], + sessionMeta: { + hasMore: true, + total: 0, + lazy: true + }, + sessionsHydrated: false, + cursorSessionsHydrated: false, + codexSessionsHydrated: false, + taskmaster: { hasTaskmaster: false, hasEssentialFiles: false, - metadata: null - }; - } + metadata: null, + status: 'unknown' + } + }; projects.push(project); } diff --git a/src/App.jsx b/src/App.jsx index 513f378ad..46cfe78ca 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -89,6 +89,7 @@ function AppContent() { // Ref to track loading progress timeout for cleanup const loadingProgressTimeoutRef = useRef(null); + const attemptedUrlSessionResolutionsRef = useRef(new Set()); // Detect if running as PWA const [isPWA, setIsPWA] = useState(false); @@ -138,6 +139,35 @@ function AppContent() { fetchProjects(); }, []); + const mergeHydratedProjectData = useCallback((currentProjects, incomingProjects) => { + if (!Array.isArray(incomingProjects)) return []; + if (!Array.isArray(currentProjects) || currentProjects.length === 0) return incomingProjects; + + const currentByName = new Map(currentProjects.map((project) => [project.name, project])); + + return incomingProjects.map((incomingProject) => { + const currentProject = currentByName.get(incomingProject.name); + if (!currentProject) { + return incomingProject; + } + + const sessionsHydrated = Boolean(incomingProject.sessionsHydrated || currentProject.sessionsHydrated); + const cursorSessionsHydrated = Boolean(incomingProject.cursorSessionsHydrated || currentProject.cursorSessionsHydrated); + const codexSessionsHydrated = Boolean(incomingProject.codexSessionsHydrated || currentProject.codexSessionsHydrated); + + return { + ...incomingProject, + sessions: sessionsHydrated ? (currentProject.sessionsHydrated ? currentProject.sessions : (incomingProject.sessions || [])) : (incomingProject.sessions || []), + sessionMeta: sessionsHydrated ? (currentProject.sessionsHydrated ? currentProject.sessionMeta : incomingProject.sessionMeta) : incomingProject.sessionMeta, + sessionsHydrated, + cursorSessions: cursorSessionsHydrated ? (currentProject.cursorSessionsHydrated ? currentProject.cursorSessions : (incomingProject.cursorSessions || [])) : (incomingProject.cursorSessions || []), + cursorSessionsHydrated, + codexSessions: codexSessionsHydrated ? (currentProject.codexSessionsHydrated ? currentProject.codexSessions : (incomingProject.codexSessions || [])) : (incomingProject.codexSessions || []), + codexSessionsHydrated + }; + }); + }, []); + // Helper function to determine if an update is purely additive (new sessions/projects) // vs modifying existing selected items that would interfere with active conversations const isUpdateAdditive = (currentProjects, updatedProjects, selectedProject, selectedSession) => { @@ -231,7 +261,7 @@ function AppContent() { if (hasActiveSession) { // Allow updates but be selective: permit additions, prevent changes to existing items - const updatedProjects = latestMessage.projects; + const updatedProjects = mergeHydratedProjectData(projects, latestMessage.projects); const currentProjects = projects; // Check if this is purely additive (new sessions/projects) vs modification of existing ones @@ -245,7 +275,7 @@ function AppContent() { } // Update projects state with the new data from WebSocket - const updatedProjects = latestMessage.projects; + const updatedProjects = mergeHydratedProjectData(projects, latestMessage.projects); setProjects(updatedProjects); // Update selected project if it exists in the updated projects @@ -258,14 +288,24 @@ function AppContent() { } if (selectedSession) { - const allSessions = [ - ...(updatedSelectedProject.sessions || []), - ...(updatedSelectedProject.codexSessions || []), - ...(updatedSelectedProject.cursorSessions || []) - ]; - const updatedSelectedSession = allSessions.find(s => s.id === selectedSession.id); - if (!updatedSelectedSession) { - setSelectedSession(null); + const provider = selectedSession.__provider || 'claude'; + const providerHydrated = provider === 'cursor' + ? Boolean(updatedSelectedProject.cursorSessionsHydrated) + : provider === 'codex' + ? Boolean(updatedSelectedProject.codexSessionsHydrated) + : Boolean(updatedSelectedProject.sessionsHydrated); + + if (providerHydrated) { + const providerSessions = provider === 'cursor' + ? (updatedSelectedProject.cursorSessions || []) + : provider === 'codex' + ? (updatedSelectedProject.codexSessions || []) + : (updatedSelectedProject.sessions || []); + + const updatedSelectedSession = providerSessions.find(s => s.id === selectedSession.id); + if (!updatedSelectedSession) { + setSelectedSession(null); + } } } } @@ -279,7 +319,7 @@ function AppContent() { loadingProgressTimeoutRef.current = null; } }; - }, [latestMessage, selectedProject, selectedSession, activeSessions]); + }, [latestMessage, selectedProject, selectedSession, activeSessions, projects, mergeHydratedProjectData]); const fetchProjects = async () => { try { @@ -289,13 +329,15 @@ function AppContent() { // Optimize to preserve object references when data hasn't changed setProjects(prevProjects => { + const mergedProjects = mergeHydratedProjectData(prevProjects, data); + // If no previous projects, just set the new data if (prevProjects.length === 0) { - return data; + return mergedProjects; } // Check if the projects data has actually changed - const hasChanges = data.some((newProject, index) => { + const hasChanges = mergedProjects.some((newProject, index) => { const prevProject = prevProjects[index]; if (!prevProject) return true; @@ -308,10 +350,10 @@ function AppContent() { JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) || JSON.stringify(newProject.cursorSessions) !== JSON.stringify(prevProject.cursorSessions) ); - }) || data.length !== prevProjects.length; + }) || mergedProjects.length !== prevProjects.length; // Only update if there are actual changes - return hasChanges ? data : prevProjects; + return hasChanges ? mergedProjects : prevProjects; }); // Don't auto-select any project - user should choose manually @@ -333,6 +375,8 @@ function AppContent() { // Handle URL-based session loading useEffect(() => { + let cancelled = false; + if (sessionId && projects.length > 0) { // Only switch tabs on initial load, not on every project update const shouldSwitchTab = !selectedSession || selectedSession.id !== sessionId; @@ -359,12 +403,54 @@ function AppContent() { return; } } - - // If session not found, it might be a newly created session - // Just navigate to it and it will be found when the sidebar refreshes - // Don't redirect to home, let the session load naturally + + // Lazy session hydration can leave cursor sessions unloaded on deep links. + // Try one targeted lookup per sessionId to recover the selected session. + if (!attemptedUrlSessionResolutionsRef.current.has(sessionId)) { + attemptedUrlSessionResolutionsRef.current.add(sessionId); + + (async () => { + for (const project of projects) { + try { + const response = await api.cursorSessions(project.fullPath || project.path); + if (!response.ok) continue; + + const data = await response.json(); + const cursorSessions = data.success && Array.isArray(data.sessions) ? data.sessions : []; + const cSession = cursorSessions.find((session) => session.id === sessionId); + if (!cSession || cancelled) continue; + + const hydratedProject = { + ...project, + cursorSessions, + cursorSessionsHydrated: true + }; + + setProjects((prevProjects) => + prevProjects.map((prevProject) => + prevProject.name === project.name + ? { ...prevProject, cursorSessions, cursorSessionsHydrated: true } + : prevProject + ) + ); + setSelectedProject(hydratedProject); + setSelectedSession({ ...cSession, __provider: 'cursor' }); + if (shouldSwitchTab) { + setActiveTab('chat'); + } + return; + } catch (_) { + // Ignore per-project lookup errors and keep searching. + } + } + })(); + } } - }, [sessionId, projects, navigate]); + + return () => { + cancelled = true; + }; + }, [sessionId, projects, navigate, selectedSession]); const handleProjectSelect = (project) => { setSelectedProject(project); @@ -442,11 +528,12 @@ function AppContent() { try { const response = await api.projects(); const freshProjects = await response.json(); + const mergedFreshProjects = mergeHydratedProjectData(projects, freshProjects); // Optimize to preserve object references and minimize re-renders setProjects(prevProjects => { // Check if projects data has actually changed - const hasChanges = freshProjects.some((newProject, index) => { + const hasChanges = mergedFreshProjects.some((newProject, index) => { const prevProject = prevProjects[index]; if (!prevProject) return true; @@ -457,14 +544,14 @@ function AppContent() { JSON.stringify(newProject.sessionMeta) !== JSON.stringify(prevProject.sessionMeta) || JSON.stringify(newProject.sessions) !== JSON.stringify(prevProject.sessions) ); - }) || freshProjects.length !== prevProjects.length; + }) || mergedFreshProjects.length !== prevProjects.length; - return hasChanges ? freshProjects : prevProjects; + return hasChanges ? mergedFreshProjects : prevProjects; }); // If we have a selected project, make sure it's still selected after refresh if (selectedProject) { - const refreshedProject = freshProjects.find(p => p.name === selectedProject.name); + const refreshedProject = mergedFreshProjects.find(p => p.name === selectedProject.name); if (refreshedProject) { // Only update selected project if it actually changed if (JSON.stringify(refreshedProject) !== JSON.stringify(selectedProject)) { @@ -485,6 +572,44 @@ function AppContent() { } }; + const handleProjectHydrate = useCallback((projectName, hydratedData) => { + if (!projectName || !hydratedData) return; + + setProjects(prevProjects => + prevProjects.map(project => { + if (project.name !== projectName) { + return project; + } + + const mergedSessionMeta = hydratedData.sessionMeta + ? { ...(project.sessionMeta || {}), ...hydratedData.sessionMeta, lazy: false } + : project.sessionMeta; + + return { + ...project, + ...hydratedData, + sessionMeta: mergedSessionMeta + }; + }) + ); + + setSelectedProject(prevSelectedProject => { + if (!prevSelectedProject || prevSelectedProject.name !== projectName) { + return prevSelectedProject; + } + + const mergedSessionMeta = hydratedData.sessionMeta + ? { ...(prevSelectedProject.sessionMeta || {}), ...hydratedData.sessionMeta, lazy: false } + : prevSelectedProject.sessionMeta; + + return { + ...prevSelectedProject, + ...hydratedData, + sessionMeta: mergedSessionMeta + }; + }); + }, []); + const handleProjectDelete = (projectName) => { // If the deleted project was currently selected, clear it if (selectedProject?.name === projectName) { @@ -776,6 +901,7 @@ function AppContent() { onNewSession={handleNewSession} onSessionDelete={handleSessionDelete} onProjectDelete={handleProjectDelete} + onProjectHydrate={handleProjectHydrate} isLoading={isLoadingProjects} loadingProgress={loadingProgress} onRefresh={handleSidebarRefresh} @@ -871,6 +997,7 @@ function AppContent() { onNewSession={handleNewSession} onSessionDelete={handleSessionDelete} onProjectDelete={handleProjectDelete} + onProjectHydrate={handleProjectHydrate} isLoading={isLoadingProjects} loadingProgress={loadingProgress} onRefresh={handleSidebarRefresh} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index a495cfc91..6b1889921 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -53,6 +53,7 @@ function Sidebar({ onNewSession, onSessionDelete, onProjectDelete, + onProjectHydrate, isLoading, loadingProgress, onRefresh, @@ -123,29 +124,54 @@ function Sidebar({ return () => clearInterval(timer); }, []); - // Clear additional sessions when projects list changes (e.g., after refresh) + // Keep additional sessions for existing projects when project list refreshes useEffect(() => { - setAdditionalSessions({}); - setInitialSessionsLoaded(new Set()); + const projectNames = new Set(projects.map(project => project.name)); + + setAdditionalSessions(prev => { + const next = {}; + for (const [projectName, sessions] of Object.entries(prev)) { + if (projectNames.has(projectName)) { + next[projectName] = sessions; + } + } + return next; + }); }, [projects]); // Auto-expand project folder when a session is selected useEffect(() => { if (selectedSession && selectedProject) { setExpandedProjects(prev => new Set([...prev, selectedProject.name])); + if (!initialSessionsLoaded.has(selectedProject.name)) { + hydrateProjectSessions(selectedProject); + } } - }, [selectedSession, selectedProject]); + }, [selectedSession, selectedProject, initialSessionsLoaded]); // Mark sessions as loaded when projects come in useEffect(() => { if (projects.length > 0 && !isLoading) { - const newLoaded = new Set(); - projects.forEach(project => { - if (project.sessions && project.sessions.length >= 0) { - newLoaded.add(project.name); + const existingProjectNames = new Set(projects.map(project => project.name)); + setInitialSessionsLoaded(prev => { + const next = new Set(); + + // Keep already-loaded projects that still exist + for (const projectName of prev) { + if (existingProjectNames.has(projectName)) { + next.add(projectName); + } } + + // Also include projects explicitly marked hydrated by parent + projects.forEach(project => { + if (project.sessionsHydrated) { + next.add(project.name); + } + }); + + return next; }); - setInitialSessionsLoaded(newLoaded); } }, [projects, isLoading]); @@ -188,15 +214,101 @@ function Sidebar({ }; }, []); + const hydrateProjectSessions = async (project) => { + if (!project || loadingSessions[project.name]) { + return; + } + + setLoadingSessions(prev => ({ ...prev, [project.name]: true })); + + const projectPath = project.fullPath || project.path; + const fallbackTotal = typeof project.sessionMeta?.total === 'number' ? project.sessionMeta.total : 0; + + const cursorSessionsPromise = api.cursorSessions(projectPath) + .then(async (response) => { + if (!response.ok) return []; + const result = await response.json(); + return result.success && Array.isArray(result.sessions) ? result.sessions : []; + }) + .catch(() => []); + + const codexSessionsPromise = api.codexSessions(projectPath) + .then(async (response) => { + if (!response.ok) return []; + const result = await response.json(); + return result.success && Array.isArray(result.sessions) ? result.sessions : []; + }) + .catch(() => []); + + try { + let sessions = Array.isArray(project.sessions) ? project.sessions : []; + let sessionMeta = { + hasMore: project.sessionMeta?.hasMore !== false, + total: fallbackTotal + }; + let claudeHydrated = false; + + try { + const claudeResponse = await api.sessions(project.name, 5, 0); + if (claudeResponse.ok) { + const sessionResult = await claudeResponse.json(); + sessions = Array.isArray(sessionResult.sessions) ? sessionResult.sessions : []; + sessionMeta = { + hasMore: sessionResult.hasMore !== false, + total: typeof sessionResult.total === 'number' ? sessionResult.total : Math.max(fallbackTotal, sessions.length) + }; + claudeHydrated = true; + } + } catch (_) { + // Keep fallback session meta on request failure + } + + if (claudeHydrated && onProjectHydrate) { + onProjectHydrate(project.name, { + sessions, + sessionMeta, + sessionsHydrated: true + }); + + setInitialSessionsLoaded(prev => new Set([...prev, project.name])); + } + } catch (error) { + console.error(`Error loading sessions for project ${project.name}:`, error); + } finally { + setLoadingSessions(prev => ({ ...prev, [project.name]: false })); + } + + Promise.allSettled([cursorSessionsPromise, codexSessionsPromise]).then((results) => { + const cursorSessions = results[0].status === 'fulfilled' ? results[0].value : []; + const codexSessions = results[1].status === 'fulfilled' ? results[1].value : []; + + if (onProjectHydrate) { + onProjectHydrate(project.name, { + cursorSessions, + cursorSessionsHydrated: true, + codexSessions, + codexSessionsHydrated: true + }); + } + }); + }; const toggleProject = (projectName) => { + const isExpanding = !expandedProjects.has(projectName); const newExpanded = new Set(); // If clicking the already-expanded project, collapse it (newExpanded stays empty) // If clicking a different project, expand only that one - if (!expandedProjects.has(projectName)) { + if (isExpanding) { newExpanded.add(projectName); } setExpandedProjects(newExpanded); + + if (isExpanding && !initialSessionsLoaded.has(projectName)) { + const project = projects.find(item => item.name === projectName); + if (project) { + hydrateProjectSessions(project); + } + } }; // Wrapper to attach project context when session is clicked @@ -241,6 +353,25 @@ function Sidebar({ return [...claudeSessions, ...cursorSessions, ...codexSessions].sort((a, b) => normalizeDate(b) - normalizeDate(a)); }; + const getProjectSessionCountInfo = (project) => { + const hydrated = initialSessionsLoaded.has(project.name) || project.sessionsHydrated; + const hasMore = project.sessionMeta?.hasMore !== false; + + if (hydrated) { + const sessionCount = getAllSessions(project).length; + return { + numeric: sessionCount, + label: hasMore && sessionCount >= 5 ? `${sessionCount}+` : `${sessionCount}` + }; + } + + const total = typeof project.sessionMeta?.total === 'number' ? project.sessionMeta.total : 0; + return { + numeric: total, + label: hasMore && total >= 5 ? '5+' : `${total}` + }; + }; + // Helper function to get the last activity date for a project const getProjectLastActivity = (project) => { const allSessions = getAllSessions(project); @@ -434,6 +565,11 @@ function Sidebar({ }; const loadMoreSessions = async (project) => { + if (!initialSessionsLoaded.has(project.name)) { + await hydrateProjectSessions(project); + return; + } + // Check if we can load more sessions const canLoadMore = project.sessionMeta?.hasMore !== false; @@ -460,9 +596,14 @@ function Sidebar({ })); // Update project metadata if needed - if (result.hasMore === false) { - // Mark that there are no more sessions to load - project.sessionMeta = { ...project.sessionMeta, hasMore: false }; + if (result.hasMore === false && onProjectHydrate) { + onProjectHydrate(project.name, { + sessionMeta: { + ...project.sessionMeta, + hasMore: false + }, + sessionsHydrated: true + }); } } } catch (error) { @@ -921,10 +1062,8 @@ function Sidebar({

{(() => { - const sessionCount = getAllSessions(project).length; - const hasMore = project.sessionMeta?.hasMore !== false; - const count = hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount; - return `${count} session${count === 1 ? '' : 's'}`; + const countInfo = getProjectSessionCountInfo(project); + return `${countInfo.label} session${countInfo.numeric === 1 ? '' : 's'}`; })()}

@@ -1065,9 +1204,7 @@ function Sidebar({
{(() => { - const sessionCount = getAllSessions(project).length; - const hasMore = project.sessionMeta?.hasMore !== false; - return hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount; + return getProjectSessionCountInfo(project).label; })()} {project.fullPath !== project.displayName && ( diff --git a/src/utils/api.js b/src/utils/api.js index c423875da..c22c37dae 100644 --- a/src/utils/api.js +++ b/src/utils/api.js @@ -48,6 +48,10 @@ export const api = { projects: () => authenticatedFetch('/api/projects'), sessions: (projectName, limit = 5, offset = 0) => authenticatedFetch(`/api/projects/${projectName}/sessions?limit=${limit}&offset=${offset}`), + cursorSessions: (projectPath) => + authenticatedFetch(`/api/cursor/sessions?projectPath=${encodeURIComponent(projectPath)}`), + codexSessions: (projectPath) => + authenticatedFetch(`/api/codex/sessions?projectPath=${encodeURIComponent(projectPath)}`), sessionMessages: (projectName, sessionId, limit = null, offset = 0, provider = 'claude') => { const params = new URLSearchParams(); if (limit !== null) { @@ -182,4 +186,4 @@ export const api = { // Generic GET method for any endpoint get: (endpoint) => authenticatedFetch(`/api${endpoint}`), -}; \ No newline at end of file +}; From c19c81026577cfdb7498c99cc9f7577f23cae3ef Mon Sep 17 00:00:00 2001 From: KyungTae Kim Date: Wed, 11 Feb 2026 14:46:41 +0900 Subject: [PATCH 3/4] fix: address PR review blockers in project metadata/session readers --- server/projects.js | 140 +++++++++++++++++++++++++-------------------- 1 file changed, 77 insertions(+), 63 deletions(-) diff --git a/server/projects.js b/server/projects.js index 0b18dbd14..8719901c7 100755 --- a/server/projects.js +++ b/server/projects.js @@ -347,12 +347,13 @@ async function getSessionsPreview(projectName, limit = 5, offset = 0) { } const session = sessions.get(entry.sessionId); + session.messageCount += 1; if (entry.timestamp) { const timestamp = new Date(entry.timestamp); if (!Number.isNaN(timestamp.getTime())) { const currentTime = timestamp.toISOString(); - if (!session.lastActivity || new Date(currentTime) > new Date(session.lastActivity)) { + if (!session.lastActivity || currentTime > session.lastActivity) { session.lastActivity = currentTime; } } @@ -369,7 +370,6 @@ async function getSessionsPreview(projectName, limit = 5, offset = 0) { const role = entry.message?.role; if (role === 'user') { - session.messageCount += 1; if (!session.lastUserMessage) { const text = getMessageText(entry.message?.content); if (text && !isSystemUserMessage(text)) { @@ -377,7 +377,6 @@ async function getSessionsPreview(projectName, limit = 5, offset = 0) { } } } else if (role === 'assistant') { - session.messageCount += 1; if (!session.lastAssistantMessage && entry.isApiErrorMessage !== true) { const text = getMessageText(entry.message?.content); if (text && !isSystemAssistantMessage(text)) { @@ -1101,15 +1100,7 @@ async function parseJsonlSessions(filePath) { // Filter out sessions that contain JSON responses (Task Master errors) const allSessions = Array.from(sessions.values()); - const filteredSessions = allSessions.filter(session => { - const shouldFilter = session.summary.startsWith('{ "'); - if (shouldFilter) { - } - // Log a sample of summaries to debug - if (Math.random() < 0.01) { // Log 1% of sessions - } - return !shouldFilter; - }); + const filteredSessions = allSessions.filter(session => !session.summary.startsWith('{ "')); return { @@ -1200,10 +1191,16 @@ async function renameProject(projectName, newDisplayName) { if (!newDisplayName || newDisplayName.trim() === '') { // Remove custom name if empty, will fall back to auto-generated - delete config[projectName]; + if (config[projectName]) { + delete config[projectName].displayName; + if (Object.keys(config[projectName]).length === 0) { + delete config[projectName]; + } + } } else { - // Set custom display name + // Set custom display name while preserving existing project settings config[projectName] = { + ...config[projectName], displayName: newDisplayName.trim() }; } @@ -1377,7 +1374,22 @@ async function addProjectManually(projectPath, displayName = null) { displayName: displayName || await generateDisplayName(projectName, absolutePath), isManuallyAdded: true, sessions: [], - cursorSessions: [] + cursorSessions: [], + codexSessions: [], + sessionMeta: { + hasMore: true, + total: 0, + lazy: true + }, + sessionsHydrated: false, + cursorSessionsHydrated: false, + codexSessionsHydrated: false, + taskmaster: { + hasTaskmaster: false, + hasEssentialFiles: false, + metadata: null, + status: 'unknown' + } }; } @@ -1421,60 +1433,62 @@ async function getCursorSessions(projectPath) { driver: sqlite3.Database, mode: sqlite3.OPEN_READONLY }); - - // Get metadata from meta table - const metaRows = await db.all(` - SELECT key, value FROM meta - `); - - // Parse metadata - let metadata = {}; - for (const row of metaRows) { - if (row.value) { - try { - // Try to decode as hex-encoded JSON - const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/); - if (hexMatch) { - const jsonStr = Buffer.from(row.value, 'hex').toString('utf8'); - metadata[row.key] = JSON.parse(jsonStr); - } else { + + try { + // Get metadata from meta table + const metaRows = await db.all(` + SELECT key, value FROM meta + `); + + // Parse metadata + let metadata = {}; + for (const row of metaRows) { + if (row.value) { + try { + // Try to decode as hex-encoded JSON + const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/); + if (hexMatch) { + const jsonStr = Buffer.from(row.value, 'hex').toString('utf8'); + metadata[row.key] = JSON.parse(jsonStr); + } else { + metadata[row.key] = row.value.toString(); + } + } catch (e) { metadata[row.key] = row.value.toString(); } - } catch (e) { - metadata[row.key] = row.value.toString(); } } + + // Get message count + const messageCountResult = await db.get(` + SELECT COUNT(*) as count FROM blobs + `); + + // Extract session info + const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session'; + + // Determine timestamp - prefer createdAt from metadata, fall back to db file mtime + let createdAt = null; + if (metadata.createdAt) { + createdAt = new Date(metadata.createdAt).toISOString(); + } else if (dbStatMtimeMs) { + createdAt = new Date(dbStatMtimeMs).toISOString(); + } else { + createdAt = new Date().toISOString(); + } + + sessions.push({ + id: sessionId, + name: sessionName, + createdAt: createdAt, + lastActivity: createdAt, // For compatibility with Claude sessions + messageCount: messageCountResult.count || 0, + projectPath: projectPath + }); + } finally { + await db.close(); } - // Get message count - const messageCountResult = await db.get(` - SELECT COUNT(*) as count FROM blobs - `); - - await db.close(); - - // Extract session info - const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session'; - - // Determine timestamp - prefer createdAt from metadata, fall back to db file mtime - let createdAt = null; - if (metadata.createdAt) { - createdAt = new Date(metadata.createdAt).toISOString(); - } else if (dbStatMtimeMs) { - createdAt = new Date(dbStatMtimeMs).toISOString(); - } else { - createdAt = new Date().toISOString(); - } - - sessions.push({ - id: sessionId, - name: sessionName, - createdAt: createdAt, - lastActivity: createdAt, // For compatibility with Claude sessions - messageCount: messageCountResult.count || 0, - projectPath: projectPath - }); - } catch (error) { console.warn(`Could not read Cursor session ${sessionId}:`, error.message); } From f2e335ddb04bd00c54b5f945a0f1513c63af0b83 Mon Sep 17 00:00:00 2001 From: KyungTae Kim Date: Wed, 11 Feb 2026 14:54:55 +0900 Subject: [PATCH 4/4] fix: resolve PR review blockers for lazy project loading --- server/projects.js | 119 ++-------------------------- src/App.jsx | 18 +++-- src/components/Sidebar.jsx | 5 +- src/i18n/locales/en/sidebar.json | 4 +- src/i18n/locales/ko/sidebar.json | 6 +- src/i18n/locales/zh-CN/sidebar.json | 4 +- 6 files changed, 34 insertions(+), 122 deletions(-) diff --git a/server/projects.js b/server/projects.js index 8719901c7..08975c41a 100755 --- a/server/projects.js +++ b/server/projects.js @@ -205,15 +205,6 @@ function clearProjectDirectoryCache() { projectDirectoryCache.clear(); } -function normalizeCodexPath(projectPath = '') { - if (!projectPath || typeof projectPath !== 'string') { - return ''; - } - - const cleanedPath = projectPath.startsWith('\\\\?\\') ? projectPath.slice(4) : projectPath; - return path.normalize(cleanedPath); -} - function getMessageText(content) { if (Array.isArray(content)) { const textParts = content @@ -442,99 +433,6 @@ async function getSessionsPreview(projectName, limit = 5, offset = 0) { } } -async function findJsonlFilesRecursive(dir) { - const files = []; - try { - const entries = await fs.readdir(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...await findJsonlFilesRecursive(fullPath)); - } else if (entry.name.endsWith('.jsonl')) { - files.push(fullPath); - } - } - } catch (_) { - // Skip directories we cannot read - } - return files; -} - -async function buildCodexSessionsIndex(limitPerProject = 5) { - const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions'); - const sessionsByCwd = new Map(); - - try { - await fs.access(codexSessionsDir); - } catch { - return sessionsByCwd; - } - - const jsonlFiles = await findJsonlFilesRecursive(codexSessionsDir); - - for (const filePath of jsonlFiles) { - try { - const sessionData = await parseCodexSessionFile(filePath); - if (!sessionData?.cwd) { - continue; - } - - const normalizedCwd = normalizeCodexPath(sessionData.cwd); - if (!normalizedCwd) { - continue; - } - - const session = { - id: sessionData.id, - summary: sessionData.summary || 'Codex Session', - messageCount: sessionData.messageCount || 0, - lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(), - cwd: sessionData.cwd, - model: sessionData.model, - filePath, - provider: 'codex' - }; - - if (!sessionsByCwd.has(normalizedCwd)) { - sessionsByCwd.set(normalizedCwd, []); - } - sessionsByCwd.get(normalizedCwd).push(session); - } catch (error) { - console.warn(`Could not parse Codex session file ${filePath}:`, error.message); - } - } - - for (const [cwd, sessions] of sessionsByCwd.entries()) { - const sortedSessions = sessions - .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)) - .slice(0, limitPerProject); - sessionsByCwd.set(cwd, sortedSessions); - } - - return sessionsByCwd; -} - -function getCodexSessionsForProjectFromIndex(codexSessionsIndex, projectPath, limit = 5) { - const normalizedProjectPath = normalizeCodexPath(projectPath); - if (!normalizedProjectPath || codexSessionsIndex.size === 0) { - return []; - } - - const matchedSessions = []; - for (const [sessionCwd, sessions] of codexSessionsIndex.entries()) { - try { - if (sessionCwd === normalizedProjectPath || path.relative(sessionCwd, normalizedProjectPath) === '') { - matchedSessions.push(...sessions); - } - } catch (_) { - // Ignore cross-device / invalid relative path comparisons - } - } - - matchedSessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity)); - return limit > 0 ? matchedSessions.slice(0, limit) : matchedSessions; -} - // Load project configuration file async function loadProjectConfig() { const configPath = path.join(os.homedir(), '.claude', 'project-config.json'); @@ -757,10 +655,7 @@ async function getProjects(progressCallback = null) { for (const entry of directories) { emitProjectProgress(entry.name); - const [previewResult, sessionMetaResult] = await Promise.all([ - getSessionsPreview(entry.name, 5, 0), - getSessions(entry.name, 5, 0).catch(() => ({ hasMore: false, total: 0 })) - ]); + const previewResult = await getSessionsPreview(entry.name, 5, 0); const decodedProjectPath = entry.name.replace(/-/g, '/'); const configuredProjectPath = config[entry.name]?.originalPath; const actualProjectDir = configuredProjectPath || previewResult.projectPathHint || decodedProjectPath; @@ -775,8 +670,8 @@ async function getProjects(progressCallback = null) { isCustomName: !!customName, sessions: previewResult.sessions || [], sessionMeta: { - hasMore: sessionMetaResult.hasMore, - total: sessionMetaResult.total, + hasMore: previewResult.hasMore, + total: previewResult.total, lazy: true }, sessionsHydrated: false, @@ -811,7 +706,7 @@ async function getProjects(progressCallback = null) { cursorSessions: [], codexSessions: [], sessionMeta: { - hasMore: true, + hasMore: false, total: 0, lazy: true }, @@ -1377,7 +1272,7 @@ async function addProjectManually(projectPath, displayName = null) { cursorSessions: [], codexSessions: [], sessionMeta: { - hasMore: true, + hasMore: false, total: 0, lazy: true }, @@ -1529,7 +1424,7 @@ async function getCodexSessions(projectPath, options = {}) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { + if (entry.isDirectory() && !entry.isSymbolicLink()) { files.push(...await findJsonlFiles(fullPath)); } else if (entry.name.endsWith('.jsonl')) { files.push(fullPath); @@ -1900,7 +1795,7 @@ async function deleteCodexSession(sessionId) { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { + if (entry.isDirectory() && !entry.isSymbolicLink()) { files.push(...await findJsonlFiles(fullPath)); } else if (entry.name.endsWith('.jsonl')) { files.push(fullPath); diff --git a/src/App.jsx b/src/App.jsx index 46cfe78ca..fb54a3d77 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -71,6 +71,7 @@ function AppContent() { const [autoScrollToBottom, setAutoScrollToBottom] = useLocalStorage('autoScrollToBottom', true); const [sendByCtrlEnter, setSendByCtrlEnter] = useLocalStorage('sendByCtrlEnter', false); const [sidebarVisible, setSidebarVisible] = useLocalStorage('sidebarVisible', true); + const projectsRef = useRef([]); // Session Protection System: Track sessions with active conversations to prevent // automatic project updates from interrupting ongoing chats. When a user sends // a message, the session is marked as "active" and project updates are paused @@ -139,6 +140,10 @@ function AppContent() { fetchProjects(); }, []); + useEffect(() => { + projectsRef.current = projects; + }, [projects]); + const mergeHydratedProjectData = useCallback((currentProjects, incomingProjects) => { if (!Array.isArray(incomingProjects)) return []; if (!Array.isArray(currentProjects) || currentProjects.length === 0) return incomingProjects; @@ -261,8 +266,8 @@ function AppContent() { if (hasActiveSession) { // Allow updates but be selective: permit additions, prevent changes to existing items - const updatedProjects = mergeHydratedProjectData(projects, latestMessage.projects); - const currentProjects = projects; + const currentProjects = projectsRef.current; + const updatedProjects = mergeHydratedProjectData(currentProjects, latestMessage.projects); // Check if this is purely additive (new sessions/projects) vs modification of existing ones const isAdditiveUpdate = isUpdateAdditive(currentProjects, updatedProjects, selectedProject, selectedSession); @@ -275,7 +280,8 @@ function AppContent() { } // Update projects state with the new data from WebSocket - const updatedProjects = mergeHydratedProjectData(projects, latestMessage.projects); + const updatedProjects = mergeHydratedProjectData(projectsRef.current, latestMessage.projects); + projectsRef.current = updatedProjects; setProjects(updatedProjects); // Update selected project if it exists in the updated projects @@ -319,7 +325,7 @@ function AppContent() { loadingProgressTimeoutRef.current = null; } }; - }, [latestMessage, selectedProject, selectedSession, activeSessions, projects, mergeHydratedProjectData]); + }, [latestMessage, selectedProject, selectedSession, activeSessions, mergeHydratedProjectData]); const fetchProjects = async () => { try { @@ -528,10 +534,12 @@ function AppContent() { try { const response = await api.projects(); const freshProjects = await response.json(); - const mergedFreshProjects = mergeHydratedProjectData(projects, freshProjects); + let mergedFreshProjects = []; // Optimize to preserve object references and minimize re-renders setProjects(prevProjects => { + mergedFreshProjects = mergeHydratedProjectData(prevProjects, freshProjects); + // Check if projects data has actually changed const hasChanges = mergedFreshProjects.some((newProject, index) => { const prevProject = prevProjects[index]; diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 6b1889921..d31d0dd76 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1063,7 +1063,10 @@ function Sidebar({

{(() => { const countInfo = getProjectSessionCountInfo(project); - return `${countInfo.label} session${countInfo.numeric === 1 ? '' : 's'}`; + return t('projects.sessionCountLabel', { + count: countInfo.numeric, + label: countInfo.label + }); })()}

diff --git a/src/i18n/locales/en/sidebar.json b/src/i18n/locales/en/sidebar.json index e1b67ae91..853fa9002 100644 --- a/src/i18n/locales/en/sidebar.json +++ b/src/i18n/locales/en/sidebar.json @@ -17,7 +17,9 @@ "projects": "projects", "noMatchingProjects": "No matching projects", "tryDifferentSearch": "Try adjusting your search term", - "runClaudeCli": "Run Claude CLI in a project directory to get started" + "runClaudeCli": "Run Claude CLI in a project directory to get started", + "sessionCountLabel_one": "{{label}} session", + "sessionCountLabel_other": "{{label}} sessions" }, "app": { "title": "Claude Code UI", diff --git a/src/i18n/locales/ko/sidebar.json b/src/i18n/locales/ko/sidebar.json index 2daa76586..26f098591 100644 --- a/src/i18n/locales/ko/sidebar.json +++ b/src/i18n/locales/ko/sidebar.json @@ -17,7 +17,9 @@ "projects": "프로젝트", "noMatchingProjects": "일치하는 프로젝트 없음", "tryDifferentSearch": "검색어를 변경해보세요", - "runClaudeCli": "프로젝트 디렉토리에서 Claude CLI를 실행하여 시작하세요" + "runClaudeCli": "프로젝트 디렉토리에서 Claude CLI를 실행하여 시작하세요", + "sessionCountLabel_one": "{{label}}개 대화", + "sessionCountLabel_other": "{{label}}개 대화" }, "app": { "title": "Claude Code UI", @@ -109,4 +111,4 @@ "allConversationsDeleted": "모든 대화가 영구적으로 삭제됩니다.", "cannotUndo": "이 작업은 취소할 수 없습니다." } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-CN/sidebar.json b/src/i18n/locales/zh-CN/sidebar.json index 60d399154..c060bfb2b 100644 --- a/src/i18n/locales/zh-CN/sidebar.json +++ b/src/i18n/locales/zh-CN/sidebar.json @@ -17,7 +17,9 @@ "projects": "项目", "noMatchingProjects": "未找到匹配的项目", "tryDifferentSearch": "尝试调整您的搜索词", - "runClaudeCli": "在项目目录中运行 Claude CLI 以开始使用" + "runClaudeCli": "在项目目录中运行 Claude CLI 以开始使用", + "sessionCountLabel_one": "{{label}} 个对话", + "sessionCountLabel_other": "{{label}} 个对话" }, "app": { "title": "Claude Code UI",