Skip to content

perf(refresh): O(1) cached lookup in refreshFolder - #5

Merged
JeanBaptisteRenard merged 9 commits into
mainfrom
fix/refresh-folder-O1-lookup
May 22, 2026
Merged

perf(refresh): O(1) cached lookup in refreshFolder#5
JeanBaptisteRenard merged 9 commits into
mainfrom
fix/refresh-folder-O1-lookup

Conversation

@JeanBaptisteRenard

Copy link
Copy Markdown
Collaborator

Symptom

UI freezes/blocks while a live Claude session is appending JSONL. Worse on projects with many cached subagents (this fork's host: ~1372 subagents).

Diagnosis

refreshFolder is invoked synchronously on the main process from fs.watch's flush callback (debounced 500ms). For each on-disk file it ran:

for (const [dbId, entry] of cachedMap) {
  if (entry.filePath === filePath) { ... break; }
}

= O(N) lookup × N files = O(N²) per flush. With N ~= 1500 and the watcher refiring every ~500ms while Claude writes, the main event loop was effectively starved.

Fix

Build an inverted filePath → dbId index once before the file loop. Lookup becomes O(1). No behaviour change.

Verification

$ npm test
# tests 24 / pass 24 / fail 0

Same 10 read-session-file + 14 coverage tests pass with the new lookup path.

refreshFolder used a linear scan of cachedMap for every on-disk file
(O(N²) on a folder with N cached sessions). For projects with thousands
of subagent transcripts this froze the main process on every fs.watch
flush — fs.watch fires often while live Claude sessions append JSONL,
so the freeze recurred every 500ms (the debounce interval).

Build an inverted filePath → dbId index once and look up O(1) per file.

Confirmed: 24/24 tests still pass.
Even with the O(1) lookup, refreshFolder still ran enumerateSessionFiles
(many readdirSyncs) and fs.statSync on every file in the folder on
every flush. For projects with thousands of cached subagents and many
concurrent active agents writing JSONL, the main process kept blocking
on syscalls every 500ms.

The watcher already knows which file changed. Plumb that information
through to refreshFolder via opts.files; in targeted mode, skip
enumerateSessionFiles entirely and only stat the dirty paths.

- main.js: pendingFolders (Set) → pendingChanges (Map<folder, Set<relPath>|true>)
- session-cache.js: refreshFolder(folder, { files }) — when files is a
  non-empty Set, scan only those entries; otherwise full walk
- Targeted-mode deletion: rely on per-file ENOENT in statSync, skip the
  whole-folder GC sweep (no longer accurate when we only saw a subset)
- Falls back to full walk for folder-level events / bootstrap

24/24 tests still pass.
Live Claude session JSONLs grow without bound — observed a 218 MB
host-session file in a real workload. refreshFolder ran
fs.readFileSync + JSON.parse on the full file every time the
watcher fired (every ~500 ms while the session was being written),
making the main process freeze for 1-2 seconds at a time.

When a file is already cached and now exceeds HUGE_FILE_BYTES
(5 MB), skip the re-read entirely: just bump the modified timestamp
in the DB so the sidebar shows activity. Summary/slug/title were
captured when the file was smaller and rarely change after the first
turn; the next cold start or a shrink below threshold refreshes them.

Adds db.touchCachedModified(sessionId, modified) — a one-row UPDATE
that avoids the 14-column upsert when only mtime matters.

24/24 tests still pass.
…w ones

User feedback: "for display we shouldn't need to go that deep into the
file — full read should only happen for search". Implements that split.

readSessionDisplayHeader: stream-reads the first ~256 KB / 500 lines and
extracts only what the sidebar needs (summary, slug, customTitle,
aiTitle, agentId, isSidechain marker, subagent sidecar). No textContent,
no messageCount. ~1 ms even for a 200 MB host-session JSONL.

refreshFolder flow:
- NEW file (no cache row) → full readSessionFile, seeds FTS body
- EXISTING file → header-only refresh, merges fresh display fields with
  cached body. NO FTS write — search index for live sessions lags until
  cold-start
- Header fails → mtime-only touch as last resort

cacheGetByFolder widened to SELECT * so refresh can merge unchanged
fields (created, messageCount, textContent) without re-reading.

Drops the HUGE_FILE_BYTES hack from the previous commit — the header
approach handles size uniformly so no special-casing.

24/24 tests still pass.
The header-only refresh leaves search_fts stale for active sessions —
content typed after the last cold-start isn't indexed. Adds a user
trigger that runs the full worker re-scan (which rewrites FTS from
the live JSONL tails) and then re-fires the current query.

UI
  - New refresh button inside the search bar (circular-arrow icon)
  - Enter in the search input triggers the same path
  - Spinner on the button while reindexing
  - Search debounce bumped from 200ms to 350ms (gentler under heavy
    concurrent workloads)

Wiring
  - main.js: ipcMain.handle('rebuild-cache') → populateCacheViaWorker
  - preload.js: window.api.rebuildCache()
  - app.js: runSearchQuery() extracted; triggerRebuildAndSearch()
    serialises rebuild + refire and guards against double-clicks

24/24 tests still pass.
Renderer threw ReferenceError on every project iteration because the
result destructure at sidebar.js:438 was missing 'subagentIndex' even
though processProjectSessions returns it and buildSessionsList expects
it at line 477. The error aborted the project loop, leaving the
sidebar blank while the backend correctly returned 13 projects / 1500+
sessions. Pre-existing bug from PR#2's hierarchical sidebar — became
visible now because every project in this workload has subagents.

Also nudges #search-refresh-btn from right:42px to right:60px so it no
longer overlaps with #search-clear (the × button at right:40px).
…efault

On long-lived projects the orphan-subagent list can grow huge (>1000 in
this session's host project) and pushes the rest of the project out of
view. Default the section to collapsed and let the user toggle it with
a click on the label. Adds a right-pointing caret that rotates 90°
when expanded and a per-project state in localStorage so the choice
sticks across reloads.

- localStorage key: 'orphanExpanded:<projectPath>' = '0' | '1'
- Default: collapsed (no key set)
- Label format: '▸ Orphan subagents <count>'
The previous commit referenced 'project.projectPath' inside
buildSessionsList, which never had 'project' in scope — runtime
ReferenceError on every render, sidebar blank again. Pass projectPath
as an argument from both call sites (regular projects and worktrees).

Also leaves the renderer console→main bridge in place under
mainWindow.webContents 'console-message' — paid for itself twice now.
Live JSONL writes fire the watcher every ~500ms, triggering
notifyRendererProjectsChanged on each flush. Even with the header-only
refresh, the renderer was re-fetching projects and running morphdom
diff over 100+ session items at that cadence, producing visible
sidebar flicker. User flagged it as 'UI glitch on left side during
refresh'.

- session-cache.js: leading-edge throttle on notifyRendererProjectsChanged,
  1.5s cooldown with trailing flush so the first change is instant but
  bursts coalesce.
- public/app.js: bump renderer debounce 300ms → 900ms. Combined with the
  main-side throttle the sidebar redraws at most ~1×/sec under heavy load.
@JeanBaptisteRenard
JeanBaptisteRenard merged commit b4d1c48 into main May 22, 2026
7 checks passed
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.

1 participant