diff --git a/db.js b/db.js index eaef03e6..82036069 100644 --- a/db.js +++ b/db.js @@ -182,11 +182,12 @@ 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 = ?'), 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/main.js b/main.js index 62772694..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 }) => { @@ -496,6 +499,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(); @@ -1477,20 +1495,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 +1531,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 +1554,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/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..caa40c21 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; @@ -1047,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/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/sidebar.js b/public/sidebar.js index e16615da..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; @@ -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:' + 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)); } @@ -435,7 +447,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); @@ -474,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) { @@ -526,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 diff --git a/public/style.css b/public/style.css index 39212bc2..31c85dad 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: 60px; + 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 { @@ -1567,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 ========== */ 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(/|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); @@ -71,13 +81,46 @@ 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); + // Keep the full row so refresh can merge display-only header updates with + // unchanged fields (created, messageCount, textContent) without re-reading + // the file body. + cachedMap.set(row.sessionId, { ...row, filePath }); + 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(); @@ -89,24 +132,24 @@ function refreshFolder(folder) { const namesToSet = []; const sessionsToDelete = []; - for (const { filePath, parentSessionId } of enumerateSessionFiles(folderPath)) { - // 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; } - - // 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; - } - } + // Refresh strategy: + // - NEW file (no cache row): full readSessionFile — small at first turn, + // seeds session_cache + FTS body in one shot. + // - EXISTING file (already cached): header-only read (~256 KB / 500 lines). + // Updates display fields (summary, slug, titles, mtime) without reading + // the full body. Avoids re-reading 200+ MB live host-session JSONLs on + // every watcher flush. Side-effect: FTS body for live sessions goes + // stale until the next cold-start (acceptable trade-off). + // - Header read failing (truncated chunk, partial JSON): fall back to a + // mtime-only DB touch so the sidebar still reflects activity. + + for (const { filePath, parentSessionId } of filesToScan) { + 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; if (cachedDbId !== null) currentIds.add(cachedDbId); @@ -114,10 +157,41 @@ function refreshFolder(folder) { continue; // unchanged, skip } - // File is new or modified — re-read it + 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; + } + + // 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 @@ -132,11 +206,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; + } } } @@ -295,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) {