From d58be567f3bf3c5e36d4064e1e3a4f43abb68388 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 10:58:00 +0200 Subject: [PATCH 1/9] perf(refresh): O(1) cached lookup in refreshFolder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- session-cache.js | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/session-cache.js b/session-cache.js index 7d55ed48..0ce3790d 100644 --- a/session-cache.js +++ b/session-cache.js @@ -71,13 +71,17 @@ function refreshFolder(folder) { // Get what's currently cached for this folder. // cachedMap: DB sessionId → { modified, filePath } so we can do mtime comparison // even for subagents whose DB sessionId differs from the on-disk filename. + // filePathToDbId: inverted index so the per-file lookup is O(1) — without it, + // refreshing a folder with N cached sessions costs O(N²) per flush (the watcher + // fires frequently while live Claude sessions append JSONL, freezing the main + // process for folders with thousands of subagents). const cachedSessions = getCachedByFolder(folder); - const cachedMap = new Map(); // DB sessionId → { modified, filePath } + const cachedMap = new Map(); + const filePathToDbId = new Map(); for (const row of cachedSessions) { - cachedMap.set(row.sessionId, { - modified: row.modified, - filePath: resolveJsonlPath(PROJECTS_DIR, row), - }); + const filePath = resolveJsonlPath(PROJECTS_DIR, row); + cachedMap.set(row.sessionId, { modified: row.modified, filePath }); + filePathToDbId.set(filePath, row.sessionId); } const currentIds = new Set(); @@ -97,16 +101,8 @@ function refreshFolder(folder) { let fileMtime; try { fileMtime = fs.statSync(filePath).mtime.toISOString(); } catch { continue; } - // Find cached entry by file path (handles both top-level and subagent IDs) - let cachedEntry = null; - let cachedDbId = null; - for (const [dbId, entry] of cachedMap) { - if (entry.filePath === filePath) { - cachedEntry = entry; - cachedDbId = dbId; - break; - } - } + const cachedDbId = filePathToDbId.get(filePath) || null; + const cachedEntry = cachedDbId ? cachedMap.get(cachedDbId) : null; if (cachedDbId !== null) currentIds.add(cachedDbId); From d1f901cf7d6e8d86e43fe1758f0589db09b7b170 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 11:04:49 +0200 Subject: [PATCH 2/9] =?UTF-8?q?perf(refresh):=20targeted=20refresh=20?= =?UTF-8?q?=E2=80=94=20only=20stat=20files=20the=20watcher=20flagged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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|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. --- main.js | 43 ++++++++++++++++++++++++------ session-cache.js | 69 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 96 insertions(+), 16 deletions(-) diff --git a/main.js b/main.js index 62772694..7081fc52 100644 --- a/main.js +++ b/main.js @@ -1477,20 +1477,31 @@ let projectsWatcher = null; function startProjectsWatcher() { if (!fs.existsSync(PROJECTS_DIR)) return; - const pendingFolders = new Set(); + // pendingChanges: folder → Set | true. + // Set — only the listed files changed (targeted refresh, fast path) + // true — folder-level event or unknown scope, do a full walk + // The watcher reports the specific filename, so for the common case of a + // subagent appending JSONL we can stat one file instead of thousands. + const pendingChanges = new Map(); let debounceTimer = null; function flushChanges() { debounceTimer = null; - const folders = new Set(pendingFolders); - pendingFolders.clear(); + // Drain pendingChanges into a local copy so events arriving during the + // synchronous flush land in a fresh batch for the next tick. + const work = new Map(pendingChanges); + pendingChanges.clear(); let changed = false; - for (const folder of folders) { + for (const [folder, scope] of work) { const folderPath = path.join(PROJECTS_DIR, folder); if (fs.existsSync(folderPath)) { detectSessionTransitions(folder); - refreshFolder(folder); + if (scope === true) { + refreshFolder(folder); + } else { + refreshFolder(folder, { files: scope }); + } } else { deleteCachedFolder(folder); } @@ -1502,6 +1513,20 @@ function startProjectsWatcher() { } } + function recordChange(folder, relPath) { + const existing = pendingChanges.get(folder); + if (existing === true) return; + if (relPath === null) { + pendingChanges.set(folder, true); + return; + } + if (existing instanceof Set) { + existing.add(relPath); + } else { + pendingChanges.set(folder, new Set([relPath])); + } + } + try { projectsWatcher = fs.watch(PROJECTS_DIR, { recursive: true }, (_eventType, filename) => { if (!filename) return; @@ -1511,12 +1536,14 @@ function startProjectsWatcher() { const folder = parts[0]; if (!folder || folder === '.git') return; - // Only care about .jsonl changes or top-level folder add/remove const basename = parts[parts.length - 1]; if (parts.length === 1) { - pendingFolders.add(folder); + // Top-level folder add/remove — must re-scan the whole folder + recordChange(folder, null); } else if (basename.endsWith('.jsonl')) { - pendingFolders.add(folder); + // Specific .jsonl changed — targeted refresh on just this file + const rel = parts.slice(1).join(path.sep); + recordChange(folder, rel); } else { return; } diff --git a/session-cache.js b/session-cache.js index 0ce3790d..4aa73df2 100644 --- a/session-cache.js +++ b/session-cache.js @@ -54,8 +54,17 @@ function readFolderFromFilesystem(folder) { return { projectPath, sessions }; } -/** Refresh a single folder incrementally: only re-read changed/new .jsonl files */ -function refreshFolder(folder) { +/** Refresh a single folder incrementally: only re-read changed/new .jsonl files. + * + * @param {string} folder folder name relative to PROJECTS_DIR + * @param {object} [opts] + * @param {Set|null} [opts.files] if provided, ONLY scan these on-disk + * relative paths within the folder instead of walking everything. Used by the + * fs.watch flush to avoid statSync'ing thousands of files when only a handful + * of subagent transcripts were appended. When null/undefined, walk the whole + * folder (used for bootstrap and folder-level events). + */ +function refreshFolder(folder, opts = {}) { const folderPath = path.join(PROJECTS_DIR, folder); if (!fs.existsSync(folderPath)) { deleteCachedFolder(folder); @@ -84,6 +93,32 @@ function refreshFolder(folder) { filePathToDbId.set(filePath, row.sessionId); } + // Targeted refresh: walk only the files the watcher said changed, not the + // entire folder. Skips enumerateSessionFiles (which does many readdirSyncs on + // every subagent subdir) and only stats the dirty files. Falls back to full + // walk when opts.files is omitted (bootstrap / folder-level events / cold + // delete-detection). + const targeted = opts.files instanceof Set && opts.files.size > 0; + let filesToScan; + if (targeted) { + filesToScan = []; + for (const rel of opts.files) { + const filePath = path.join(folderPath, rel); + // Derive parentSessionId for subagent paths: //subagents/agent-X.jsonl + const parts = rel.split(path.sep); + let parentSessionId = null; + if (parts.length === 3 && parts[1] === 'subagents') { + parentSessionId = parts[0]; + } else if (parts.length === 2) { + // legacy //agent-X.jsonl layout (no subagents/ subdir) + parentSessionId = parts[0]; + } + filesToScan.push({ filePath, parentSessionId }); + } + } else { + filesToScan = enumerateSessionFiles(folderPath); + } + const currentIds = new Set(); let changed = false; @@ -93,7 +128,7 @@ function refreshFolder(folder) { const namesToSet = []; const sessionsToDelete = []; - for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { + for (const { filePath, parentSessionId } of filesToScan) { // Check if file mtime changed. // We need the DB sessionId to look up the cache, but we don't know it until after // readSessionFile — for subagents it's sub::. Use the file path @@ -128,11 +163,29 @@ function refreshFolder(folder) { changed = true; } - // Remove sessions whose .jsonl files were deleted - for (const sessionId of cachedMap.keys()) { - if (!currentIds.has(sessionId)) { - sessionsToDelete.push(sessionId); - changed = true; + // Remove sessions whose .jsonl files were deleted. Skip in targeted mode — + // we only stat'd the dirty files, so cachedMap entries not in currentIds + // weren't checked and may still exist on disk. Targeted-mode deletions are + // handled by the watcher path-stat: missing files surface via statSync's + // ENOENT in the loop above and produce no upsert. A full walk picks up any + // drift on the next folder-level event. + if (!targeted) { + for (const sessionId of cachedMap.keys()) { + if (!currentIds.has(sessionId)) { + sessionsToDelete.push(sessionId); + changed = true; + } + } + } else { + // Targeted mode still needs to delete entries for files explicitly deleted + // in this flush — detected by statSync failing on a path we tried to scan. + for (const { filePath } of filesToScan) { + const dbId = filePathToDbId.get(filePath); + if (!dbId) continue; + try { fs.statSync(filePath); } catch { + sessionsToDelete.push(dbId); + changed = true; + } } } From d32dcaeaad724356f7e28444189c448d7aa0b110 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 11:09:44 +0200 Subject: [PATCH 3/9] perf(refresh): bump-only update for huge cached files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- db.js | 2 ++ session-cache.js | 27 ++++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/db.js b/db.js index eaef03e6..b2149e6e 100644 --- a/db.js +++ b/db.js @@ -187,6 +187,7 @@ const stmts = { cacheGetSession: db.prepare('SELECT * FROM session_cache WHERE sessionId = ?'), cacheDeleteSession: db.prepare('DELETE FROM session_cache WHERE sessionId = ?'), cacheDeleteFolder: db.prepare('DELETE FROM session_cache WHERE folder = ?'), + cacheTouchModified: db.prepare('UPDATE session_cache SET modified = ? WHERE sessionId = ?'), // Cache meta statements metaGet: db.prepare('SELECT * FROM cache_meta WHERE folder = ?'), metaGetAll: db.prepare('SELECT * FROM cache_meta'), @@ -404,6 +405,7 @@ function closeDb() { module.exports = { getMeta, getAllMeta, setName, toggleStar, setArchived, isCachePopulated, getAllCached, getCachedByFolder, getCachedByParent, getCachedFolder, getCachedSession, upsertCachedSessions, + touchCachedModified: (sessionId, modified) => stmts.cacheTouchModified.run(modified, sessionId), deleteCachedSession, deleteCachedFolder, getFolderMeta, getAllFolderMeta, setFolderMeta, upsertSearchEntries, updateSearchTitle, deleteSearchSession, deleteSearchFolder, deleteSearchType, diff --git a/session-cache.js b/session-cache.js index 4aa73df2..86c1b31b 100644 --- a/session-cache.js +++ b/session-cache.js @@ -11,7 +11,7 @@ const { encodeProjectPath } = require('./encode-project-path'); * Call init(ctx) once with the shared context object. */ let PROJECTS_DIR, activeSessions, getMainWindow, log; -let deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession; +let deleteCachedFolder, getCachedByFolder, upsertCachedSessions, deleteCachedSession, touchCachedModified; let deleteSearchFolder, deleteSearchSession, upsertSearchEntries; let setFolderMeta, getAllFolderMeta, getAllMeta, getAllCached, getSetting, getMeta, setName; @@ -24,6 +24,7 @@ function init(ctx) { deleteCachedFolder = ctx.db.deleteCachedFolder; getCachedByFolder = ctx.db.getCachedByFolder; upsertCachedSessions = ctx.db.upsertCachedSessions; + touchCachedModified = ctx.db.touchCachedModified; deleteCachedSession = ctx.db.deleteCachedSession; deleteSearchFolder = ctx.db.deleteSearchFolder; deleteSearchSession = ctx.db.deleteSearchSession; @@ -128,13 +129,25 @@ function refreshFolder(folder, opts = {}) { const namesToSet = []; const sessionsToDelete = []; + // Skip the full re-read for already-cached files above this size. Live + // Claude session JSONLs grow without bound (can exceed 200 MB); re-reading + // and JSON.parsing the whole thing on every fs.watch flush froze the main + // process. The cached metadata (summary, slug, customTitle) was captured + // when the file was small enough to read, and rarely changes after the + // first turn anyway — bump the mtime in the DB so the sidebar reflects + // activity, and trust the next cold-start (or a smaller file) to refresh + // the rest. Subagent transcripts are usually small and stay under this + // threshold; the host conversation's own JSONL is the typical offender. + const HUGE_FILE_BYTES = 5 * 1024 * 1024; + for (const { filePath, parentSessionId } of filesToScan) { // Check if file mtime changed. // We need the DB sessionId to look up the cache, but we don't know it until after // readSessionFile — for subagents it's sub::. Use the file path // to find a matching cached entry instead. - let fileMtime; - try { fileMtime = fs.statSync(filePath).mtime.toISOString(); } catch { continue; } + let stat; + try { stat = fs.statSync(filePath); } catch { continue; } + const fileMtime = stat.mtime.toISOString(); const cachedDbId = filePathToDbId.get(filePath) || null; const cachedEntry = cachedDbId ? cachedMap.get(cachedDbId) : null; @@ -145,6 +158,14 @@ function refreshFolder(folder, opts = {}) { continue; // unchanged, skip } + // Huge cached file: bump mtime only, skip the multi-hundred-MB readFileSync. + if (cachedEntry && stat.size > HUGE_FILE_BYTES) { + touchCachedModified(cachedDbId, fileMtime); + cachedEntry.modified = fileMtime; + changed = true; + continue; + } + // File is new or modified — re-read it const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); if (s) { From ba1d55ab120332402b3989647a7b593aa5465371 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 11:15:50 +0200 Subject: [PATCH 4/9] perf(refresh): header-only read for cached sessions, full read for new ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- db.js | 2 +- read-session-file.js | 90 +++++++++++++++++++++++++++++++++++++++++++- session-cache.js | 66 +++++++++++++++++++++----------- 3 files changed, 134 insertions(+), 24 deletions(-) diff --git a/db.js b/db.js index b2149e6e..82036069 100644 --- a/db.js +++ b/db.js @@ -182,7 +182,7 @@ const stmts = { subagentType = excluded.subagentType, description = excluded.description `), cacheGetByParent: db.prepare('SELECT * FROM session_cache WHERE parentSessionId = ? ORDER BY created ASC'), - cacheGetByFolder: db.prepare('SELECT sessionId, modified, parentSessionId, agentId FROM session_cache WHERE folder = ?'), + cacheGetByFolder: db.prepare('SELECT * FROM session_cache WHERE folder = ?'), cacheGetFolder: db.prepare('SELECT folder FROM session_cache WHERE sessionId = ?'), cacheGetSession: db.prepare('SELECT * FROM session_cache WHERE sessionId = ?'), cacheDeleteSession: db.prepare('DELETE FROM session_cache WHERE sessionId = ?'), diff --git a/read-session-file.js b/read-session-file.js index d304d162..e8cc41df 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -184,4 +184,92 @@ function enumerateSessionFiles(folderPath) { return out; } -module.exports = { readSessionFile, subagentSessionId, resolveJsonlPath, readSubagentMeta, enumerateSessionFiles }; +/** Lightweight refresh path. Reads only the first ~256 KB / 500 lines of a + * jsonl file to extract display-level metadata (summary, slug, titles, + * agentId). Does NOT compute textContent or messageCount — the caller is + * expected to merge with the cached row for unchanged fields. Designed so + * the fs.watch flush can update a live 200+ MB host-session JSONL in ~ms + * instead of seconds. + * + * Returns the same shape as the display subset of readSessionFile() so it + * can be merged into a cached row before upsert. Returns null if the chunk + * doesn't yet contain a usable first-user-message. + */ +function readSessionDisplayHeader(filePath, opts = {}) { + const fileBase = path.basename(filePath, '.jsonl'); + const isSubagent = Boolean(opts.parentSessionId); + const MAX_BYTES = 256 * 1024; + const MAX_LINES = 500; + try { + const stat = fs.statSync(filePath); + const readLen = Math.min(MAX_BYTES, stat.size); + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(readLen); + const n = fs.readSync(fd, buf, 0, readLen, 0); + fs.closeSync(fd); + const text = buf.toString('utf8', 0, n); + const lines = text.split('\n'); + // Drop the potentially-partial last line unless we read the whole file + if (n < stat.size) lines.pop(); + + let summary = ''; + let slug = null, customTitle = null, aiTitle = null, agentId = null; + let sidechainSeen = false; + let lineCount = 0; + for (const line of lines) { + if (!line) continue; + if (++lineCount > MAX_LINES) break; + let entry; + try { entry = JSON.parse(line); } catch { continue; } + if (entry.slug && !slug) slug = entry.slug; + if (entry.agentId && !agentId) agentId = entry.agentId; + if (entry.isSidechain) sidechainSeen = true; + if (entry.type === 'custom-title' && entry.customTitle && !customTitle) customTitle = entry.customTitle; + if (entry.type === 'ai-title' && entry.aiTitle && !aiTitle) aiTitle = entry.aiTitle; + const msg = entry.message; + const txt = typeof msg === 'string' ? msg : + (typeof msg?.content === 'string' ? msg.content : + (msg?.content?.[0]?.text || '')); + if (!summary && (entry.type === 'user' || (entry.type === 'message' && entry.role === 'user'))) { + if (txt && !/||/.test(txt)) { + const taskMatch = txt.match(/:. Use the file path - // to find a matching cached entry instead. let stat; try { stat = fs.statSync(filePath); } catch { continue; } const fileMtime = stat.mtime.toISOString(); @@ -158,18 +157,41 @@ function refreshFolder(folder, opts = {}) { continue; // unchanged, skip } - // Huge cached file: bump mtime only, skip the multi-hundred-MB readFileSync. - if (cachedEntry && stat.size > HUGE_FILE_BYTES) { - touchCachedModified(cachedDbId, fileMtime); - cachedEntry.modified = fileMtime; + if (cachedEntry) { + // EXISTING — header-only refresh. + const h = readSessionDisplayHeader(filePath, { parentSessionId }); + if (h) { + // Merge: keep cached body/messageCount/created, overlay fresh display fields. + const merged = { + ...cachedEntry, + folder, projectPath, + summary: h.summary || cachedEntry.summary, + firstPrompt: h.firstPrompt || cachedEntry.firstPrompt, + modified: fileMtime, + slug: h.slug || cachedEntry.slug, + aiTitle: h.aiTitle || cachedEntry.aiTitle, + parentSessionId: h.parentSessionId || cachedEntry.parentSessionId, + agentId: h.agentId || cachedEntry.agentId, + subagentType: h.subagentType || cachedEntry.subagentType, + description: h.description || cachedEntry.description, + }; + sessionsToUpsert.push(merged); + if (h.customTitle && h.customTitle !== cachedEntry.customTitle) { + namesToSet.push({ id: merged.sessionId, name: h.customTitle }); + } + } else { + // Header read couldn't extract signal — just bump mtime so sort order stays current. + touchCachedModified(cachedDbId, fileMtime); + cachedEntry.modified = fileMtime; + } changed = true; continue; } - // File is new or modified — re-read it + // NEW file — full readSessionFile so the FTS index gets seeded. const s = readSessionFile(filePath, folder, projectPath, { parentSessionId }); if (s) { - currentIds.add(s.sessionId); // ensure we don't delete a newly-read subagent row + currentIds.add(s.sessionId); sessionsToUpsert.push(s); // Title precedence: user rename (session_meta.name) > JSONL custom-title > JSONL ai-title. // Only customTitle (Claude /title) promotes to session_meta.name — AI titles must NEVER From 65c30dad9cca4d9e45cbf7cd29342b256c2aa722 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 11:41:47 +0200 Subject: [PATCH 5/9] feat(search): explicit reindex via Enter or refresh button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- main.js | 15 ++++++ preload.js | 1 + public/app.js | 122 ++++++++++++++++++++++++++++++---------------- public/index.html | 1 + public/style.css | 17 +++++++ 5 files changed, 113 insertions(+), 43 deletions(-) diff --git a/main.js b/main.js index 7081fc52..5f3ef6ae 100644 --- a/main.js +++ b/main.js @@ -496,6 +496,21 @@ ipcMain.handle('unwatch-file', (_event, filePath) => { return { ok: true }; }); +// Full re-scan triggered from the UI. Re-reads every jsonl file in the worker +// thread, which is the only path that rebuilds search_fts with the live tail +// of active sessions (refreshFolder uses a header-only read by design — see +// session-cache.js). Concurrent callers share the same in-flight worker via +// populateCacheViaWorker's internal Promise. +ipcMain.handle('rebuild-cache', async () => { + try { + await populateCacheViaWorker(); + return { ok: true }; + } catch (err) { + console.error('Error rebuilding cache:', err); + return { ok: false, error: err.message }; + } +}); + ipcMain.handle('get-projects', async (_event, showArchived) => { try { const needsPopulate = !isCachePopulated() || !isSearchIndexPopulated(); diff --git a/preload.js b/preload.js index 3341ab4e..7ace90e1 100644 --- a/preload.js +++ b/preload.js @@ -12,6 +12,7 @@ contextBridge.exposeInMainWorld('api', { readMemory: (filePath) => ipcRenderer.invoke('read-memory', filePath), saveMemory: (filePath, content) => ipcRenderer.invoke('save-memory', filePath, content), getProjects: (showArchived) => ipcRenderer.invoke('get-projects', showArchived), + rebuildCache: () => ipcRenderer.invoke('rebuild-cache'), getActiveSessions: () => ipcRenderer.invoke('get-active-sessions'), getActiveTerminals: () => ipcRenderer.invoke('get-active-terminals'), stopSession: (id) => ipcRenderer.invoke('stop-session', id), diff --git a/public/app.js b/public/app.js index 9ab01b86..3ee713fc 100644 --- a/public/app.js +++ b/public/app.js @@ -461,56 +461,92 @@ searchClear.addEventListener('click', () => { searchInput.focus(); }); +// Extracted so the rebuild-cache button and Enter handler can call it too. +async function runSearchQuery() { + const query = searchInput.value.trim(); + if (!query) { + clearSearch(); + return; + } + try { + if (activeTab === 'sessions') { + const results = await window.api.search('session', query, searchTitlesOnly); + searchMatchIds = new Set(results.map(r => r.id)); + searchMatchProjectPaths = null; + if (searchTitlesOnly) { + const lowerQ = query.toLowerCase(); + for (const p of cachedAllProjects) { + const shortName = p.projectPath.split('/').filter(Boolean).slice(-2).join('/'); + if (shortName.toLowerCase().includes(lowerQ)) { + if (!searchMatchProjectPaths) searchMatchProjectPaths = new Set(); + searchMatchProjectPaths.add(p.projectPath); + } + } + } + refreshSidebar({ resort: true }); + } else if (activeTab === 'plans') { + const results = await window.api.search('plan', query, searchTitlesOnly); + const matchIds = new Set(results.map(r => r.id)); + renderPlans(cachedPlans.filter(p => matchIds.has(p.filename))); + } else if (activeTab === 'memory') { + const results = await window.api.search('memory', query, searchTitlesOnly); + const matchIds = new Set(results.map(r => r.id)); + renderMemories(matchIds); + } + } catch { + if (activeTab === 'sessions') { + searchMatchIds = null; + searchMatchProjectPaths = null; + refreshSidebar({ resort: true }); + } + } +} + +// Debounced search-as-you-type. Bumped from 200ms to 350ms — gentler under +// heavy workloads (many active subagents) and gives the user time to finish +// a word before searching. Explicit triggers (Enter, refresh button) bypass +// the debounce. searchInput.addEventListener('input', () => { - // Toggle clear button visibility searchBar.classList.toggle('has-query', searchInput.value.length > 0); - if (searchDebounceTimer) clearTimeout(searchDebounceTimer); - searchDebounceTimer = setTimeout(async () => { + searchDebounceTimer = setTimeout(() => { searchDebounceTimer = null; - const query = searchInput.value.trim(); - - if (!query) { - clearSearch(); - return; - } + runSearchQuery(); + }, 350); +}); - try { - if (activeTab === 'sessions') { - const results = await window.api.search('session', query, searchTitlesOnly); - searchMatchIds = new Set(results.map(r => r.id)); - // When title-only, also match project names - searchMatchProjectPaths = null; - if (searchTitlesOnly) { - const lowerQ = query.toLowerCase(); - for (const p of cachedAllProjects) { - const shortName = p.projectPath.split('/').filter(Boolean).slice(-2).join('/'); - if (shortName.toLowerCase().includes(lowerQ)) { - if (!searchMatchProjectPaths) searchMatchProjectPaths = new Set(); - searchMatchProjectPaths.add(p.projectPath); - } - } - } - refreshSidebar({ resort: true }); - } else if (activeTab === 'plans') { - const results = await window.api.search('plan', query, searchTitlesOnly); - const matchIds = new Set(results.map(r => r.id)); - renderPlans(cachedPlans.filter(p => matchIds.has(p.filename))); - } else if (activeTab === 'memory') { - const results = await window.api.search('memory', query, searchTitlesOnly); - const matchIds = new Set(results.map(r => r.id)); - renderMemories(matchIds); - } - } catch { - if (activeTab === 'sessions') { - searchMatchIds = null; - searchMatchProjectPaths = null; - refreshSidebar({ resort: true }); - } - } - }, 200); +// Enter in the search field = "I want fresh results": trigger a full worker +// reindex (which rewrites search_fts with the live content of active session +// JSONLs), then re-run the query. Pending debounce gets cancelled. +searchInput.addEventListener('keydown', async (e) => { + if (e.key !== 'Enter') return; + e.preventDefault(); + if (searchDebounceTimer) { clearTimeout(searchDebounceTimer); searchDebounceTimer = null; } + await triggerRebuildAndSearch(); }); +// Refresh button in the search bar — same behavior as pressing Enter. +const searchRefreshBtn = document.getElementById('search-refresh-btn'); +if (searchRefreshBtn) { + searchRefreshBtn.addEventListener('click', () => triggerRebuildAndSearch()); +} + +let rebuildInFlight = false; +async function triggerRebuildAndSearch() { + if (rebuildInFlight) return; + rebuildInFlight = true; + if (searchRefreshBtn) searchRefreshBtn.classList.add('spinning'); + try { + await window.api.rebuildCache(); + } catch {} + finally { + rebuildInFlight = false; + if (searchRefreshBtn) searchRefreshBtn.classList.remove('spinning'); + } + // After reindex, refire the current query so the user sees fresh hits. + await runSearchQuery(); +} + // --- Stop session helper --- async function confirmAndStopSession(sessionId) { if (!confirm('Stop this session?')) return; diff --git a/public/index.html b/public/index.html index 84848576..64155567 100644 --- a/public/index.html +++ b/public/index.html @@ -34,6 +34,7 @@ + diff --git a/public/style.css b/public/style.css index 39212bc2..c8ca491e 100644 --- a/public/style.css +++ b/public/style.css @@ -341,6 +341,23 @@ body { display: flex; flex-direction: column; } color: #ffffff; } +#search-refresh-btn { + position: absolute; + right: 42px; + top: 0; + height: calc(100% - 14px); + display: flex; + align-items: center; + background: none; + border: none; + color: #5a5a70; + cursor: pointer; + padding: 0 4px; + transition: color 0.15s; +} +#search-refresh-btn:hover { color: #b0b0c4; } +#search-refresh-btn.spinning svg { animation: spin-dot 0.8s linear infinite; } + /* ---- Scrollable content ---- */ #sidebar-content { From 206f393ba7a300d8464fcd6b5920c9a4e95e7492 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 12:02:01 +0200 Subject: [PATCH 6/9] fix(sidebar): destructure subagentIndex (empty-sidebar regression) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- public/sidebar.js | 2 +- public/style.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/public/sidebar.js b/public/sidebar.js index e16615da..3d63900c 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -435,7 +435,7 @@ function renderProjects(projects, resort) { const result = processProjectSessions(project, resort); if (!result) continue; - const { filtered, visible, older, sortOrderEntry } = result; + const { filtered, visible, older, subagentIndex, sortOrderEntry } = result; newSortedOrder.push(sortOrderEntry); const fId = folderId(project.projectPath); diff --git a/public/style.css b/public/style.css index c8ca491e..11f4b191 100644 --- a/public/style.css +++ b/public/style.css @@ -343,7 +343,7 @@ body { display: flex; flex-direction: column; } #search-refresh-btn { position: absolute; - right: 42px; + right: 60px; top: 0; height: calc(100% - 14px); display: flex; From 50f8743193f85ebfe7fb88c29111d96ac2568c2f Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 13:11:15 +0200 Subject: [PATCH 7/9] feat(sidebar): collapsible 'Orphan subagents' section, collapsed by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:' = '0' | '1' - Default: collapsed (no key set) - Label format: '▸ Orphan subagents ' --- public/sidebar.js | 16 ++++++++++++++-- public/style.css | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/public/sidebar.js b/public/sidebar.js index 3d63900c..a1ed0bb3 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -413,12 +413,24 @@ function renderProjects(projects, resort) { } } if (orphans.length > 0) { + // Persist expand/collapse per project. Default = collapsed: this + // section is rarely the user's focus and can grow long on long-lived + // projects (this very session has 1300+ orphan subagents). + const orphanStateKey = 'orphanExpanded:' + project.projectPath; + const expanded = localStorage.getItem(orphanStateKey) === '1'; + const orphanGroup = document.createElement('div'); - orphanGroup.className = 'sidebar-orphan-subagents'; + orphanGroup.className = 'sidebar-orphan-subagents' + (expanded ? '' : ' collapsed'); + const orphanLabel = document.createElement('div'); orphanLabel.className = 'sidebar-orphan-label'; - orphanLabel.textContent = 'Orphan subagents'; + orphanLabel.innerHTML = ` Orphan subagents ${orphans.length}`; + orphanLabel.addEventListener('click', () => { + const isCollapsed = orphanGroup.classList.toggle('collapsed'); + localStorage.setItem(orphanStateKey, isCollapsed ? '0' : '1'); + }); orphanGroup.appendChild(orphanLabel); + for (const orphan of orphans) { orphanGroup.appendChild(buildSubagentItem(orphan)); } diff --git a/public/style.css b/public/style.css index 11f4b191..31c85dad 100644 --- a/public/style.css +++ b/public/style.css @@ -1584,6 +1584,27 @@ body { display: flex; flex-direction: column; } color: #6a6a80; text-transform: uppercase; letter-spacing: 0.5px; + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 4px; +} +.sidebar-orphan-label:hover { color: #b0b0c4; } +.sidebar-orphan-label .orphan-caret { + display: inline-block; + transition: transform 0.12s; + font-size: 10px; +} +.sidebar-orphan-subagents:not(.collapsed) .orphan-caret { + transform: rotate(90deg); +} +.sidebar-orphan-label .orphan-count { + margin-left: 4px; + opacity: 0.6; +} +.sidebar-orphan-subagents.collapsed > :not(.sidebar-orphan-label) { + display: none; } /* ========== SUBAGENT GRID PILLS ========== */ From 69476e9ca948f0ff51e09dd9d822a3aaf3c9c0ed Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 13:23:26 +0200 Subject: [PATCH 8/9] fix(sidebar): scope projectPath into buildSessionsList for orphan toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- main.js | 3 +++ public/sidebar.js | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/main.js b/main.js index 5f3ef6ae..ce5f0585 100644 --- a/main.js +++ b/main.js @@ -129,6 +129,9 @@ function createWindow() { } mainWindow.loadFile(path.join(__dirname, 'public', 'index.html')); + mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => { + if (level >= 2) log.error(`[renderer:${level}] ${sourceId}:${line} ${message}`); + }); // Open external links in the system browser instead of a child BrowserWindow mainWindow.webContents.setWindowOpenHandler(({ url }) => { diff --git a/public/sidebar.js b/public/sidebar.js index a1ed0bb3..7ca2bc42 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -375,7 +375,7 @@ function renderProjects(projects, resort) { } // Build the sessions list DOM (shared between projects and worktrees) - function buildSessionsList(fId, visible, older, subagentIndex) { + function buildSessionsList(fId, visible, older, subagentIndex, projectPath) { const sessionsList = document.createElement('div'); sessionsList.className = 'project-sessions'; sessionsList.id = 'sessions-' + fId; @@ -416,7 +416,7 @@ function renderProjects(projects, resort) { // Persist expand/collapse per project. Default = collapsed: this // section is rarely the user's focus and can grow long on long-lived // projects (this very session has 1300+ orphan subagents). - const orphanStateKey = 'orphanExpanded:' + project.projectPath; + const orphanStateKey = 'orphanExpanded:' + projectPath; const expanded = localStorage.getItem(orphanStateKey) === '1'; const orphanGroup = document.createElement('div'); @@ -486,7 +486,7 @@ function renderProjects(projects, resort) { newBtn.title = 'New session'; header.appendChild(newBtn); - const sessionsList = buildSessionsList(fId, visible, older, subagentIndex); + const sessionsList = buildSessionsList(fId, visible, older, subagentIndex, project.projectPath); // Auto-collapse if most recent session is older than threshold, or project matched with no sessions if (project._projectMatchedOnly) { @@ -538,7 +538,7 @@ function renderProjects(projects, resort) { wtNewBtn.title = 'New session in worktree'; wtHeader.appendChild(wtNewBtn); - const wtSessionsList = buildSessionsList(wtFId, wtResult.visible, wtResult.older, wtResult.subagentIndex); + const wtSessionsList = buildSessionsList(wtFId, wtResult.visible, wtResult.older, wtResult.subagentIndex, wt.projectPath); wtSessionsList.className = 'worktree-sessions'; // Auto-collapse worktree if stale From e76a3df05ac2d4842c0cf18477725a9478760e19 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Fri, 22 May 2026 13:32:44 +0200 Subject: [PATCH 9/9] perf(ui): throttle projects-changed notifies + renderer debounce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- public/app.js | 5 ++++- session-cache.js | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/public/app.js b/public/app.js index 3ee713fc..caa40c21 100644 --- a/public/app.js +++ b/public/app.js @@ -1083,10 +1083,13 @@ window.api.onProjectsChanged(() => { projectsChangedWhileAway = true; return; } + // 300ms debounce was visibly flickering while live JSONLs trigger watcher + // flushes every ~500ms; with the main-side notify throttle (1.5s) too the + // sidebar redraws at most ~1×/sec. projectsChangedTimer = setTimeout(() => { projectsChangedTimer = null; loadProjects(); - }, 300); + }, 900); }); // Status bar diff --git a/session-cache.js b/session-cache.js index 08cc1ff5..fb606d97 100644 --- a/session-cache.js +++ b/session-cache.js @@ -387,11 +387,27 @@ function buildProjectsFromCache(showArchived) { } +// Throttle projects-changed IPC: live sessions appending JSONL trigger a flush +// every ~500ms; without throttling the renderer re-runs getProjects + morphdom +// over 100+ items at that cadence, producing visible flicker. Leading-edge fire +// + trailing flush so the first change is instant but subsequent bursts coalesce. +const NOTIFY_THROTTLE_MS = 1500; +let _notifyCooldown = false; +let _notifyPending = false; function notifyRendererProjectsChanged() { + if (_notifyCooldown) { _notifyPending = true; return; } const mainWindow = getMainWindow(); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('projects-changed'); } + _notifyCooldown = true; + setTimeout(() => { + _notifyCooldown = false; + if (_notifyPending) { + _notifyPending = false; + notifyRendererProjectsChanged(); + } + }, NOTIFY_THROTTLE_MS); } function sendStatus(text, type) {