diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index b1498478..276b6d36 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -18,7 +18,7 @@ This file is the **canonical inventory** of the IPC surface. When you add a new | IPC | Args | Returns | Notes | |---|---|---|---| -| `get-projects` | `(showArchived)` | `Project[]` | Sidebar payload. Reads from cache. | +| `get-projects` | `()` | `{ projects: Project[], allProjects: Project[] }` | Sidebar payload, archived sessions hidden / shown, from one build. Reads from cache. See `session-cache.md` ("One build, two views"). | | `get-active-sessions` | — | `{sessionId, busy}[]` | Currently open PTY sessions plus each one's live `_cliBusy` flag — see "Busy-state reconciliation" below. | | `get-active-terminals` | — | `Terminal[]` | Active PTY identifiers | | `open-terminal` | `(id, projectPath, isNew, sessionOptions)` | `{ok, error?, mcpActive}` | Spawn or attach a PTY. | diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index cd786457..bd6b63fe 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -29,7 +29,7 @@ From `session-cache.js`: - `init(ctx)` — wire main process → cache (mainWindow ref for IPC events) - `refreshFolder(folder, opts)` — opts `{files: Set}` for targeted refresh (watcher payload). Defaults to full folder walk. - `populateCacheFromFilesystem()` / `populateCacheViaWorker()` — initial scan / re-scan. The worker (`workers/scan-projects.js`) streams one `{type:'folder', result, current, total}` message per on-disk folder (plus a final `{type:'done'}`) instead of buffering the whole tree, so each folder is written to the DB and pushed to the renderer as soon as it's read — a large history no longer leaves the sidebar empty for the entire scan. -- `buildProjectsFromCache(showArchived)` — produces the sidebar payload (sorted, grouped by project, missing flag computed here). It also injects every live plain-terminal PTY from `activeSessions` as a synthetic row, so a terminal sorts among the cached sessions even though it has no JSONL; the row's `summary` is the hard-coded string `Terminal`, which is what the sidebar displays — the renderer's own session object is never the one shown. A panel shell is a plain terminal too and is excluded here by `isPanelShellSession` (`panel-terminal-target.js`); see `.ai/contexts/panel-terminal.md` for the full list of places that have to skip one. +- `buildProjectViewsFromCache()` — returns `{ projects, allProjects }` (archived hidden / shown) from one read of the cache; this is what `get-projects` serves (see "One build, two views"). `buildProjectsFromCache(showArchived)` builds one of the two — produces the sidebar payload (sorted, grouped by project, missing flag computed here). It also injects every live plain-terminal PTY from `activeSessions` as a synthetic row, so a terminal sorts among the cached sessions even though it has no JSONL; the row's `summary` is the hard-coded string `Terminal`, which is what the sidebar displays — the renderer's own session object is never the one shown. A panel shell is a plain terminal too and is excluded here by `isPanelShellSession` (`panel-terminal-target.js`); see `.ai/contexts/panel-terminal.md` for the full list of places that have to skip one. - `notifyRendererProjectsChanged()` — throttled (~1.5s leading-edge) push to renderer - `sendIndexingProgress()` (internal) — emits the `indexing-progress` IPC event, gated on `coldStart` (captured once at the top of `populateCacheViaWorker()` via `!isInitialScanComplete()`) and throttled to ~4 events/s (the first event and every `done:true` always pass). Feeds the renderer's first-run banner; see `.ai/contexts/ipc-bridge.md`. A `done:true` payload carrying `error` keeps the banner visible with the failure message instead of hiding it. @@ -76,6 +76,39 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree - **Neither the working-set restore nor the reload path resumes a session that is live in another process.** `runRestore` and the post-`loadProjects` re-open of `sessionStorage.activeSessionId` call `openSession(..., { automatic: true })`, which skips the session without a prompt when `guardResume` reports it live elsewhere; the skipped entry is not activated, stays in the persisted working set at its saved position, and is reported by a one-line notice. See `.ai/contexts/cli-session-state.md` ("Live elsewhere"). +## Sidebar refresh cost + +Measured with 6 live Claude sessions: the renderer burned ~34% of a core and main ~17% (spikes to 110%). Every JSONL append reaches `notifyRendererProjectsChanged` (1.5 s throttle), then the renderer's `onProjectsChanged` (900 ms debounce), then `loadProjects`, which rebuilt the whole sidebar (~22k-node detached tree + morphdom) about once a second. Two changes remove most of that. + +### One build, two views + +The renderer keeps two lists: `cachedProjects` (archived sessions hidden) and `cachedAllProjects` (everything; used by the archive toggle, search, the status-bar totals and pending-session reconciliation). `loadProjects` used to fetch them with two `get-projects` calls (`showArchived` false and true), so every refresh paid two `getAllCached()` (`SELECT *` over every row), two `getAllMeta()`, two `readdirSync` of the projects dirs plus the per-project `existsSync` probes, two `reconcileCacheFromFilesystem()` stat sweeps and two structured clones over IPC. + +`get-projects` now takes no argument and returns `{ projects, allProjects }` from a single `buildProjectViewsFromCache()`: + +- `gatherProjectInputs()` does all the reading once: cache rows, meta, folder meta, the empty-dir listing (including the `deriveProjectPath` / `setFolderMeta` backfill), the plain-terminal rows. It builds each session object once and tags it `hiddenUnlessArchived` (archived itself, or a subagent of an archived parent — the same two filters as before). +- `assembleProjects(inputs, showArchived)` is the old grouping/sorting pass, run twice on the same inputs. `existsSync` is memoised per build, so the `missing` probe runs once per path. +- The two views share their session objects. Structured clone keeps shared references within one message, so the payload carries each session once, and the renderer's `dedup()` already expected the two lists to point at the same objects. + +The views are not derived from each other in the renderer on purpose: a project whose every session is archived appears in the hidden-archived view only through the on-disk empty-dir pass (with that pass's `folder`/`missing` values), which the renderer cannot reproduce without the directory listing. `buildProjectsFromCache(showArchived)` stays as a one-view wrapper over the same two functions; `test/sidebar-refresh-single-fetch.test.js` checks the two views equal the old per-flag builds. + +Not done: memoising the built views in main between calls. With one call per refresh there is no second caller in the same tick to share it with, and invalidating it correctly (cache writes, meta writes, hidden projects, terminals, remote descriptors) would cost more risk than it saves. + +### Skipping an unchanged sidebar render + +`renderProjects(projects, resort, { skipIfUnchanged })` returns `false` without touching the DOM when the signature of what it would render equals the one recorded after the last real render (from any caller). Only the `projects-changed` reload passes `skipIfUnchanged: true`; every other caller (toggles, archive, rename, remote-host refresh, tab return, `resort`) still renders unconditionally, because some of them patch the DOM by hand and count on the re-render to put it back. When the render is skipped, `refreshSidebar` runs `refreshSessionTimeLabels()` (the 30 s ticker's body) so the time labels still pick up the new `modified`. + +The signature (`sidebarRenderSignature` in `public/sidebar.js`) is recorded *after* rendering, because rendering itself writes state (`paintSessionIcon` creates and updates `localPtyStates`, `seedRemoteActivity` marks remote rows busy). It covers: + +- every field of every project and session, except `firstPrompt` and `created` (the sidebar never reads them) and with `modified` rounded down to the minute; +- projects and sessions in a canonical order (by content), not the order main sends. Main sorts by exact recency, so a live session's append reorders the list on every flush, but the renderer keeps its own order (`sortedOrder`) for items it has already shown. The places where it does follow the data order are kept in the signature: the members of each slug group and the subagents of each parent, in data order, for every group with more than one member; +- renderer state the build reads: the filters, search sets, `visibleSessionCount`, `sessionMaxAgeDays`, `activeSessionId`, `activePtyIds`, `pendingSessions`, `activeSubagentsByParent`, and every state snapshot in `localPtyStates` / `remoteSessionStates` / `localTranscriptStates` except `lastActivityAt` / `lastActivitySource`; +- the current minute, so anything that depends on the clock (the age cutoff that moves sessions into "older", the stale-project auto-collapse, the "today" filter, slug-group header times, status ages) is re-rendered at least once a minute while updates keep coming. + +So a live session that only bumps its `modified` costs one render per minute instead of one per second, plus a label refresh. A change to any displayed field, a new or removed session, a different running/busy state or a minute boundary still renders. If you add something the sidebar renders, it is in the signature automatically when it is a field on the project or session object; renderer state it reads from elsewhere has to be added to `sidebarRenderSignature` by hand. + +The signature costs ~30 ms for ~5k sessions in jsdom (measured), against seconds for the render it replaces there. + ## Remote SSH hosts (issue #201) A declared SSH host's `~/.claude/projects` is mirrored into diff --git a/main.js b/main.js index 02578e5e..e7647219 100644 --- a/main.js +++ b/main.js @@ -453,7 +453,7 @@ sessionCache.init({ }, }); const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem, - buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, + buildProjectViewsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, scanFoldersViaWorker, setRemoteRoots, resolveFolderDir } = sessionCache; const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file'); @@ -1048,7 +1048,7 @@ ipcMain.handle('rebuild-cache', async () => { } }); -ipcMain.handle('get-projects', async (_event, showArchived) => { +ipcMain.handle('get-projects', async () => { try { // "Cache has rows" is NOT enough to call the start warm: the scan worker // streams one DB write per folder, so killing the app mid-scan leaves @@ -1074,7 +1074,7 @@ ipcMain.handle('get-projects', async (_event, showArchived) => { // (see populateCacheViaWorker in session-cache.js), and emits // `indexing-progress` events the renderer turns into a one-time banner. // This immediate response returns whatever's cached right now (empty - // session rows on a true first run, but buildProjectsFromCache still + // session rows on a true first run, but buildProjectViewsFromCache still // lists every on-disk project directory synchronously below). populateCacheViaWorker(); } else { @@ -1094,10 +1094,15 @@ ipcMain.handle('get-projects', async (_event, showArchived) => { reconcileCacheFromFilesystem(); } - return annotateRemoteAttachable(mergePlaceholderSessions(buildProjectsFromCache(showArchived))); + // see .ai/contexts/session-cache.md ("One build, two views") + const views = buildProjectViewsFromCache(); + return { + projects: annotateRemoteAttachable(mergePlaceholderSessions(views.projects)), + allProjects: annotateRemoteAttachable(mergePlaceholderSessions(views.allProjects)), + }; } catch (err) { console.error('Error listing projects:', err); - return []; + return { projects: [], allProjects: [] }; } }); diff --git a/preload.js b/preload.js index f45cdc12..7bb38a6c 100644 --- a/preload.js +++ b/preload.js @@ -12,7 +12,7 @@ contextBridge.exposeInMainWorld('api', { getWorkFiles: () => ipcRenderer.invoke('get-work-files'), readWorkFile: (filePath) => ipcRenderer.invoke('read-work-file', filePath), deleteWorkFile: (filePath) => ipcRenderer.invoke('delete-work-file', filePath), - getProjects: (showArchived) => ipcRenderer.invoke('get-projects', showArchived), + getProjects: () => ipcRenderer.invoke('get-projects'), rebuildCache: () => ipcRenderer.invoke('rebuild-cache'), getActiveSessions: () => ipcRenderer.invoke('get-active-sessions'), getSessionLiveElsewhere: (id) => ipcRenderer.invoke('session-live-elsewhere', id), diff --git a/public/app.js b/public/app.js index 9b87eaf4..03955059 100644 --- a/public/app.js +++ b/public/app.js @@ -562,7 +562,7 @@ window.api.onCliBusyState((sessionId, busy) => { // --- Single entry point for all sidebar renders --- // resort=true: re-sort items by priority+time (use for user-initiated actions) // resort=false (default): preserve existing DOM order, new items go to top -function refreshSidebar({ resort = false } = {}) { +function refreshSidebar({ resort = false, skipIfUnchanged = false } = {}) { // When searching, always use all projects (search ignores archive filter) let projects = (searchMatchIds !== null) ? cachedAllProjects @@ -581,7 +581,10 @@ function refreshSidebar({ resort = false } = {}) { }).filter(Boolean); } - renderProjects(projects, resort); + if (!renderProjects(projects, resort, { skipIfUnchanged })) { + refreshSessionTimeLabels(); + return; + } pruneRemoteActivityTimers(); pruneLocalTranscriptTimers(); } @@ -984,8 +987,7 @@ function updatePtyTitle() { scheduleActiveSessionsPoll(); -// Refresh sidebar timeago labels every 30s so "just now" ticks forward -setInterval(() => { +function refreshSessionTimeLabels() { for (const [sessionId, session] of sessionMap) { if (!session.modified) continue; const item = document.getElementById('si-' + sessionId); @@ -995,7 +997,10 @@ setInterval(() => { const msgSuffix = session.messageCount ? ' \u00b7 ' + session.messageCount + ' msgs' : ''; timeEl.textContent = formatDate(new Date(session.modified)) + msgSuffix; } -}, 30000); +} + +// Refresh sidebar timeago labels every 30s so "just now" ticks forward +setInterval(refreshSessionTimeLabels, 30000); // Shared session map so all caches reference the same objects const sessionMap = new Map(); @@ -1014,17 +1019,15 @@ function dedup(projects) { } } -async function loadProjects({ resort = false } = {}) { +async function loadProjects({ resort = false, skipIfUnchanged = false } = {}) { const wasEmpty = cachedProjects.length === 0; if (wasEmpty) { loadingStatus.textContent = 'Loading\u2026'; loadingStatus.className = 'active'; loadingStatus.style.display = ''; } - const [defaultProjects, allProjects] = await Promise.all([ - window.api.getProjects(false), - window.api.getProjects(true), - ]); + // see .ai/contexts/session-cache.md ("One build, two views") + const { projects: defaultProjects, allProjects } = await window.api.getProjects(); cachedProjects = defaultProjects; cachedAllProjects = allProjects; loadingStatus.style.display = 'none'; @@ -1076,7 +1079,7 @@ async function loadProjects({ resort = false } = {}) { } catch {} await pollActiveSessions(); - refreshSidebar({ resort }); + refreshSidebar({ resort, skipIfUnchanged }); renderDefaultStatus(); } @@ -1466,7 +1469,7 @@ window.api.onProjectsChanged(() => { // sidebar redraws at most ~1×/sec. projectsChangedTimer = setTimeout(() => { projectsChangedTimer = null; - loadProjects().then(() => maybeRetryRestoreWorkingSet()); + loadProjects({ skipIfUnchanged: true }).then(() => maybeRetryRestoreWorkingSet()); }, 900); }); diff --git a/public/sidebar.js b/public/sidebar.js index 4ebec99e..22a72442 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -573,8 +573,78 @@ function buildSlugGroup(slug, sessions, subagentIndex) { return group; } -function renderProjects(projects, resort) { +// see .ai/contexts/session-cache.md ("Skipping an unchanged sidebar render") +let lastSidebarRenderSignature = null; + +function sortedKeys(collection) { + if (!collection) return null; + return [...(typeof collection.keys === 'function' ? collection.keys() : collection)].map(String).sort(); +} + +const SIGNATURE_SKIPPED_FIELDS = new Set(['sessions', 'firstPrompt', 'created', 'lastActivityAt', 'lastActivitySource']); + +function signatureOfFields(obj) { + let out = ''; + for (const key in obj) { + if (SIGNATURE_SKIPPED_FIELDS.has(key)) continue; + let value = obj[key]; + if (key === 'modified') { + const t = Date.parse(value); + if (Number.isFinite(t)) value = Math.floor(t / 60000); + } + out += key + '\u0001' + (value !== null && typeof value === 'object' ? JSON.stringify(value) : String(value)) + '\u0002'; + } + return out; +} + +function sidebarRenderSignature(projects) { + try { + const projectParts = []; + for (const p of projects) { + const sessionParts = []; + const groups = new Map(); + for (const s of p.sessions) { + sessionParts.push(signatureOfFields(s)); + const key = s.parentSessionId ? 'p:' + s.parentSessionId : (s.slug ? 's:' + s.slug : null); + if (!key) continue; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(s.sessionId); + } + const order = []; + for (const [key, ids] of groups) if (ids.length > 1) order.push(key + '=' + ids.join(',')); + projectParts.push(signatureOfFields(p) + '\u0003' + sessionParts.sort().join('\u0004') + '\u0003' + order.sort().join('\u0004')); + } + const activity = []; + const stateMaps = [ + ['l', typeof localPtyStates !== 'undefined' ? localPtyStates : null], + ['r', typeof remoteSessionStates !== 'undefined' ? remoteSessionStates : null], + ['t', typeof localTranscriptStates !== 'undefined' ? localTranscriptStates : null], + ]; + for (const [tag, map] of stateMaps) { + if (!map) continue; + for (const [id, st] of map) activity.push(tag + ':' + id + ':' + signatureOfFields(st.snapshot())); + } + const subagents = []; + for (const [parentId, agents] of activeSubagentsByParent) subagents.push(parentId + ':' + sortedKeys(agents).join(',')); + const state = JSON.stringify({ + now: Math.floor(Date.now() / 60000), + starred: showStarredOnly, running: showRunningOnly, today: showTodayOnly, + search: sortedKeys(searchMatchIds), searchProjects: sortedKeys(searchMatchProjectPaths), + visible: visibleSessionCount, maxAge: sessionMaxAgeDays, active: activeSessionId, + pty: sortedKeys(activePtyIds), pending: sortedKeys(pendingSessions), subagents: subagents.sort(), + }); + return state + '\u0005' + activity.sort().join('\u0004') + '\u0005' + projectParts.sort().join('\u0005'); + } catch { + return null; + } +} + +function renderProjects(projects, resort, { skipIfUnchanged = false } = {}) { pruneStaleSubagents(); + const renderedProjects = projects; + if (skipIfUnchanged && !resort && lastSidebarRenderSignature !== null + && sidebarRenderSignature(projects) === lastSidebarRenderSignature) return false; + lastSidebarRenderSignature = null; pendingSubagentRest.clear(); // see .ai/contexts/session-cache.md ("Remote hosts — busy spinner (issue #242)") for (const project of projects) { @@ -1036,6 +1106,8 @@ function renderProjects(projects, resort) { if (activeSessionId && openSessions.has(activeSessionId) && !isUserTyping) { openSessions.get(activeSessionId).terminal.focus(); } + lastSidebarRenderSignature = sidebarRenderSignature(renderedProjects); + return true; } function rebindSidebarEvents(projects) { diff --git a/session-cache.js b/session-cache.js index bb25dc4b..a304d0a0 100644 --- a/session-cache.js +++ b/session-cache.js @@ -398,8 +398,8 @@ function isProjectHidden(hiddenProjects, alias, projectPath) { return alias !== null && hiddenProjects.has(joinFolderKey(alias, projectPath)); } -/** Build projects response from cached data */ -function buildProjectsFromCache(showArchived) { +// see .ai/contexts/session-cache.md ("One build, two views") +function gatherProjectInputs() { const metaMap = getAllMeta(); const cachedRows = getAllCached(); const global = getSetting('global') || {}; @@ -428,10 +428,7 @@ function buildProjectsFromCache(showArchived) { const cachedIds = new Set(cachedRows.map(r => r.sessionId)); const isArchivedParent = (id) => cachedIds.has(id) && !!metaMap.get(id)?.archived; - // Keyed on alias + projectPath: two hosts can hold the same absolute path. - const projectMap = new Map(); - // '|' cannot occur in an alias (remote-hosts.js ALIAS_RE): the key is injective. - const groupKey = (alias, projectPath) => (alias === null ? '' : alias) + '|' + projectPath; + const sessionEntries = []; for (const row of cachedRows) { if (row.mergedIntoSessionId) continue; // rolled up into its parent below, not its own entry if (!row.projectPath) continue; @@ -464,23 +461,9 @@ function buildProjectsFromCache(showArchived) { archived: meta?.archived || 0, remoteAlias: alias, }; - if (!showArchived && s.archived) continue; // see .ai/contexts/subagent-observability.md ("A subagent follows its archived parent") - if (!showArchived && s.parentSessionId && isArchivedParent(s.parentSessionId)) continue; - const key = groupKey(alias, row.projectPath); - if (!projectMap.has(key)) { - projectMap.set(key, { - folder: alias === null - ? encodeProjectPath(row.projectPath) - : joinFolderKey(alias, encodeProjectPath(row.projectPath)), - projectPath: row.projectPath, - remoteAlias: alias, - sessions: [], - // A remote root is never on this filesystem. see .ai/contexts/session-cache.md ("Remote SSH hosts") - missing: alias === null ? !fs.existsSync(row.projectPath) : false, - }); - } - projectMap.get(key).sessions.push(s); + const hiddenUnlessArchived = !!s.archived || (!!s.parentSessionId && isArchivedParent(s.parentSessionId)); + sessionEntries.push({ alias, projectPath: row.projectPath, session: s, hiddenUnlessArchived }); } // Include empty project directories (no sessions yet). Resolve folder->projectPath @@ -492,14 +475,14 @@ function buildProjectsFromCache(showArchived) { // While the INITIAL scan is still incomplete (completeness marker absent), // that fallback is forbidden: cache_meta is mostly/entirely empty, so // deriveProjectPath (readdir + up to 256 KB JSONL read) would run for every - // folder under PROJECTS_DIR — twice per sidebar paint (the renderer's - // showArchived false/true Promise.all) — synchronously on the main process, - // undermining the "return whatever's cached, zero per-folder I/O" contract - // of the non-blocking first launch. Use a best-effort decode of the folder + // folder under PROJECTS_DIR on every sidebar paint, synchronously on the + // main process, undermining the "return whatever's cached, zero per-folder + // I/O" contract of the non-blocking first launch. Use a best-effort decode of the folder // name instead (display-only, corrected per folder as the scan worker fills // cache_meta and fires projects-changed) and skip the fs.existsSync missing // probe for the same zero-I/O reason. Nothing is written to cache_meta on // this path — a lossy guess must never shadow the real derived path. + const dirEntries = []; try { const scanComplete = isInitialScanComplete ? isInitialScanComplete() : true; const folderMeta = getAllFolderMeta(); @@ -525,48 +508,91 @@ function buildProjectsFromCache(showArchived) { } if (!projectPath) continue; if (isProjectHidden(hiddenProjects, alias, projectPath)) continue; - const key = groupKey(alias, projectPath); - if (projectMap.has(key)) continue; // For a placeholder the on-disk name IS the ground truth — re-encoding // the lossy decode could diverge from it (>200-char hashed names). const bare = placeholder ? d.name : encodeProjectPath(projectPath); - projectMap.set(key, { - folder: alias === null ? bare : joinFolderKey(alias, bare), - projectPath, - remoteAlias: alias, - sessions: [], - // A remote root is never on this filesystem — see above. - missing: (placeholder || alias !== null) ? false : !fs.existsSync(projectPath), - }); + dirEntries.push({ alias, projectPath, placeholder, bare }); } } } catch {} // Inject active plain terminal sessions so they participate in sorting + const terminalEntries = []; for (const [sessionId, session] of activeSessions) { if (session.exited || !session.isPlainTerminal) continue; // see .ai/contexts/panel-terminal.md ("A panel shell is not a session") if (isPanelShellSession(session)) continue; if (!session.projectPath) continue; if (isProjectHidden(hiddenProjects, null, session.projectPath)) continue; - const localKey = groupKey(null, session.projectPath); + terminalEntries.push({ + sessionId, summary: 'Terminal', firstPrompt: '', projectPath: session.projectPath, + name: null, starred: 0, archived: 0, messageCount: 0, + modified: new Date(session._openedAt).toISOString(), + created: new Date(session._openedAt).toISOString(), + type: 'terminal', + }); + } + + const existsMemo = new Map(); + const pathExists = (p) => { + if (!existsMemo.has(p)) existsMemo.set(p, fs.existsSync(p)); + return existsMemo.get(p); + }; + + return { sessionEntries, dirEntries, terminalEntries, pathExists }; +} + +function assembleProjects(inputs, showArchived) { + const { sessionEntries, dirEntries, terminalEntries, pathExists } = inputs; + // Keyed on alias + projectPath: two hosts can hold the same absolute path. + const projectMap = new Map(); + // '|' cannot occur in an alias (remote-hosts.js ALIAS_RE): the key is injective. + const groupKey = (alias, projectPath) => (alias === null ? '' : alias) + '|' + projectPath; + + for (const { alias, projectPath, session, hiddenUnlessArchived } of sessionEntries) { + if (!showArchived && hiddenUnlessArchived) continue; + const key = groupKey(alias, projectPath); + if (!projectMap.has(key)) { + projectMap.set(key, { + folder: alias === null + ? encodeProjectPath(projectPath) + : joinFolderKey(alias, encodeProjectPath(projectPath)), + projectPath, + remoteAlias: alias, + sessions: [], + // A remote root is never on this filesystem. see .ai/contexts/session-cache.md ("Remote SSH hosts") + missing: alias === null ? !pathExists(projectPath) : false, + }); + } + projectMap.get(key).sessions.push(session); + } + + for (const { alias, projectPath, placeholder, bare } of dirEntries) { + const key = groupKey(alias, projectPath); + if (projectMap.has(key)) continue; + projectMap.set(key, { + folder: alias === null ? bare : joinFolderKey(alias, bare), + projectPath, + remoteAlias: alias, + sessions: [], + // A remote root is never on this filesystem — see above. + missing: (placeholder || alias !== null) ? false : !pathExists(projectPath), + }); + } + + for (const terminal of terminalEntries) { + const localKey = groupKey(null, terminal.projectPath); if (!projectMap.has(localKey)) { projectMap.set(localKey, { - folder: encodeProjectPath(session.projectPath), - projectPath: session.projectPath, + folder: encodeProjectPath(terminal.projectPath), + projectPath: terminal.projectPath, remoteAlias: null, sessions: [], }); } const proj = projectMap.get(localKey); - if (!proj.sessions.some(s => s.sessionId === sessionId)) { - proj.sessions.push({ - sessionId, summary: 'Terminal', firstPrompt: '', projectPath: session.projectPath, - name: null, starred: 0, archived: 0, messageCount: 0, - modified: new Date(session._openedAt).toISOString(), - created: new Date(session._openedAt).toISOString(), - type: 'terminal', - }); + if (!proj.sessions.some(s => s.sessionId === terminal.sessionId)) { + proj.sessions.push(terminal); } } @@ -591,6 +617,16 @@ function buildProjectsFromCache(showArchived) { return projects; } +/** Build projects response from cached data */ +function buildProjectsFromCache(showArchived) { + return assembleProjects(gatherProjectInputs(), showArchived); +} + +function buildProjectViewsFromCache() { + const inputs = gatherProjectInputs(); + return { projects: assembleProjects(inputs, false), allProjects: assembleProjects(inputs, true) }; +} + // Throttle projects-changed IPC: live sessions appending JSONL trigger a flush // every ~500ms; without throttling the renderer re-runs getProjects + morphdom @@ -912,6 +948,7 @@ module.exports = { refreshFolder, reconcileCacheFromFilesystem, buildProjectsFromCache, + buildProjectViewsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, diff --git a/test/get-projects-cold-start-reconcile.test.js b/test/get-projects-cold-start-reconcile.test.js index adc52042..629a8b9b 100644 --- a/test/get-projects-cold-start-reconcile.test.js +++ b/test/get-projects-cold-start-reconcile.test.js @@ -45,8 +45,8 @@ function makeHandler(mocks) { const fn = new Function( 'isCachePopulated', 'isSearchIndexPopulated', 'isInitialScanComplete', 'populateCacheViaWorker', - 'reconcileCacheFromFilesystem', 'buildProjectsFromCache', 'mergePlaceholderSessions', - 'annotateRemoteAttachable', 'showArchived', + 'reconcileCacheFromFilesystem', 'buildProjectViewsFromCache', 'mergePlaceholderSessions', + 'annotateRemoteAttachable', body ); // annotateRemoteAttachable (remote-attach join, issue #221) and @@ -58,8 +58,8 @@ function makeHandler(mocks) { return () => fn( mocks.isCachePopulated, mocks.isSearchIndexPopulated, mocks.isInitialScanComplete, mocks.populateCacheViaWorker, - mocks.reconcileCacheFromFilesystem, mocks.buildProjectsFromCache, mergePlaceholderSessions, - annotateRemoteAttachable, false + mocks.reconcileCacheFromFilesystem, mocks.buildProjectViewsFromCache, mergePlaceholderSessions, + annotateRemoteAttachable ); } @@ -71,7 +71,7 @@ test('get-projects on a cold cache never runs reconcileCacheFromFilesystem synch isInitialScanComplete: () => false, populateCacheViaWorker: () => { calls.push('populate'); }, reconcileCacheFromFilesystem: () => { calls.push('reconcile'); }, - buildProjectsFromCache: () => { calls.push('build'); return []; }, + buildProjectViewsFromCache: () => { calls.push('build'); return { projects: [], allProjects: [] }; }, }); handler(); @@ -92,7 +92,7 @@ test('get-projects on a warm cache still reconciles (stat-gated, cheap when noth isInitialScanComplete: () => true, // marker present: the scan really finished populateCacheViaWorker: () => { calls.push('populate'); }, reconcileCacheFromFilesystem: () => { calls.push('reconcile'); }, - buildProjectsFromCache: () => { calls.push('build'); return []; }, + buildProjectViewsFromCache: () => { calls.push('build'); return { projects: [], allProjects: [] }; }, }); handler(); @@ -119,7 +119,7 @@ test('get-projects on a partial cache (interrupted first scan: rows present, mar isInitialScanComplete: () => false, // ...but the scan never reached done populateCacheViaWorker: () => { calls.push('populate'); }, reconcileCacheFromFilesystem: () => { calls.push('reconcile'); }, - buildProjectsFromCache: () => { calls.push('build'); return []; }, + buildProjectViewsFromCache: () => { calls.push('build'); return { projects: [], allProjects: [] }; }, }); handler(); diff --git a/test/sidebar-refresh-single-fetch.test.js b/test/sidebar-refresh-single-fetch.test.js new file mode 100644 index 00000000..619269b3 --- /dev/null +++ b/test/sidebar-refresh-single-fetch.test.js @@ -0,0 +1,277 @@ +// One projects fetch per sidebar refresh, and no sidebar rebuild when nothing +// it shows changed. See .ai/contexts/session-cache.md ("One build, two views", +// "Skipping an unchanged sidebar render"). + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const vm = require('node:vm'); + +const sessionCache = require('../session-cache'); +const { setupSidebarDom } = require('./dom-setup'); + +const APP_SRC = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8').replace(/\r\n/g, '\n'); + +function functionSource(src, name) { + const start = src.indexOf(`function ${name}(`); + assert.notEqual(start, -1, `${name} not found`); + const body = src.indexOf(') {', start) + 2; + let depth = 0; + for (let i = body; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1); + } + throw new Error(`unbalanced ${name}`); +} + +// ---- loadProjects: one IPC --------------------------------------------------- + +function runLoadProjects(payload, opts) { + const calls = { getProjects: [], refreshSidebar: [] }; + const ctx = { + window: { + api: { + getProjects: (...args) => { calls.getProjects.push(args); return Promise.resolve(payload); }, + getActiveTerminals: () => Promise.resolve([]), + }, + }, + loadingStatus: { style: {}, className: '', textContent: '' }, + cachedProjects: [], + cachedAllProjects: [], + sessionMap: new Map(), + pendingSessions: new Map(), + reportActivityFocus: () => {}, + reportActivityTitles: () => {}, + encodeProjectPath: (p) => p.replace(/\//g, '-'), + pollActiveSessions: () => Promise.resolve(), + refreshSidebar: (o) => { calls.refreshSidebar.push(o); }, + renderDefaultStatus: () => {}, + }; + vm.createContext(ctx); + vm.runInContext(functionSource(APP_SRC, 'dedup'), ctx); + vm.runInContext('async ' + functionSource(APP_SRC, 'loadProjects'), ctx); + ctx.__opts = opts; + return vm.runInContext('loadProjects(__opts)', ctx).then(() => ({ ctx, calls })); +} + +test('loadProjects asks main for the projects once and takes both views from that answer', async () => { + const shared = { sessionId: 'a', projectPath: '/p', modified: '2026-10-01T10:00:00.000Z' }; + const archived = { sessionId: 'b', projectPath: '/p', modified: '2026-10-01T09:00:00.000Z', archived: 1 }; + const payload = { + projects: [{ projectPath: '/p', folder: '-p', sessions: [shared] }], + allProjects: [{ projectPath: '/p', folder: '-p', sessions: [shared, archived] }], + }; + const { ctx, calls } = await runLoadProjects(payload); + assert.equal(calls.getProjects.length, 1); + assert.deepEqual(calls.getProjects[0], []); + assert.deepEqual(ctx.cachedProjects[0].sessions.map(s => s.sessionId), ['a']); + assert.deepEqual(ctx.cachedAllProjects[0].sessions.map(s => s.sessionId), ['a', 'b']); + assert.equal(ctx.cachedProjects[0].sessions[0], ctx.cachedAllProjects[0].sessions[0]); +}); + +test('only the projects-changed reload may skip an unchanged render; a direct reload always renders', async () => { + const payload = { projects: [], allProjects: [] }; + const direct = await runLoadProjects(payload); + assert.equal(direct.calls.refreshSidebar[0].skipIfUnchanged, false); + const live = await runLoadProjects(payload, { skipIfUnchanged: true }); + assert.equal(live.calls.refreshSidebar[0].skipIfUnchanged, true); + assert.match(APP_SRC, /window\.api\.onProjectsChanged\([\s\S]*?loadProjects\(\{ skipIfUnchanged: true \}\)/); +}); + +// ---- main: one build, both views ---------------------------------------------- + +const PROJECT = '/home/dev/views-proj'; +const FOLDER = '-home-dev-views-proj'; +const ALL_ARCHIVED = '/home/dev/all-archived'; +const ALL_ARCHIVED_FOLDER = '-home-dev-all-archived'; + +function row(sessionId, projectPath, folder, modified, extra = {}) { + return { + sessionId, folder, projectPath, summary: sessionId, firstPrompt: sessionId, + modified, created: modified, messageCount: 1, parentSessionId: null, agentId: null, + subagentType: null, description: null, slug: null, aiTitle: null, ...extra, + }; +} + +function withCache(fn) { + const projectsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-views-')); + fs.mkdirSync(path.join(projectsDir, ALL_ARCHIVED_FOLDER)); + fs.mkdirSync(path.join(projectsDir, '-home-dev-empty')); + const rows = [ + row('live', PROJECT, FOLDER, '2026-10-01T10:00:00.000Z'), + row('old-archived', PROJECT, FOLDER, '2026-10-01T09:00:00.000Z'), + row('parent', PROJECT, FOLDER, '2026-10-01T08:00:00.000Z'), + row('kid', PROJECT, FOLDER, '2026-10-01T08:30:00.000Z', { parentSessionId: 'parent', agentId: 'kid' }), + row('gone', ALL_ARCHIVED, ALL_ARCHIVED_FOLDER, '2026-09-01T10:00:00.000Z'), + ]; + const meta = new Map([ + ['old-archived', { archived: 1 }], + ['parent', { archived: 1 }], + ['gone', { archived: 1 }], + ]); + const folderMeta = new Map([ + [ALL_ARCHIVED_FOLDER, { projectPath: ALL_ARCHIVED }], + ['-home-dev-empty', { projectPath: '/home/dev/empty' }], + ]); + let cachedReads = 0; + try { + sessionCache.init({ + PROJECTS_DIR: projectsDir, + activeSessions: new Map(), + getMainWindow: () => null, + log: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} }, + db: { + getAllFolderMeta: () => folderMeta, + getAllMeta: () => meta, + getAllCached: () => { cachedReads++; return rows.map(r => ({ ...r })); }, + getSetting: () => ({}), + setFolderMeta: () => {}, + isInitialScanComplete: () => true, + }, + }); + return fn(() => cachedReads); + } finally { + fs.rmSync(projectsDir, { recursive: true, force: true }); + } +} + +test('the two views built together equal the two views built separately', () => { + withCache(() => { + const views = sessionCache.buildProjectViewsFromCache(); + assert.deepEqual(views.projects, sessionCache.buildProjectsFromCache(false)); + assert.deepEqual(views.allProjects, sessionCache.buildProjectsFromCache(true)); + + const ids = (list, p) => list.find(x => x.projectPath === p).sessions.map(s => s.sessionId); + assert.deepEqual(ids(views.projects, PROJECT), ['live']); + assert.deepEqual(ids(views.allProjects, PROJECT), ['live', 'old-archived', 'kid', 'parent']); + // a project whose every session is archived stays listed by its on-disk folder, empty + assert.deepEqual(ids(views.projects, ALL_ARCHIVED), []); + assert.deepEqual(ids(views.allProjects, ALL_ARCHIVED), ['gone']); + }); +}); + +test('both views come from one read of the cache and share their session objects', () => { + withCache((cachedReads) => { + const views = sessionCache.buildProjectViewsFromCache(); + assert.equal(cachedReads(), 1); + const live = views.projects.find(p => p.projectPath === PROJECT).sessions[0]; + const liveAll = views.allProjects.find(p => p.projectPath === PROJECT).sessions.find(s => s.sessionId === 'live'); + assert.equal(live, liveAll); + }); +}); + +test('the get-projects handler answers both views from one build', () => { + const src = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); + const start = src.indexOf("ipcMain.handle('get-projects'"); + const handler = src.slice(start, src.indexOf("ipcMain.handle('get-stats'", start)); + assert.equal((handler.match(/buildProjectViewsFromCache\(\)/g) || []).length, 1); + assert.doesNotMatch(handler, /buildProjectsFromCache\(/); +}); + +// ---- renderer: skip an unchanged render ----------------------------------------- + +const T0 = Date.parse('2026-10-01T10:00:10.000Z'); + +function project(overrides = {}) { + return { + projectPath: '/home/dev/skip', + folder: '-home-dev-skip', + sessions: [ + { sessionId: 's1', summary: 'first', modified: new Date(T0).toISOString(), messageCount: 3, archived: 0, starred: 0 }, + { sessionId: 's2', summary: 'second', modified: new Date(T0 - 5000).toISOString(), messageCount: 1, archived: 0, starred: 0 }, + { sessionId: 'k1', parentSessionId: 's1', agentId: 'k1', subagentType: 'explore', description: 'k1', modified: new Date(T0 - 1000).toISOString(), messageCount: 1 }, + { sessionId: 'k2', parentSessionId: 's1', agentId: 'k2', subagentType: 'explore', description: 'k2', modified: new Date(T0 - 2000).toISOString(), messageCount: 1 }, + ], + ...overrides, + }; +} + +function clone(p) { + return JSON.parse(JSON.stringify(p)); +} + +function withSidebar(fn) { + const ctx = setupSidebarDom(); + ctx.window.Date.now = () => T0 + 20000; + try { + return fn(ctx); + } finally { + ctx.destroy(); + } +} + +test('an unchanged reload does not rebuild the sidebar', () => { + withSidebar((ctx) => { + const { renderProjects } = ctx.sidebar; + assert.equal(renderProjects([project()], false), true); + const item = ctx.document.getElementById('si-s1'); + assert.ok(item); + assert.equal(renderProjects([clone(project())], false, { skipIfUnchanged: true }), false); + assert.equal(ctx.document.getElementById('si-s1'), item); + }); +}); + +test('a session bumped within the same minute, or reordered by it, does not rebuild', () => { + withSidebar((ctx) => { + const { renderProjects } = ctx.sidebar; + renderProjects([project()], false); + const bumped = project(); + bumped.sessions[1].modified = new Date(T0 + 1000).toISOString(); + bumped.sessions = [bumped.sessions[1], bumped.sessions[0], ...bumped.sessions.slice(2)]; + assert.equal(renderProjects([bumped], false, { skipIfUnchanged: true }), false); + }); +}); + +test('every displayed change rebuilds the sidebar', () => { + const changes = { + 'a title': (p) => { p.sessions[0].summary = 'renamed'; }, + 'a name': (p) => { p.sessions[0].name = 'named'; }, + 'a message count': (p) => { p.sessions[0].messageCount = 4; }, + 'a modified minute': (p) => { p.sessions[1].modified = new Date(T0 + 60000).toISOString(); }, + 'a star': (p) => { p.sessions[1].starred = 1; }, + 'an archive flag': (p) => { p.sessions[1].archived = 1; }, + 'a status': (p) => { p.sessions[0].status = 'busy'; }, + 'a new session': (p) => { p.sessions.push({ sessionId: 's3', summary: 'third', modified: new Date(T0).toISOString(), messageCount: 0 }); }, + 'a removed session': (p) => { p.sessions.splice(1, 1); }, + 'the subagent order': (p) => { p.sessions = [p.sessions[0], p.sessions[1], p.sessions[3], p.sessions[2]]; }, + 'a missing flag': (p) => { p.missing = true; }, + }; + for (const [label, change] of Object.entries(changes)) { + withSidebar((ctx) => { + const { renderProjects } = ctx.sidebar; + renderProjects([project()], false); + const next = project(); + change(next); + assert.equal(renderProjects([next], false, { skipIfUnchanged: true }), true, label); + }); + } +}); + +test('renderer state the sidebar shows, a new minute, or a resort all rebuild', () => { + withSidebar((ctx) => { + const { renderProjects } = ctx.sidebar; + renderProjects([project()], false); + ctx.window.activePtyIds = new Set(['s2']); + assert.equal(renderProjects([project()], false, { skipIfUnchanged: true }), true, 'running set'); + ctx.setActivity('s2', true); + assert.equal(renderProjects([project()], false, { skipIfUnchanged: true }), true, 'busy state'); + assert.equal(renderProjects([project()], false, { skipIfUnchanged: true }), false, 'settled'); + ctx.window.showStarredOnly = true; + assert.equal(renderProjects([project()], false, { skipIfUnchanged: true }), true, 'filter'); + assert.equal(renderProjects([project()], true, { skipIfUnchanged: true }), true, 'resort'); + ctx.window.Date.now = () => T0 + 120000; + assert.equal(renderProjects([project()], false, { skipIfUnchanged: true }), true, 'next minute'); + }); +}); + +test('without the skip option every call renders', () => { + withSidebar((ctx) => { + const { renderProjects } = ctx.sidebar; + renderProjects([project()], false); + assert.equal(renderProjects([project()], false), true); + }); +});