Repository navigation
fix: sessions not loading on project navigation without restart - #137
Conversation
Sessions don't appear when navigating to a project until the app is restarted. Three root causes: 1. Renderer _sessionCache returns stale empty data on navigation — clear cache entry after showing cached data so fetchSessionsInitial always overwrites with fresh backend data 2. ProjectScanner internal caches (contentPresenceCache, sessionMetadataCache, sessionPreviewCache) have no external invalidation — add invalidateCachesForProject() and wire it into FileWatcher.processProjectsChange() 3. FileWatcher.activeSessionFiles never seeded at startup — add seedActiveSessionFiles() that scans recent .jsonl files so the catch-up timer can detect growth in pre-existing sessions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request resolves issues where project sessions were not consistently loading or appearing after navigating between projects or upon application startup. It introduces robust cache invalidation mechanisms across the renderer and main process, and ensures that active sessions are properly initialized when the application starts, leading to a more reliable and responsive user experience for session management. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces a new invalidateCachesForProject method in ProjectScanner to clear various caches for a given project ID. The FileWatcher is updated to utilize this new method for cache invalidation when session files change, and a new seedActiveSessionFiles method is added to pre-populate active session files for the catch-up scan. The ServiceContext now correctly links the FileWatcher and ProjectScanner. Additionally, the projectSlice in the renderer now invalidates its session cache when a project is deselected. A performance improvement opportunity was identified in the seedActiveSessionFiles method, suggesting parallelization of filesystem operations to avoid sequential I/O, especially for remote filesystems.
| private async seedActiveSessionFiles(): Promise<void> { | ||
| const now = Date.now(); | ||
| try { | ||
| if (!(await this.fsProvider.exists(this.projectsPath))) { | ||
| return; | ||
| } | ||
|
|
||
| const projectDirs = await this.fsProvider.readdir(this.projectsPath); | ||
| for (const dir of projectDirs) { | ||
| if (!dir.isDirectory()) continue; | ||
|
|
||
| const projectPath = path.join(this.projectsPath, dir.name); | ||
| let entries: FsDirent[]; | ||
| try { | ||
| entries = await this.fsProvider.readdir(projectPath); | ||
| } catch { | ||
| continue; | ||
| } | ||
|
|
||
| for (const entry of entries) { | ||
| if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue; | ||
|
|
||
| const fullPath = path.join(projectPath, entry.name); | ||
| try { | ||
| const stats = await this.fsProvider.stat(fullPath); | ||
| if (now - stats.mtimeMs <= CATCH_UP_MAX_AGE_MS) { | ||
| const sessionId = path.basename(entry.name, '.jsonl'); | ||
| this.activeSessionFiles.set(fullPath, { | ||
| projectId: dir.name, | ||
| sessionId, | ||
| }); | ||
| } | ||
| } catch { | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (this.activeSessionFiles.size > 0) { | ||
| logger.info( | ||
| `FileWatcher: Seeded ${this.activeSessionFiles.size} active session files` | ||
| ); | ||
| } | ||
| } catch (err) { | ||
| logger.error('Error seeding active session files:', err); | ||
| } | ||
| } |
There was a problem hiding this comment.
The current implementation of seedActiveSessionFiles uses nested loops with await inside, which results in sequential filesystem operations. This can be inefficient, especially for remote filesystems (SSH), if there are many projects and session files.
You can improve performance by parallelizing these operations using Promise.all. This would involve first collecting all file paths and then getting their stats in parallel. This change would also make the code flatter and potentially easier to reason about.
Here is an example of how you could refactor it:
private async seedActiveSessionFiles(): Promise<void> {
const now = Date.now();
try {
if (!(await this.fsProvider.exists(this.projectsPath))) {
return;
}
const projectDirs = await this.fsProvider.readdir(this.projectsPath);
const allSessionFiles = (await Promise.all(
projectDirs
.filter((dir) => dir.isDirectory())
.map(async (dir) => {
const projectPath = path.join(this.projectsPath, dir.name);
try {
const entries = await this.fsProvider.readdir(projectPath);
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
.map((entry) => ({
fullPath: path.join(projectPath, entry.name),
dirName: dir.name,
entryName: entry.name,
}));
} catch {
return []; // Ignore projects that can't be read
}
})
)).flat();
await Promise.all(
allSessionFiles.map(async (file) => {
try {
const stats = await this.fsProvider.stat(file.fullPath);
if (now - stats.mtimeMs <= CATCH_UP_MAX_AGE_MS) {
this.activeSessionFiles.set(file.fullPath, {
projectId: file.dirName,
sessionId: path.basename(file.entryName, '.jsonl'),
});
}
} catch {
// Ignore files that can't be stat-ed
}
})
);
if (this.activeSessionFiles.size > 0) {
logger.info(
`FileWatcher: Seeded ${this.activeSessionFiles.size} active session files`
);
}
} catch (err) {
logger.error('Error seeding active session files:', err);
}
}
📝 WalkthroughWalkthroughThis PR adds cache invalidation capabilities to ProjectScanner and integrates it with FileWatcher to automatically clear stale session/file presence caches when projects are modified, while also seeding active session files on startup and ensuring the renderer discards cached session data when projects are re-selected. Changes
Possibly related PRs
Suggested labels
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/main/services/discovery/ProjectScanner.ts`:
- Around line 1057-1069: invalidateCachesForProject currently decodes projectId
with decodeURIComponent and uses startsWith(prefix) which can both mis-decode
composite IDs and over-match sibling project paths; replace
decodeURIComponent(projectId) with extractBaseDir(projectId) to get the correct
directory name, compute prefix = path.join(this.projectsDir, baseDir), and
change the cache eviction checks for contentPresenceCache, sessionMetadataCache,
and sessionPreviewCache to only delete keys that are exactly the prefix or start
with prefix + path.sep (i.e., key === prefix || key.startsWith(prefix +
path.sep)) to avoid invalidating sibling projects.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b1bf1f30-a42a-4606-aaa2-f9939e694477
📒 Files selected for processing (4)
src/main/services/discovery/ProjectScanner.tssrc/main/services/infrastructure/FileWatcher.tssrc/main/services/infrastructure/ServiceContext.tssrc/renderer/store/slices/projectSlice.ts
| invalidateCachesForProject(projectId: string): void { | ||
| // projectId is URL-encoded; the cache keys are absolute file paths containing the decoded dir | ||
| const decoded = decodeURIComponent(projectId); | ||
| const prefix = path.join(this.projectsDir, decoded); | ||
| for (const key of this.contentPresenceCache.keys()) { | ||
| if (key.startsWith(prefix)) this.contentPresenceCache.delete(key); | ||
| } | ||
| for (const key of this.sessionMetadataCache.keys()) { | ||
| if (key.startsWith(prefix)) this.sessionMetadataCache.delete(key); | ||
| } | ||
| for (const key of this.sessionPreviewCache.keys()) { | ||
| if (key.startsWith(prefix)) this.sessionPreviewCache.delete(key); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Verify project-id encoding and invalidation assumptions.
rg -n -C3 "extractBaseDir|isValidEncodedPath|decodeURIComponent|invalidateCachesForProject" \
src/main/utils/pathDecoder.ts \
src/main/services/discovery/ProjectScanner.ts \
src/main/services/infrastructure/FileWatcher.tsRepository: matt1398/claude-devtools
Length of output: 10043
🏁 Script executed:
# Read pathDecoder.ts to understand encoding scheme
head -250 src/main/utils/pathDecoder.ts | tail -100Repository: matt1398/claude-devtools
Length of output: 3091
🏁 Script executed:
# Look at the beginning of pathDecoder.ts to see the encoding/decoding functions
head -120 src/main/utils/pathDecoder.tsRepository: matt1398/claude-devtools
Length of output: 4213
🏁 Script executed:
# Search for other uses of decodeURIComponent in the codebase
rg -n "decodeURIComponent" src/Repository: matt1398/claude-devtools
Length of output: 169
🏁 Script executed:
# Check if there are any other encoding/decoding utilities we should be aware of
rg -n "decodePath|encodePath" src/main/services/discovery/ProjectScanner.tsRepository: matt1398/claude-devtools
Length of output: 50
invalidateCachesForProject() uses wrong decoding function and can over-match cache keys.
On Line 1059, decodeURIComponent(projectId) is incorrect because project IDs use slash-to-dash encoding (e.g., /Users/name/project → -Users-name-project), not URI-encoding. Use extractBaseDir() instead, which is already imported and handles both plain and composite IDs. Also, startsWith(prefix) on Lines 1062/1065/1068 can invalidate sibling projects with shared prefixes (e.g., /foo matches /foo-bar).
💡 Proposed fix
invalidateCachesForProject(projectId: string): void {
- // projectId is URL-encoded; the cache keys are absolute file paths containing the decoded dir
- const decoded = decodeURIComponent(projectId);
- const prefix = path.join(this.projectsDir, decoded);
- for (const key of this.contentPresenceCache.keys()) {
- if (key.startsWith(prefix)) this.contentPresenceCache.delete(key);
- }
- for (const key of this.sessionMetadataCache.keys()) {
- if (key.startsWith(prefix)) this.sessionMetadataCache.delete(key);
- }
- for (const key of this.sessionPreviewCache.keys()) {
- if (key.startsWith(prefix)) this.sessionPreviewCache.delete(key);
- }
+ const baseDir = extractBaseDir(projectId);
+ const projectRoot = path.join(this.projectsDir, baseDir);
+ const projectPrefix = `${projectRoot}${path.sep}`;
+
+ const invalidate = <T>(cache: Map<string, T>): void => {
+ for (const key of cache.keys()) {
+ const normalizedKey = path.normalize(key);
+ if (normalizedKey === projectRoot || normalizedKey.startsWith(projectPrefix)) {
+ cache.delete(key);
+ }
+ }
+ };
+
+ invalidate(this.contentPresenceCache);
+ invalidate(this.sessionMetadataCache);
+ invalidate(this.sessionPreviewCache);
}📝 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.
| invalidateCachesForProject(projectId: string): void { | |
| // projectId is URL-encoded; the cache keys are absolute file paths containing the decoded dir | |
| const decoded = decodeURIComponent(projectId); | |
| const prefix = path.join(this.projectsDir, decoded); | |
| for (const key of this.contentPresenceCache.keys()) { | |
| if (key.startsWith(prefix)) this.contentPresenceCache.delete(key); | |
| } | |
| for (const key of this.sessionMetadataCache.keys()) { | |
| if (key.startsWith(prefix)) this.sessionMetadataCache.delete(key); | |
| } | |
| for (const key of this.sessionPreviewCache.keys()) { | |
| if (key.startsWith(prefix)) this.sessionPreviewCache.delete(key); | |
| } | |
| invalidateCachesForProject(projectId: string): void { | |
| const baseDir = extractBaseDir(projectId); | |
| const projectRoot = path.join(this.projectsDir, baseDir); | |
| const projectPrefix = `${projectRoot}${path.sep}`; | |
| const invalidate = <T>(cache: Map<string, T>): void => { | |
| for (const key of cache.keys()) { | |
| const normalizedKey = path.normalize(key); | |
| if (normalizedKey === projectRoot || normalizedKey.startsWith(projectPrefix)) { | |
| cache.delete(key); | |
| } | |
| } | |
| }; | |
| invalidate(this.contentPresenceCache); | |
| invalidate(this.sessionMetadataCache); | |
| invalidate(this.sessionPreviewCache); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/main/services/discovery/ProjectScanner.ts` around lines 1057 - 1069,
invalidateCachesForProject currently decodes projectId with decodeURIComponent
and uses startsWith(prefix) which can both mis-decode composite IDs and
over-match sibling project paths; replace decodeURIComponent(projectId) with
extractBaseDir(projectId) to get the correct directory name, compute prefix =
path.join(this.projectsDir, baseDir), and change the cache eviction checks for
contentPresenceCache, sessionMetadataCache, and sessionPreviewCache to only
delete keys that are exactly the prefix or start with prefix + path.sep (i.e.,
key === prefix || key.startsWith(prefix + path.sep)) to avoid invalidating
sibling projects.
Summary
Sessions don't appear when navigating to a project until the app is restarted. Three root causes fixed:
Renderer
_sessionCachestale hit:selectProject()returns cached empty sessions from a previous visit. Fixed by clearing the cache entry after showing cached data sofetchSessionsInitial()always overwrites with fresh backend data.No external cache invalidation on ProjectScanner:
contentPresenceCache,sessionMetadataCache,sessionPreviewCacheare private Maps with no invalidation API. AddedinvalidateCachesForProject(projectId)and wired it intoFileWatcher.processProjectsChange().activeSessionFilesnever seeded at startup: Sessions existing at app startup aren't added toactiveSessionFiles, so the catch-up timer never checks them. AddedseedActiveSessionFiles()that scans recent.jsonlfiles (last hour) on startup.Test plan
pnpm typecheck— no type errorspnpm lint:fix— no lint errors🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes