Skip to content

perf: speed up project/session scanning I/O#378

Open
GizAI wants to merge 5 commits intositeboon:mainfrom
GizAI:perf/project-scan-io
Open

perf: speed up project/session scanning I/O#378
GizAI wants to merge 5 commits intositeboon:mainfrom
GizAI:perf/project-scan-io

Conversation

@GizAI
Copy link

@GizAI GizAI commented Feb 11, 2026

Summary

  • optimize Claude session scanning by reading JSONL tails instead of full files
  • add lightweight project/session preview extraction path and codex index pass
  • remove redundant per-project Cursor session fetch loop in frontend startup

Why

  • large ~/.claude/projects JSONL files were fully read on each refresh, causing slow project loading
  • goal is to keep SSOT behavior while reducing I/O and startup latency

Notes

  • no new persistence layer introduced
  • preserves existing API shape

Summary by CodeRabbit

  • New Features

    • Paginated session previews and indexing for faster browsing across providers.
    • On-demand project hydration from multiple session sources via Sidebar callback.
    • New client API methods to fetch Cursor and Codex sessions.
    • Manual project registration with richer metadata and display counts.
  • Performance Improvements

    • Consolidated project/session loading with progress events and incremental hydration to reduce redundant fetches.
  • Bug Fixes

    • Safer file traversal and more robust session extraction to avoid corrupt or symlinked data.
  • Localization

    • Added singular/plural session count labels for multiple locales.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

Walkthrough

Adds JSONL tail reading, recursive discovery and Codex session indexing on the server; exposes new session/index helpers. Frontend gains hydration-aware project/session merging, URL-based one-time hydration attempts, Sidebar hydration callbacks, and new client API helpers for cursor/codex sessions.

Changes

Cohort / File(s) Summary
Backend: projects & Codex indexing
server/projects.js
Added JSONL tail reader (readJsonlTailLines), recursive .jsonl discovery, session preview builder (getSessionsPreview), Codex sessions index creation (buildCodexSessionsIndex) and lookup (getCodexSessionsForProjectFromIndex); richer session/message normalization, index-backed retrieval, non-symlink traversal guards, and exported new helpers.
Frontend: App hydration & project handling
src/App.jsx
Added hydration-aware merge logic (mergeHydratedProjectData), projectsRef tracking, one-time URL session resolution, handleProjectHydrate plumbing, and updated update/selection flows to preserve hydrated subviews.
Frontend: Sidebar session hydration
src/components/Sidebar.jsx
Added onProjectHydrate prop and hydrateProjectSessions flow to request/hydrate projects (Claude/Cursor/Codex), preserve existing additionalSessions on refresh, adjust auto-expansion to trigger hydration, and update session-count display logic.
Client API helpers
src/utils/api.js
Added cursorSessions(projectPath) and codexSessions(projectPath) helpers mapping to /api/cursor/sessions and /api/codex/sessions.
i18n: sidebar locales
src/i18n/locales/en/sidebar.json, src/i18n/locales/ko/sidebar.json, src/i18n/locales/zh-CN/sidebar.json
Added pluralization keys sessionCountLabel_one and sessionCountLabel_other for session count labels.

Sequence Diagram(s)

sequenceDiagram
  participant Frontend
  participant Server
  participant FS as FileSystem
  participant CodexIndex

  Frontend->>Server: GET /api/projects
  Server->>CodexIndex: buildCodexSessionsIndex(limitPerProject)
  CodexIndex->>FS: findJsonlFilesRecursive(projectPaths)
  loop per file
    CodexIndex->>FS: readJsonlTailLines(filePath, maxBytes, maxLines)
    FS-->>CodexIndex: tail lines
    CodexIndex-->>CodexIndex: parse/aggregate session previews
  end
  CodexIndex-->>Server: codexSessionsIndex
  Server-->>Frontend: projects payload (with preview/index hints)
  Frontend->>Server: POST /api/projects/:id/hydrate (onProjectHydrate)
  Server->>CodexIndex: getCodexSessionsForProjectFromIndex(projectPath, limit)
  CodexIndex-->>Server: sessions list
  Server-->>Frontend: hydrated session data
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Suggested reviewers

  • blackmammoth

Poem

🐇 I nibble at JSON tails beneath the moon,
I hop through folders, index each tune,
I stitch sessions, whisper hydration cheer,
Little previews bloom, the paths appear —
🥕 Bounce, fetch, and hop the data near!

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'perf: speed up project/session scanning I/O' clearly and concisely summarizes the main objective of the changeset: optimizing I/O performance for project and session scanning operations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@GizAI GizAI mentioned this pull request Feb 11, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
server/projects.js (3)

1229-1244: ⚠️ Potential issue | 🔴 Critical

Clearing a display name deletes the entire project config, including manuallyAdded and originalPath.

When newDisplayName is empty, delete config[projectName] (line 1234) removes the whole config object. For manually-added projects this wipes manuallyAdded and originalPath, effectively unregistering them. Only the displayName key should be removed.

🐛 Proposed fix
 async function renameProject(projectName, newDisplayName) {
   const config = await loadProjectConfig();
   
   if (!newDisplayName || newDisplayName.trim() === '') {
-    // Remove custom name if empty, will fall back to auto-generated
-    delete config[projectName];
+    // Remove custom name if empty, will fall back to auto-generated
+    if (config[projectName]) {
+      delete config[projectName].displayName;
+    }
   } else {
-    // Set custom display name
-    config[projectName] = {
-      displayName: newDisplayName.trim()
-    };
+    // Set custom display name, preserving other config
+    config[projectName] = {
+      ...config[projectName],
+      displayName: newDisplayName.trim()
+    };
   }
   
   await saveProjectConfig(config);
   return true;
 }

1400-1412: ⚠️ Potential issue | 🟡 Minor

addProjectManually returns a project object missing codexSessions, taskmaster, and sessionMeta fields.

getProjects now returns project objects with codexSessions, taskmaster, and sessionMeta properties. The object returned by addProjectManually (used when a user adds a project via UI) lacks these fields, which can cause undefined property access in frontend components that render the response directly.

🛡️ Proposed fix — align return shape
   return {
     name: projectName,
     path: absolutePath,
     fullPath: absolutePath,
     displayName: displayName || await generateDisplayName(projectName, absolutePath),
     isManuallyAdded: true,
     sessions: [],
-    cursorSessions: []
+    cursorSessions: [],
+    codexSessions: [],
+    sessionMeta: { hasMore: false, total: 0 },
+    taskmaster: {
+      hasTaskmaster: false,
+      hasEssentialFiles: false,
+      metadata: null,
+      status: 'not-configured'
+    }
   };

1450-1511: ⚠️ Potential issue | 🟠 Major

SQLite connection is leaked when a query throws before db.close().

If any of the queries between open (line 1450) and db.close() (line 1485) throw, execution jumps to the outer catch at line 1509 without closing the database. Over many sessions, this leaks file descriptors.

🐛 Proposed fix — wrap in try/finally
         // Open SQLite database
         const db = await open({
           filename: storeDbPath,
           driver: sqlite3.Database,
           mode: sqlite3.OPEN_READONLY
         });
-        
-        // Get metadata from meta table
-        const metaRows = await db.all(`
-          SELECT key, value FROM meta
-        `);
-        
-        // Parse metadata
-        ...
-        
-        // Get message count
-        const messageCountResult = await db.get(`
-          SELECT COUNT(*) as count FROM blobs
-        `);
-        
-        await db.close();
+
+        try {
+          // Get metadata from meta table
+          const metaRows = await db.all(`
+            SELECT key, value FROM meta
+          `);
+
+          // ... (parse metadata, get message count — unchanged) ...
+
+          const messageCountResult = await db.get(`
+            SELECT COUNT(*) as count FROM blobs
+          `);
+
+          // Extract session info & push to sessions array here
+          // (move the push logic inside this try block)
+        } finally {
+          await db.close();
+        }
🤖 Fix all issues with AI agents
In `@server/projects.js`:
- Around line 375-392: getSessionsPreview currently increments
session.messageCount only for 'user' and 'assistant' roles which differs from
parseJsonlSessions (which increments for every entry with a sessionId), causing
inconsistent counts; update getSessionsPreview (or alternatively rename the
preview field) so the semantics match parseJsonlSessions: either increment
session.messageCount for every entry with a sessionId (including system/summary
entries) to mirror parseJsonlSessions, or rename session.messageCount in
getSessionsPreview to something like userAssistantCount and update consumers;
refer to getSessionsPreview, parseJsonlSessions, and the
session.messageCount/userAssistantCount identifiers when making the change.
- Line 758: The promise returned by buildCodexSessionsIndex is not guarded, so
codexSessionsIndexPromise can reject and abort getProjects; wrap the call site
by attaching a .catch(...) to codexSessionsIndexPromise (where const
codexSessionsIndexPromise = buildCodexSessionsIndex(5)) so it always resolves to
a safe fallback (e.g., null or an empty index) on error; ensure downstream
awaits (the places that await codexSessionsIndexPromise in getProjects) handle
that fallback value gracefully.
🧹 Nitpick comments (6)
server/projects.js (6)

356-364: Redundant Date object allocations in the inner loop.

On every JSONL entry with a timestamp, two new Date() objects are created for comparison (line 360). Since both values are ISO 8601 strings, a simple string comparison (currentTime > session.lastActivity) is sufficient and avoids allocation churn across thousands of lines.

♻️ Suggested diff
         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;
             }
           }
         }

446-462: Duplicate recursive JSONL file finders can be consolidated.

findJsonlFilesRecursive duplicates the local findJsonlFiles closures inside getCodexSessions (line 1543) and deleteCodexSession (line 1914). Consider replacing those inline closures with calls to this shared utility to reduce duplication.


508-513: lastActivity is already a Date; wrapping it again is redundant.

At line 492, lastActivity is assigned as a Date object. The sort comparator at line 510 wraps it in new Date(...) again unnecessarily.

♻️ Suggested diff
   for (const [cwd, sessions] of sessionsByCwd.entries()) {
     const sortedSessions = sessions
-      .sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity))
+      .sort((a, b) => b.lastActivity - a.lastActivity)
       .slice(0, limitPerProject);
     sessionsByCwd.set(cwd, sortedSessions);
   }

518-537: Prefer Map.get for O(1) lookup before falling back to iteration.

Both keys and the lookup value go through normalizeCodexPath, so a direct Map.get(normalizedProjectPath) will match in the common case. The current implementation iterates the entire index for every project, turning the overall complexity to O(projects × index entries).

♻️ Suggested diff
 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
-    }
-  }
+  // Fast path: direct map lookup
+  const direct = codexSessionsIndex.get(normalizedProjectPath);
+  if (direct) {
+    const sorted = [...direct].sort((a, b) => b.lastActivity - a.lastActivity);
+    return limit > 0 ? sorted.slice(0, limit) : sorted;
+  }
 
-  matchedSessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
+  // Slow path: fallback for normalization edge cases
+  const matchedSessions = [];
+  for (const [sessionCwd, sessions] of codexSessionsIndex.entries()) {
+    try {
+      if (path.relative(sessionCwd, normalizedProjectPath) === '') {
+        matchedSessions.push(...sessions);
+      }
+    } catch (_) {}
+  }
+  matchedSessions.sort((a, b) => b.lastActivity - a.lastActivity);
   return limit > 0 ? matchedSessions.slice(0, limit) : matchedSessions;
 }

760-813: Sequential per-project I/O limits the benefit of the tail-read optimization.

Each project in the loop sequentially calls getSessionsPreview (which does tail I/O), generateDisplayName (which may read package.json), getCursorSessions (SQLite), and detectTaskMasterFolder (filesystem). For repositories with many projects, this serialization still accumulates significant latency.

Consider batching projects in small parallel groups (e.g., Promise.all over chunks of 4–6 projects) to exploit I/O concurrency without overwhelming the filesystem.


1134-1143: Dead debug blocks left behind in parseJsonlSessions.

Lines 1137–1138 and 1140–1141 are empty conditional blocks (the console.log calls were likely stripped). These are noise and can be removed.

♻️ Suggested cleanup
     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;
+      return !session.summary.startsWith('{ "');
     });

@GizAI
Copy link
Author

GizAI commented Feb 11, 2026

Addressed CodeRabbit's actionable issues in commit c19c810 (on top of 602df0b baseline):

  • Fixed renameProject to preserve existing project config (manuallyAdded, originalPath) when clearing display name.
  • Aligned addProjectManually response shape with getProjects (added codexSessions, sessionMeta, hydration flags, taskmaster).
  • Wrapped Cursor SQLite reads in try/finally to guarantee db.close() on all paths.
  • Matched preview messageCount semantics with parseJsonlSessions.
  • Removed dead debug filter block in parseJsonlSessions.

Validation run:

  • pnpm -s run typecheck
  • pnpm -s run build

@coderabbitai please re-review the latest commit.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

@GizAI Thank you for the comprehensive fixes! I can see you've systematically addressed all the actionable issues:

renameProject - Now preserves manuallyAdded and originalPath
addProjectManually - Response shape aligned with full hydration
Cursor SQLite - Proper resource cleanup with try/finally
Preview semantics - messageCount now consistent
Code cleanup - Removed dead filter block

I appreciate the validation run with typecheck and build. I'll review the latest commit now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/App.jsx (1)

526-549: ⚠️ Potential issue | 🟠 Major

Stale closure: merge computed outside setProjects callback.

mergedFreshProjects at line 531 is computed from the projects closure value, but the setProjects updater (line 534) receives the real latest prevProjects. If a concurrent update (e.g., from WebSocket or hydration) changes projects between line 531 and the updater execution, the merge is based on stale data, potentially dropping hydration state.

Move the merge inside the functional updater to use the latest state:

🐛 Proposed fix
   const handleSidebarRefresh = async () => {
     try {
       const response = await api.projects();
       const freshProjects = await response.json();
-      const mergedFreshProjects = mergeHydratedProjectData(projects, freshProjects);
       
       setProjects(prevProjects => {
+        const mergedFreshProjects = mergeHydratedProjectData(prevProjects, freshProjects);
         const hasChanges = mergedFreshProjects.some((newProject, index) => {
           // ...
         }) || mergedFreshProjects.length !== prevProjects.length;
         
         return hasChanges ? mergedFreshProjects : prevProjects;
       });
       
       if (selectedProject) {
+        // Re-compute merge for selected project lookup
+        const mergedFreshProjects = mergeHydratedProjectData(projects, freshProjects);
         const refreshedProject = mergedFreshProjects.find(p => p.name === selectedProject.name);
         // ...
🤖 Fix all issues with AI agents
In `@server/projects.js`:
- Around line 799-831: The manual project builder is setting
sessionMeta.hasMore: true for entries added from manualProjects which causes the
UI to attempt loading non-existent sessions; change the default to
sessionMeta.hasMore: false (or remove the hardcoded hasMore and let the existing
hydration logic update it) in the project object created inside the
manualProjects loop (the block that calls emitProjectProgress and
generateDisplayName), so cursorSessions/codexSessions/sessions hydration can
determine the real value.
- Around line 758-764: The code currently calls getSessionsPreview(...) and
getSessions(...) in parallel for each project, doubling disk I/O because
getSessions still parses full JSONL via parseJsonlSessions; change this to avoid
calling getSessions at startup: use the metadata already computed by
getSessionsPreview (it returns hasMore/total) to populate hasMore/total for the
project (referencing getSessionsPreview and parseJsonlSessions), and remove or
defer the getSessions(...) call so full parsing only runs when the user expands
a project (i.e., call getSessions inside the expansion handler instead of in the
projects loop where directories and emitProjectProgress are used).
- Around line 208-215: normalizeCodexPath currently returns '' for
non-string/falsy inputs but falls through to path.normalize for strings, which
turns an input like '\\\\?\\' into '.' and breaks callers that do falsy checks
(e.g., where normalizedCwd and normalizedProjectPath are used). Change
normalizeCodexPath so that after stripping a leading '\\\\?\\' you explicitly
return '' when cleanedPath === '' (preserving the original falsy semantic for
inputs that become empty), otherwise return path.normalize(cleanedPath); keep
the initial non-string/falsy guard and the unique function name
normalizeCodexPath to locate the change.
- Around line 518-537: The two functions buildCodexSessionsIndex and
getCodexSessionsForProjectFromIndex are dead code (not exported or referenced)
and should be removed; delete their full definitions from the module (remove the
function declarations and any associated unused imports/variables they rely on,
e.g., normalizeCodexPath and any local use of codexSessionsIndex) and run the
test/lint step to ensure no remaining references; if these are intended for
future use, instead add a clear TODO comment and export them from the module so
callers can use them.
- Around line 446-462: The recursive file-walk functions are following symlinks
and can loop; update findJsonlFilesRecursive and the two inline findJsonlFiles
used by getCodexSessions and deleteCodexSession to skip symbolic links by
checking !entry.isSymbolicLink() before recursing into directories (i.e., only
recurse when entry.isDirectory() && !entry.isSymbolicLink()); also consider
extracting a single shared utility (e.g., findJsonlFiles) and replacing the
three implementations with that to ensure consistent symlink protection across
callers.

In `@src/App.jsx`:
- Line 322: The effect is re-triggering infinitely because projects is in the
dependency array and setProjects(…) creates a new array reference (via
mergeHydratedProjectData.map), so update the effect to stop depending on
projects/mergeHydratedProjectData: either (preferred) use a functional state
update like setProjects(prev => mergeHydratedProjectData(prev, ...)) and remove
projects and mergeHydratedProjectData from the dependency array, or implement a
last-processed message id/ref (e.g., lastProcessedMessageIdRef) to skip
reprocessing the same latestMessage; also refactor any isUpdateAdditive and
selectedProject updates to read from refs or the functional updater so they no
longer require projects in the deps, leaving only stable deps (latestMessage,
selectedSession, activeSessions, etc.) in the array.

In `@src/components/Sidebar.jsx`:
- Around line 1063-1067: Replace the hardcoded English pluralization in the
Sidebar JSX (the IIFE that calls getProjectSessionCountInfo(project)) with the
i18n t() call so translations handle singular/plural forms; call
t('project.session.count', { label: countInfo.label, count: countInfo.numeric })
(or your app's pluralization key) instead of manually appending 's', and add
corresponding keys to your locale files (e.g., "project.session.count" with
plural forms like "project.session.count" and "project.session.count_plural") so
non-English locales receive correct pluralization.
🧹 Nitpick comments (5)
server/projects.js (1)

414-422: legacyTotalCap logic is overly complex and the Math.max is a no-op.

Math.max(limit + offset, (limit + offset) * 2) always equals (limit + offset) * 2 since limit and offset are non-negative. The surrounding heuristic (lines 416-420) to inflate effectiveTotal when a truncated read is detected is fragile and difficult to reason about — it can report more sessions than actually exist.

Consider simplifying or adding a brief comment explaining the exact contract this total provides to consumers.

src/App.jsx (2)

142-169: Deeply nested ternaries in merge logic harm readability.

Lines 160-166 use triple-nested ternaries that are difficult to follow. Consider extracting a small helper function (e.g., pickSessions(hydrated, current, incoming)) to make the merge intent explicit.

♻️ Example helper extraction
+  const pickMerged = (isHydrated, currentHydrated, currentVal, incomingVal) =>
+    isHydrated
+      ? (currentHydrated ? currentVal : (incomingVal || []))
+      : (incomingVal || []);
+
   return {
     ...incomingProject,
-    sessions: sessionsHydrated ? (currentProject.sessionsHydrated ? currentProject.sessions : (incomingProject.sessions || [])) : (incomingProject.sessions || []),
-    sessionMeta: sessionsHydrated ? (currentProject.sessionsHydrated ? currentProject.sessionMeta : incomingProject.sessionMeta) : incomingProject.sessionMeta,
+    sessions: pickMerged(sessionsHydrated, currentProject.sessionsHydrated, currentProject.sessions, incomingProject.sessions),
+    sessionMeta: pickMerged(sessionsHydrated, currentProject.sessionsHydrated, currentProject.sessionMeta, incomingProject.sessionMeta),
     sessionsHydrated,
-    cursorSessions: cursorSessionsHydrated ? (currentProject.cursorSessionsHydrated ? currentProject.cursorSessions : (incomingProject.cursorSessions || [])) : (incomingProject.cursorSessions || []),
+    cursorSessions: pickMerged(cursorSessionsHydrated, currentProject.cursorSessionsHydrated, currentProject.cursorSessions, incomingProject.cursorSessions),
     cursorSessionsHydrated,
-    codexSessions: codexSessionsHydrated ? (currentProject.codexSessionsHydrated ? currentProject.codexSessions : (incomingProject.codexSessions || [])) : (incomingProject.codexSessions || []),
+    codexSessions: pickMerged(codexSessionsHydrated, currentProject.codexSessionsHydrated, currentProject.codexSessions, incomingProject.codexSessions),
     codexSessionsHydrated
   };

406-447: Lazy cursor session resolution iterates all projects sequentially — consider scoping the search.

When a deep-linked sessionId isn't found in already-loaded data, this code sequentially calls api.cursorSessions(...) for every project (line 413-445). With many projects, this can be slow and generates N API requests. Consider limiting the search to the most likely project (e.g., from URL context) or parallelizing with Promise.allSettled.

src/components/Sidebar.jsx (2)

217-294: Loading spinner clears before cursor/codex hydration completes.

setLoadingSessions is reset to false in the finally block (line 278), but the cursor/codex Promise.allSettled at line 281 hasn't resolved yet. Users see the loading indicator disappear, then cursor/codex sessions appear shortly after with no visual feedback.

Consider either:

  • Moving the finally block into the Promise.allSettled .then() callback, or
  • Using a separate loading indicator for cursor/codex sessions.

143-150: Missing hydrateProjectSessions in useEffect dependency array.

The effect calls hydrateProjectSessions (line 147) but it's not listed in the dependency array (line 150: [selectedSession, selectedProject, initialSessionsLoaded]). While this works because hydrateProjectSessions is redefined on every render (not wrapped in useCallback), the exhaustive-deps lint rule would flag this. Since hydrateProjectSessions depends on loadingSessions, projects, and onProjectHydrate, adding it to deps could cause unwanted re-triggers. Consider wrapping it in useCallback or using a ref.

Comment on lines 446 to 462
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;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, check the exact code at the mentioned lines
wc -l server/projects.js
echo "---"
# Get the findJsonlFilesRecursive function
sed -n '446,462p' server/projects.js

Repository: siteboon/claudecodeui

Length of output: 543


🏁 Script executed:

#!/bin/bash
# Check lines 1512 and 1883 for similar patterns
echo "=== Around line 1512 ==="
sed -n '1505,1525p' server/projects.js
echo ""
echo "=== Around line 1883 ==="
sed -n '1876,1896p' server/projects.js

Repository: siteboon/claudecodeui

Length of output: 1100


🏁 Script executed:

#!/bin/bash
# Search for all isDirectory() calls to understand the pattern
rg -n 'isDirectory' server/projects.js

Repository: siteboon/claudecodeui

Length of output: 330


🏁 Script executed:

#!/bin/bash
# Look for how findJsonlFilesRecursive is called and what dir parameter is passed
rg -B3 -A3 'findJsonlFilesRecursive' server/projects.js | head -60

Repository: siteboon/claudecodeui

Length of output: 638


🏁 Script executed:

#!/bin/bash
# Get full function context for the isDirectory calls at 1532 and 1903
echo "=== Function containing line 1532 ==="
sed -n '1517,1560p' server/projects.js
echo ""
echo "=== Function containing line 1903 ==="
sed -n '1892,1935p' server/projects.js

Repository: siteboon/claudecodeui

Length of output: 3068


🏁 Script executed:

#!/bin/bash
# Check if there are inline findJsonlFiles functions or recursive patterns
rg -B5 -A10 'function.*find.*jsonl|const.*find.*jsonl.*=' server/projects.js | head -80

Repository: siteboon/claudecodeui

Length of output: 47


Unbounded recursion without symlink protection in three locations.

findJsonlFilesRecursive (line 446) and two inline findJsonlFiles functions in getCodexSessions (line 1532) and deleteCodexSession (line 1903) follow directories without checking for symbolic links. A symlink cycle under ~/.codex/sessions would cause infinite recursion and stack overflow. Add !entry.isSymbolicLink() checks to skip symlinks in all three functions.

🛡️ Proposed fix

Change the directory check in all three recursive functions from:

-      if (entry.isDirectory()) {
+      if (entry.isDirectory() && !entry.isSymbolicLink()) {

This prevents following symlink cycles while still recursing into actual directories.

Consider unifying the three findJsonlFiles implementations into a single reusable function to reduce code duplication and ensure consistent symlink handling across all callers.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 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() && !entry.isSymbolicLink()) {
files.push(...await findJsonlFilesRecursive(fullPath));
} else if (entry.name.endsWith('.jsonl')) {
files.push(fullPath);
}
}
} catch (_) {
// Skip directories we cannot read
}
return files;
}
🤖 Prompt for AI Agents
In `@server/projects.js` around lines 446 - 462, The recursive file-walk functions
are following symlinks and can loop; update findJsonlFilesRecursive and the two
inline findJsonlFiles used by getCodexSessions and deleteCodexSession to skip
symbolic links by checking !entry.isSymbolicLink() before recursing into
directories (i.e., only recurse when entry.isDirectory() &&
!entry.isSymbolicLink()); also consider extracting a single shared utility
(e.g., findJsonlFiles) and replacing the three implementations with that to
ensure consistent symlink protection across callers.

@GizAI
Copy link
Author

GizAI commented Feb 11, 2026

Follow-up fixes pushed in f2e335d to address the latest review blockers:

  • server/projects.js

    • Stopped startup double-scan in getProjects by removing eager getSessions(...) call; now uses getSessionsPreview(...) metadata for initial list loading.
    • Manual projects now default to sessionMeta.hasMore: false (both getProjects manual loop and addProjectManually return shape).
    • Removed unused codex index code paths (findJsonlFilesRecursive, buildCodexSessionsIndex, getCodexSessionsForProjectFromIndex) and their unused normalization helper.
    • Added symlink protection in recursive codex JSONL walkers (entry.isDirectory() && !entry.isSymbolicLink()).
  • src/App.jsx

    • Fixed stale merge path in sidebar refresh by computing merge inside setProjects(prev => ...).
    • Prevented projects_updated effect feedback loop by using a projectsRef and removing projects from that effect dependency chain.
  • src/components/Sidebar.jsx + locales

    • Replaced hardcoded English session pluralization with i18n key (projects.sessionCountLabel) and added keys in en, ko, zh-CN locale files.

Validation:

  • pnpm -s run typecheck
  • pnpm -s run build

@coderabbitai please re-review the latest commit.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 11, 2026

@GizAI Thank you for the comprehensive follow-up fixes in commit f2e335d! I can see you've systematically addressed all the review blockers:

Double-scan eliminated - Removed eager getSessions() call in favor of preview metadata
Manual projects metadata - Proper hasMore: false defaults
Code cleanup - Removed unused codex index paths
Symlink protection - Added safety checks in recursive walkers
Stale merge fix - Computing merge inside setProjects callback
Effect loop fix - Using projectsRef to break feedback loop
i18n improvements - Internationalized session count labels across locales

I appreciate the validation run with typecheck and build. I'll review the latest commit now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
server/projects.js (1)

1559-1575: ⚠️ Potential issue | 🟡 Minor

findSessionFile in getCodexSessionMessages is missing the symlink guard added elsewhere.

The two findJsonlFiles helpers (lines 1427 and 1798) were updated to skip symlinks, but findSessionFile at line 1564 still recurses unconditionally into entry.isDirectory(). A symlink cycle under ~/.codex/sessions would cause a stack overflow here.

🛡️ Proposed fix
-          if (entry.isDirectory()) {
+          if (entry.isDirectory() && !entry.isSymbolicLink()) {
🤖 Fix all issues with AI agents
In `@src/components/Sidebar.jsx`:
- Around line 1208-1211: The desktop view currently renders the raw numeric
label from getProjectSessionCountInfo(project).label; update the JSX to use the
i18n key like the mobile view by calling t('projects.sessionCountLabel', {
count: getProjectSessionCountInfo(project).count ||
parseInt(getProjectSessionCountInfo(project).label, 10) }) (or pass the
appropriate numeric value returned by getProjectSessionCountInfo) instead of
rendering .label directly so desktop uses the same localized/pluralized string
as mobile; keep the same surrounding element (the div with className "text-xs
text-muted-foreground") and use the existing t function imported in the
component.
🧹 Nitpick comments (5)
server/projects.js (2)

380-383: truncatedReadDetected is file-scoped but used as a project-wide flag — may scan more files than necessary.

Once any file triggers a truncated read, the early-exit condition on line 380 can never be satisfied, forcing a scan of every remaining JSONL file even if enough sessions were already discovered. For projects with many files where the newest (largest) file is the only truncated one, this eliminates the I/O savings the PR aims for.

Consider tracking truncation per-file and only keeping the project-wide flag for the hasMore heuristic, or allowing the early exit when sessions.size >= targetSessionCount regardless of truncation:

-      if (sessions.size >= targetSessionCount && !truncatedReadDetected) {
+      if (sessions.size >= targetSessionCount) {
         break;
       }

The hasMore computation (lines 408–410) can still use truncatedReadDetected to signal incompleteness.


655-691: Sequential await getSessionsPreview in the for loop serializes I/O across all project directories.

Each project's preview is fetched one at a time. For users with many projects, this serialization adds up. Consider batching with bounded concurrency (e.g., Promise.all over small chunks) to leverage parallel filesystem I/O.

src/components/Sidebar.jsx (1)

217-294: hydrateProjectSessions — overall structure is sound; note the fire-and-forget pattern for cursor/codex.

The cursor and codex promises (lines 227–241) are started in parallel with the Claude fetch, but their results are consumed after the finally block clears loadingSessions. This means the loading spinner disappears before cursor/codex data arrives. If that's the intended UX (show Claude sessions immediately, then silently backfill cursor/codex), this is fine. If the spinner should cover all three providers, move the Promise.allSettled into the try block before the finally.

src/App.jsx (2)

163-173: Nested ternaries in mergeHydratedProjectData are correct but hard to follow.

The logic for preserving hydrated data is sound — once a provider's data is hydrated, the hydrated source wins. However, three levels of nested ternaries repeated for each field make this difficult to reason about during future maintenance.

Consider extracting a small helper:

♻️ Suggested readability improvement
+  const pickHydrated = (field, hydratedFlag) => {
+    const isHydrated = Boolean(incomingProject[hydratedFlag] || currentProject[hydratedFlag]);
+    if (!isHydrated) return incomingProject[field] || [];
+    return currentProject[hydratedFlag] ? currentProject[field] : (incomingProject[field] || []);
+  };
+
   return {
     ...incomingProject,
-    sessions: sessionsHydrated ? (currentProject.sessionsHydrated ? currentProject.sessions : (incomingProject.sessions || [])) : (incomingProject.sessions || []),
-    sessionMeta: sessionsHydrated ? (currentProject.sessionsHydrated ? currentProject.sessionMeta : incomingProject.sessionMeta) : incomingProject.sessionMeta,
+    sessions: pickHydrated('sessions', 'sessionsHydrated'),
+    sessionMeta: sessionsHydrated
+      ? (currentProject.sessionsHydrated ? currentProject.sessionMeta : incomingProject.sessionMeta)
+      : incomingProject.sessionMeta,
     sessionsHydrated,
-    cursorSessions: cursorSessionsHydrated ? (currentProject.cursorSessionsHydrated ? currentProject.cursorSessions : (incomingProject.cursorSessions || [])) : (incomingProject.cursorSessions || []),
+    cursorSessions: pickHydrated('cursorSessions', 'cursorSessionsHydrated'),
     cursorSessionsHydrated,
-    codexSessions: codexSessionsHydrated ? (currentProject.codexSessionsHydrated ? currentProject.codexSessions : (incomingProject.codexSessions || [])) : (incomingProject.codexSessions || []),
+    codexSessions: pickHydrated('codexSessions', 'codexSessionsHydrated'),
     codexSessionsHydrated
   };

265-285: Redundant mergeHydratedProjectData call when hasActiveSession && isAdditiveUpdate.

When the active-session branch (line 267) determines the update is additive and falls through, line 283 recomputes the same merge that was already done at line 270. Consider reusing the result:

♻️ Suggested fix
       if (hasActiveSession) {
         const currentProjects = projectsRef.current;
         const updatedProjects = mergeHydratedProjectData(currentProjects, latestMessage.projects);
         
         const isAdditiveUpdate = isUpdateAdditive(currentProjects, updatedProjects, selectedProject, selectedSession);
         
         if (!isAdditiveUpdate) {
           return;
         }
-        // Continue with additive updates below
+        // Additive update — reuse the already-merged projects
+        projectsRef.current = updatedProjects;
+        setProjects(updatedProjects);
+      } else {
+        const updatedProjects = mergeHydratedProjectData(projectsRef.current, latestMessage.projects);
+        projectsRef.current = updatedProjects;
+        setProjects(updatedProjects);
       }
-        
-      const updatedProjects = mergeHydratedProjectData(projectsRef.current, latestMessage.projects);
-      projectsRef.current = updatedProjects;
-      setProjects(updatedProjects);

Comment on lines 1208 to 1211
<div className="text-xs text-muted-foreground">
{(() => {
const sessionCount = getAllSessions(project).length;
const hasMore = project.sessionMeta?.hasMore !== false;
return hasMore && sessionCount >= 5 ? `${sessionCount}+` : sessionCount;
return getProjectSessionCountInfo(project).label;
})()}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Desktop session count doesn't use the i18n sessionCountLabel key.

The mobile view (line 1066) renders the localized t('projects.sessionCountLabel', ...) string (e.g., "5 sessions"), but the desktop view here renders only the raw numeric label (e.g., "5+") without the localized noun. This means non-English users will see a bare number on desktop but a translated string on mobile.

If the compact display is intentional, consider this a nit. Otherwise, apply the same t() call here.

🤖 Prompt for AI Agents
In `@src/components/Sidebar.jsx` around lines 1208 - 1211, The desktop view
currently renders the raw numeric label from
getProjectSessionCountInfo(project).label; update the JSX to use the i18n key
like the mobile view by calling t('projects.sessionCountLabel', { count:
getProjectSessionCountInfo(project).count ||
parseInt(getProjectSessionCountInfo(project).label, 10) }) (or pass the
appropriate numeric value returned by getProjectSessionCountInfo) instead of
rendering .label directly so desktop uses the same localized/pluralized string
as mobile; keep the same surrounding element (the div with className "text-xs
text-muted-foreground") and use the existing t function imported in the
component.

@jiangqilong41-ops
Copy link

Great work on this performance optimization! 🚀

I've been using Claude Code UI with multiple projects and noticed the scanning can be slow sometimes. This PR looks like it addresses exactly that pain point.

A few thoughts:

  • Have you considered adding a progress indicator during the scan so users know it's working?
  • Would be interesting to see benchmark numbers (before vs after) if you have them

Looking forward to seeing this merged. I'm also tracking #379 (slow response with many MCP servers) — these two improvements together would make a big difference for power users.

Happy to help test if needed!

@jiangqilong41-ops
Copy link

Wow! 🔥 This PR seems to hit two birds with one stone:

  1. Performance: Incremental loading/tail reading is exactly what we need for heavy projects.
  2. i18n: Just noticed in the file list! As a Chinese user, this is huge.

Really excited for this to land! Let me know if you need help verifying the Chinese translations or testing performance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants